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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 10:39:09 +02:00
1 parent f56caced32
commit dd92128464
33 files changed
+1158 -28

No files matched your search

@@ -0,0 +1,61 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// <summary>Eine Spalte der Punktetabelle auf dem Klausur-Deckblatt. Bonusaufgaben tragen keine
/// Nummer; <see cref="AchievedPoints"/> ist null, solange nichts eingetragen werden soll.</summary>
public sealed record ExamCoverTaskColumn(string Number, double MaxPoints, bool IsBonus, double? AchievedPoints);
public static class ExamCoverSheetService
{
/// <summary>
/// Spalten für genau die Fassung, die der Schüler geschrieben hat. Nummeriert wird innerhalb
/// dieser Fassung (wie auf dem gedruckten Aufgabenblatt), Bonusaufgaben zählen nicht mit.
/// <paramref name="points"/> folgt wie <see cref="ExamResult.Points"/> der Reihenfolge aller
/// Aufgaben, nicht nur der dieser Fassung.
/// </summary>
public static List<ExamCoverTaskColumn> Columns(IReadOnlyList<ExamTask> orderedTasks, ExamVariant variant,
IReadOnlyList<double>? points)
{
var columns = new List<ExamCoverTaskColumn>();
var number = 0;
for (var index = 0; index < orderedTasks.Count; index++)
{
var task = orderedTasks[index];
if (!ExamScoringService.AppliesTo(task, variant)) continue;
double? achieved = points is null ? null : index < points.Count ? points[index] : 0;
columns.Add(new(task.IsBonus ? "" : (++number).ToString(), task.MaxPoints, task.IsBonus, achieved));
}
return columns;
}
/// <summary>Ein Ergebnis gilt als korrigiert, sobald Punkte oder eine Note eingetragen sind.</summary>
public static bool IsCorrected(ExamResult? result) =>
result is { Absent: false } && (result.Points.Count > 0 || !string.IsNullOrWhiteSpace(result.Grade));
}
/// <summary>Wer eine Arbeit mitschreibt — eine Regel für Punkteeingabe und Deckblätter.</summary>
public static class ExamParticipantService
{
/// <summary>Bei einer Nachschreibarbeit: Schüler, die die erste Arbeit bereits mit Note
/// geschrieben haben. Sonst leer.</summary>
public static HashSet<Guid> WroteOriginal(Exam exam, Func<Guid, List<ExamResult>> resultsByExam)
{
var wroteOriginal = new HashSet<Guid>();
if (exam.IsMakeUp && exam.RepeatOfExamId is { } originalId)
foreach (var r in resultsByExam(originalId).Where(r => !r.Absent && r.Grade is not null))
wroteOriginal.Add(r.StudentId);
return wroteOriginal;
}
public static bool Includes(Exam exam, GroupMembership? membership, bool hasResult, bool wroteOriginal)
{
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) return false;
// Niveau-Klausur: nur Schüler mit passendem Niveau. Klausuren ohne Niveau-Zuordnung gelten
// weiterhin für die ganze Gruppe.
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) return false;
// Nachschreibarbeit: wer die erste Arbeit schon mit Note geschrieben hat, taucht nicht auf —
// außer es gibt hier bereits ein Ergebnis (nichts verstecken, was schon eingetragen wurde).
return hasResult || !wroteOriginal;
}
}
@@ -0,0 +1,245 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class ExamCoverSheetTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-cover-tests-{Guid.NewGuid():N}");
private readonly LearningGroup _group = new() { Name = "9 Ch G", SchoolYear = "2025/26", GradeLevel = 9 };
private readonly Student _anna = new() { FirstName = "Anna", LastName = "Adler" };
private readonly Student _ben = new() { FirstName = "Ben", LastName = "Bauer" };
private readonly Exam _exam;
private readonly FakeResults _results = new();
private const string Layout = """
PAGE 210 297 mm
#pragma format-version 3
#pragma page-template first
TEXT 20 20 $Student.Name size=12
#pragma flow-slot body x=20 y=40 w=170 h=230
#pragma end-page-template
#pragma content-flow body
TEXT $Exam.Title size=14
FLOWDRAWBOX $Exam.PointsTable w=170 h=120 fit=content gap=10
TEXT "Gesamtpunkte: $Result.Points von $Exam.MaxPoints Punkte" size=11 gap=8
TEXT "Note: $Result.Grade" size=14 gap=4
#pragma end-content-flow
""";
public ExamCoverSheetTests()
{
Directory.CreateDirectory(_directory);
_exam = new Exam
{
GroupId = _group.Id, Title = "Atombau und PSE", Date = new DateOnly(2025, 12, 18), ExamNumber = 1,
Tasks =
[
new() { Nr = 1, MaxPoints = 5 }, new() { Nr = 2, MaxPoints = 12 },
new() { Nr = 3, MaxPoints = 3, IsBonus = true },
],
GradingKey = [new() { Grade = "1", MinPercent = 90 }, new() { Grade = "6", MinPercent = 0 }],
};
}
[Fact]
public void PointsTable_BrichtBeiFesterSpaltenbreiteInWeitereBloeckeUm()
{
var eight = Enumerable.Range(1, 8).Select(n => new ExamCoverTaskColumn(n.ToString(), n, false, null)).ToList();
var oneBlock = ExamPointsTableDrawingBuilder.Build(eight.Take(6).ToList(), 170);
var twoBlocks = ExamPointsTableDrawingBuilder.Build(eight, 170);
Assert.Equal(6, ExamPointsTableDrawingBuilder.ColumnsPerBlock(170));
Assert.True(twoBlocks.ContentHeight > 2 * oneBlock.ContentHeight);
// Spalten bleiben gleich breit - der zweite Block verkleinert nichts.
Assert.Equal(Widths(oneBlock), Widths(twoBlocks));
Assert.All(twoBlocks.Commands.OfType<DrawRectangle>(), r => Assert.True(r.X + r.Width <= 170));
}
[Fact]
public void PointsTable_Bonusaufgabe_OhneNummerMitPlusVorDerPunktzahl()
{
var drawing = ExamPointsTableDrawingBuilder.Build(
[
new("1", 5, false, 4.5), new("", 3, true, 1),
]);
var texts = drawing.Commands.OfType<DrawStringEx>().Select(x => x.Text).ToList();
Assert.Equal(["Aufgabe", "Max. Punktzahl", "Punktzahl", "Nr. 1", "5", "4,5", "+3", "1"], texts);
}
[Fact]
public void PointsTable_VorlageInPunkt_SkaliertDenMillimeterEntwurf()
{
List<ExamCoverTaskColumn> columns = [new("1", 5, false, null)];
var scale = 72f / 25.4f;
var millimeters = ExamPointsTableDrawingBuilder.Build(columns, 170);
var points = ExamPointsTableDrawingBuilder.Build(columns, 170 * scale, scale);
Assert.Equal(millimeters.ContentHeight * scale, points.ContentHeight, 2);
}
[Fact]
public void Dialog_ZeigtNurDeckblattVorlagen_AusBeidenBibliotheken()
{
var letters = Store(); var worksheets = Store();
Import(letters, "brief", "Brief", null);
Import(letters, "deckblatt-a", "Deckblatt A", TemplateReportTypes.ExamCover);
Import(worksheets, "deckblatt-b", "Deckblatt B", TemplateReportTypes.ExamCover);
var vm = Build([letters, worksheets]);
Assert.Equal(["Deckblatt A", "Deckblatt B"], vm.Templates.Select(x => x.Name));
}
[Fact]
public void Dialog_VorDerKorrektur_BlankoVorausgewaehlt_NachBeginnAusgefuellt()
{
Assert.False(Build([Store()]).FillResults);
_results.Add(new ExamResult { ExamId = _exam.Id, StudentId = _anna.Id, Points = [4, 10, 0], TotalPoints = 14 });
Assert.True(Build([Store()]).FillResults);
}
[Fact]
public void Dialog_AbwesenderSchueler_IstStandardmaessigAbgewaehlt()
{
_results.Add(new ExamResult { ExamId = _exam.Id, StudentId = _ben.Id, Absent = true });
var vm = Build([Store()]);
Assert.True(vm.Students.Single(s => s.Model.Id == _anna.Id).IsIncluded);
Assert.False(vm.Students.Single(s => s.Model.Id == _ben.Id).IsIncluded);
}
[Fact]
public void Generate_SchreibtEinSammelPdfMitEinerSeiteJeSchueler()
{
_results.Add(new ExamResult
{ ExamId = _exam.Id, StudentId = _anna.Id, Points = [4.5, 12, 1], TotalPoints = 17.5 });
var store = Store();
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
var vm = Build([store]);
var output = Path.Combine(_directory, "deckblaetter.pdf");
Assert.True(vm.Generate(output));
using var document = UglyToad.PdfPig.PdfDocument.Open(output);
Assert.Equal(2, document.NumberOfPages);
var anna = Normalize(document.GetPage(1).Text);
var ben = Normalize(document.GetPage(2).Text);
Assert.Contains(Normalize(_anna.FullName), anna);
Assert.Contains(Normalize(_ben.FullName), ben);
Assert.Contains("AtombauundPSE", anna);
// Bonusaufgabe zählt nicht zur erreichbaren Punktzahl; erreichte Bonuspunkte schon.
Assert.Contains("Gesamtpunkte:17,5von17Punkte", anna);
Assert.Contains("Note:1", anna);
Assert.Contains("+3", anna);
// Ben ist noch nicht korrigiert: Linien zum Ausfüllen statt Punkten und Note.
Assert.Contains($"Gesamtpunkte:{ExamCoverPlaceholderBuilder.BlankPoints}von17Punkte", ben);
Assert.Contains("1 davon ohne Punkte und Note", vm.StatusMessage);
}
[Fact]
public void Generate_OhnePunkteEintragen_BleibtAuchBeiKorrigiertenBlanko()
{
_results.Add(new ExamResult
{ ExamId = _exam.Id, StudentId = _anna.Id, Points = [4.5, 12, 1], TotalPoints = 17.5 });
var store = Store();
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
var vm = Build([store]);
vm.FillResults = false;
var output = Path.Combine(_directory, "blanko.pdf");
Assert.True(vm.Generate(output));
using var document = UglyToad.PdfPig.PdfDocument.Open(output);
Assert.DoesNotContain("17,5", Normalize(document.GetPage(1).Text));
}
[Fact]
public void Generate_ZuVieleAufgabenFuerEineSeite_WarntImStatus()
{
_exam.Tasks = Enumerable.Range(1, 80).Select(n => new ExamTask { Nr = n, MaxPoints = 1 }).ToList();
var store = Store();
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
var vm = Build([store]);
Assert.True(vm.Generate(Path.Combine(_directory, "lang.pdf")));
Assert.Contains("passen nicht auf eine Seite", vm.StatusMessage);
}
[Theory]
[InlineData(GroupType.Course, Niveau.E, "E-Kurs")]
[InlineData(GroupType.Course, Niveau.G, "G-Kurs")]
[InlineData(GroupType.Course, Niveau.Foerder, "")]
[InlineData(GroupType.Course, null, "")]
[InlineData(GroupType.Class, Niveau.G, "")]
public void CourseLabel_NurImKursMitEOderGNiveau(GroupType type, Niveau? niveau, string expected) =>
Assert.Equal(expected, ExamCoverPlaceholderBuilder.CourseLabel(new LearningGroup { Type = type }, niveau));
[Fact]
public void Platzhalter_KursAusKlausurOderMitgliedschaft_KuerzelAusDemDialog()
{
_group.Type = GroupType.Course;
var store = Store();
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
var template = store.Load(store.GetTemplates().Single());
Dictionary<string, PlaceholderValue> Values(Niveau? studentNiveau) => ExamCoverPlaceholderBuilder.Build(
_exam, _group, "Chemie", _anna, null, false, new GradingService(), template, new DateOnly(2026, 1, 1),
studentNiveau, " MON HED ");
Assert.Equal(new TextValue("E-Kurs"), Values(Niveau.E)["Exam.Course"]);
Assert.Equal(new TextValue("MON HED"), Values(null)["Teacher.Abbreviation"]);
_exam.Niveau = Niveau.G;
Assert.Equal(new TextValue("G-Kurs"), Values(Niveau.E)["Exam.Course"]);
}
[Fact]
public void TeacherSettings_SpeichertDasKuerzel()
{
new TeacherSettingsService(_directory).SaveAbbreviation(" HED ");
Assert.Equal("HED", new TeacherSettingsService(_directory).LoadAbbreviation());
}
private static string Normalize(string text) => text.Replace(" ", "");
private static List<float> Widths(DrawingValue drawing) =>
drawing.Commands.OfType<DrawRectangle>().Select(r => r.Width).Distinct().Order().ToList();
private ExamCoverSheetDialogViewModel Build(IReadOnlyList<TemplateStore> stores) =>
new(_exam, _group, "Chemie", new FakeStudents([_anna, _ben]), new FakeMemberships([]), _results,
new GradingService(), stores, new QuestTemplateRenderer());
private TemplateStore Store() => new(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
private void Import(TemplateStore store, string id, string name, string? reportType)
{
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
var manifest = new TemplateManifest
{
Id = id, Name = name,
Placeholders =
[
new("Student.Name", PlaceholderType.Text), new("Exam.Title", PlaceholderType.Text),
new("Exam.MaxPoints", PlaceholderType.Text), new("Result.Points", PlaceholderType.Text),
new("Result.Grade", PlaceholderType.Text),
new(ExamPointsTableDrawingBuilder.PlaceholderName, PlaceholderType.Drawing),
],
};
if (reportType is not null) manifest.Metadata[TemplateMetadataKeys.ReportType] = reportType;
TemplatePackage.Create(source, manifest, Layout, new Dictionary<string, byte[]>());
store.Import(source);
}
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
}
@@ -9,6 +9,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="PdfPig" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
+2
View File
@@ -122,6 +122,7 @@ public static class AppBootstrapper
services.AddSingleton<PdfExportService>();
services.AddSingleton<ITemplateLoader, TemplateLoader>();
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
services.AddSingleton<ITemplateBatchRenderer, QuestTemplateRenderer>();
services.AddSingleton(_ => new TemplateStore(appData));
services.AddSingleton(_ => new WorksheetTemplateStore(new TemplateStore(appData, subfolder: "worksheet-template-packages")));
@@ -211,6 +212,7 @@ public static class AppBootstrapper
services.AddSingleton(_ => new WindowSettingsService(appData));
services.AddSingleton(_ => new ExamGradingSettingsService(appData));
services.AddSingleton(_ => new AppearanceSettingsService(appData));
services.AddSingleton(_ => new TeacherSettingsService(appData));
services.AddSingleton<PlanningExchangeService>();
// ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ──────
@@ -0,0 +1,77 @@
using System.Globalization;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Services;
/// <summary>Platzhalterwerte für Klausur-Deckblätter (Vorlagen mit
/// <c>report-type=exam-cover</c>): die Standardwerte der Elternbriefe (Student.*, Group.Name, …)
/// plus Exam.*, Result.* und die Punktetabelle. Alle Punktwerte gelten für die Fassung, die der
/// Schüler geschrieben hat.</summary>
public static class ExamCoverPlaceholderBuilder
{
/// <summary>Stehen statt Punkten bzw. Note auf dem Blatt, solange nichts eingetragen wird —
/// als Linie zum Ausfüllen von Hand.</summary>
public const string BlankPoints = "_________", BlankGrade = "_____";
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
/// <param name="result">Ergebnis des Schülers oder null. Punkte und Note werden nur
/// eingetragen, wenn <paramref name="fillResults"/> gesetzt und das Ergebnis korrigiert ist.</param>
/// <param name="studentNiveau">Niveau des Schülers in dieser Lerngruppe, falls die Arbeit
/// selbst keinem Niveau zugeordnet ist.</param>
public static Dictionary<string, PlaceholderValue> Build(Exam exam, LearningGroup group, string subjectName,
Student student, ExamResult? result, bool fillResults, GradingService grading, LoadedTemplate template,
DateOnly today, Niveau? studentNiveau = null, string teacherAbbreviation = "")
{
var tasks = exam.Tasks.OrderBy(t => t.Nr).ToList();
var variant = result?.Variant ?? ExamVariant.A;
var maximum = ExamScoringService.Maximum(tasks, variant);
var filled = fillResults && ExamCoverSheetService.IsCorrected(result);
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact: null, group, today, "", "");
values["Group.GradeLevel"] = new TextValue(group.GradeLevel > 0 ? group.GradeLevel.ToString() : "");
values["Subject.Name"] = new TextValue(subjectName);
values["Exam.Title"] = new TextValue(exam.Title);
values["Exam.Date"] = new DateValue(exam.Date);
values["Exam.Number"] = new TextValue(exam.ExamNumber?.ToString() ?? "");
values["Exam.Kind"] = new TextValue(exam.Kind.Label());
values["Exam.Niveau"] = new TextValue(exam.Niveau.HasValue ? NiveauDisplay.ToName(exam.Niveau) : "");
values["Exam.Course"] = new TextValue(CourseLabel(group, exam.Niveau ?? studentNiveau));
values["Teacher.Abbreviation"] = new TextValue(teacherAbbreviation.Trim());
values["Exam.Variant"] = new TextValue(tasks.Any(t => t.Variant is not null) ? variant.ToString() : "");
values["Exam.MaxPoints"] = new TextValue(Format(maximum));
values["Exam.ApprovalDate"] = new TextValue(Format(exam.ApprovalGrantedAt));
values["Exam.AnnouncedDate"] = new TextValue(Format(exam.AnnouncedAt));
values["Exam.ReturnDate"] = new TextValue(Format(exam.ReturnedAt));
values["Result.Points"] = new TextValue(filled ? Format(result!.TotalPoints) : BlankPoints);
values["Result.Percent"] = new TextValue(filled && maximum > 0
? (result!.TotalPoints / maximum).ToString("0 %", German) : "");
values["Result.Grade"] = new TextValue(filled
? result!.Grade ?? grading.CalculateGrade(result.TotalPoints, maximum, exam.GradingKey) : BlankGrade);
// Ohne Box in der Vorlage gibt es nichts zu zeichnen (die Tabelle ist optional).
values[ExamPointsTableDrawingBuilder.PlaceholderName] =
LetterPlaceholderBuilder.FindDeclaredDrawingBox(template, ExamPointsTableDrawingBuilder.PlaceholderName) is { } box
? ExamPointsTableDrawingBuilder.Build(
ExamCoverSheetService.Columns(tasks, variant, filled ? result!.Points : null),
box.Width, box.MillimeterScale)
: new DrawingValue([], 0);
return values;
}
/// <summary>"E-Kurs" / "G-Kurs" für die äußere Differenzierung; leer im Klassenverband und
/// ohne E-/G-Zuordnung.</summary>
public static string CourseLabel(LearningGroup group, Niveau? niveau) =>
group.Type == GroupType.Class ? "" : niveau switch
{
Niveau.E => "E-Kurs",
Niveau.G => "G-Kurs",
_ => "",
};
private static string Format(double value) => value.ToString("0.##", German);
private static string Format(DateOnly? date) => date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "";
}
@@ -0,0 +1,33 @@
using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Templating;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Services;
/// <summary>Öffnet den Deckblatt-Dialog zu einer Klausur — ein Einstieg für die Klausuren-
/// Hauptseite und den Klausuren-Tab der Lerngruppe.</summary>
public static class ExamCoverSheetDialogs
{
public static async Task ShowAsync(Control source, Exam exam)
{
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(exam.GroupId);
if (group is null || TopLevel.GetTopLevel(source) is not Window owner) return;
var subject = group.SubjectId is { } subjectId
? App.Services.GetRequiredService<ISubjectRepository>().GetById(subjectId)?.Name ?? ""
: "";
var vm = new ExamCoverSheetDialogViewModel(exam, group, subject,
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IExamResultRepository>(),
App.Services.GetRequiredService<GradingService>(),
[App.Services.GetRequiredService<TemplateStore>(), App.Services.GetRequiredService<WorksheetTemplateStore>().Store],
App.Services.GetRequiredService<ITemplateBatchRenderer>(),
App.Services.GetRequiredService<TeacherSettingsService>().LoadAbbreviation());
await new ExamCoverSheetDialog { DataContext = vm }.ShowDialog(owner);
}
}
@@ -0,0 +1,82 @@
using System.Globalization;
using LehrerApp.Core.Services;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Services;
/// <summary>Zeichnet die Punktetabelle des Klausur-Deckblatts (Aufgabe / Max. Punktzahl /
/// Punktzahl). Die Spalten haben eine feste Breite: passen nicht alle Aufgaben nebeneinander in die
/// Box der Vorlage, folgt darunter ein weiterer Tabellenblock, statt alles zu verkleinern. Gedacht
/// für eine <c>FLOWDRAWBOX … fit=content</c>, damit nachfolgende Elemente mitwandern.</summary>
public static class ExamPointsTableDrawingBuilder
{
public const string PlaceholderName = "Exam.PointsTable";
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
// Referenzentwurf in Millimetern, abgemessen an der Schulvorlage.
private const float LabelWidth = 38f, ColumnWidth = 20.5f;
private const float HeaderHeight = 5.5f, MaximumHeight = 8.5f, PointsHeight = 11.5f;
private const float BlockGap = 4f, Padding = 1.5f, Stroke = 0.2f, HeavyStroke = 0.45f;
private const float FontSize = 10f;
/// <param name="contentWidth">Breite der deklarierten Box in der Maßeinheit der Vorlage.</param>
/// <param name="millimeterScale">1 mm in der Maßeinheit der Vorlage (siehe
/// <see cref="LetterPlaceholderBuilder.FindDeclaredDrawingBox(LoadedTemplate, string)"/>).</param>
public static DrawingValue Build(IReadOnlyList<ExamCoverTaskColumn> columns, float contentWidth = 170,
float millimeterScale = 1)
{
if (columns.Count == 0) return new DrawingValue([], 0);
var k = millimeterScale;
var perBlock = ColumnsPerBlock(contentWidth, millimeterScale);
// Halbe Strichstärke Abstand zum Rand, sonst schneidet die Box die äußeren Linien an.
var origin = HeavyStroke / 2 * k;
var blockHeight = (HeaderHeight + MaximumHeight + PointsHeight) * k;
var commands = new List<DrawingCommand>();
var y = origin;
for (var start = 0; start < columns.Count; start += perBlock)
{
if (start > 0) y += BlockGap * k;
var block = columns.Skip(start).Take(perBlock).ToList();
var maximumY = y + HeaderHeight * k;
var pointsY = maximumY + MaximumHeight * k;
Cell(commands, origin, y, LabelWidth * k, HeaderHeight * k, "Aufgabe", DrawingTextAlignment.AlignRight, false, k);
Cell(commands, origin, maximumY, LabelWidth * k, MaximumHeight * k, "Max. Punktzahl", DrawingTextAlignment.AlignRight, false, k);
Cell(commands, origin, pointsY, LabelWidth * k, PointsHeight * k, "Punktzahl", DrawingTextAlignment.AlignRight, true, k);
for (var index = 0; index < block.Count; index++)
{
var column = block[index];
var x = origin + (LabelWidth + index * ColumnWidth) * k;
Cell(commands, x, y, ColumnWidth * k, HeaderHeight * k,
column.IsBonus ? "" : $"Nr. {column.Number}", DrawingTextAlignment.AlignCenter, false, k);
Cell(commands, x, maximumY, ColumnWidth * k, MaximumHeight * k,
(column.IsBonus ? "+" : "") + Format(column.MaxPoints), DrawingTextAlignment.AlignCenter, false, k);
Cell(commands, x, pointsY, ColumnWidth * k, PointsHeight * k,
column.AchievedPoints is { } achieved ? Format(achieved) : "", DrawingTextAlignment.AlignCenter, false, k);
}
commands.Add(new DrawLine(origin, pointsY, origin + (LabelWidth + block.Count * ColumnWidth) * k, pointsY,
StrokeWidth: HeavyStroke * k));
y += blockHeight;
}
return new DrawingValue(commands, y + origin);
}
/// <summary>Wie viele Aufgaben bei fester Spaltenbreite nebeneinander in die Box passen.</summary>
public static int ColumnsPerBlock(float contentWidth, float millimeterScale = 1) =>
Math.Max(1, (int)Math.Floor((contentWidth / millimeterScale - HeavyStroke - LabelWidth + 0.01f) / ColumnWidth));
private static void Cell(List<DrawingCommand> commands, float x, float y, float width, float height,
string text, DrawingTextAlignment alignment, bool bold, float k)
{
commands.Add(new DrawRectangle(x, y, width, height, StrokeWidth: Stroke * k));
if (text.Length == 0) return;
// DrawStringEx setzt die Grundlinie auf Y + Schriftgröße; so sitzt die Zeile optisch mittig.
var fontHeight = FontSize * UnitConverter.Points(1, "pt") / UnitConverter.Points(1, "mm") * k;
var textY = y + height / 2 - fontHeight * 0.64f;
commands.Add(new DrawStringEx(x + Padding * k, textY, y + height - textY, width - 2 * Padding * k, text,
alignment, FontSize, Bold: bold));
}
private static string Format(double value) => value.ToString("0.##", German);
}
@@ -26,7 +26,7 @@ public class LetterTemplateTools(
public List<LetterTemplateDto> ListLetterTemplates()
{
var result = new List<LetterTemplateDto>();
foreach (var installed in templates.GetTemplates())
foreach (var installed in templates.GetTemplates().Where(x => !x.IsExamCover))
{
try
{
@@ -0,0 +1,41 @@
using System.Text.Json;
namespace LehrerApp.Desktop.Services;
internal sealed class TeacherSettingsConfig
{
public string Abbreviation { get; set; } = "";
}
/// <summary>Angaben zur Lehrkraft, die auf Formularen erscheinen (bisher nur das Kürzel fürs
/// Klausur-Deckblatt) — aus der Konfiguration statt fest in der Vorlage, damit eine weitergegebene
/// Vorlage nicht das Kürzel ihres Erstellers trägt.</summary>
public sealed class TeacherSettingsService
{
private readonly string _configPath;
public TeacherSettingsService(string appDataPath) =>
_configPath = Path.Combine(appDataPath, "teachersettings.json");
public string LoadAbbreviation()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<TeacherSettingsConfig>(File.ReadAllText(_configPath))
?.Abbreviation ?? "";
}
catch { /* beschädigte Konfiguration -> leer */ }
return "";
}
public void SaveAbbreviation(string abbreviation)
{
try
{
File.WriteAllText(_configPath,
JsonSerializer.Serialize(new TeacherSettingsConfig { Abbreviation = abbreviation.Trim() }));
}
catch { /* Einstellung ist Komfort — Fehler beim Schreiben nicht an die UI durchreichen */ }
}
}
@@ -43,6 +43,7 @@ public partial class ExamsOverviewViewModel : ObservableObject
public Func<Exam, Task>? OnGradeExam { get; set; }
public Func<Exam, Task>? OnEvaluateExam { get; set; }
public Func<Exam, Task>? OnRevealExam { get; set; }
public Func<Exam, Task>? OnCoverSheets { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; }
public Action? OnNavigateToGroups { get; set; }
public bool HasNoExams => Rows.Count == 0;
@@ -177,6 +178,13 @@ public partial class ExamsOverviewViewModel : ObservableObject
await OnRevealExam(SelectedRow.Exam);
}
[RelayCommand]
private async Task CoverSheetsSelected()
{
if (SelectedRow is null || OnCoverSheets is null) return;
await OnCoverSheets(SelectedRow.Exam);
}
[RelayCommand]
private void GoToGroup()
{
@@ -0,0 +1,116 @@
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 };
}
}
@@ -54,25 +54,14 @@ public partial class ExamGradingDialogViewModel : ObservableObject
var membershipList = memberships.GetByGroup(groupId);
var existing = results.GetByExam(exam.Id).ToDictionary(r => r.StudentId);
// Nachschreibarbeit: nur Schüler, die die erste Arbeit nicht mitgeschrieben haben. Wer dort
// schon eine Note hat, taucht nicht auf — außer es gibt hier bereits ein Ergebnis (nichts
// verstecken, was schon eingetragen wurde).
var wroteOriginal = new HashSet<Guid>();
if (exam.IsMakeUp && exam.RepeatOfExamId is { } originalId)
foreach (var r in results.GetByExam(originalId).Where(r => !r.Absent && r.Grade is not null))
wroteOriginal.Add(r.StudentId);
var wroteOriginal = ExamParticipantService.WroteOriginal(exam, results.GetByExam);
foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
var membership = membershipList.FirstOrDefault(e => e.StudentId == s.Id);
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) continue;
// Niveau-Klausur: nur Schüler mit passendem Niveau zeigen. Klausuren ohne
// Niveau-Zuordnung gelten weiterhin für die ganze Gruppe.
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue;
existing.TryGetValue(s.Id, out var result);
if (result is null && wroteOriginal.Contains(s.Id)) continue;
if (!ExamParticipantService.Includes(exam, membership, result is not null, wroteOriginal.Contains(s.Id)))
continue;
var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey,
ExamScoringService.Maximum(Tasks, ExamVariant.A), _grading);
row.OnChanged = SaveRow;
@@ -235,6 +235,7 @@ public partial class GroupDetailViewModel : ObservableObject
public Func<Exam, Task>? OnGradeExam { get; set; }
public Func<Exam, Task>? OnEvaluateExam { get; set; }
public Func<Exam, Task>? OnRevealExam { get; set; }
public Func<Exam, Task>? OnExamCoverSheets { get; set; }
public Func<Task<bool>>? OnConfirmReactivate { get; set; }
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
@@ -421,6 +422,15 @@ public partial class GroupDetailViewModel : ObservableObject
await OnRevealExam(exam);
}
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private async Task ExamCoverSheets()
{
if (SelectedExam is null || OnExamCoverSheets is null) return;
var exam = _exams.GetById(SelectedExam.Id);
if (exam is null) return;
await OnExamCoverSheets(exam);
}
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private async Task DuplicateExam()
{
@@ -496,6 +506,7 @@ public partial class GroupDetailViewModel : ObservableObject
GradeExamCommand.NotifyCanExecuteChanged();
EvaluateExamCommand.NotifyCanExecuteChanged();
RevealExamCommand.NotifyCanExecuteChanged();
ExamCoverSheetsCommand.NotifyCanExecuteChanged();
DuplicateExamCommand.NotifyCanExecuteChanged();
DeleteExamCommand.NotifyCanExecuteChanged();
AdvanceExamStatusCommand.NotifyCanExecuteChanged();
@@ -526,6 +537,7 @@ public partial class GroupDetailViewModel : ObservableObject
GradeExamCommand.NotifyCanExecuteChanged();
EvaluateExamCommand.NotifyCanExecuteChanged();
RevealExamCommand.NotifyCanExecuteChanged();
ExamCoverSheetsCommand.NotifyCanExecuteChanged();
DuplicateExamCommand.NotifyCanExecuteChanged();
DeleteExamCommand.NotifyCanExecuteChanged();
AdvanceExamStatusCommand.NotifyCanExecuteChanged();
@@ -33,7 +33,7 @@ public partial class PersonalizeWorksheetDialogViewModel : ObservableObject
IStudentRepository students, WorksheetTemplateStore templates, ITemplateRenderer renderer)
{
_group = group; _templates = templates; _renderer = renderer;
foreach (var template in templates.Store.GetTemplates()) Templates.Add(new(template));
foreach (var template in templates.Store.GetTemplates().Where(x => !x.IsExamCover)) Templates.Add(new(template));
SelectedTemplate = Templates.FirstOrDefault();
foreach (var id in studentIds)
if (students.GetById(id) is { } student) Students.Add(new(student));
@@ -1,5 +1,6 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Desktop.Services;
using LehrerApp.Templating;
using System.Collections.ObjectModel;
@@ -7,6 +8,13 @@ namespace LehrerApp.Desktop.ViewModels.Settings;
public partial class SettingsViewModel
{
private readonly TeacherSettingsService? _teacherSettings;
/// <summary>Eigenes Kürzel für Formulare (Platzhalter <c>Teacher.Abbreviation</c> auf dem
/// Klausur-Deckblatt). Wird sofort gespeichert; im Deckblatt-Dialog je Druck überschreibbar.</summary>
[ObservableProperty] private string _teacherAbbreviation = "";
partial void OnTeacherAbbreviationChanged(string value) => _teacherSettings?.SaveAbbreviation(value);
[ObservableProperty] private string _worksheetTemplateStatus = "";
public ObservableCollection<LetterTemplateListItem> WorksheetTemplateList { get; } = [];
@@ -121,8 +121,11 @@ public partial class SettingsViewModel : ObservableObject
AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null,
SchoolWeatherService? schoolWeather = null, WebUntisIntegrationService? untisIntegration = null)
SchoolWeatherService? schoolWeather = null, WebUntisIntegrationService? untisIntegration = null,
TeacherSettingsService? teacherSettings = null)
{
_teacherSettings = teacherSettings;
_teacherAbbreviation = teacherSettings?.LoadAbbreviation() ?? "";
_logger = logger;
_syncKeyRecovery = syncKeyRecovery;
SyncKeyWasRegenerated = syncKeyStatus.KeyWasRegenerated;
@@ -60,7 +60,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject
_attendanceCalendarFactory = attendanceCalendarFactory;
_absenceDayListFactory = absenceDayListFactory;
_attendanceDataRefresher = attendanceDataRefresher;
foreach (var template in templates.GetTemplates()) Templates.Add(new(template));
foreach (var template in templates.GetTemplates().Where(x => !x.IsExamCover)) Templates.Add(new(template));
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact));
foreach (var membership in memberships.GetByStudent(student.Id))
if (groups.GetById(membership.GroupId) is { } group) Groups.Add(new(group));
@@ -177,6 +177,8 @@
<Button Content="Einzelansicht" Command="{Binding RevealSelectedCommand}"
IsVisible="{Binding ShowGradeSummary}"
ToolTip.Tip="Zeigt das Ergebnis eines einzelnen Schülers – zunächst nur den Namen, erst nach „Aufdecken“ Note und Punkte."/>
<Button Content="Deckblätter…" Command="{Binding CoverSheetsSelectedCommand}"
ToolTip.Tip="Individuelle Deckblätter für diese Arbeit als ein PDF erzeugen."/>
<Button Content="Zum Kurs →" Command="{Binding GoToGroupCommand}" HorizontalAlignment="Right"/>
</StackPanel>
</StackPanel>
@@ -2,6 +2,7 @@ using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Exams;
using LehrerApp.Desktop.ViewModels.Groups;
@@ -24,6 +25,7 @@ public partial class ExamsOverviewView : UserControl
vm.OnGradeExam = ShowGradeExamDialog;
vm.OnEvaluateExam = ShowEvaluateExamDialog;
vm.OnRevealExam = ShowRevealExamDialog;
vm.OnCoverSheets = exam => ExamCoverSheetDialogs.ShowAsync(this, exam);
vm.OnNavigateToGroup = groupId => App.Services.GetRequiredService<MainWindowViewModel>()
.NavigateToGroupDetail(groupId, GroupDetailKlausurenTabIndex);
}
@@ -0,0 +1,59 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.ExamCoverSheetDialog"
x:DataType="vm:ExamCoverSheetDialogViewModel"
Title="Deckblätter drucken"
Width="460" Height="640"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="4" Margin="0,0,0,14">
<TextBlock Text="Deckblätter drucken" Classes="dialogtitle"/>
<TextBlock Text="{Binding ExamLabel}" FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Grid.Row="1" Spacing="10" Margin="0,0,0,14">
<TextBlock Text="Vorlage" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Templates}" SelectedItem="{Binding SelectedTemplate}"
HorizontalAlignment="Stretch" DisplayMemberBinding="{Binding Name}"/>
<TextBlock Text="Noch keine Deckblatt-Vorlage importiert — in den Einstellungen unter „Briefvorlagen“ eine .lavorlage-Datei mit dem Metadatum report-type=exam-cover hinzufügen."
Classes="emptyhint" TextWrapping="Wrap"
IsVisible="{Binding HasNoTemplates}"/>
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
<TextBlock Grid.Column="0" Text="Kürzel" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding TeacherAbbreviation}" PlaceholderText="z. B. MON HED"
ToolTip.Tip="Vorbelegt mit dem eigenen Kürzel aus den Einstellungen. Änderungen gelten nur für diesen Druck."/>
</Grid>
<CheckBox Content="Punkte und Note eintragen" IsChecked="{Binding FillResults}"
ToolTip.Tip="Aus: Blanko-Deckblatt mit Namen und leerer Punktetabelle zum Ausfüllen von Hand."/>
</StackPanel>
<ScrollViewer Grid.Row="2">
<StackPanel Spacing="4">
<TextBlock Text="Für diese Schüler erzeugen" FontSize="12" FontWeight="SemiBold" Opacity="0.7" Margin="0,0,0,4"/>
<ItemsControl ItemsSource="{Binding Students}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ExamCoverStudentChoice">
<Grid ColumnDefinitions="*,Auto" Margin="0,3">
<CheckBox Grid.Column="0" Content="{Binding FullName}" IsChecked="{Binding IsIncluded}"/>
<TextBlock Grid.Column="1" Text="{Binding Hint}" FontSize="11" Opacity="0.6" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" FontSize="12" Margin="0,10,0,0" TextWrapping="Wrap"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid Grid.Row="4" ColumnDefinitions="*,8,*" Margin="0,16,0,0">
<Button Grid.Column="0" Content="Schließen" HorizontalAlignment="Stretch" Click="OnClose"/>
<Button Grid.Column="2" Content="PDF erzeugen…" HorizontalAlignment="Stretch" Click="OnGenerate"
IsEnabled="{Binding !HasNoTemplates}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,29 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class ExamCoverSheetDialog : Window
{
public ExamCoverSheetDialog() => InitializeComponent();
private async void OnGenerate(object? sender, RoutedEventArgs e)
{
if (DataContext is not ExamCoverSheetDialogViewModel vm) return;
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Deckblätter speichern",
SuggestedFileName = vm.SuggestedFileName,
DefaultExtension = "pdf",
FileTypeChoices = [new FilePickerFileType("PDF") { Patterns = ["*.pdf"] }],
});
if (file is null) return;
var path = file.Path.LocalPath;
if (vm.Generate(path))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
@@ -120,6 +120,8 @@
<Button Content="Auswertung" Command="{Binding EvaluateExamCommand}"/>
<Button Content="Einzelansicht" Command="{Binding RevealExamCommand}"
ToolTip.Tip="Zeigt das Ergebnis eines einzelnen Schülers – zunächst nur den Namen, erst nach „Aufdecken“ Note und Punkte."/>
<Button Content="Deckblätter…" Command="{Binding ExamCoverSheetsCommand}"
ToolTip.Tip="Individuelle Deckblätter für diese Arbeit als ein PDF erzeugen."/>
<Button Content="Bearbeiten" Command="{Binding EditExamCommand}"/>
<Button Content="Duplizieren" Command="{Binding DuplicateExamCommand}"/>
<SplitButton Content="Status ▸" Command="{Binding AdvanceExamStatusCommand}">
@@ -167,6 +169,7 @@
<MenuItem Header="Punkte eingeben" Command="{Binding GradeExamCommand}"/>
<MenuItem Header="Auswertung" Command="{Binding EvaluateExamCommand}"/>
<MenuItem Header="Einzelansicht" Command="{Binding RevealExamCommand}"/>
<MenuItem Header="Deckblätter…" Command="{Binding ExamCoverSheetsCommand}"/>
<MenuItem Header="Bearbeiten" Command="{Binding EditExamCommand}"/>
<MenuItem Header="Duplizieren" Command="{Binding DuplicateExamCommand}"/>
<MenuItem Header="Status">
@@ -38,6 +38,7 @@ public partial class GroupDetailView : UserControl
vm.OnGradeExam = ShowGradeExamDialog;
vm.OnEvaluateExam = ShowEvaluateExamDialog;
vm.OnRevealExam = ShowRevealExamDialog;
vm.OnExamCoverSheets = exam => ExamCoverSheetDialogs.ShowAsync(this, exam);
vm.OnConfirmReactivate = ShowReactivateConfirmDialog;
vm.OverviewTab.OnNavigateToWorkload = () =>
App.Services.GetRequiredService<MainWindowViewModel>().NavigateToWorkload();
@@ -515,6 +515,15 @@
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Separator Margin="0,8"/>
<TextBlock Text="Klausur-Deckblätter" FontSize="15" FontWeight="SemiBold"/>
<TextBlock Text="Vorlagen mit dem Metadatum report-type=exam-cover erscheinen nur unter „Deckblätter…“ bei einer Klausur, egal in welche der beiden Listen oben sie importiert wurden. Das Kürzel füllt den Platzhalter Teacher.Abbreviation und lässt sich beim Erzeugen noch ändern, etwa für einen gemeinsam unterrichteten Kurs."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="12">
<TextBlock Text="Eigenes Kürzel" VerticalAlignment="Center"/>
<TextBox Text="{Binding TeacherAbbreviation}" Width="120" PlaceholderText="z. B. HED"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
@@ -0,0 +1,115 @@
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Templating.Tests;
public sealed class ExamCoverTemplatingTests : IDisposable
{
private readonly string _appData = Path.Combine(Path.GetTempPath(), $"lavorlage-cover-{Guid.NewGuid():N}");
private const string FlowLayout = """
PAGE 210 297 mm
#pragma format-version 3
#pragma page-template first
#pragma flow-slot body x=20 y=20 w=170 h=250
#pragma end-page-template
#pragma content-flow body
FLOWDRAWBOX $Tabelle w=170 h=100 {0}
TEXT "DANACH" size=11
#pragma end-content-flow
""";
private static readonly DrawingValue ShortDrawing = new(
[new DrawStringEx(0, 0, 12, 170, "TABELLE", DrawingTextAlignment.AlignLeft, 10)], 20);
[Fact]
public void FlowDrawBox_FitContent_NachfolgendesElementRuecktAnDenInhalt()
{
var fixedHeight = TextTop(Render("", ShortDrawing), "DANACH");
var fitted = TextTop(Render("fit=content", ShortDrawing), "DANACH");
// PDF-Koordinaten wachsen nach oben: 100 mm Box gegen 20 mm Inhalt = 80 mm höher.
Assert.Equal(80 * 72 / 25.4, fitted - fixedHeight, 0);
}
[Fact]
public void FlowDrawBox_FitContent_LangerInhaltFliesstWeiterhinUeberVolleAusschnitte()
{
var drawing = new DrawingValue(
[
new DrawStringEx(0, 0, 12, 170, "OBEN", DrawingTextAlignment.AlignLeft, 10),
new DrawStringEx(0, 105, 12, 170, "UNTEN", DrawingTextAlignment.AlignLeft, 10),
], 120);
using var document = UglyToad.PdfPig.PdfDocument.Open(Render("fit=content", drawing));
var text = document.GetPage(1).Text;
Assert.Contains("OBEN", text);
Assert.Contains("UNTEN", text);
Assert.Contains("DANACH", text);
}
[Fact]
public void FlowDrawBox_UnbekannterFitWert_IstEinLayoutfehler() =>
Assert.Throws<TemplateValidationException>(() =>
new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Tabelle fit=page"));
[Fact]
public void RenderBatchToPdf_FuegtDatensaetzeZusammenUndMeldetSeitenzahlen()
{
var template = Template("fit=content");
var tall = new DrawingValue(
[new DrawStringEx(0, 290, 12, 170, "LANG", DrawingTextAlignment.AlignLeft, 10)], 300);
var result = new QuestTemplateRenderer().RenderBatchToPdf(template,
[
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Tabelle"] = ShortDrawing }),
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Tabelle"] = tall }),
]);
using var document = UglyToad.PdfPig.PdfDocument.Open(result.Pdf);
Assert.Equal([1, 2], result.PageCounts);
Assert.Equal(3, document.NumberOfPages);
Assert.Contains("TABELLE", document.GetPage(1).Text);
}
[Fact]
public void TemplateStore_LiestVorlagentypAusDenMetadaten()
{
var store = new TemplateStore(_appData);
var cover = Path.Combine(_appData, "cover.lavorlage");
var letter = Path.Combine(_appData, "letter.lavorlage");
TemplatePackage.Create(cover, new TemplateManifest
{
Id = "deckblatt", Name = "Deckblatt",
Metadata = new(StringComparer.OrdinalIgnoreCase) { [TemplateMetadataKeys.ReportType] = TemplateReportTypes.ExamCover },
}, "PAGE 210 297 mm\nTEXT 20 20 \"Text\"", new Dictionary<string, byte[]>());
TemplatePackage.Create(letter, new TemplateManifest { Id = "brief", Name = "Brief" },
"PAGE 210 297 mm\nTEXT 20 20 \"Text\"", new Dictionary<string, byte[]>());
Assert.True(store.Import(cover).IsExamCover);
store.Import(letter);
Assert.Equal(["deckblatt"], store.GetTemplates().Where(x => x.IsExamCover).Select(x => x.Id));
}
private static LoadedTemplate Template(string attributes) => new(new TemplateManifest
{
Placeholders = [new("Tabelle", PlaceholderType.Drawing, true)],
}, new LayoutParser().Parse(FlowLayout.Replace("{0}", attributes)), new Dictionary<string, byte[]>());
private static byte[] Render(string attributes, DrawingValue drawing) =>
new QuestTemplateRenderer().RenderToPdf(Template(attributes),
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Tabelle"] = drawing }));
private static double TextTop(byte[] pdf, string word)
{
using var document = UglyToad.PdfPig.PdfDocument.Open(pdf);
return document.GetPage(1).GetWords().Single(x => x.Text == word).BoundingBox.Top;
}
public void Dispose()
{
if (Directory.Exists(_appData)) Directory.Delete(_appData, recursive: true);
}
}
+3
View File
@@ -159,6 +159,9 @@ public sealed class LayoutParser
var nestedFlowDrawing = currentFlowName is not null;
Require(tokens, nestedFlowDrawing ? 2 : 6);
var flowDrawingAttributes = Attributes(tokens, nestedFlowDrawing ? 2 : 6);
if (flowDrawingAttributes.TryGetValue("fit", out var fit)
&& !fit.Equals("content", StringComparison.OrdinalIgnoreCase))
throw new FormatException("FLOWDRAWBOX fit muss content sein.");
parsedElement = new FlowDrawBoxElement(lineNumber,
nestedFlowDrawing ? 0 : Number(tokens[1]), nestedFlowDrawing ? 0 : Number(tokens[2]),
nestedFlowDrawing ? RequiredPositiveNumber(flowDrawingAttributes, "w") : Number(tokens[3]),
+9
View File
@@ -186,6 +186,15 @@ public interface ITemplateRenderer
byte[] RenderToPdf(LoadedTemplate template, ITemplateDataProvider data);
}
/// <summary>Ergebnis einer Stapelerzeugung: ein Sammel-PDF und die Seitenzahl je Datensatz.</summary>
public sealed record BatchRenderResult(byte[] Pdf, IReadOnlyList<int> PageCounts);
public interface ITemplateBatchRenderer
{
/// <summary>Rendert dieselbe Vorlage je Datensatz und fügt alles zu einem PDF zusammen.</summary>
BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList<ITemplateDataProvider> data);
}
public interface ITemplatePreviewRenderer
{
byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120);
+42 -7
View File
@@ -7,13 +7,26 @@ using QuestPDF.Infrastructure;
namespace LehrerApp.Templating;
public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewRenderer
public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewRenderer, ITemplateBatchRenderer
{
static QuestTemplateRenderer() => QuestPDF.Settings.License = LicenseType.Community;
public byte[] RenderToPdf(LoadedTemplate template, ITemplateDataProvider data) =>
BuildDocument(template, ValidateData(template, data)).GeneratePdf();
public BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList<ITemplateDataProvider> data)
{
if (data.Count == 0) throw new ArgumentException("Mindestens ein Datensatz ist erforderlich.", nameof(data));
var documents = data.Select(x => BuildDocument(template, ValidateData(template, x))).ToList();
// Seitenzahl je Datensatz getrennt ermitteln: das zusammengeführte Dokument verrät nicht
// mehr, welcher Datensatz unerwartet auf eine Folgeseite übergelaufen ist.
var pageCounts = documents.Select(x => PdfPageCounter.Count(x.GeneratePdf())).ToList();
var pdf = documents.Count == 1
? documents[0].GeneratePdf()
: Document.Merge(documents).UseOriginalPageNumbers().GeneratePdf();
return new(pdf, pageCounts);
}
public byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
{
return RenderPagesToPng(template, data, dpi).First();
@@ -126,7 +139,8 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
values, template.Manifest, box.Attributes);
else if (element is FlowDrawBoxElement drawing
&& values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue)
DrawingElementRenderer.RenderFlow(content, drawingValue, drawing.Width, drawing.Height, unit);
DrawingElementRenderer.RenderFlow(content, drawingValue, drawing.Width, drawing.Height, unit,
FitsContent(drawing));
});
}
@@ -242,6 +256,11 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
}
}
// fit=content (Opt-in): die FLOWDRAWBOX belegt nur die Höhe ihres tatsächlichen Inhalts statt
// immer die volle deklarierte Höhe - nachfolgende Flow-Elemente rücken direkt an.
private static bool FitsContent(FlowDrawBoxElement drawing) =>
drawing.Attributes.TryGetValue("fit", out var fit) && fit.Equals("content", StringComparison.OrdinalIgnoreCase);
private static void RenderFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
@@ -267,7 +286,7 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
break;
case FlowDrawBoxElement drawing when values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue:
DrawingElementRenderer.RenderFlow(container, drawingValue,
drawing.Width, drawing.Height, template.Layout.Unit);
drawing.Width, drawing.Height, template.Layout.Unit, FitsContent(drawing));
break;
case ImageElement image:
var imageContainer = container;
@@ -520,7 +539,8 @@ internal static class DrawingElementRenderer
container.Svg(BuildSvg(drawing, width, height, 0, unit));
}
public static void RenderFlow(IContainer container, PlaceholderValue value, float width, float pageHeight, string unit)
public static void RenderFlow(IContainer container, PlaceholderValue value, float width, float pageHeight, string unit,
bool fitContent = false)
{
var pages = value switch
{
@@ -529,6 +549,11 @@ internal static class DrawingElementRenderer
.Select(commands => new DrawingValue(commands, pageHeight)).ToList(),
_ => throw new InvalidDataException("FLOWDRAWBOX erwartet einen Drawing-Platzhalter."),
};
// Nur ein direkter DrawingValue kennt seine Inhaltshöhe; ein per Callback aufgezeichneter
// Inhalt füllt weiterhin jede Box vollständig.
var lastHeight = fitContent && value is DrawingValue fitted
? Math.Clamp(fitted.ContentHeight - (pages.Count - 1) * pageHeight, 0, pageHeight)
: pageHeight;
container.Column(column =>
{
// Kein manueller PageBreak() zwischen den Ausschnitten: RenderFlow sitzt in einer
@@ -538,12 +563,14 @@ internal static class DrawingElementRenderer
// z.B. begann der zweite Monat der Fehlzeitenliste auf Seite 3, obwohl auf Seite 2
// noch reichlich Platz war. QuestPDF schiebt einen Ausschnitt, der nicht mehr passt,
// ohnehin automatisch auf die nächste Seite.
foreach (var page in pages)
for (var index = 0; index < pages.Count; index++)
{
var height = index == pages.Count - 1 ? lastHeight : pageHeight;
if (height <= 0) continue;
// Koordinaten sind nach Slice()/RecordPages() bereits seitenlokal (bei 0 beginnend) -
// kein zusätzlicher viewBox-Y-Offset nötig oder sinnvoll (siehe Slice()).
column.Item().Height(UnitConverter.Points(pageHeight, unit)).Svg(
BuildSvg(page, width, pageHeight, 0, unit));
column.Item().Height(UnitConverter.Points(height, unit)).Svg(
BuildSvg(pages[index], width, height, 0, unit));
}
});
}
@@ -745,6 +772,14 @@ internal static class DrawingElementRenderer
}
}
/// <summary>Zählt die Seiten eines von QuestPDF erzeugten PDFs anhand der unkomprimierten
/// Seitenobjekte ("/Type /Page", nicht "/Type /Pages").</summary>
internal static class PdfPageCounter
{
public static int Count(byte[] pdf) => System.Text.RegularExpressions.Regex
.Matches(Encoding.Latin1.GetString(pdf), @"/Type\s*/Page(?![A-Za-z])").Count;
}
internal static class ChartElementRenderer
{
public static void Render(IContainer container, ChartValue value, string chartType,
+44
View File
@@ -51,6 +51,21 @@ gleich hohe Seitenfenster und setzt ihn auf Folgeseiten fort. Wie bei `FLOWBOX`
höchstens ein fließendes Element enthalten; bei einem eigenen Folgeseitenlayout müssen Typ,
Position und Größe übereinstimmen.
Standardmäßig belegt jeder Ausschnitt einer `FLOWDRAWBOX` die volle deklarierte Höhe, auch wenn
der Inhalt kürzer ist. Mit dem Opt-in-Attribut `fit=content` belegt der letzte Ausschnitt nur die
tatsächliche Inhaltshöhe (`ContentHeight`), sodass nachfolgende Elemente eines `content-flow`
direkt anschließen und bei wachsendem Inhalt nach unten wandern:
```text
#pragma content-flow body
FLOWDRAWBOX $Exam.PointsTable w=165 h=150 fit=content
TEXT "Gesamtpunkte: $Result.Points von $Exam.MaxPoints Punkte" gap=14
#pragma end-content-flow
```
`fit=content` wirkt nur bei einem direkt gelieferten `DrawingValue`; ein per Callback
aufgezeichneter Inhalt (`PagedDrawingValue`) füllt weiterhin jede Box vollständig.
Ein Vorlagenpaket kann keinen Callback und keinen Typ aus einer fremden Assembly einschleusen.
Farben, Zahlen, Bildformate, Bildgröße und Gesamtzahl der portablen Befehle werden validiert.
Damit bleibt die paketfähige Schnittstelle deterministisch. Ein `PagedDrawingValue` ist dagegen
@@ -195,6 +210,35 @@ eindeutig und Werte bleiben einzeilig. Der Loader stellt sie der Anwendung direk
weiterhin mit einer leeren Metadatensammlung geladen. Die empfohlenen Standardschlüssel stehen
zusätzlich als `TemplateMetadataKeys.Language` und `TemplateMetadataKeys.ReportType` bereit.
## Klausur-Deckblätter (`report-type=exam-cover`)
Eine Vorlage mit dem Metadatum `report-type=exam-cover` (`TemplateReportTypes.ExamCover`) erscheint
in der Hauptapp ausschließlich im Dialog „Deckblätter…" einer Klausur und nicht mehr in der Auswahl
für Elternbriefe und Arbeitsblätter. Importiert wird sie wie jede andere `.lavorlage` in den
Einstellungen. Je ausgewähltem Schüler entsteht eine Seite in einem Sammel-PDF
(`ITemplateBatchRenderer.RenderBatchToPdf`). Zusätzlich zu den Elternbrief-Platzhaltern
(`Student.Name`, `Student.FirstName`, `Student.LastName`, `Group.Name`, `SchoolYear`, `Datum`)
stehen bereit:
| Platzhalter | Typ | Inhalt |
| --- | --- | --- |
| `Exam.Title`, `Exam.Number`, `Exam.Kind`, `Exam.Niveau` | Text | Titel, Nummer, Art, Niveau der Arbeit |
| `Exam.Date` | Date | Datum der Arbeit |
| `Exam.ApprovalDate`, `Exam.AnnouncedDate`, `Exam.ReturnDate` | Text | Genehmigung, Ankündigung, Rückgabe (`TT.MM.JJJJ`, leer wenn nicht gesetzt) |
| `Exam.Course` | Text | `E-Kurs` / `G-Kurs` (Niveau der Arbeit, sonst des Schülers in der Lerngruppe); leer im Klassenverband |
| `Teacher.Abbreviation` | Text | Kürzel der Lehrkraft aus den Einstellungen, im Deckblatt-Dialog je Druck änderbar |
| `Exam.Variant` | Text | geschriebene Fassung (A/B), leer ohne Fassungen |
| `Exam.MaxPoints` | Text | erreichbare Punktzahl der geschriebenen Fassung, ohne Bonusaufgaben |
| `Result.Points`, `Result.Grade` | Text | erreichte Punkte und Note; als Blanko eine Linie aus Unterstrichen |
| `Result.Percent` | Text | erreichter Anteil, als Blanko leer |
| `Subject.Name`, `Group.GradeLevel` | Text | Fach und Jahrgangsstufe der Lerngruppe |
| `Exam.PointsTable` | Drawing | Punktetabelle (Aufgabe / Max. Punktzahl / Punktzahl) |
Die Punktetabelle hat feste Spaltenbreiten (38 mm Beschriftung, 20,5 mm je Aufgabe). Passen nicht
alle Aufgaben in die Breite der Box, folgt darunter ein weiterer Block; verkleinert wird nichts.
Sie gehört deshalb als `FLOWDRAWBOX … fit=content` in einen `content-flow`. Bonusaufgaben stehen
ohne Nummer und mit `+` vor der Punktzahl in der Tabelle.
## Bildskalierung
`IMG` unterstützt neben dem festen Begrenzungsrahmen eine optionale prozentuale Skalierung:
@@ -6,6 +6,14 @@ public static class TemplateMetadataKeys
public const string ReportType = "report-type";
}
/// <summary>Bekannte Werte für <see cref="TemplateMetadataKeys.ReportType"/>. Pakete ohne Angabe
/// (oder mit einem anderen Wert) gelten wie bisher als Brief-/Arbeitsblattvorlage.</summary>
public static class TemplateReportTypes
{
public const string Letter = "letter";
public const string ExamCover = "exam-cover";
}
public static class TemplateMetadataText
{
public const string FileName = "metadata.txt";
+10 -3
View File
@@ -63,7 +63,10 @@ public static class TemplatePackage
}
public sealed record InstalledTemplate(string Id, string Name, string Description, string PackagePath,
DateTime ImportedAtUtc);
DateTime ImportedAtUtc, string ReportType = "")
{
public bool IsExamCover => ReportType.Equals(TemplateReportTypes.ExamCover, StringComparison.OrdinalIgnoreCase);
}
public sealed class TemplateStore
{
@@ -86,7 +89,7 @@ public sealed class TemplateStore
{
var loaded = _loader.LoadFromPackage(path);
result.Add(new(loaded.Manifest.Id, loaded.Manifest.Name, loaded.Manifest.Description, path,
File.GetLastWriteTimeUtc(path)));
File.GetLastWriteTimeUtc(path), ReportTypeOf(loaded)));
}
catch (Exception ex) when (ex is InvalidDataException or TemplateValidationException) { }
}
@@ -102,9 +105,13 @@ public sealed class TemplateStore
var temporary = destination + ".tmp";
File.Copy(sourcePath, temporary, overwrite: true);
File.Move(temporary, destination, overwrite: true);
return new(loaded.Manifest.Id, loaded.Manifest.Name, loaded.Manifest.Description, destination, DateTime.UtcNow);
return new(loaded.Manifest.Id, loaded.Manifest.Name, loaded.Manifest.Description, destination, DateTime.UtcNow,
ReportTypeOf(loaded));
}
private static string ReportTypeOf(LoadedTemplate loaded) =>
loaded.Manifest.Metadata.GetValueOrDefault(TemplateMetadataKeys.ReportType, "");
public LoadedTemplate Load(InstalledTemplate template) => _loader.LoadFromPackage(template.PackagePath);
public void Delete(string id)
{
@@ -0,0 +1,83 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
public class ExamCoverSheetServiceTests
{
[Fact]
public void Columns_BonusaufgabeHatKeineNummerUndZaehltNichtMit()
{
List<ExamTask> tasks =
[
new() { Nr = 1, MaxPoints = 5 },
new() { Nr = 2, MaxPoints = 3, IsBonus = true },
new() { Nr = 3, MaxPoints = 12 },
];
var columns = ExamCoverSheetService.Columns(tasks, ExamVariant.A, null);
Assert.Equal(["1", "", "2"], columns.Select(c => c.Number));
Assert.True(columns[1].IsBonus);
Assert.All(columns, c => Assert.Null(c.AchievedPoints));
}
[Fact]
public void Columns_NurAufgabenDerGeschriebenenFassung_MitPunktenAusDerGesamtreihenfolge()
{
List<ExamTask> tasks =
[
new() { Nr = 1, MaxPoints = 5 },
new() { Nr = 2, MaxPoints = 6, Variant = ExamVariant.A },
new() { Nr = 3, MaxPoints = 7, Variant = ExamVariant.B },
new() { Nr = 4, MaxPoints = 4 },
];
var columns = ExamCoverSheetService.Columns(tasks, ExamVariant.B, [4, 0, 6.5, 3]);
Assert.Equal(["1", "2", "3"], columns.Select(c => c.Number));
Assert.Equal([5, 7, 4], columns.Select(c => c.MaxPoints));
Assert.Equal([4, 6.5, 3], columns.Select(c => c.AchievedPoints!.Value));
}
[Fact]
public void IsCorrected_AbwesendOderLeer_GiltNichtAlsKorrigiert()
{
Assert.False(ExamCoverSheetService.IsCorrected(null));
Assert.False(ExamCoverSheetService.IsCorrected(new ExamResult()));
Assert.False(ExamCoverSheetService.IsCorrected(new ExamResult { Absent = true, Points = [1] }));
Assert.True(ExamCoverSheetService.IsCorrected(new ExamResult { Points = [1] }));
Assert.True(ExamCoverSheetService.IsCorrected(new ExamResult { Grade = "2" }));
}
[Fact]
public void Includes_NiveauKlausur_NurPassendesNiveau()
{
var exam = new Exam { Date = new DateOnly(2025, 12, 18), Niveau = Niveau.G };
Assert.True(ExamParticipantService.Includes(exam, new GroupMembership { Niveau = Niveau.G }, false, false));
Assert.False(ExamParticipantService.Includes(exam, new GroupMembership { Niveau = Niveau.E }, false, false));
}
[Fact]
public void Includes_Nachschreibarbeit_OhneErgebnisNurWerDieErsteArbeitVersaeumtHat()
{
var original = new Exam();
var makeUp = new Exam { IsMakeUp = true, RepeatOfExamId = original.Id };
var wrote = Guid.NewGuid();
var missed = Guid.NewGuid();
List<ExamResult> originalResults =
[
new() { ExamId = original.Id, StudentId = wrote, Grade = "3" },
new() { ExamId = original.Id, StudentId = missed, Absent = true },
];
var wroteOriginal = ExamParticipantService.WroteOriginal(makeUp, _ => originalResults);
Assert.Equal([wrote], wroteOriginal);
Assert.False(ExamParticipantService.Includes(makeUp, null, false, wroteOriginal.Contains(wrote)));
Assert.True(ExamParticipantService.Includes(makeUp, null, true, wroteOriginal.Contains(wrote)));
Assert.True(ExamParticipantService.Includes(makeUp, null, false, wroteOriginal.Contains(missed)));
}
}
+43
View File
@@ -4828,6 +4828,49 @@ beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuhole
`MainWindowViewModel.CanPersonalizeWorksheet` beobachtet dafür `GroupDetailViewModel.Group`
per PropertyChanged-Abo, weil dessen Wert erst nach dem Seitenwechsel per `LoadGroup` gesetzt
wird (siehe Kommentar in `NavigateToGroupDetail`).
- [x] **11.5d** Individuelle Klausur-Deckblätter nach Schulvorgabe als Sammel-PDF
(Nutzer-Wunsch). Einstieg über "Deckblätter…" auf der Klausuren-Hauptseite und im
Klausuren-Tab der Lerngruppe: Vorlage wählen, Schüler anhaken, ein PDF mit einer Seite je
Schüler erzeugen.
**Vorlagentyp statt eigener Bibliothek:** Deckblatt-Vorlagen sind normale `.lavorlage`-Pakete
mit dem Metadatum `report-type=exam-cover` (`TemplateReportTypes.ExamCover`).
`InstalledTemplate` trägt dafür den `ReportType`; der Deckblatt-Dialog liest beide
vorhandenen Bibliotheken (Elternbrief + Arbeitsblatt) und zeigt nur diesen Typ, während
Elternbrief-, Arbeitsblatt- und MCP-Auswahl ihn ausblenden. Pakete ohne Typ verhalten sich
wie bisher.
**Platzhalter:** `ExamCoverPlaceholderBuilder` ergänzt die Elternbrief-Standardwerte um
`Exam.*`, `Result.*`, `Subject.Name`, `Group.GradeLevel` und die Punktetabelle
`Exam.PointsTable` (Liste in [README.md](LehrerApp.Templating/README.md)). Maximalpunktzahl
und Tabelle gelten je Schüler für die geschriebene Fassung; Bonusaufgaben haben keine Nummer,
ein "+" vor der Punktzahl und zählen nicht zur erreichbaren Punktzahl
(`ExamCoverSheetService.Columns`, nummeriert wird innerhalb der Fassung).
Nachgezogen (Nutzer-Feedback): `Exam.Course` liefert "E-Kurs"/"G-Kurs" (Niveau der Arbeit,
sonst der Mitgliedschaft) und bleibt im Klassenverband (`GroupType.Class`) leer.
`Teacher.Abbreviation` kommt aus der neuen `TeacherSettingsService`-Konfiguration
(Einstellungen → Briefvorlagen → "Klausur-Deckblätter", `teachersettings.json`) statt fest
aus der Vorlage und ist im Dialog je Druck überschreibbar, ohne die Einstellung zu ändern —
für gemeinsam unterrichtete Kurse mit zwei Kürzeln.
**Punktetabelle ohne Verkleinern:** `ExamPointsTableDrawingBuilder` zeichnet feste
Spaltenbreiten und bricht in weitere Blöcke um. Damit "Gesamtpunkte"/"Note" darunter
mitwandern, bekam `FLOWDRAWBOX` das Opt-in-Attribut `fit=content` (belegt nur die
Inhaltshöhe statt der vollen deklarierten Höhe; bestehende Vorlagen unverändert).
**Blanko oder ausgefüllt:** Schalter "Punkte und Note eintragen", vorausgewählt sobald
mindestens ein Ergebnis korrigiert ist. Als Blanko (und für noch nicht korrigierte Schüler)
stehen Unterstrich-Linien statt Punkten/Note auf dem Blatt. Abwesende sind standardmäßig
abgewählt. Die Schülerauswahl folgt derselben Regel wie die Punkteeingabe (Niveau,
Nachschreibarbeit, Mitgliedschaft am Klausurtag), dafür nach `ExamParticipantService`
ausgelagert.
**Sammel-PDF:** neues `ITemplateBatchRenderer.RenderBatchToPdf` (QuestPDF `Document.Merge`)
liefert zusätzlich die Seitenzahl je Datensatz; läuft ein Deckblatt wegen zu vieler Aufgaben
auf eine zweite Seite, warnt der Dialog. Offen: Der TemplateDesigner bietet die
Klausur-Platzhalter noch nicht mit Beispieldaten an — `report-type` und Platzhalternamen
werden dort von Hand eingetragen.
- [ ] **11.6** Vollständiger Datenexport eines Schuljahres (Archivierung).
---