From e2f019667a1c21d13e339d0002dd61fb5e772842 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Thu, 22 Oct 2026 11:24:43 +0200 Subject: [PATCH] =?UTF-8?q?Sch=C3=BClerberichte=20und=20erweiterte=20Deckb?= =?UTF-8?q?latt-Bausteine=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExamCoverSheetTests.cs | 48 +++++++ LehrerApp.Desktop.Tests/StudentReportTests.cs | 129 ++++++++++++++++++ LehrerApp.Desktop/AppBootstrapper.cs | 1 + .../Services/ExamCoverPlaceholderBuilder.cs | 7 +- .../Services/ExamCoverSheetDialogs.cs | 4 +- .../Services/GradeReportDrawingBuilder.cs | 50 +++++++ .../Services/Mcp/Tools/LetterTemplateTools.cs | 2 +- .../Services/StudentReportService.cs | 107 +++++++++++++++ .../Groups/ExamCoverSheetDialogViewModel.cs | 39 +++++- .../PersonalizeWorksheetDialogViewModel.cs | 2 +- .../Students/CreateLetterDialogViewModel.cs | 2 +- .../Students/StudentReportDialogViewModel.cs | 106 ++++++++++++++ .../ViewModels/Students/StudentViewModels.cs | 7 + .../Views/Groups/ExamCoverSheetDialog.axaml | 12 +- .../Views/Students/StudentDetailView.axaml | 2 + .../Views/Students/StudentDetailView.axaml.cs | 17 +++ .../Views/Students/StudentReportDialog.axaml | 56 ++++++++ .../Students/StudentReportDialog.axaml.cs | 28 ++++ .../ExamCoverDesignerTests.cs | 17 +++ .../DesignerViewModel.cs | 52 ++++++- LehrerApp.TemplateDesigner/MainWindow.axaml | 1 + .../MainWindow.axaml.cs | 1 + .../ReportDrawingTests.cs | 37 +++++ LehrerApp.Templating/ExamCover.cs | 2 + LehrerApp.Templating/QuestTemplateRenderer.cs | 12 ++ LehrerApp.Templating/README.md | 36 +++++ LehrerApp.Templating/ReportTextDrawing.cs | 61 +++++++++ LehrerApp.Templating/StudentReport.cs | 114 ++++++++++++++++ LehrerApp.Templating/TemplateMetadataText.cs | 1 + LehrerApp.Templating/TemplatePackage.cs | 1 + TODO.md | 21 +++ 31 files changed, 961 insertions(+), 14 deletions(-) create mode 100644 LehrerApp.Desktop.Tests/StudentReportTests.cs create mode 100644 LehrerApp.Desktop/Services/GradeReportDrawingBuilder.cs create mode 100644 LehrerApp.Desktop/Services/StudentReportService.cs create mode 100644 LehrerApp.Desktop/ViewModels/Students/StudentReportDialogViewModel.cs create mode 100644 LehrerApp.Desktop/Views/Students/StudentReportDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Students/StudentReportDialog.axaml.cs create mode 100644 LehrerApp.Templating.Tests/ReportDrawingTests.cs create mode 100644 LehrerApp.Templating/ReportTextDrawing.cs create mode 100644 LehrerApp.Templating/StudentReport.cs diff --git a/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs b/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs index 85f9c15..daa2698 100644 --- a/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs +++ b/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs @@ -262,6 +262,54 @@ public sealed class ExamCoverSheetTests : IDisposable 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()); + var drawing = GradeReportDrawingBuilder.Repeat(_exam, original, new ExamResult { Grade = "4" }, template); + var text = string.Join(" ", drawing.Commands.OfType().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().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()); + 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 List Widths(DrawingValue drawing) => drawing.Commands.OfType().Select(r => r.Width).Distinct().Order().ToList(); diff --git a/LehrerApp.Desktop.Tests/StudentReportTests.cs b/LehrerApp.Desktop.Tests/StudentReportTests.cs new file mode 100644 index 0000000..2d159c5 --- /dev/null +++ b/LehrerApp.Desktop.Tests/StudentReportTests.cs @@ -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 _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 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(() => Build(new(_date, _date, interim))); + interim.StudentId = _student.Id; interim.GroupId = Guid.NewGuid(); + Assert.Throws(() => Build(new(_date, _date, interim))); + interim.GroupId = _group.Id; interim.Date = _date.AddDays(1); + Assert.Throws(() => 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 values) : ITemplateDataProvider + { public IReadOnlyDictionary GetValues() => values; } + + private static string Text(DrawingValue drawing) => string.Join(" ", drawing.Commands.OfType().Select(c => c.Text)); +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 58c4081..97173fa 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -238,6 +238,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/LehrerApp.Desktop/Services/ExamCoverPlaceholderBuilder.cs b/LehrerApp.Desktop/Services/ExamCoverPlaceholderBuilder.cs index aa6f7d6..4c24690 100644 --- a/LehrerApp.Desktop/Services/ExamCoverPlaceholderBuilder.cs +++ b/LehrerApp.Desktop/Services/ExamCoverPlaceholderBuilder.cs @@ -27,7 +27,8 @@ public static class ExamCoverPlaceholderBuilder public static Dictionary Build(Exam exam, LearningGroup group, string subjectName, Student student, ExamResult? result, bool fillResults, GradingService grading, LoadedTemplate template, 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 variant = result?.Variant ?? ExamVariant.A; @@ -62,6 +63,10 @@ public static class ExamCoverPlaceholderBuilder ExamCoverSheetService.Columns(tasks, variant, filled ? result!.Points : null), box.Width, box.MillimeterScale) : 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; } diff --git a/LehrerApp.Desktop/Services/ExamCoverSheetDialogs.cs b/LehrerApp.Desktop/Services/ExamCoverSheetDialogs.cs index 8b1cddf..0af2321 100644 --- a/LehrerApp.Desktop/Services/ExamCoverSheetDialogs.cs +++ b/LehrerApp.Desktop/Services/ExamCoverSheetDialogs.cs @@ -27,7 +27,9 @@ public static class ExamCoverSheetDialogs App.Services.GetRequiredService(), [App.Services.GetRequiredService(), App.Services.GetRequiredService().Store], App.Services.GetRequiredService(), - App.Services.GetRequiredService().LoadAbbreviation()); + App.Services.GetRequiredService().LoadAbbreviation(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService()); await new ExamCoverSheetDialog { DataContext = vm }.ShowDialog(owner); } } diff --git a/LehrerApp.Desktop/Services/GradeReportDrawingBuilder.cs b/LehrerApp.Desktop/Services/GradeReportDrawingBuilder.cs new file mode 100644 index 0000000..61cdbff --- /dev/null +++ b/LehrerApp.Desktop/Services/GradeReportDrawingBuilder.cs @@ -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 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; +} diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs index 01731d8..88c6324 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs @@ -26,7 +26,7 @@ public class LetterTemplateTools( public List ListLetterTemplates() { var result = new List(); - foreach (var installed in templates.GetTemplates().Where(x => !x.IsExamCover)) + foreach (var installed in templates.GetTemplates().Where(x => !x.IsExamCover && !x.IsStudentReport)) { try { diff --git a/LehrerApp.Desktop/Services/StudentReportService.cs b/LehrerApp.Desktop/Services/StudentReportService.cs new file mode 100644 index 0000000..1828c90 --- /dev/null +++ b/LehrerApp.Desktop/Services/StudentReportService.cs @@ -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 = ""); + +/// Stellt die vorhandenen Bewertungen eines Schülers für eine Lerngruppe zusammen. +/// Die Berechnungen stammen aus denselben Diensten wie die interaktiven Auswertungen. +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 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() : new() { [student.Id] = membership }, + grading, group.Id, group.GradingSystem); + var points = timeline.BuildPoints(student.Id).Where(p => !p.IsExam && Within(p.Date)).ToList(); + List 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 lines, string empty) => + values[name] = GradeReportDrawingBuilder.Text(template, name, title, lines.Count > 0 ? lines : [empty]); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs index 70efeb2..d97d8e7 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs @@ -21,6 +21,17 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject private readonly GradingService _grading; private readonly TemplateStore _loader; private readonly ITemplateBatchRenderer _renderer; + private readonly Exam? _original; + private readonly Dictionary _originalResults; + [ObservableProperty] private ReportInterimRunChoice? _selectedInterimRun; + [ObservableProperty] private bool _usesInterimGrades; + public ObservableCollection 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 bool _fillResults; @@ -50,12 +61,19 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject public ExamCoverSheetDialogViewModel(Exam exam, LearningGroup group, string subjectName, IStudentRepository students, IGroupMembershipRepository memberships, IExamResultRepository results, GradingService grading, IReadOnlyList templateStores, ITemplateBatchRenderer renderer, - string teacherAbbreviation = "") + string teacherAbbreviation = "", IExamRepository? exams = null, IInterimGradeRepository? interims = null) { _teacherAbbreviation = teacherAbbreviation; _coverTitle = exam.Title; _exam = exam; _group = group; _subjectName = subjectName; _grading = grading; _renderer = renderer; _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) .DistinctBy(x => x.Id).OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase)) Templates.Add(new(template)); @@ -87,10 +105,20 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject try { 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 data = included.Select(s => (ITemplateDataProvider)new LetterDataProvider( ExamCoverPlaceholderBuilder.Build(_exam, _group, _subjectName, s.Model, s.Result, FillResults, - _grading, loaded, today, s.Niveau, TeacherAbbreviation, CoverTitle))).ToList(); + _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); IsGenerating = true; var progress = new Progress(done => ReportProgress(done, included.Count)); @@ -110,7 +138,7 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject StatusMessage = message; 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; } finally { IsGenerating = false; } } @@ -146,3 +174,8 @@ public partial class ExamCoverStudentChoice : ObservableObject _isIncluded = result is not { Absent: true }; } } + +public sealed record ReportInterimRunChoice(DateOnly Date, string Occasion, IReadOnlyList Grades) +{ + public string Label => $"{Date:dd.MM.yyyy} · {Occasion}"; +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/PersonalizeWorksheetDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/PersonalizeWorksheetDialogViewModel.cs index 7561ccf..f21d968 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PersonalizeWorksheetDialogViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PersonalizeWorksheetDialogViewModel.cs @@ -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().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(); foreach (var id in studentIds) if (students.GetById(id) is { } student) Students.Add(new(student)); diff --git a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs index 408b430..24c3f0e 100644 --- a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs @@ -60,7 +60,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject _attendanceCalendarFactory = attendanceCalendarFactory; _absenceDayListFactory = absenceDayListFactory; _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 membership in memberships.GetByStudent(student.Id)) if (groups.GetById(membership.GroupId) is { } group) Groups.Add(new(group)); diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentReportDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/StudentReportDialogViewModel.cs new file mode 100644 index 0000000..2e179cd --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Students/StudentReportDialogViewModel.cs @@ -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 Groups { get; } = []; + public ObservableCollection Templates { get; } = []; + public ObservableCollection 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 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 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}"; +} diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs index 49d890d..b241d46 100644 --- a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs @@ -276,6 +276,7 @@ public partial class StudentDetailViewModel : ObservableObject public Func>? OnConductParentCall { get; set; } public Func>? OnManageStudent { get; set; } public Func? OnCreateLetter { get; set; } + public Func? OnCreateStudentReport { get; set; } public Action? OnReturnToStudentList { get; set; } 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 async Task CreateStudentReport() + { + if (Student is not null && OnCreateStudentReport is not null) await OnCreateStudentReport(); + } + [RelayCommand] private async Task CreateLetter() { diff --git a/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml index d731c9e..e3983d4 100644 --- a/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml @@ -4,8 +4,8 @@ x:Class="LehrerApp.Desktop.Views.Groups.ExamCoverSheetDialog" x:DataType="vm:ExamCoverSheetDialogViewModel" Title="Deckblätter drucken" - Width="460" Height="680" - CanResize="False" WindowStartupLocation="CenterOwner"> + Width="460" Height="740" MinWidth="440" MinHeight="500" + CanResize="True" WindowStartupLocation="CenterOwner"> @@ -29,6 +29,14 @@ + + + + + diff --git a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml index bc90c72..577112e 100644 --- a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml +++ b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml @@ -29,6 +29,8 @@ +