From 99efa3a0fea756d60dd3f8049ff0a26a6e3b07db Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Tue, 18 Aug 2026 00:44:07 +0200 Subject: [PATCH] feat: Teilnehmerlisten in Lerngruppen importieren --- .../Importing/DelimitedTextParser.cs | 77 +++++++++++ .../LessonStudentListCsvImportHandler.cs | 121 ++++++++++++++++++ .../MarksPerLessonCsvImportHandler.cs | 40 ++++++ .../Importing/StudentImportModels.cs | 5 + .../StudentMasterDataCsvImportHandler.cs | 73 ----------- .../Services/StudentImportService.cs | 65 +++++++++- LehrerApp.Desktop/AppBootstrapper.cs | 2 + .../Views/Groups/GroupDetailView.axaml | 2 + .../Views/Groups/GroupDetailView.axaml.cs | 63 +++++++++ .../LessonStudentListCsvImportHandlerTests.cs | 59 +++++++++ LehrerApp.Tests/StudentImportServiceTests.cs | 61 +++++++++ 11 files changed, 489 insertions(+), 79 deletions(-) create mode 100644 LehrerApp.Core/Importing/DelimitedTextParser.cs create mode 100644 LehrerApp.Core/Importing/LessonStudentListCsvImportHandler.cs create mode 100644 LehrerApp.Core/Importing/MarksPerLessonCsvImportHandler.cs create mode 100644 LehrerApp.Tests/LessonStudentListCsvImportHandlerTests.cs diff --git a/LehrerApp.Core/Importing/DelimitedTextParser.cs b/LehrerApp.Core/Importing/DelimitedTextParser.cs new file mode 100644 index 0000000..d56ba60 --- /dev/null +++ b/LehrerApp.Core/Importing/DelimitedTextParser.cs @@ -0,0 +1,77 @@ +using System.Text; + +namespace LehrerApp.Core.Importing; + +internal sealed record DelimitedRecord(int LineNumber, IReadOnlyList Fields); + +internal static class DelimitedTextParser +{ + public static bool TryParse( + string text, + char delimiter, + out List records, + out string? error) + { + records = []; + error = null; + var fields = new List(); + var field = new StringBuilder(); + var inQuotes = false; + var line = 1; + var recordStartLine = 1; + + for (var index = 0; index < text.Length; index++) + { + var current = text[index]; + if (current == '"') + { + if (inQuotes && index + 1 < text.Length && text[index + 1] == '"') + { + field.Append('"'); + index++; + } + else + { + inQuotes = !inQuotes; + } + continue; + } + + if (current == delimiter && !inQuotes) + { + fields.Add(field.ToString()); + field.Clear(); + continue; + } + + if ((current == '\r' || current == '\n') && !inQuotes) + { + if (current == '\r' && index + 1 < text.Length && text[index + 1] == '\n') + index++; + fields.Add(field.ToString()); + field.Clear(); + records.Add(new DelimitedRecord(recordStartLine, fields.ToList())); + fields.Clear(); + line++; + recordStartLine = line; + continue; + } + + if (current == '\n') line++; + field.Append(current); + } + + if (inQuotes) + { + error = $"Ein Textfeld ab Zeile {recordStartLine} besitzt kein schließendes Anführungszeichen."; + return false; + } + + if (field.Length > 0 || fields.Count > 0) + { + fields.Add(field.ToString()); + records.Add(new DelimitedRecord(recordStartLine, fields.ToList())); + } + return true; + } +} diff --git a/LehrerApp.Core/Importing/LessonStudentListCsvImportHandler.cs b/LehrerApp.Core/Importing/LessonStudentListCsvImportHandler.cs new file mode 100644 index 0000000..193c1df --- /dev/null +++ b/LehrerApp.Core/Importing/LessonStudentListCsvImportHandler.cs @@ -0,0 +1,121 @@ +using System.Text; +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Importing; + +/// Importiert den tabulatorgetrennten Export „Teilnehmerliste“ eines Unterrichts. +public sealed class LessonStudentListCsvImportHandler : IImportHandler +{ + private static readonly string[] SignatureHeaders = + ["Langname", "Vorname", "Klasse", "Geschlecht", "Eintrittsdatum", "Austrittsdatum", + "E-Mail Adresse", "Mobiltelefon", "Telefonnummer"]; + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + public ImportFormatId FormatId => StudentImportFormats.LessonStudentListCsv; + public string DisplayName => "Unterrichts-Teilnehmerliste"; + public IReadOnlyCollection SupportedExtensions { get; } = [".csv"]; + + public ValueTask DetectAsync(ImportFile file, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!TryDecode(file, out var text)) + return ValueTask.FromResult(ImportDetection.NoMatch("Die Datei ist kein gültiges UTF-8.")); + var headers = FirstLine(text).Split('\t').Select(x => x.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return ValueTask.FromResult(SignatureHeaders.All(headers.Contains) + ? ImportDetection.Match(file.Extension.Equals(".csv", StringComparison.OrdinalIgnoreCase) ? 100 : 95, + "Die charakteristischen Spalten der Unterrichts-Teilnehmerliste wurden gefunden.") + : ImportDetection.NoMatch("Die erwarteten Spalten der Unterrichts-Teilnehmerliste fehlen.")); + } + + public ValueTask> ParseAsync(ImportFile file, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var messages = new List(); + if (!TryDecode(file, out var text)) + return Result([], [Error("lesson-students.encoding", "Die Teilnehmerliste ist nicht als gültiges UTF-8 gespeichert.")]); + if (!DelimitedTextParser.TryParse(text, '\t', out var records, out var parseError)) + return Result([], [Error("lesson-students.structure", parseError ?? "Die Teilnehmerliste ist syntaktisch ungültig.")]); + if (records.Count == 0) + return Result([], [Error("lesson-students.empty", "Die Teilnehmerliste ist leer.")]); + + var header = records[0].Fields.Select((name, index) => (Name: name.Trim().TrimStart('\uFEFF'), Index: index)).ToList(); + var duplicate = header.GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(x => x.Count() > 1); + if (duplicate is not null) + messages.Add(Error("lesson-students.header.duplicate", $"Die Spalte „{duplicate.Key}“ kommt mehrfach vor.", "Kopfzeile")); + var columns = header.GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.First().Index, StringComparer.OrdinalIgnoreCase); + foreach (var required in SignatureHeaders.Where(x => !columns.ContainsKey(x))) + messages.Add(Error("lesson-students.header.missing", $"Die erforderliche Spalte „{required}“ fehlt.", "Kopfzeile")); + if (messages.Any(x => x.Severity == ImportMessageSeverity.Error)) return Result([], messages); + + var students = new List(); + foreach (var record in records.Skip(1)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (record.Fields.All(string.IsNullOrWhiteSpace)) continue; + var source = $"Zeile {record.LineNumber}"; + if (record.Fields.Count != header.Count) + { + messages.Add(Error("lesson-students.columns", + $"Die Zeile enthält {record.Fields.Count} statt {header.Count} Spalten.", source)); + continue; + } + students.Add(new ImportedStudent + { + LastName = Value(record, columns, "Langname"), + FirstName = Value(record, columns, "Vorname"), + GroupName = NullIfEmpty(Value(record, columns, "Klasse")), + Gender = ParseGender(Value(record, columns, "Geschlecht"), source, messages), + Contact = new ImportedStudentContact + { + Email = NullIfEmpty(Value(record, columns, "E-Mail Adresse")), + MobilePhone = NullIfEmpty(Value(record, columns, "Mobiltelefon")), + Phone = NullIfEmpty(Value(record, columns, "Telefonnummer")), + }, + SourceReference = source, + }); + } + return Result(students, messages); + } + + private static Gender? ParseGender(string value, string source, ICollection messages) + { + if (string.IsNullOrWhiteSpace(value)) return null; + return value.Trim().ToLowerInvariant() switch + { + "m" or "männlich" or "maennlich" => Gender.M, + "w" or "weiblich" => Gender.W, + "d" or "divers" => Gender.D, + _ => UnknownGender(value, source, messages), + }; + } + + private static Gender? UnknownGender(string value, string source, ICollection messages) + { + messages.Add(new ImportMessage(ImportMessageSeverity.Warning, "lesson-students.gender.unknown", + $"Das Geschlecht „{value.Trim()}“ ist unbekannt und wird nicht übernommen.", source)); + return null; + } + + private static bool TryDecode(ImportFile file, out string text) + { + try { text = StrictUtf8.GetString(file.Content.Span); return true; } + catch (DecoderFallbackException) { text = ""; return false; } + } + + private static string FirstLine(string text) + { + var end = text.IndexOfAny(['\r', '\n']); + return (end >= 0 ? text[..end] : text).TrimStart('\uFEFF'); + } + + private static string Value(DelimitedRecord row, IReadOnlyDictionary columns, string name) => + columns.TryGetValue(name, out var index) && index < row.Fields.Count ? row.Fields[index].Trim() : ""; + private static string? NullIfEmpty(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static ImportMessage Error(string code, string message, string? source = null) => + new(ImportMessageSeverity.Error, code, message, source); + private static ValueTask> Result( + IReadOnlyList students, IReadOnlyList messages) => + ValueTask.FromResult(new ImportParseResult(students, messages)); +} diff --git a/LehrerApp.Core/Importing/MarksPerLessonCsvImportHandler.cs b/LehrerApp.Core/Importing/MarksPerLessonCsvImportHandler.cs new file mode 100644 index 0000000..4bb7f50 --- /dev/null +++ b/LehrerApp.Core/Importing/MarksPerLessonCsvImportHandler.cs @@ -0,0 +1,40 @@ +using System.Text; + +namespace LehrerApp.Core.Importing; + +/// Erkennt den Notenlistenexport und erklärt dessen fehlende Teilnehmerdaten. +public sealed class MarksPerLessonCsvImportHandler : IImportHandler +{ + private static readonly string[] SignatureHeaders = + ["Datum", "Name", "Klasse", "Fach", "Prüfungsart", "Note", "Bemerkung", "Benutzer", "Schlüssel (extern)", "Gesamtnote"]; + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + public ImportFormatId FormatId => StudentImportFormats.MarksPerLessonCsv; + public string DisplayName => "Unterrichts-Notenliste"; + public IReadOnlyCollection SupportedExtensions { get; } = [".csv"]; + + public ValueTask DetectAsync(ImportFile file, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + string text; + try { text = StrictUtf8.GetString(file.Content.Span); } + catch (DecoderFallbackException) { return ValueTask.FromResult(ImportDetection.NoMatch()); } + var end = text.IndexOfAny(['\r', '\n']); + var firstLine = (end >= 0 ? text[..end] : text).TrimStart('\uFEFF'); + var headers = firstLine.Split('\t').Select(x => x.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase); + return ValueTask.FromResult(SignatureHeaders.All(headers.Contains) + ? ImportDetection.Match(file.Extension.Equals(".csv", StringComparison.OrdinalIgnoreCase) ? 100 : 95) + : ImportDetection.NoMatch()); + } + + public ValueTask> ParseAsync(ImportFile file, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + IReadOnlyList messages = + [ + new(ImportMessageSeverity.Error, "marks-per-lesson.no-participants", + "Die Notenliste enthält keine verlässlichen Teilnehmerdaten. Bitte exportiere die echte Kurs-/Teilnehmerliste.") + ]; + return ValueTask.FromResult(new ImportParseResult([], messages)); + } +} diff --git a/LehrerApp.Core/Importing/StudentImportModels.cs b/LehrerApp.Core/Importing/StudentImportModels.cs index 453ee40..1c48d87 100644 --- a/LehrerApp.Core/Importing/StudentImportModels.cs +++ b/LehrerApp.Core/Importing/StudentImportModels.cs @@ -32,6 +32,8 @@ public sealed record ImportedStudent public static class StudentImportFormats { public static readonly ImportFormatId MasterDataCsv = new("student-master-data.csv"); + public static readonly ImportFormatId LessonStudentListCsv = new("lesson-student-list.csv"); + public static readonly ImportFormatId MarksPerLessonCsv = new("marks-per-lesson.csv"); } public enum StudentImportResolutionKind { CreateNew, UseExisting, Skip } @@ -60,6 +62,7 @@ public sealed class StudentImportPreview public IReadOnlyList Messages { get; } public IReadOnlyList Conflicts { get; } public IReadOnlyList GroupAssignments { get; } + public Guid? TargetGroupId { get; } internal string ExistingStudentsFingerprint { get; } internal string GroupStateFingerprint { get; } @@ -73,6 +76,7 @@ public sealed class StudentImportPreview IEnumerable messages, IEnumerable conflicts, IEnumerable groupAssignments, + Guid? targetGroupId, string existingStudentsFingerprint, string groupStateFingerprint) { @@ -82,6 +86,7 @@ public sealed class StudentImportPreview Messages = ReadOnly(messages); Conflicts = ReadOnly(conflicts); GroupAssignments = ReadOnly(groupAssignments); + TargetGroupId = targetGroupId; ExistingStudentsFingerprint = existingStudentsFingerprint; GroupStateFingerprint = groupStateFingerprint; } diff --git a/LehrerApp.Core/Importing/StudentMasterDataCsvImportHandler.cs b/LehrerApp.Core/Importing/StudentMasterDataCsvImportHandler.cs index f5c2928..c920c28 100644 --- a/LehrerApp.Core/Importing/StudentMasterDataCsvImportHandler.cs +++ b/LehrerApp.Core/Importing/StudentMasterDataCsvImportHandler.cs @@ -222,77 +222,4 @@ public sealed class StudentMasterDataCsvImportHandler : IImportHandler string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - private sealed record DelimitedRecord(int LineNumber, IReadOnlyList Fields); - - private static class DelimitedTextParser - { - public static bool TryParse( - string text, - char delimiter, - out List records, - out string? error) - { - records = []; - error = null; - var fields = new List(); - var field = new StringBuilder(); - var inQuotes = false; - var line = 1; - var recordStartLine = 1; - - for (var index = 0; index < text.Length; index++) - { - var current = text[index]; - if (current == '"') - { - if (inQuotes && index + 1 < text.Length && text[index + 1] == '"') - { - field.Append('"'); - index++; - } - else - { - inQuotes = !inQuotes; - } - continue; - } - - if (current == delimiter && !inQuotes) - { - fields.Add(field.ToString()); - field.Clear(); - continue; - } - - if ((current == '\r' || current == '\n') && !inQuotes) - { - if (current == '\r' && index + 1 < text.Length && text[index + 1] == '\n') - index++; - fields.Add(field.ToString()); - field.Clear(); - records.Add(new DelimitedRecord(recordStartLine, fields.ToList())); - fields.Clear(); - line++; - recordStartLine = line; - continue; - } - - if (current == '\n') line++; - field.Append(current); - } - - if (inQuotes) - { - error = $"Ein Textfeld ab Zeile {recordStartLine} besitzt kein schließendes Anführungszeichen."; - return false; - } - - if (field.Length > 0 || fields.Count > 0) - { - fields.Add(field.ToString()); - records.Add(new DelimitedRecord(recordStartLine, fields.ToList())); - } - return true; - } - } } diff --git a/LehrerApp.Core/Services/StudentImportService.cs b/LehrerApp.Core/Services/StudentImportService.cs index 936b8ca..37c2b38 100644 --- a/LehrerApp.Core/Services/StudentImportService.cs +++ b/LehrerApp.Core/Services/StudentImportService.cs @@ -43,7 +43,19 @@ public sealed class StudentImportService public async Task AnalyzeAsync( ImportFile file, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default) => + await AnalyzeInternalAsync(file, null, cancellationToken).ConfigureAwait(false); + + public async Task AnalyzeAsync( + ImportFile file, + Guid targetGroupId, + CancellationToken cancellationToken = default) => + await AnalyzeInternalAsync(file, targetGroupId, cancellationToken).ConfigureAwait(false); + + private async Task AnalyzeInternalAsync( + ImportFile file, + Guid? targetGroupId, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(file); var detected = await _handlers.DetectAsync(file, cancellationToken).ConfigureAwait(false); @@ -61,6 +73,15 @@ public sealed class StudentImportService var existing = _students.GetAll(includeInactive: true); var groups = _groups.GetAll(includeInactive: true); + var groupMemberships = groups.SelectMany(group => _memberships.GetByGroup(group.Id)).ToList(); + var targetGroup = targetGroupId is null + ? null + : groups.SingleOrDefault(group => group.Id == targetGroupId.Value); + if (targetGroupId is not null && targetGroup is not { IsActive: true }) + messages.Add(new ImportMessage( + ImportMessageSeverity.Error, + "student.target-group.unavailable", + "Die Ziel-Lerngruppe existiert nicht oder ist archiviert.")); var currentSchoolYear = _schoolYears.CurrentSchoolYear(); var currentGroups = groups .Where(group => group.IsActive && group.SchoolYear == currentSchoolYear) @@ -107,6 +128,17 @@ public sealed class StudentImportService ? sameName.Where(student => student.DateOfBirth == candidate.DateOfBirth).ToList() : []; + if (candidate.DateOfBirth is null && exact.Count == 0 && targetGroup is not null) + { + var memberIds = groupMemberships + .Where(membership => membership.GroupId == targetGroup.Id) + .Select(membership => membership.StudentId) + .ToHashSet(); + var memberMatches = sameName.Where(student => memberIds.Contains(student.Id)).ToList(); + if (memberMatches.Count == 1) + exact = memberMatches; + } + if (exact.Count == 1) { AddDifferenceWarning(candidate, exact[0], messages); @@ -139,8 +171,11 @@ public sealed class StudentImportService entries.Add(new StudentImportEntry(entryId, candidate, null, conflictId, [])); } - var groupAssignments = BuildGroupAssignments(imported, currentGroups, conflicts, messages); - var groupMemberships = groups.SelectMany(group => _memberships.GetByGroup(group.Id)).ToList(); + var groupAssignments = targetGroup is { IsActive: true } + ? [new StudentImportGroupAssignment(targetGroup.Name, targetGroup.Id, null)] + : targetGroupId is null + ? BuildGroupAssignments(imported, currentGroups, conflicts, messages) + : []; return new StudentImportPreview( detected.Handler.FormatId, @@ -149,6 +184,7 @@ public sealed class StudentImportService messages, conflicts, groupAssignments, + targetGroupId, Fingerprint(existing), GroupStateFingerprint(groups, groupMemberships, currentSchoolYear)); } @@ -235,10 +271,9 @@ public sealed class StudentImportService foreach (var entry in preview.Entries) { cancellationToken.ThrowIfCancellationRequested(); - if (entry.Student.GroupName is not { } sourceGroup - || !studentIdsByEntry.TryGetValue(entry.Id, out var studentId) + if (!studentIdsByEntry.TryGetValue(entry.Id, out var studentId) || studentId is null - || !groupResolutions.TryGetValue(sourceGroup, out var groupId) + || !TryResolveTargetGroup(preview, entry, groupResolutions, out var groupId) || groupId is null || _memberships.GetByStudentAndGroup(studentId.Value, groupId.Value) is not null) continue; @@ -262,6 +297,24 @@ public sealed class StudentImportService createdIds.AsReadOnly())); } + private static bool TryResolveTargetGroup( + StudentImportPreview preview, + StudentImportEntry entry, + IReadOnlyDictionary groupResolutions, + out Guid? groupId) + { + if (preview.TargetGroupId is not null) + { + groupId = preview.TargetGroupId; + return true; + } + if (entry.Student.GroupName is { } sourceGroup + && groupResolutions.TryGetValue(sourceGroup, out groupId)) + return true; + groupId = null; + return false; + } + private static ImportedStudent Normalize(ImportedStudent student) => student with { FirstName = student.FirstName?.Trim() ?? "", diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index d190302..60097a5 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -113,6 +113,8 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton, StudentMasterDataCsvImportHandler>(); + services.AddSingleton, LessonStudentListCsvImportHandler>(); + services.AddSingleton, MarksPerLessonCsvImportHandler>(); services.AddSingleton(); // ── Datenbank ───────────────────────────────────────────────────────── diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml index cda8b18..a5d8057 100644 --- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml +++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml @@ -23,6 +23,8 @@