feat: Teilnehmerlisten in Lerngruppen importieren
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
using System.Text;
|
||||
|
||||
namespace LehrerApp.Core.Importing;
|
||||
|
||||
internal sealed record DelimitedRecord(int LineNumber, IReadOnlyList<string> Fields);
|
||||
|
||||
internal static class DelimitedTextParser
|
||||
{
|
||||
public static bool TryParse(
|
||||
string text,
|
||||
char delimiter,
|
||||
out List<DelimitedRecord> records,
|
||||
out string? error)
|
||||
{
|
||||
records = [];
|
||||
error = null;
|
||||
var fields = new List<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Text;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Importing;
|
||||
|
||||
/// <summary>Importiert den tabulatorgetrennten Export „Teilnehmerliste“ eines Unterrichts.</summary>
|
||||
public sealed class LessonStudentListCsvImportHandler : IImportHandler<ImportedStudent>
|
||||
{
|
||||
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<string> SupportedExtensions { get; } = [".csv"];
|
||||
|
||||
public ValueTask<ImportDetection> 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<ImportParseResult<ImportedStudent>> ParseAsync(ImportFile file, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var messages = new List<ImportMessage>();
|
||||
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<ImportedStudent>();
|
||||
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<ImportMessage> 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<ImportMessage> 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<string, int> 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<ImportParseResult<ImportedStudent>> Result(
|
||||
IReadOnlyList<ImportedStudent> students, IReadOnlyList<ImportMessage> messages) =>
|
||||
ValueTask.FromResult(new ImportParseResult<ImportedStudent>(students, messages));
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text;
|
||||
|
||||
namespace LehrerApp.Core.Importing;
|
||||
|
||||
/// <summary>Erkennt den Notenlistenexport und erklärt dessen fehlende Teilnehmerdaten.</summary>
|
||||
public sealed class MarksPerLessonCsvImportHandler : IImportHandler<ImportedStudent>
|
||||
{
|
||||
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<string> SupportedExtensions { get; } = [".csv"];
|
||||
|
||||
public ValueTask<ImportDetection> 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<ImportParseResult<ImportedStudent>> ParseAsync(ImportFile file, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
IReadOnlyList<ImportMessage> 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<ImportedStudent>([], messages));
|
||||
}
|
||||
}
|
||||
@@ -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<ImportMessage> Messages { get; }
|
||||
public IReadOnlyList<ImportConflict> Conflicts { get; }
|
||||
public IReadOnlyList<StudentImportGroupAssignment> GroupAssignments { get; }
|
||||
public Guid? TargetGroupId { get; }
|
||||
internal string ExistingStudentsFingerprint { get; }
|
||||
internal string GroupStateFingerprint { get; }
|
||||
|
||||
@@ -73,6 +76,7 @@ public sealed class StudentImportPreview
|
||||
IEnumerable<ImportMessage> messages,
|
||||
IEnumerable<ImportConflict> conflicts,
|
||||
IEnumerable<StudentImportGroupAssignment> 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;
|
||||
}
|
||||
|
||||
@@ -222,77 +222,4 @@ public sealed class StudentMasterDataCsvImportHandler : IImportHandler<ImportedS
|
||||
private static string? NullIfWhiteSpace(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private sealed record DelimitedRecord(int LineNumber, IReadOnlyList<string> Fields);
|
||||
|
||||
private static class DelimitedTextParser
|
||||
{
|
||||
public static bool TryParse(
|
||||
string text,
|
||||
char delimiter,
|
||||
out List<DelimitedRecord> records,
|
||||
out string? error)
|
||||
{
|
||||
records = [];
|
||||
error = null;
|
||||
var fields = new List<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user