Schülerberichte und erweiterte Deckblatt-Bausteine ergänzen
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
admin committed 2026-10-22 11:24:43 +02:00
1 parent 336869e75f
commit e2f019667a
31 files changed
+961 -14

No files matched your search

@@ -262,6 +262,54 @@ public sealed class ExamCoverSheetTests : IDisposable
Assert.Equal("HED", new TeacherSettingsService(_directory).LoadAbbreviation()); Assert.Equal("HED", new TeacherSettingsService(_directory).LoadAbbreviation());
} }
[Theory]
[InlineData(RepeatGradeRule.Better, "bessere Note")]
[InlineData(RepeatGradeRule.Repeat, "Note der Wiederholungsarbeit")]
[InlineData(RepeatGradeRule.Original, "Note der ersten Arbeit")]
public void Wiederholungsbaustein_ZeigtDieGespeicherteBewertungsregel(RepeatGradeRule rule, string expected)
{
var original = new Exam { GroupId = _group.Id, Title = "Erste Arbeit", Date = _exam.Date.AddDays(-14) };
_exam.RepeatOfExamId = original.Id; _exam.RepeatGradeRule = rule;
var template = new LoadedTemplate(new TemplateManifest(), new LayoutParser().Parse(
"PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 240 $Exam.RepeatInfo fit=content"), new Dictionary<string, byte[]>());
var drawing = GradeReportDrawingBuilder.Repeat(_exam, original, new ExamResult { Grade = "4" }, template);
var text = string.Join(" ", drawing.Commands.OfType<DrawStringEx>().Select(c => c.Text));
Assert.Contains(expected, text);
Assert.Contains("Note der ersten Arbeit: 4", text);
_exam.IsMakeUp = true;
text = string.Join(" ", GradeReportDrawingBuilder.Repeat(_exam, original, null, template).Commands.OfType<DrawStringEx>().Select(c => c.Text));
Assert.Contains("Nachschreibarbeit", text);
Assert.Contains("ersetzt die versäumte", text);
}
[Fact]
public async Task Zwischenstandsbaustein_VerlangtDurchgangUndAlleAusgewaehltenSchueler()
{
var store = Store();
var source = Path.Combine(_directory, "zwischenstand.lavorlage");
TemplatePackage.Create(source, new TemplateManifest { Id = "zwischenstand", Name = "Zwischenstand",
Metadata = new() { [TemplateMetadataKeys.ReportType] = TemplateReportTypes.ExamCover },
Placeholders = [new(ReportTextDrawing.InterimGrades, PlaceholderType.Drawing)] },
"PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 240 $Student.InterimGrades fit=content", new Dictionary<string, byte[]>());
store.Import(source);
var interims = new FakeInterimGrades();
interims.Save(new() { StudentId = _anna.Id, GroupId = _group.Id, Date = _exam.Date, Occasion = "Sprechtag", OverallValue = "2" });
var vm = new ExamCoverSheetDialogViewModel(_exam, _group, "Chemie", new FakeStudents([_anna, _ben]),
new FakeMemberships([]), _results, new GradingService(), [store], new QuestTemplateRenderer(), interims: interims);
var output = Path.Combine(_directory, "zwischenstand.pdf");
Assert.True(vm.UsesInterimGrades);
Assert.False(await vm.GenerateAsync(output));
Assert.Contains("auswählen", vm.StatusMessage);
vm.SelectedInterimRun = vm.InterimRuns.Single();
Assert.False(await vm.GenerateAsync(output));
Assert.Contains(_ben.FullName, vm.StatusMessage);
Assert.False(File.Exists(output));
vm.Students.Single(s => s.Model.Id == _ben.Id).IsIncluded = false;
Assert.True(await vm.GenerateAsync(output));
using var document = UglyToad.PdfPig.PdfDocument.Open(output);
Assert.Contains("Sprechtag", document.GetPage(1).Text);
}
private static string Normalize(string text) => text.Replace(" ", ""); private static string Normalize(string text) => text.Replace(" ", "");
private static List<float> Widths(DrawingValue drawing) => private static List<float> Widths(DrawingValue drawing) =>
drawing.Commands.OfType<DrawRectangle>().Select(r => r.Width).Distinct().Order().ToList(); drawing.Commands.OfType<DrawRectangle>().Select(r => r.Width).Distinct().Order().ToList();
@@ -0,0 +1,129 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class StudentReportTests
{
private readonly Student _student = new() { FirstName = "Erika", LastName = "Beispiel" };
private readonly LearningGroup _group = new() { Name = "9 Ch G", SchoolYear = "2026/27" };
private readonly DateOnly _date = new(2026, 10, 1);
private readonly FakeDocumentation _docs = new();
private readonly FakeEntries _entries = new();
private readonly FakeAspects _aspects = new();
private readonly List<ParticipationSession> _sessions = [];
private StudentReportService Service() => new(new FakeSessions(_sessions), _entries, _aspects,
new FakeExams([]), new FakeResults(), new FakeGrades(), new FakeMemberships([]),
new FakeCompetencyDomains(), _docs, new GradingService(), new CompetencyAnalysisService());
private Dictionary<string, PlaceholderValue> Build(StudentReportOptions? options = null) => Service().Build(
_student, _group, StudentReportTemplate.Create(), options ?? new(_date, _date.AddMonths(1), null, IncludeInterim: false));
[Fact]
public void Dokumentation_IstAufSchuelerGruppeZeitraumUndFreigabeBegrenzt()
{
Documentation Doc(string title) => new() { StudentId = _student.Id, GroupId = _group.Id, Date = _date, Title = title };
_docs.Add(Doc("sichtbar"));
var confidential = Doc("vertraulich"); confidential.IsConfidential = true; _docs.Add(confidential);
var draft = Doc("Entwurf"); draft.IsDraft = true; _docs.Add(draft);
var deleted = Doc("gelöscht"); deleted.IsDeleted = true; _docs.Add(deleted);
var otherGroup = Doc("fremdeGruppe"); otherGroup.GroupId = Guid.NewGuid(); _docs.Add(otherGroup);
var otherStudent = Doc("fremderSchüler"); otherStudent.StudentId = Guid.NewGuid(); _docs.Add(otherStudent);
var outside = Doc("außerhalb"); outside.Date = _date.AddDays(-1); _docs.Add(outside);
var text = Text((DrawingValue)Build()[StudentReportTemplate.Documentation]);
Assert.Contains("sichtbar", text);
foreach (var hidden in new[] { "vertraulich", "Entwurf", "gelöscht", "fremdeGruppe", "fremderSchüler", "außerhalb" })
Assert.DoesNotContain(hidden, text);
var included = Build(new(_date, _date, null, IncludeInterim: false, IncludeConfidential: true));
Assert.Contains("[Vertraulich]", Text((DrawingValue)included[StudentReportTemplate.Documentation]));
}
[Fact]
public void Zwischenstand_MussZumSchuelerZurGruppeUndZumZeitraumPassen()
{
var interim = new InterimGrade { StudentId = _student.Id, GroupId = _group.Id, Date = _date,
Occasion = "Sprechtag", OralValue = "2", WrittenValue = "3", OverallValue = "2-3" };
var values = Build(new(_date, _date, interim));
Assert.Contains("2-3", Text((DrawingValue)values[ReportTextDrawing.InterimGrades]));
interim.StudentId = Guid.NewGuid();
Assert.Throws<InvalidDataException>(() => Build(new(_date, _date, interim)));
interim.StudentId = _student.Id; interim.GroupId = Guid.NewGuid();
Assert.Throws<InvalidDataException>(() => Build(new(_date, _date, interim)));
interim.GroupId = _group.Id; interim.Date = _date.AddDays(1);
Assert.Throws<InvalidDataException>(() => Build(new(_date, _date, interim)));
}
[Fact]
public void AbgewaehlteBausteineBleibenLeer()
{
var values = Build(new(_date, _date, null, false, false, false, false, false));
Assert.All(StudentReportTemplate.Drawings, name => Assert.Empty(((DrawingValue)values[name]).Commands));
}
[Fact]
public void Hausaufgaben_AltdatenUndFremdeSitzungen()
{
var session = new ParticipationSession { GroupId = _group.Id, Date = _date };
_sessions.Add(session);
_entries.Add(new() { StudentId = _student.Id, SessionId = session.Id, HomeworkMissing = true });
_entries.Add(new() { StudentId = _student.Id, SessionId = Guid.NewGuid(), Homework = HomeworkStatus.Completed });
var text = Text((DrawingValue)Build()[StudentReportTemplate.Homework]);
Assert.Contains("Nicht gemacht", text);
Assert.DoesNotContain("Hausaufgabe gemacht", text);
}
[Fact]
public void Standardbericht_RenderbarMitGraphUndMehrseitigerDokumentation()
{
_aspects.Save(new() { Key = "quality", Label = "Qualität", Weight = 1 });
for (var i = 0; i < 4; i++)
{
var session = new ParticipationSession { GroupId = _group.Id, Date = _date.AddDays(i) };
_sessions.Add(session);
_entries.Add(new() { StudentId = _student.Id, SessionId = session.Id,
Ratings = [new() { Key = "quality", Value = i - 2 }], Homework = HomeworkStatus.Completed });
}
for (var i = 0; i < 55; i++) _docs.Add(new() { StudentId = _student.Id, GroupId = _group.Id,
Date = _date, Title = $"Eintrag {i:D2}", Content = "Lange Beobachtung mit einem ausführlichen Text über die regelmäßige Mitarbeit im Unterricht und die Lernfortschritte." });
var pdf = new QuestTemplateRenderer().RenderToPdf(StudentReportTemplate.Create(), new ReportProvider(Build()));
using var document = UglyToad.PdfPig.PdfDocument.Open(pdf);
Assert.True(document.NumberOfPages > 1);
Assert.All(document.GetPages(), page => Assert.Contains(_student.FullName.Replace(" ", ""), page.Text.Replace(" ", "")));
var text = string.Concat(document.GetPages().Select(p => p.Text)).Replace(" ", "");
Assert.Contains(_student.FullName.Replace(" ", ""), text);
Assert.Contains("Lerngruppe:9ChG", text);
Assert.Contains("GewichteteMitarbeit", text);
Assert.Contains("Eintrag54", text);
Assert.Contains("Lernfortschritte.", text);
}
[Fact]
public async Task Dialog_UngueltigeDatumsangabenErzeugenKeineDatei()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
try
{
var vm = new StudentReportDialogViewModel(_student,
new FakeMemberships([new() { StudentId = _student.Id, GroupId = _group.Id }]),
new FakeGroups([_group]), new FakeInterimGrades(), Service(), new QuestTemplateRenderer(),
new SchoolYearService(), [new TemplateStore(directory)]);
vm.From = "31.02.2026";
var output = Path.Combine(directory, "bericht.pdf");
Assert.False(await vm.GenerateAsync(output));
Assert.False(File.Exists(output));
Assert.Contains("TT.MM.JJJJ", vm.StatusMessage);
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
private sealed class ReportProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
private static string Text(DrawingValue drawing) => string.Join(" ", drawing.Commands.OfType<DrawStringEx>().Select(c => c.Text));
}
+1
View File
@@ -238,6 +238,7 @@ public static class AppBootstrapper
services.AddSingleton<GroupMembershipTools>(); services.AddSingleton<GroupMembershipTools>();
services.AddSingleton<LetterTemplateTools>(); services.AddSingleton<LetterTemplateTools>();
services.AddSingleton<StudentAttendanceCalendarService>(); services.AddSingleton<StudentAttendanceCalendarService>();
services.AddSingleton<StudentReportService>();
services.AddSingleton<CompetencyTools>(); services.AddSingleton<CompetencyTools>();
services.AddSingleton<GroupTools>(); services.AddSingleton<GroupTools>();
services.AddSingleton<ParticipationTools>(); services.AddSingleton<ParticipationTools>();
@@ -27,7 +27,8 @@ public static class ExamCoverPlaceholderBuilder
public static Dictionary<string, PlaceholderValue> Build(Exam exam, LearningGroup group, string subjectName, public static Dictionary<string, PlaceholderValue> Build(Exam exam, LearningGroup group, string subjectName,
Student student, ExamResult? result, bool fillResults, GradingService grading, LoadedTemplate template, Student student, ExamResult? result, bool fillResults, GradingService grading, LoadedTemplate template,
DateOnly today, Niveau? studentNiveau = null, string teacherAbbreviation = "", DateOnly today, Niveau? studentNiveau = null, string teacherAbbreviation = "",
string? title = null) string? title = null, Exam? original = null, ExamResult? originalResult = null,
InterimGrade? interim = null)
{ {
var tasks = exam.Tasks.OrderBy(t => t.Nr).ToList(); var tasks = exam.Tasks.OrderBy(t => t.Nr).ToList();
var variant = result?.Variant ?? ExamVariant.A; var variant = result?.Variant ?? ExamVariant.A;
@@ -62,6 +63,10 @@ public static class ExamCoverPlaceholderBuilder
ExamCoverSheetService.Columns(tasks, variant, filled ? result!.Points : null), ExamCoverSheetService.Columns(tasks, variant, filled ? result!.Points : null),
box.Width, box.MillimeterScale) box.Width, box.MillimeterScale)
: new DrawingValue([], 0); : new DrawingValue([], 0);
values[ReportTextDrawing.RepeatInfo] = DrawingBoxLocator.Find(template, ReportTextDrawing.RepeatInfo) is null
? new DrawingValue([], 0) : GradeReportDrawingBuilder.Repeat(exam, original, originalResult, template);
values[ReportTextDrawing.InterimGrades] = interim is null
? new DrawingValue([], 0) : GradeReportDrawingBuilder.Interim(interim, template);
return values; return values;
} }
@@ -27,7 +27,9 @@ public static class ExamCoverSheetDialogs
App.Services.GetRequiredService<GradingService>(), App.Services.GetRequiredService<GradingService>(),
[App.Services.GetRequiredService<TemplateStore>(), App.Services.GetRequiredService<WorksheetTemplateStore>().Store], [App.Services.GetRequiredService<TemplateStore>(), App.Services.GetRequiredService<WorksheetTemplateStore>().Store],
App.Services.GetRequiredService<ITemplateBatchRenderer>(), App.Services.GetRequiredService<ITemplateBatchRenderer>(),
App.Services.GetRequiredService<TeacherSettingsService>().LoadAbbreviation()); App.Services.GetRequiredService<TeacherSettingsService>().LoadAbbreviation(),
App.Services.GetRequiredService<IExamRepository>(),
App.Services.GetRequiredService<IInterimGradeRepository>());
await new ExamCoverSheetDialog { DataContext = vm }.ShowDialog(owner); await new ExamCoverSheetDialog { DataContext = vm }.ShowDialog(owner);
} }
} }
@@ -0,0 +1,50 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Services;
public static class GradeReportDrawingBuilder
{
public static DrawingValue Interim(InterimGrade grade, LoadedTemplate template) =>
Text(template, ReportTextDrawing.InterimGrades, "Vorläufiger Notenzwischenstand",
[
$"Stichtag: {grade.Date:dd.MM.yyyy} · {grade.Occasion}",
$"Mündlich: {Value(grade.OralValue)}",
$"Schriftlich: {Value(grade.WrittenValue)}",
$"Vorläufige Zeugnisnote: {Value(grade.OverallValue)}",
"Dieser Zwischenstand ist keine festgeschriebene Zeugnisnote.",
]);
public static DrawingValue Repeat(Exam exam, Exam? original, ExamResult? originalResult,
LoadedTemplate template)
{
if (exam.RepeatOfExamId is null) return new DrawingValue([], 0);
if (original is null || original.Id != exam.RepeatOfExamId || original.GroupId != exam.GroupId)
throw new InvalidDataException("Die verknüpfte erste Arbeit ist nicht verfügbar.");
var rule = exam.IsMakeUp ? "Die Nachschreibarbeit ersetzt die versäumte erste Arbeit. Liegen beide Noten vor, zählt die erste Arbeit."
: exam.RepeatGradeRule switch
{
RepeatGradeRule.Repeat => "Für die Bewertung zählt die Note der Wiederholungsarbeit.",
RepeatGradeRule.Original => "Für die Bewertung zählt die Note der ersten Arbeit.",
_ => "Für die Bewertung zählt die bessere Note aus beiden Arbeiten.",
};
var grade = originalResult?.Grade;
if (string.IsNullOrWhiteSpace(grade) && ExamCoverSheetService.IsCorrected(originalResult))
grade = new GradingService().CalculateGrade(originalResult!.TotalPoints,
ExamScoringService.Maximum(original.Tasks, originalResult.Variant), original.GradingKey);
var firstGrade = originalResult is { Absent: true } ? "abwesend" : Value(grade);
return Text(template, ReportTextDrawing.RepeatInfo,
exam.IsMakeUp ? "Nachschreibarbeit" : "Wiederholungsarbeit",
[$"Erste Arbeit: {original.Title} vom {original.Date:dd.MM.yyyy}",
$"Note der ersten Arbeit: {firstGrade}", rule]);
}
public static DrawingValue Text(LoadedTemplate template, string name, string title, IEnumerable<string> paragraphs)
{
if (DrawingBoxLocator.Find(template, name) is not { } box) return new DrawingValue([], 0);
return ReportTextDrawing.Build(title, paragraphs, box.Width, box.MillimeterScale, box.Height, box.IsFixed);
}
private static string Value(string? value) => string.IsNullOrWhiteSpace(value) ? "nicht erfasst" : value;
}
@@ -26,7 +26,7 @@ public class LetterTemplateTools(
public List<LetterTemplateDto> ListLetterTemplates() public List<LetterTemplateDto> ListLetterTemplates()
{ {
var result = new List<LetterTemplateDto>(); var result = new List<LetterTemplateDto>();
foreach (var installed in templates.GetTemplates().Where(x => !x.IsExamCover)) foreach (var installed in templates.GetTemplates().Where(x => !x.IsExamCover && !x.IsStudentReport))
{ {
try try
{ {
@@ -0,0 +1,107 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Services;
public sealed record StudentReportOptions(DateOnly From, DateOnly To, InterimGrade? Interim,
bool IncludeInterim = true, bool IncludeParticipation = true, bool IncludeCompetencies = true,
bool IncludeHomework = true, bool IncludeDocumentation = true, bool IncludeConfidential = false,
string Comment = "");
/// <summary>Stellt die vorhandenen Bewertungen eines Schülers für eine Lerngruppe zusammen.
/// Die Berechnungen stammen aus denselben Diensten wie die interaktiven Auswertungen.</summary>
public sealed class StudentReportService(
IParticipationSessionRepository sessions, IParticipationRepository entries,
IParticipationAspectRepository aspects, IExamRepository exams, IExamResultRepository results,
IGradeRepository grades, IGroupMembershipRepository memberships,
ICompetencyDomainRepository domains, IDocumentationRepository documentation,
GradingService grading, CompetencyAnalysisService competencies)
{
public Dictionary<string, PlaceholderValue> Build(Student student, LearningGroup group,
LoadedTemplate template, StudentReportOptions options)
{
if (options.From > options.To) throw new InvalidDataException("Das Ende des Berichtszeitraums liegt vor dem Beginn.");
var values = LetterPlaceholderBuilder.BuildStandardValues(student, null, group, DateOnly.FromDateTime(DateTime.Today), "", "");
values["Report.Period"] = new TextValue($"{options.From:dd.MM.yyyy} - {options.To:dd.MM.yyyy}");
values["Report.Comment"] = new MultilineValue(options.Comment);
foreach (var name in StudentReportTemplate.Drawings) values[name] = new DrawingValue([], 0);
if (options.IncludeInterim && Uses(ReportTextDrawing.InterimGrades))
{
var interim = options.Interim;
if (interim is null || interim.StudentId != student.Id || interim.GroupId != group.Id
|| interim.Date < options.From || interim.Date > options.To)
throw new InvalidDataException("Bitte einen gespeicherten Zwischenstand dieses Schülers im Berichtszeitraum auswählen oder den Baustein abwählen.");
values[ReportTextDrawing.InterimGrades] = GradeReportDrawingBuilder.Interim(interim, template);
}
var membership = memberships.GetByStudentAndGroup(student.Id, group.Id);
bool Within(DateOnly date) => date >= options.From && date <= options.To
&& (membership is null || GroupMembershipService.IsActiveOn(membership, date));
var groupSessions = sessions.GetByGroup(group.Id).Where(s => Within(s.Date)).OrderBy(s => s.Date).ToList();
var bySession = groupSessions.ToDictionary(s => s.Id);
var studentEntries = entries.GetByStudent(student.Id).Where(e => bySession.ContainsKey(e.SessionId)).ToList();
var applicableAspects = aspects.GetDefaults().Concat(aspects.GetByGroup(group.Id))
.GroupBy(a => a.Key).Select(g => g.Last()).ToList();
if (options.IncludeParticipation && Uses(StudentReportTemplate.Participation))
{
var timeline = new ParticipationTimelineBuilder(sessions, entries, aspects, exams, results, grades,
membership is null ? new Dictionary<Guid, GroupMembership>() : new() { [student.Id] = membership },
grading, group.Id, group.GradingSystem);
var points = timeline.BuildPoints(student.Id).Where(p => !p.IsExam && Within(p.Date)).ToList();
List<ReportChartSeries> series = [new("Gewichtete Mitarbeit", "#6C5CE7",
points.Where(p => p.WeightedValue.HasValue).Select(p => new ReportChartPoint(p.Date, p.WeightedValue!.Value)).ToList())];
string[] colors = ["#2E86DE", "#E67E22", "#16A085", "#C0392B"];
foreach (var (aspect, index) in applicableAspects.Select((a, i) => (a, i)))
series.Add(new(aspect.Label, colors[index % colors.Length],
points.Where(p => p.AspectValues.ContainsKey(aspect.Key)).Select(p => new ReportChartPoint(p.Date,
ParticipationRatingScale.Normalize(aspect.ValueType, p.AspectValues[aspect.Key], aspect.MaxPoints))).ToList()));
var box = DrawingBoxLocator.Find(template, StudentReportTemplate.Participation)!.Value;
var drawing = ParticipationReportDrawing.Build(series, options.From, options.To, box.Width, box.MillimeterScale);
if (drawing.ContentHeight > box.Height)
throw new InvalidDataException("Der Mitarbeitsgraph benötigt eine höhere Zeichenbox in der Vorlage.");
values[StudentReportTemplate.Participation] = drawing;
}
if (options.IncludeCompetencies && Uses(StudentReportTemplate.Competencies))
{
var groupExams = exams.GetByGroup(group.Id).Where(e => Within(e.Date)).ToList();
var examIds = groupExams.Select(e => e.Id).ToHashSet();
var scores = competencies.Analyze(domains.GetAll(), [], groupExams,
results.GetByStudent(student.Id).Where(r => examIds.Contains(r.ExamId)).ToList(),
groupSessions, studentEntries, applicableAspects);
var lines = scores.Where(s => s.StudentScores.ContainsKey(student.Id)).Select(s =>
{
var score = s.StudentScores[student.Id];
return $"{s.Code} {s.Description}: {score.Percent.ToString("0", System.Globalization.CultureInfo.GetCultureInfo("de-DE"))} % ({score.EvidenceCount} Bewertungen)";
}).ToList();
Add(StudentReportTemplate.Competencies, "Kompetenzen", lines, "Keine Kompetenzbewertungen im gewählten Zeitraum.");
}
if (options.IncludeHomework && Uses(StudentReportTemplate.Homework))
Add(StudentReportTemplate.Homework, "Hausaufgaben", studentEntries.Where(e => HomeworkDisplay.Effective(e) is not null)
.OrderBy(e => bySession[e.SessionId].Date).Select(e =>
$"{bySession[e.SessionId].Date:dd.MM.yyyy}: {HomeworkDisplay.Label(HomeworkDisplay.Effective(e))}").ToList(),
"Keine Hausaufgabenkontrollen im gewählten Zeitraum.");
if (options.IncludeDocumentation && Uses(StudentReportTemplate.Documentation))
{
var lines = documentation.GetByStudent(student.Id)
.Where(d => !d.IsDeleted && !d.IsDraft && (options.IncludeConfidential || !d.IsConfidential)
&& d.GroupId == group.Id && Within(d.Date) && d.Concerns(student.Id))
.OrderBy(d => d.Date).Select(d =>
$"{d.Date:dd.MM.yyyy} · {DocumentationTypeDisplay.Label(d.Type)}: {d.Title}"
+ (d.IsConfidential ? " [Vertraulich]" : "") + "\n" + d.Content).ToList();
lines.AddRange(studentEntries.Where(e => !string.IsNullOrWhiteSpace(e.Note)).OrderBy(e => bySession[e.SessionId].Date)
.Select(e => $"{bySession[e.SessionId].Date:dd.MM.yyyy} · Unterrichtsnotiz: {e.Note}"));
Add(StudentReportTemplate.Documentation, "Klassenbucheinträge und Unterrichtsnotizen", lines,
"Keine freigegebenen Einträge mit Bezug zu dieser Lerngruppe im gewählten Zeitraum.");
}
return values;
bool Uses(string name) => DrawingBoxLocator.Find(template, name) is not null;
void Add(string name, string title, List<string> lines, string empty) =>
values[name] = GradeReportDrawingBuilder.Text(template, name, title, lines.Count > 0 ? lines : [empty]);
}
}
@@ -21,6 +21,17 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
private readonly GradingService _grading; private readonly GradingService _grading;
private readonly TemplateStore _loader; private readonly TemplateStore _loader;
private readonly ITemplateBatchRenderer _renderer; private readonly ITemplateBatchRenderer _renderer;
private readonly Exam? _original;
private readonly Dictionary<Guid, ExamResult> _originalResults;
[ObservableProperty] private ReportInterimRunChoice? _selectedInterimRun;
[ObservableProperty] private bool _usesInterimGrades;
public ObservableCollection<ReportInterimRunChoice> InterimRuns { get; } = [];
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value)
{
UsesInterimGrades = value is not null &&
DrawingBoxLocator.Find(_loader.Load(value.Model), ReportTextDrawing.InterimGrades) is not null;
}
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate; [ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
[ObservableProperty] private bool _fillResults; [ObservableProperty] private bool _fillResults;
@@ -50,12 +61,19 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
public ExamCoverSheetDialogViewModel(Exam exam, LearningGroup group, string subjectName, public ExamCoverSheetDialogViewModel(Exam exam, LearningGroup group, string subjectName,
IStudentRepository students, IGroupMembershipRepository memberships, IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships, IExamResultRepository results,
GradingService grading, IReadOnlyList<TemplateStore> templateStores, ITemplateBatchRenderer renderer, GradingService grading, IReadOnlyList<TemplateStore> templateStores, ITemplateBatchRenderer renderer,
string teacherAbbreviation = "") string teacherAbbreviation = "", IExamRepository? exams = null, IInterimGradeRepository? interims = null)
{ {
_teacherAbbreviation = teacherAbbreviation; _teacherAbbreviation = teacherAbbreviation;
_coverTitle = exam.Title; _coverTitle = exam.Title;
_exam = exam; _group = group; _subjectName = subjectName; _grading = grading; _renderer = renderer; _exam = exam; _group = group; _subjectName = subjectName; _grading = grading; _renderer = renderer;
_loader = templateStores[0]; _loader = templateStores[0];
_original = exam.RepeatOfExamId is { } originalId ? exams?.GetById(originalId) : null;
_originalResults = exam.RepeatOfExamId is { } firstId
? results.GetByExam(firstId).ToDictionary(r => r.StudentId) : [];
foreach (var run in (interims?.GetByGroup(group.Id) ?? [])
.GroupBy(g => (g.Date, g.Occasion)).OrderByDescending(g => g.Key.Date).ThenBy(g => g.Key.Occasion))
InterimRuns.Add(new(run.Key.Date, run.Key.Occasion, run.ToList()));
// Bewusste Auswahl statt stillschweigend den neuesten Zwischenstand zu drucken.
foreach (var template in templateStores.SelectMany(x => x.GetTemplates()).Where(x => x.IsExamCover) foreach (var template in templateStores.SelectMany(x => x.GetTemplates()).Where(x => x.IsExamCover)
.DistinctBy(x => x.Id).OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase)) .DistinctBy(x => x.Id).OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
Templates.Add(new(template)); Templates.Add(new(template));
@@ -87,10 +105,20 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
try try
{ {
var loaded = _loader.Load(SelectedTemplate.Model); var loaded = _loader.Load(SelectedTemplate.Model);
if (UsesInterimGrades && SelectedInterimRun is null)
{ StatusMessage = "Bitte einen gespeicherten Notenzwischenstand auswählen."; return false; }
if (UsesInterimGrades)
{
var missing = included.Where(s => !SelectedInterimRun!.Grades.Any(g => g.StudentId == s.Model.Id)).ToList();
if (missing.Count > 0)
{ StatusMessage = "Im gewählten Zwischenstand fehlen: " + string.Join(", ", missing.Select(s => s.FullName)) + ". Bitte Schüler abwählen oder einen anderen Durchgang wählen."; return false; }
}
var today = DateOnly.FromDateTime(DateTime.Now); var today = DateOnly.FromDateTime(DateTime.Now);
var data = included.Select(s => (ITemplateDataProvider)new LetterDataProvider( var data = included.Select(s => (ITemplateDataProvider)new LetterDataProvider(
ExamCoverPlaceholderBuilder.Build(_exam, _group, _subjectName, s.Model, s.Result, FillResults, ExamCoverPlaceholderBuilder.Build(_exam, _group, _subjectName, s.Model, s.Result, FillResults,
_grading, loaded, today, s.Niveau, TeacherAbbreviation, CoverTitle))).ToList(); _grading, loaded, today, s.Niveau, TeacherAbbreviation, CoverTitle,
_original, _originalResults.GetValueOrDefault(s.Model.Id),
UsesInterimGrades ? SelectedInterimRun!.Grades.Single(g => g.StudentId == s.Model.Id) : null))).ToList();
ReportProgress(0, included.Count); ReportProgress(0, included.Count);
IsGenerating = true; IsGenerating = true;
var progress = new Progress<int>(done => ReportProgress(done, included.Count)); var progress = new Progress<int>(done => ReportProgress(done, included.Count));
@@ -110,7 +138,7 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
StatusMessage = message; StatusMessage = message;
return true; return true;
} }
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException) catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException or QuestPDF.Drawing.Exceptions.DocumentLayoutException)
{ StatusMessage = $"Deckblätter konnten nicht erzeugt werden: {ex.Message}"; return false; } { StatusMessage = $"Deckblätter konnten nicht erzeugt werden: {ex.Message}"; return false; }
finally { IsGenerating = false; } finally { IsGenerating = false; }
} }
@@ -146,3 +174,8 @@ public partial class ExamCoverStudentChoice : ObservableObject
_isIncluded = result is not { Absent: true }; _isIncluded = result is not { Absent: true };
} }
} }
public sealed record ReportInterimRunChoice(DateOnly Date, string Occasion, IReadOnlyList<InterimGrade> Grades)
{
public string Label => $"{Date:dd.MM.yyyy} · {Occasion}";
}
@@ -33,7 +33,7 @@ public partial class PersonalizeWorksheetDialogViewModel : ObservableObject
IStudentRepository students, WorksheetTemplateStore templates, ITemplateRenderer renderer) IStudentRepository students, WorksheetTemplateStore templates, ITemplateRenderer renderer)
{ {
_group = group; _templates = templates; _renderer = renderer; _group = group; _templates = templates; _renderer = renderer;
foreach (var template in templates.Store.GetTemplates().Where(x => !x.IsExamCover)) Templates.Add(new(template)); foreach (var template in templates.Store.GetTemplates().Where(x => !x.IsExamCover && !x.IsStudentReport)) Templates.Add(new(template));
SelectedTemplate = Templates.FirstOrDefault(); SelectedTemplate = Templates.FirstOrDefault();
foreach (var id in studentIds) foreach (var id in studentIds)
if (students.GetById(id) is { } student) Students.Add(new(student)); if (students.GetById(id) is { } student) Students.Add(new(student));
@@ -60,7 +60,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject
_attendanceCalendarFactory = attendanceCalendarFactory; _attendanceCalendarFactory = attendanceCalendarFactory;
_absenceDayListFactory = absenceDayListFactory; _absenceDayListFactory = absenceDayListFactory;
_attendanceDataRefresher = attendanceDataRefresher; _attendanceDataRefresher = attendanceDataRefresher;
foreach (var template in templates.GetTemplates().Where(x => !x.IsExamCover)) Templates.Add(new(template)); foreach (var template in templates.GetTemplates().Where(x => !x.IsExamCover && !x.IsStudentReport)) 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 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)) foreach (var membership in memberships.GetByStudent(student.Id))
if (groups.GetById(membership.GroupId) is { } group) Groups.Add(new(group)); if (groups.GetById(membership.GroupId) is { } group) Groups.Add(new(group));
@@ -0,0 +1,106 @@
using System.Collections.ObjectModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.ViewModels.Students;
public partial class StudentReportDialogViewModel : ObservableObject
{
private readonly Student _student;
private readonly StudentReportService _reports;
private readonly ITemplateRenderer _renderer;
private readonly IInterimGradeRepository _interims;
private readonly SchoolYearService _schoolYears;
private readonly TemplateStore _loader;
public string StudentName => _student.FullName;
public string SuggestedFileName => string.Concat($"Schülerbericht_{_student.LastName}_{_student.FirstName}.pdf"
.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
public ObservableCollection<StudentReportTemplateChoice> Templates { get; } = [];
public ObservableCollection<StudentReportInterimChoice> Interims { get; } = [];
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
[ObservableProperty] private StudentReportTemplateChoice? _selectedTemplate;
[ObservableProperty] private StudentReportInterimChoice? _selectedInterim;
[ObservableProperty] private string _from = "";
[ObservableProperty] private string _to = DateTime.Today.ToString("dd.MM.yyyy");
[ObservableProperty] private bool _includeInterim = true;
[ObservableProperty] private bool _includeParticipation = true;
[ObservableProperty] private bool _includeCompetencies = true;
[ObservableProperty] private bool _includeHomework = true;
[ObservableProperty] private bool _includeDocumentation = true;
[ObservableProperty] private bool _includeConfidential;
[ObservableProperty] private string _comment = "";
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty, NotifyPropertyChangedFor(nameof(CanGenerate))] private bool _isGenerating;
public bool CanGenerate => !IsGenerating && SelectedGroup is not null && SelectedTemplate is not null;
public StudentReportDialogViewModel(Student student, IGroupMembershipRepository memberships,
IGroupRepository groups, IInterimGradeRepository interims, StudentReportService reports,
ITemplateRenderer renderer, SchoolYearService schoolYears, IReadOnlyList<TemplateStore> stores)
{
_student = student; _interims = interims; _reports = reports; _renderer = renderer;
_schoolYears = schoolYears; _loader = stores[0];
Templates.Add(new(null));
foreach (var template in stores.SelectMany(s => s.GetTemplates()).Where(t => t.IsStudentReport)
.DistinctBy(t => t.PackagePath).OrderBy(t => t.Name)) Templates.Add(new(template));
foreach (var membership in memberships.GetByStudent(student.Id))
if (groups.GetById(membership.GroupId) is { } group && !Groups.Any(g => g.Model.Id == group.Id))
Groups.Add(new(group));
SelectedTemplate = Templates[0];
SelectedGroup = Groups.OrderByDescending(g => g.Model.SchoolYear).FirstOrDefault();
}
partial void OnSelectedGroupChanged(LetterGroupChoice? value)
{
Interims.Clear(); SelectedInterim = null;
if (value is not null)
{
From = _schoolYears.SchoolYearStart(value.Model.SchoolYear).ToString("dd.MM.yyyy");
To = _schoolYears.SchoolYearEnd(value.Model.SchoolYear).ToString("dd.MM.yyyy");
var today = DateOnly.FromDateTime(DateTime.Today);
if (_schoolYears.SchoolYearEnd(value.Model.SchoolYear) > today) To = today.ToString("dd.MM.yyyy");
foreach (var grade in _interims.GetByStudentAndGroup(_student.Id, value.Model.Id)
.OrderByDescending(g => g.Date).ThenBy(g => g.Occasion)) Interims.Add(new(grade));
}
OnPropertyChanged(nameof(CanGenerate));
}
partial void OnSelectedTemplateChanged(StudentReportTemplateChoice? value) => OnPropertyChanged(nameof(CanGenerate));
public async Task<bool> GenerateAsync(string outputPath)
{
if (!CanGenerate) return false;
StatusMessage = "";
if (!DateOnly.TryParseExact(From, "dd.MM.yyyy", CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out var from)
|| !DateOnly.TryParseExact(To, "dd.MM.yyyy", CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out var to))
{ StatusMessage = "Bitte Beginn und Ende als TT.MM.JJJJ eingeben."; return false; }
try
{
var template = SelectedTemplate!.Model is { } installed ? _loader.Load(installed) : StudentReportTemplate.Create();
// Datenbankzugriffe auf dem UI-Thread; nur das Setzen und Schreiben läuft im Hintergrund.
var values = _reports.Build(_student, SelectedGroup!.Model, template,
new(from, to, SelectedInterim?.Model, IncludeInterim, IncludeParticipation, IncludeCompetencies,
IncludeHomework, IncludeDocumentation, IncludeConfidential, Comment));
IsGenerating = true;
await Task.Run(() => File.WriteAllBytes(outputPath, _renderer.RenderToPdf(template, new LetterDataProvider(values))));
StatusMessage = "Schülerbericht erzeugt.";
return true;
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException or QuestPDF.Drawing.Exceptions.DocumentLayoutException)
{ StatusMessage = $"Der Schülerbericht konnte nicht erzeugt werden: {ex.Message}"; return false; }
finally { IsGenerating = false; }
}
}
public sealed record StudentReportTemplateChoice(InstalledTemplate? Model)
{
public string Name => Model?.Name ?? "Schülerbericht Standard";
}
public sealed record StudentReportInterimChoice(InterimGrade Model)
{
public string Label => $"{Model.Date:dd.MM.yyyy} · {Model.Occasion}";
}
@@ -276,6 +276,7 @@ public partial class StudentDetailViewModel : ObservableObject
public Func<Documentation, string, Task<Documentation?>>? OnConductParentCall { get; set; } public Func<Documentation, string, Task<Documentation?>>? OnConductParentCall { get; set; }
public Func<Student, Task<StudentManagementResult>>? OnManageStudent { get; set; } public Func<Student, Task<StudentManagementResult>>? OnManageStudent { get; set; }
public Func<Task>? OnCreateLetter { get; set; } public Func<Task>? OnCreateLetter { get; set; }
public Func<Task>? OnCreateStudentReport { get; set; }
public Action? OnReturnToStudentList { get; set; } public Action? OnReturnToStudentList { get; set; }
partial void OnIsStudentActiveChanged(bool value) => OnPropertyChanged(nameof(StudentStatus)); partial void OnIsStudentActiveChanged(bool value) => OnPropertyChanged(nameof(StudentStatus));
@@ -455,6 +456,12 @@ public partial class StudentDetailViewModel : ObservableObject
[RelayCommand] private void StartEdit() => IsEditing = true; [RelayCommand] private void StartEdit() => IsEditing = true;
[RelayCommand]
private async Task CreateStudentReport()
{
if (Student is not null && OnCreateStudentReport is not null) await OnCreateStudentReport();
}
[RelayCommand] [RelayCommand]
private async Task CreateLetter() private async Task CreateLetter()
{ {
@@ -4,8 +4,8 @@
x:Class="LehrerApp.Desktop.Views.Groups.ExamCoverSheetDialog" x:Class="LehrerApp.Desktop.Views.Groups.ExamCoverSheetDialog"
x:DataType="vm:ExamCoverSheetDialogViewModel" x:DataType="vm:ExamCoverSheetDialogViewModel"
Title="Deckblätter drucken" Title="Deckblätter drucken"
Width="460" Height="680" Width="460" Height="740" MinWidth="440" MinHeight="500"
CanResize="False" WindowStartupLocation="CenterOwner"> CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto" Margin="24"> <Grid RowDefinitions="Auto,Auto,*,Auto,Auto" Margin="24">
@@ -29,6 +29,14 @@
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding TeacherAbbreviation}" PlaceholderText="z. B. MON HED" <TextBox Grid.Row="1" 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."/> ToolTip.Tip="Vorbelegt mit dem eigenen Kürzel aus den Einstellungen. Änderungen gelten nur für diesen Druck."/>
</Grid> </Grid>
<StackPanel Spacing="4" IsVisible="{Binding UsesInterimGrades}">
<TextBlock Text="Gespeicherter Notenzwischenstand" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding InterimRuns}" SelectedItem="{Binding SelectedInterimRun}"
DisplayMemberBinding="{Binding Label}" HorizontalAlignment="Stretch"
PlaceholderText="Datum und Anlass auswählen"/>
<TextBlock Text="Für alle ausgewählten Schüler muss in diesem Durchgang ein Zwischenstand vorliegen."
TextWrapping="Wrap" FontSize="12" Opacity="0.65"/>
</StackPanel>
<CheckBox Content="Punkte und Note eintragen" IsChecked="{Binding FillResults}" <CheckBox Content="Punkte und Note eintragen" IsChecked="{Binding FillResults}"
ToolTip.Tip="Aus: Blanko-Deckblatt mit Namen und leerer Punktetabelle zum Ausfüllen von Hand."/> ToolTip.Tip="Aus: Blanko-Deckblatt mit Namen und leerer Punktetabelle zum Ausfüllen von Hand."/>
</StackPanel> </StackPanel>
@@ -29,6 +29,8 @@
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
<TextBlock Text="{Binding StudentStatus}" VerticalAlignment="Center" Opacity="0.65"/> <TextBlock Text="{Binding StudentStatus}" VerticalAlignment="Center" Opacity="0.65"/>
<Button Content="Schülerbericht" Command="{Binding CreateStudentReportCommand}"
IsVisible="{Binding !IsEditing}"/>
<Button Content="PDF-Brief" Command="{Binding CreateLetterCommand}" <Button Content="PDF-Brief" Command="{Binding CreateLetterCommand}"
IsVisible="{Binding !IsEditing}"/> IsVisible="{Binding !IsEditing}"/>
<Button Content="Bearbeiten" Command="{Binding StartEditCommand}" <Button Content="Bearbeiten" Command="{Binding StartEditCommand}"
@@ -27,6 +27,7 @@ public partial class StudentDetailView : UserControl
vm.OnConductParentCall = ShowParentCallSessionDialog; vm.OnConductParentCall = ShowParentCallSessionDialog;
vm.OnManageStudent = ShowManageStudentDialog; vm.OnManageStudent = ShowManageStudentDialog;
vm.OnCreateLetter = ShowCreateLetterDialog; vm.OnCreateLetter = ShowCreateLetterDialog;
vm.OnCreateStudentReport = ShowStudentReportDialog;
} }
private async Task<Contact?> ShowContactDialog(Contact? contact) private async Task<Contact?> ShowContactDialog(Contact? contact)
@@ -58,6 +59,22 @@ public partial class StudentDetailView : UserControl
await LetterDialogs.ShowCreateLetterDialogAsync(owner, student); await LetterDialogs.ShowCreateLetterDialogAsync(owner, student);
} }
private async Task ShowStudentReportDialog()
{
if (TopLevel.GetTopLevel(this) is not Window owner ||
DataContext is not StudentDetailViewModel { Student: { } student }) return;
var vm = new StudentReportDialogViewModel(student,
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<IInterimGradeRepository>(),
App.Services.GetRequiredService<StudentReportService>(),
App.Services.GetRequiredService<LehrerApp.Templating.ITemplateRenderer>(),
App.Services.GetRequiredService<LehrerApp.Core.Services.SchoolYearService>(),
[App.Services.GetRequiredService<LehrerApp.Templating.TemplateStore>(),
App.Services.GetRequiredService<LehrerApp.Templating.WorksheetTemplateStore>().Store]);
await new StudentReportDialog { DataContext = vm }.ShowDialog(owner);
}
private void ShowAddressViewer(ContactItem contact) private void ShowAddressViewer(ContactItem contact)
{ {
if (!contact.HasAddress) return; if (!contact.HasAddress) return;
@@ -0,0 +1,56 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.StudentReportDialog"
x:DataType="vm:StudentReportDialogViewModel"
Title="Schülerbericht" Width="560" Height="760" MinWidth="460" MinHeight="500"
WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto" Margin="24">
<StackPanel Spacing="4" Margin="0,0,0,16">
<TextBlock Text="Schülerbericht" Classes="dialogtitle"/>
<TextBlock Text="{Binding StudentName}"/>
</StackPanel>
<ScrollViewer Grid.Row="1">
<StackPanel Spacing="12" IsEnabled="{Binding !IsGenerating}">
<TextBlock Text="Lerngruppe und Schuljahr" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}"
DisplayMemberBinding="{Binding Display}" HorizontalAlignment="Stretch"/>
<TextBlock Text="Vorlage" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding Templates}" SelectedItem="{Binding SelectedTemplate}"
DisplayMemberBinding="{Binding Name}" HorizontalAlignment="Stretch"/>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Spacing="4">
<TextBlock Text="Von (TT.MM.JJJJ)"/>
<TextBox Text="{Binding From}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Bis (TT.MM.JJJJ)"/>
<TextBox Text="{Binding To}"/>
</StackPanel>
</Grid>
<CheckBox Content="Notenzwischenstand" IsChecked="{Binding IncludeInterim}"/>
<ComboBox ItemsSource="{Binding Interims}" SelectedItem="{Binding SelectedInterim}"
DisplayMemberBinding="{Binding Label}" HorizontalAlignment="Stretch"
PlaceholderText="Gespeicherten Zwischenstand auswählen" IsVisible="{Binding IncludeInterim}"/>
<CheckBox Content="Mitarbeitsgraph mit gewichteter Mitarbeit und Einzelaspekten" IsChecked="{Binding IncludeParticipation}"/>
<CheckBox Content="Kompetenzbewertungen" IsChecked="{Binding IncludeCompetencies}"/>
<CheckBox Content="Hausaufgabenkontrollen" IsChecked="{Binding IncludeHomework}"/>
<CheckBox Content="Klassenbucheinträge und Unterrichtsnotizen" IsChecked="{Binding IncludeDocumentation}"/>
<CheckBox Content="Vertrauliche Dokumentation einschließen" IsChecked="{Binding IncludeConfidential}"
IsVisible="{Binding IncludeDocumentation}"/>
<TextBlock Text="Dokumentation wird auf die gewählte Lerngruppe begrenzt. Entwürfe werden ausgelassen."
TextWrapping="Wrap" FontSize="12" Opacity="0.65"/>
<TextBlock Text="Ergänzender Text" FontWeight="SemiBold"/>
<TextBox Text="{Binding Comment}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="90"/>
</StackPanel>
</ScrollViewer>
<StackPanel Grid.Row="2" Spacing="10" Margin="0,12,0,0">
<ProgressBar IsVisible="{Binding IsGenerating}" IsIndeterminate="True" Height="6"/>
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="*,12,*">
<Button Content="Schließen" Click="OnClose" IsEnabled="{Binding !IsGenerating}" HorizontalAlignment="Stretch"/>
<Button Grid.Column="2" Content="PDF erzeugen…" Click="OnGenerate" IsEnabled="{Binding CanGenerate}" HorizontalAlignment="Stretch"/>
</Grid>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,28 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.Views.Students;
public partial class StudentReportDialog : Window
{
public StudentReportDialog()
{
InitializeComponent();
Closing += (_, e) => e.Cancel = DataContext is StudentReportDialogViewModel { IsGenerating: true };
}
private void OnClose(object? sender, RoutedEventArgs e) => Close();
private async void OnGenerate(object? sender, RoutedEventArgs e)
{
if (DataContext is not StudentReportDialogViewModel vm) return;
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Schülerbericht speichern", SuggestedFileName = vm.SuggestedFileName,
DefaultExtension = "pdf", FileTypeChoices = [new FilePickerFileType("PDF") { Patterns = ["*.pdf"] }],
});
if (file is null) return;
if (await vm.GenerateAsync(file.Path.LocalPath))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(file.Path.LocalPath) { UseShellExecute = true });
}
}
@@ -6,6 +6,23 @@ namespace LehrerApp.TemplateDesigner.Tests;
public sealed class ExamCoverDesignerTests public sealed class ExamCoverDesignerTests
{ {
[Fact]
public void Schuelerbericht_EinrichtenUndSonderinhalte_RendernMitBeispieldaten()
{
var vm = new DesignerViewModel();
vm.Reset();
vm.SetUpStudentReport();
vm.SetUpStudentReport();
vm.LayoutSource = StudentReportTemplate.Layout;
Assert.Equal(TemplateReportTypes.StudentReport, vm.BuildLoaded().Manifest.Metadata[TemplateMetadataKeys.ReportType]);
Assert.Equal(StudentReportTemplate.Create().Manifest.Placeholders.Count, vm.Placeholders.Count);
using var pdf = UglyToad.PdfPig.PdfDocument.Open(vm.RenderCurrentPdf());
var text = string.Concat(pdf.GetPages().Select(p => p.Text)).Replace(" ", "");
Assert.Contains("GewichteteMitarbeit", text);
Assert.Contains("Hausaufgaben", text);
Assert.Contains("VorläufigeZeugnisnote", text);
}
[Fact] [Fact]
public void SetUpExamCover_SetztVorlagentypUndErgaenztFehlendePlatzhalter() public void SetUpExamCover_SetztVorlagentypUndErgaenztFehlendePlatzhalter()
{ {
@@ -180,10 +180,31 @@ public partial class DesignerViewModel : ObservableObject
/// später in der Hauptapp.</summary> /// später in der Hauptapp.</summary>
private static ITemplateDataProvider FitSampleData(LoadedTemplate template, ITemplateDataProvider provider) private static ITemplateDataProvider FitSampleData(LoadedTemplate template, ITemplateDataProvider provider)
{ {
var values = provider.GetValues(); var values = new Dictionary<string, PlaceholderValue>(provider.GetValues(), StringComparer.Ordinal);
if (values.GetValueOrDefault(ExamPointsTableDrawing.PlaceholderName) is not DrawingValue) return provider; if (values.GetValueOrDefault(ExamPointsTableDrawing.PlaceholderName) is DrawingValue)
return new DesignerDataProvider(new Dictionary<string, PlaceholderValue>(values, StringComparer.Ordinal) values[ExamPointsTableDrawing.PlaceholderName] = ExamCoverPlaceholders.SampleTable(template);
{ [ExamPointsTableDrawing.PlaceholderName] = ExamCoverPlaceholders.SampleTable(template) }); foreach (var name in StudentReportTemplate.Drawings.Append(ReportTextDrawing.RepeatInfo))
if (values.GetValueOrDefault(name) is DrawingValue)
values[name] = StudentReportTemplate.SampleDrawing(name, DrawingBoxLocator.Find(template, name));
return new DesignerDataProvider(values);
}
public void SetUpStudentReport()
{
var reportType = MetadataItems.FirstOrDefault(x => x.Name.Trim().Equals(TemplateMetadataKeys.ReportType, StringComparison.OrdinalIgnoreCase));
if (reportType is null) MetadataItems.Add(new(TemplateMetadataKeys.ReportType, TemplateReportTypes.StudentReport));
else reportType.Value = TemplateReportTypes.StudentReport;
var conflicts = new List<string>();
foreach (var known in StudentReportTemplate.Create().Manifest.Placeholders)
{
var existing = Placeholders.FirstOrDefault(p => p.Name.Trim() == known.Name);
if (existing is null) Placeholders.Add(new(known.Name, known.Type, false,
known.Name == "Report.Period" ? "01.08.2025 - 31.01.2026" : known.Name == "Student.Name" ? "Erika Beispiel" : known.Name == "Group.Name" ? "9 Ch G" : ""));
else if (existing.Type != known.Type) conflicts.Add(known.Name);
}
CanExport = false;
SetStatus(conflicts.Count == 0 ? "Als Schülerbericht eingerichtet. Bausteine stehen unter LehrerApp-Sonderinhalte."
: "Abweichender Platzhaltertyp: " + string.Join(", ", conflicts), conflicts.Count > 0);
} }
public bool IsExamCover => MetadataItems.Any(x => public bool IsExamCover => MetadataItems.Any(x =>
@@ -796,6 +817,24 @@ public static class SpecialContentCatalog
new("Fehltage nach Datum", "Student.AbsenceDays", new("Fehltage nach Datum", "Student.AbsenceDays",
"Chronologische Liste mit Datum, Fehlzeit oder ganzem Fehltag sowie Entschuldigungsstatus.", "Chronologische Liste mit Datum, Fehlzeit oder ganzem Fehltag sowie Entschuldigungsstatus.",
"FLOWDRAWBOX", "170", "140"), "FLOWDRAWBOX", "170", "140"),
new("Wiederholungsarbeit", ReportTextDrawing.RepeatInfo,
"Erste Arbeit, bisherige Note und Bewertungsregel; wird bei normalen Arbeiten leer gelassen.",
"FLOWDRAWBOX", "165", "150", "fit=content", InContentFlow: true),
new("Notenzwischenstand", ReportTextDrawing.InterimGrades,
"Mündlich, schriftlich und vorläufige Zeugnisnote aus einem beim Druck gewählten gespeicherten Durchgang.",
"FLOWDRAWBOX", "165", "150", "fit=content", InContentFlow: true),
new("Mitarbeitsgraph (Schülerbericht)", StudentReportTemplate.Participation,
"Gewichtete Mitarbeit und Einzelaspekte im gewählten Zeitraum auf der gemeinsamen -2 bis +2-Achse.",
"FLOWDRAWBOX", "170", "230", "fit=content", InContentFlow: true),
new("Kompetenzen (Schülerbericht)", StudentReportTemplate.Competencies,
"Kompetenzbewertungen aus Klausuren und Mitarbeit im gewählten Zeitraum.",
"FLOWDRAWBOX", "170", "230", "fit=content", InContentFlow: true),
new("Hausaufgaben (Schülerbericht)", StudentReportTemplate.Homework,
"Hausaufgabenkontrollen mit Datum und aktuellem Nachreichstatus.",
"FLOWDRAWBOX", "170", "230", "fit=content", InContentFlow: true),
new("Klassenbucheinträge (Schülerbericht)", StudentReportTemplate.Documentation,
"Freigegebene Dokumentation und Unterrichtsnotizen für einen Schüler und eine Lerngruppe.",
"FLOWDRAWBOX", "170", "230", "fit=content", InContentFlow: true),
new("Punktetabelle (Klausur-Deckblatt)", ExamPointsTableDrawing.PlaceholderName, new("Punktetabelle (Klausur-Deckblatt)", ExamPointsTableDrawing.PlaceholderName,
"Aufgabe / Max. Punktzahl / Punktzahl mit festen Spaltenbreiten; bei vielen Aufgaben folgt ein weiterer Block, und die Elemente darunter im Content-Flow wandern mit. Die Breite bestimmt, wie viele Aufgaben nebeneinander passen (165 = sechs).", "Aufgabe / Max. Punktzahl / Punktzahl mit festen Spaltenbreiten; bei vielen Aufgaben folgt ein weiterer Block, und die Elemente darunter im Content-Flow wandern mit. Die Breite bestimmt, wie viele Aufgaben nebeneinander passen (165 = sechs).",
"FLOWDRAWBOX", "165", "150", "fit=content", InContentFlow: true), "FLOWDRAWBOX", "165", "150", "fit=content", InContentFlow: true),
@@ -824,7 +863,12 @@ public static class SpecialContentCatalog
new DrawString(42, 27, "Ganzer Fehltag", 7, "#374151"), new DrawString(42, 27, "Ganzer Fehltag", 7, "#374151"),
new DrawString(116, 27, "Entschuldigt", 7, "#2E7D32"), new DrawString(116, 27, "Entschuldigt", 7, "#2E7D32"),
], 38), ], 38),
ReportTextDrawing.RepeatInfo => ReportTextDrawing.Build("Wiederholungsarbeit",
["Erste Arbeit: Atombau vom 18.12.2025", "Note der ersten Arbeit: 4", "Für die Bewertung zählt die bessere Note aus beiden Arbeiten."]),
ReportTextDrawing.InterimGrades => ReportTextDrawing.Build("Vorläufiger Notenzwischenstand",
["Stichtag: 15.01.2026 · Sprechtag", "Mündlich: 2", "Schriftlich: 3", "Vorläufige Zeugnisnote: 2-3"]),
ExamPointsTableDrawing.PlaceholderName => ExamPointsTableDrawing.Build(ExamCoverPlaceholders.SampleColumns, 165), ExamPointsTableDrawing.PlaceholderName => ExamPointsTableDrawing.Build(ExamCoverPlaceholders.SampleColumns, 165),
_ when StudentReportTemplate.Drawings.Contains(placeholderName) => StudentReportTemplate.SampleDrawing(placeholderName),
_ => DesignerPlaceholder.GenericSampleDrawing(), _ => DesignerPlaceholder.GenericSampleDrawing(),
}; };
} }
@@ -85,6 +85,7 @@
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
<Button Content="Markierten Metadateneintrag entfernen" HorizontalAlignment="Left" Click="OnRemoveMetadata"/> <Button Content="Markierten Metadateneintrag entfernen" HorizontalAlignment="Left" Click="OnRemoveMetadata"/>
<Button Content="Als Schülerbericht einrichten" HorizontalAlignment="Left" Click="OnSetUpStudentReport"/>
<Button Content="Als Klausur-Deckblatt einrichten" HorizontalAlignment="Left" Click="OnSetUpExamCover" <Button Content="Als Klausur-Deckblatt einrichten" HorizontalAlignment="Left" Click="OnSetUpExamCover"
ToolTip.Tip="Setzt report-type=exam-cover und legt alle Platzhalter an, die die LehrerApp für Deckblätter befüllt (Exam.*, Result.*, …) — mit Beispielwerten für die Vorschau."/> ToolTip.Tip="Setzt report-type=exam-cover und legt alle Platzhalter an, die die LehrerApp für Deckblätter befüllt (Exam.*, Result.*, …) — mit Beispielwerten für die Vorschau."/>
</StackPanel> </StackPanel>
@@ -38,6 +38,7 @@ public partial class MainWindow : Window
if (_viewModel.SelectedMetadata is not { } selected) return; if (_viewModel.SelectedMetadata is not { } selected) return;
_viewModel.MetadataItems.Remove(selected); _viewModel.SelectedMetadata = null; _viewModel.CanExport = false; _viewModel.MetadataItems.Remove(selected); _viewModel.SelectedMetadata = null; _viewModel.CanExport = false;
} }
private void OnSetUpStudentReport(object? sender, RoutedEventArgs e) => _viewModel.SetUpStudentReport();
private void OnSetUpExamCover(object? sender, RoutedEventArgs e) => _viewModel.SetUpExamCover(); private void OnSetUpExamCover(object? sender, RoutedEventArgs e) => _viewModel.SetUpExamCover();
private void OnRemovePlaceholder(object? sender, RoutedEventArgs e) => _viewModel.RemoveSelectedPlaceholder(); private void OnRemovePlaceholder(object? sender, RoutedEventArgs e) => _viewModel.RemoveSelectedPlaceholder();
private void OnBoldSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[b]", "[/b]"); private void OnBoldSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[b]", "[/b]");
@@ -0,0 +1,37 @@
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Templating.Tests;
public sealed class ReportDrawingTests
{
[Fact]
public void LangeTexte_WerdenNurZwischenZeilenAufSeitenVerteilt()
{
var drawing = ReportTextDrawing.Build("Überschrift", [new string('W', 300)], 55, 1, 17);
var lines = drawing.Commands.OfType<DrawStringEx>().ToList();
Assert.True(drawing.ContentHeight > 17);
Assert.All(lines, line => Assert.True(line.Y % 17 + line.Height <= 17.001f));
Assert.Equal(new string('W', 300), string.Concat(lines.Skip(1).Select(line => line.Text)));
}
[Fact]
public void FesteBox_MeldetUeberlaufStattAbzuschneiden()
{
Assert.Throws<InvalidDataException>(() => ReportTextDrawing.Build("Titel", [new string('W', 300)], 55, 1, 17, true));
}
[Fact]
public void TexteUndGraphen_BeachtenDieMassEinheit()
{
var scale = 72f / 25.4f;
var mm = ReportTextDrawing.Build("Titel", ["Eine Beobachtung zum Lernfortschritt."], 170);
var pt = ReportTextDrawing.Build("Titel", ["Eine Beobachtung zum Lernfortschritt."], 170 * scale, scale, 240 * scale);
Assert.Equal(mm.ContentHeight * scale, pt.ContentHeight, 2);
var from = new DateOnly(2026, 10, 1);
ReportChartSeries[] series = [new("Qualität", "#123456", [new(from, 1), new(from.AddDays(1), -1)])];
var chartMm = ParticipationReportDrawing.Build(series, from, from.AddDays(1));
var chartPt = ParticipationReportDrawing.Build(series, from, from.AddDays(1), 170 * scale, scale);
Assert.Equal(chartMm.ContentHeight * scale, chartPt.ContentHeight, 2);
}
}
+2
View File
@@ -117,6 +117,8 @@ public static class ExamCoverPlaceholders
new("Result.Points", PlaceholderType.Text, "52,5", "Erreichte Punkte, als Blanko eine Linie"), new("Result.Points", PlaceholderType.Text, "52,5", "Erreichte Punkte, als Blanko eine Linie"),
new("Result.Percent", PlaceholderType.Text, "85 %", "Erreichter Anteil, als Blanko leer"), new("Result.Percent", PlaceholderType.Text, "85 %", "Erreichter Anteil, als Blanko leer"),
new("Result.Grade", PlaceholderType.Text, "2", "Note, als Blanko eine Linie"), new("Result.Grade", PlaceholderType.Text, "2", "Note, als Blanko eine Linie"),
new(ReportTextDrawing.RepeatInfo, PlaceholderType.Drawing, "", "Informationen zur ersten Arbeit und Bewertungsregel der Wiederholung"),
new(ReportTextDrawing.InterimGrades, PlaceholderType.Drawing, "", "Gespeicherter Zwischenstand: mündlich, schriftlich, vorläufige Zeugnisnote"),
new(ExamPointsTableDrawing.PlaceholderName, PlaceholderType.Drawing, "", new(ExamPointsTableDrawing.PlaceholderName, PlaceholderType.Drawing, "",
"Punktetabelle (Aufgabe / Max. Punktzahl / Punktzahl)"), "Punktetabelle (Aufgabe / Max. Punktzahl / Punktzahl)"),
]; ];
@@ -226,6 +226,18 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
canvas.Layers(layers => canvas.Layers(layers =>
{ {
layers.PrimaryLayer().Background(Colors.White); layers.PrimaryLayer().Background(Colors.White);
// Ohne eigene Folgeseitenvorlage ist derselbe Hintergrund auf jeder Seite
// vorgesehen. Zwei zustandsbehaftete ShowOnce/SkipOnce-Layer für dieselben
// Elemente können sich beim Paginieren gegenseitig verdrängen.
if (ReferenceEquals(first, continuation))
{
foreach (var element in first.Elements)
{
var current = element;
layers.Layer().Repeat().Element(root => RenderElement(root, current, template, values));
}
return;
}
foreach (var element in first.Elements) foreach (var element in first.Elements)
{ {
var current = element; var current = element;
+36
View File
@@ -316,3 +316,39 @@ Höhe ins Elementformular. Im Bearbeitungsmodus lassen sich vorhandene Elemente
außer einzeiligem `TEXT` - am rechten unteren Anfasser skalieren. Rasterfang und Pfeiltasten sind außer einzeiligem `TEXT` - am rechten unteren Anfasser skalieren. Rasterfang und Pfeiltasten sind
für Feinkorrekturen verfügbar. Änderungen werden in die ursprüngliche DSL-Zeile zurückgeschrieben, für Feinkorrekturen verfügbar. Änderungen werden in die ursprüngliche DSL-Zeile zurückgeschrieben,
wobei Inhalte, Platzhalter, Formatangaben und Attribute erhalten bleiben. wobei Inhalte, Platzhalter, Formatangaben und Attribute erhalten bleiben.
### Weitere Deckblatt- und Schülerbericht-Bausteine
Die Sonderinhalte im TemplateDesigner umfassen zusätzlich:
| Platzhalter (Drawing) | Inhalt |
| --- | --- |
| `Exam.RepeatInfo` | Erste Arbeit, bisherige Note und Regel der Wiederholung; bei normalen Arbeiten leer |
| `Student.InterimGrades` | Gespeicherter Zwischenstand mit Stichtag/Anlass, mündlich, schriftlich und vorläufiger Zeugnisnote |
| `Student.ParticipationChart` | Gewichtete Mitarbeit und Einzelaspekte auf der gemeinsamen -2 bis +2-Achse |
| `Student.Competencies` | Kompetenzbewertungen aus Klausuren und Mitarbeit |
| `Student.Homework` | Hausaufgabenkontrollen mit Datum und aktuellem Nachreichstatus |
| `Student.Documentation` | Dokumentation und Unterrichtsnotizen der gewählten Lerngruppe |
`Exam.RepeatInfo` und `Student.InterimGrades` sind in Klausur-Deckblättern verfügbar.
Ein Zwischenstand wird im Druckdialog über Datum und Anlass gewählt; fehlt ein ausgewählter
Schüler in diesem Durchgang, wird der Druck mit einem Hinweis verhindert.
Für Schülerberichte im Schülerdetail gibt es eine eingebaute Standardvorlage und importierbare
Pakete mit `report-type=student-report` (in einer der beiden vorhandenen Vorlagenbibliotheken).
Im Designer setzt „Als Schülerbericht einrichten“ den Typ und ergänzt die benötigten Platzhalter;
die Sonderinhalte werden danach nach Bedarf in den Content-Flow eingefügt. Es werden außerdem
`Student.Name`, `Group.Name`, `Report.Period` (Text) und `Report.Comment` (Multiline) befüllt.
Es gibt keine erforderliche Kontaktadresse: der Bericht ist für den Austausch mit Kollegen gedacht.
Für lange Inhalte `FLOWDRAWBOX … fit=content` verwenden. Ihre Höhe muss einschließlich `gap`
in den Flow-Slot passen; die Standardvorlage nutzt 230 mm Inhaltshöhe in einem 240-mm-Slot.
Feste `DRAWBOX`-Textblöcke melden Platzmangel. Der Mitarbeitsgraph braucht mindestens 50 mm
Breite und ausreichend Höhe für seine Legende. Die Textbausteine beachten die Maßeinheit der Vorlage.
Der Bericht filtert auf Schüler, Gruppe und Zeitraum. Gelöschte Dokumentation und Entwürfe werden
nicht übernommen; vertrauliche Dokumentation ist im Dialog zuschaltbar. Hausaufgabenstatus und
Dokumentation entsprechen dem aktuellen gespeicherten Zustand. Notenzwischenstände werden aus
gespeicherten Durchgängen übernommen und nicht beim Druck neu berechnet. KI-Texterzeugung ist
noch nicht angebunden; ergänzende Texte lassen sich im Berichtsdialog eingeben.
+61
View File
@@ -0,0 +1,61 @@
namespace LehrerApp.Templating;
/// <summary>Lesbare, umbrechende Textblöcke für Berichtsbausteine. Jede Zeile ist ein eigener
/// Zeichenbefehl, damit FLOWDRAWBOX auch lange Berichte an Zeilengrenzen aufteilen kann.</summary>
public static class ReportTextDrawing
{
public const string RepeatInfo = "Exam.RepeatInfo";
public const string InterimGrades = "Student.InterimGrades";
public static DrawingValue Build(string title, IEnumerable<string> paragraphs,
float width = 170, float scale = 1, float pageHeight = 240, bool fixedBox = false)
{
if (!float.IsFinite(width) || !float.IsFinite(scale) || !float.IsFinite(pageHeight)
|| scale <= 0 || width < 20 * scale || pageHeight < 12 * scale)
throw new InvalidDataException("Der Berichtsbaustein benötigt mindestens 20 × 12 mm Platz.");
var commands = new List<DrawingCommand>();
var lineHeight = 5f * scale;
var y = 0f;
// Konservative Zeichenbreiten in Gevierten der Standardschrift. Breite Buchstaben
// bekommen mehr Raum; auch einzelne überlange Wörter werden umgebrochen.
var available = (width - 2 * scale) / (10 * 25.4f / 72 * scale);
static float Advance(char c) => char.IsWhiteSpace(c) ? .4f
: c is 'm' or 'w' or 'M' or 'W' ? 1.1f
: c > 255 ? 1.1f : char.IsUpper(c) ? 1f : .7f;
void Line(string text, bool bold)
{
var offset = y % pageHeight;
if (offset + lineHeight > pageHeight + .001f) y += pageHeight - offset;
if (fixedBox && y + lineHeight > pageHeight + .001f)
throw new InvalidDataException("Der Berichtsbaustein passt nicht in die feste DRAWBOX. Bitte FLOWDRAWBOX verwenden oder die Box vergrößern.");
commands.Add(new DrawStringEx(scale, y, lineHeight, width - 2 * scale, text,
DrawingTextAlignment.AlignLeft, 10, Bold: bold));
y += lineHeight;
}
foreach (var paragraph in new[] { title }.Concat(paragraphs))
{
foreach (var original in paragraph.Replace("\r\n", "\n").Split('\n'))
{
var remaining = original.Trim();
if (remaining.Length == 0) { Line("", false); continue; }
while (remaining.Length > 0)
{
var length = 0;
var used = 0f;
while (length < remaining.Length && used + Advance(remaining[length]) <= available)
used += Advance(remaining[length++]);
length = Math.Max(1, length);
var cut = length;
if (length < remaining.Length)
{
var space = remaining.LastIndexOf(' ', length - 1, length);
if (space > 0) cut = space;
}
Line(remaining[..cut], paragraph == title);
remaining = remaining[cut..].TrimStart();
}
}
}
return new DrawingValue(commands, y);
}
}
+114
View File
@@ -0,0 +1,114 @@
namespace LehrerApp.Templating;
public static class StudentReportTemplate
{
public const string Participation = "Student.ParticipationChart";
public const string Competencies = "Student.Competencies";
public const string Homework = "Student.Homework";
public const string Documentation = "Student.Documentation";
public static IReadOnlyList<string> Drawings { get; } =
[ReportTextDrawing.InterimGrades, Participation, Competencies, Homework, Documentation];
public const string Layout = """
PAGE 210 297 mm
#pragma format-version 3
#pragma page-template first
TEXT 20 12 "Schülerbericht: $Student.Name" size=15
TEXT 20 22 "Lerngruppe: $Group.Name · $Report.Period" size=10
#pragma flow-slot body x=20 y=34 w=170 h=240
#pragma end-page-template
#pragma content-flow body
FLOWDRAWBOX $Student.InterimGrades w=170 h=230 fit=content gap=5
FLOWDRAWBOX $Student.ParticipationChart w=170 h=230 fit=content gap=5
FLOWDRAWBOX $Student.Competencies w=170 h=230 fit=content gap=5
FLOWDRAWBOX $Student.Homework w=170 h=230 fit=content gap=5
FLOWDRAWBOX $Student.Documentation w=170 h=230 fit=content gap=5
FLOWBOX $Report.Comment w=170 size=10
#pragma end-content-flow
""";
public static DrawingValue SampleDrawing(string name, DeclaredDrawingBox? declared = null)
{
var box = declared ?? new DeclaredDrawingBox(170, 240, 1, false);
var from = new DateOnly(2026, 1, 1);
if (name == Participation)
return ParticipationReportDrawing.Build([new("Gewichtete Mitarbeit", "#6C5CE7",
[new(from, -.5), new(from.AddDays(7), .5), new(from.AddDays(14), 1.2)])], from, from.AddDays(20), box.Width, box.MillimeterScale);
var (title, text) = name switch
{
ReportTextDrawing.RepeatInfo => ("Wiederholungsarbeit", new[] { "Erste Arbeit: Atombau vom 18.12.2025", "Note der ersten Arbeit: 4", "Für die Bewertung zählt die bessere Note aus beiden Arbeiten." }),
ReportTextDrawing.InterimGrades => ("Vorläufiger Notenzwischenstand", new[] { "Stichtag: 15.01.2026 · Sprechtag", "Mündlich: 2", "Schriftlich: 3", "Vorläufige Zeugnisnote: 2-3" }),
Competencies => ("Kompetenzen", new[] { "CH1 Aufbau der Atome beschreiben: 75 % (4 Bewertungen)" }),
Homework => ("Hausaufgaben", new[] { "12.01.2026: Hausaufgabe gemacht", "14.01.2026: Hausaufgabe nachgereicht" }),
_ => ("Klassenbucheinträge und Unterrichtsnotizen", new[] { "15.01.2026 · Gespräch: Lernentwicklung", "Erika arbeitet inzwischen regelmäßig mit." }),
};
return ReportTextDrawing.Build(title, text, box.Width, box.MillimeterScale, box.Height, box.IsFixed);
}
public static LoadedTemplate Create() => new(new TemplateManifest
{
Id = "student-report-standard", Name = "Schülerbericht Standard",
Metadata = new() { [TemplateMetadataKeys.ReportType] = TemplateReportTypes.StudentReport },
Placeholders = new[]
{
new PlaceholderDefinition("Student.Name", PlaceholderType.Text),
new PlaceholderDefinition("Group.Name", PlaceholderType.Text),
new PlaceholderDefinition("Report.Period", PlaceholderType.Text),
new PlaceholderDefinition("Report.Comment", PlaceholderType.Multiline),
}.Concat(Drawings.Select(n => new PlaceholderDefinition(n, PlaceholderType.Drawing))).ToList(),
}, new LayoutParser().Parse(Layout), new Dictionary<string, byte[]>());
}
public sealed record ReportChartPoint(DateOnly Date, double Value);
public sealed record ReportChartSeries(string Label, string Color, IReadOnlyList<ReportChartPoint> Points);
public static class ParticipationReportDrawing
{
public static DrawingValue Build(IReadOnlyList<ReportChartSeries> series, DateOnly from, DateOnly to,
float width = 170, float scale = 1)
{
if (from > to || width < 50 * scale || scale <= 0 || !float.IsFinite(width) || !float.IsFinite(scale))
throw new InvalidDataException("Der Mitarbeitsgraph benötigt mindestens 50 mm Breite und einen gültigen Zeitraum.");
if (series.All(s => s.Points.Count == 0))
return ReportTextDrawing.Build("Mitarbeitsentwicklung", ["Keine bewerteten Sitzungen im gewählten Zeitraum."], width, scale);
var commands = new List<DrawingCommand>();
void Text(float x, float y, string text, string color = "#334155", bool bold = false) =>
commands.Add(new DrawString(x * scale, y * scale, text, 9, color, Bold: bold));
var right = width / scale - 4;
const float left = 12, top = 14, height = 40;
Text(1, 0, "Mitarbeitsentwicklung (-2 bis +2)", bold: true);
for (var value = -2; value <= 2; value++)
{
var y = top + (2 - value) / 4f * height;
Text(1, y - 2, value.ToString(System.Globalization.CultureInfo.InvariantCulture));
commands.Add(new DrawLine(left * scale, y * scale, right * scale, y * scale, "#CBD5E1", .2f * scale));
}
var days = Math.Max(1, to.DayNumber - from.DayNumber);
foreach (var item in series)
{
(float X, float Y)? previous = null;
foreach (var point in item.Points.Where(p => p.Date >= from && p.Date <= to).OrderBy(p => p.Date))
{
var x = (left + (point.Date.DayNumber - from.DayNumber) / (float)days * (right - left)) * scale;
var y = (top + (2 - (float)Math.Clamp(point.Value, -2, 2)) / 4 * height) * scale;
if (previous is { } prev) commands.Add(new DrawLine(prev.X, prev.Y, x, y, item.Color, .5f * scale));
commands.Add(new DrawCircle(x, y, .8f * scale, item.Color, .2f * scale, item.Color));
previous = (x, y);
}
}
Text(left, 57, from.ToString("dd.MM.yyyy"));
Text(Math.Max(left, right - 26), 57, to.ToString("dd.MM.yyyy"));
var legendY = 64f;
foreach (var item in series)
{
// Jede Kurve bekommt eine eigene beschriftete Legendenzeile.
commands.Add(new DrawLine(left * scale, (legendY + 2) * scale, (left + 6) * scale, (legendY + 2) * scale, item.Color, .6f * scale));
var legend = ReportTextDrawing.Build("", [item.Label], (right - left - 10) * scale, scale);
// Legendentexte konservativ umbrechen, auch bei individuell langen Aspektnamen.
foreach (var line in legend.Commands.OfType<DrawStringEx>().Skip(1))
commands.Add(line with { X = (left + 9) * scale, Y = line.Y - 5 * scale + legendY * scale, Color = item.Color });
legendY += legend.ContentHeight / scale - 5;
}
return new DrawingValue(commands, (legendY + 2) * scale);
}
}
@@ -12,6 +12,7 @@ public static class TemplateReportTypes
{ {
public const string Letter = "letter"; public const string Letter = "letter";
public const string ExamCover = "exam-cover"; public const string ExamCover = "exam-cover";
public const string StudentReport = "student-report";
} }
public static class TemplateMetadataText public static class TemplateMetadataText
+1
View File
@@ -65,6 +65,7 @@ public static class TemplatePackage
public sealed record InstalledTemplate(string Id, string Name, string Description, string PackagePath, public sealed record InstalledTemplate(string Id, string Name, string Description, string PackagePath,
DateTime ImportedAtUtc, string ReportType = "") DateTime ImportedAtUtc, string ReportType = "")
{ {
public bool IsStudentReport => ReportType.Equals(TemplateReportTypes.StudentReport, StringComparison.OrdinalIgnoreCase);
public bool IsExamCover => ReportType.Equals(TemplateReportTypes.ExamCover, StringComparison.OrdinalIgnoreCase); public bool IsExamCover => ReportType.Equals(TemplateReportTypes.ExamCover, StringComparison.OrdinalIgnoreCase);
} }
+21
View File
@@ -4914,6 +4914,27 @@ beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuhole
`ExamCoverSheetTests` hält Katalog und `ExamCoverPlaceholderBuilder` zusammen. Beim Öffnen `ExamCoverSheetTests` hält Katalog und `ExamCoverPlaceholderBuilder` zusammen. Beim Öffnen
einer Deckblatt-Vorlage bekommen bekannte Platzhalter sprechende Beispielwerte statt einer Deckblatt-Vorlage bekommen bekannte Platzhalter sprechende Beispielwerte statt
"Beispielwert". "Beispielwert".
- [x] **11.5e** Erweiterte Deckblatt-Bausteine ([Gitea #1](https://git.science-teaching.de/admin/LehrerApp/issues/1)):
`Exam.RepeatInfo` zeigt die verknüpfte erste Arbeit, die bisherige Note und die Bewertungsregel
(inklusive Nachschreibarbeiten). `Student.InterimGrades` druckt mündlich/schriftlich/vorläufige
Zeugnisnote aus einem im Deckblatt-Dialog ausdrücklich ausgewählten gespeicherten Durchgang.
Fehlende Zwischenstände ausgewählter Schüler blockieren den Druck mit Namensliste.
Beide Inhalte sind im TemplateDesigner als Sonderinhalt mit echten Beispieldaten verfügbar.
- [~] **11.5f** Schülerbericht für Kollegen ([Gitea #2](https://git.science-teaching.de/admin/LehrerApp/issues/2)):
Im Schülerdetail über „Schülerbericht“: Lerngruppe, Zeitraum und einzelne Bausteine wählen;
Standardvorlage ohne Import oder eigene `.lavorlage` mit `report-type=student-report`.
Enthalten sind gespeicherter Notenzwischenstand, Mitarbeitsgraph (gewichtete Mitarbeit und
Einzelaspekte aus derselben Berechnung wie der Assistent), Kompetenzbewertungen,
Hausaufgabenkontrollen und Dokumentation/Unterrichtsnotizen. Der Bericht bezieht sich auf
genau einen Schüler und eine Lerngruppe; Dokumentationsentwürfe und gelöschte Einträge bleiben
ausgeschlossen, vertrauliche Dokumentation wird nur auf ausdrückliche Auswahl übernommen.
Lange Texte laufen über mehrere Seiten, feste Zeichenboxen melden Platzmangel statt Text
still abzuschneiden. Der TemplateDesigner bietet alle Bausteine und „Als Schülerbericht
einrichten“. Ergänzende Texte können manuell eingegeben werden.
**Noch offen/optional:** KI-generierte Textbausteine; hierfür enthält Issue #2 bisher nur eine
Idee, keinen festgelegten Prompt oder Übernahmeablauf. Hausaufgabenstatus und Dokumentation
zeigen ihren aktuellen gespeicherten Zustand im gewählten Zeitraum, keinen historischen
Änderungsstand zu einem vergangenen Stichtag.
- [ ] **11.6** Vollständiger Datenexport eines Schuljahres (Archivierung). - [ ] **11.6** Vollständiger Datenexport eines Schuljahres (Archivierung).
--- ---