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 class StudentImportFormats
|
||||||
{
|
{
|
||||||
public static readonly ImportFormatId MasterDataCsv = new("student-master-data.csv");
|
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 }
|
public enum StudentImportResolutionKind { CreateNew, UseExisting, Skip }
|
||||||
@@ -60,6 +62,7 @@ public sealed class StudentImportPreview
|
|||||||
public IReadOnlyList<ImportMessage> Messages { get; }
|
public IReadOnlyList<ImportMessage> Messages { get; }
|
||||||
public IReadOnlyList<ImportConflict> Conflicts { get; }
|
public IReadOnlyList<ImportConflict> Conflicts { get; }
|
||||||
public IReadOnlyList<StudentImportGroupAssignment> GroupAssignments { get; }
|
public IReadOnlyList<StudentImportGroupAssignment> GroupAssignments { get; }
|
||||||
|
public Guid? TargetGroupId { get; }
|
||||||
internal string ExistingStudentsFingerprint { get; }
|
internal string ExistingStudentsFingerprint { get; }
|
||||||
internal string GroupStateFingerprint { get; }
|
internal string GroupStateFingerprint { get; }
|
||||||
|
|
||||||
@@ -73,6 +76,7 @@ public sealed class StudentImportPreview
|
|||||||
IEnumerable<ImportMessage> messages,
|
IEnumerable<ImportMessage> messages,
|
||||||
IEnumerable<ImportConflict> conflicts,
|
IEnumerable<ImportConflict> conflicts,
|
||||||
IEnumerable<StudentImportGroupAssignment> groupAssignments,
|
IEnumerable<StudentImportGroupAssignment> groupAssignments,
|
||||||
|
Guid? targetGroupId,
|
||||||
string existingStudentsFingerprint,
|
string existingStudentsFingerprint,
|
||||||
string groupStateFingerprint)
|
string groupStateFingerprint)
|
||||||
{
|
{
|
||||||
@@ -82,6 +86,7 @@ public sealed class StudentImportPreview
|
|||||||
Messages = ReadOnly(messages);
|
Messages = ReadOnly(messages);
|
||||||
Conflicts = ReadOnly(conflicts);
|
Conflicts = ReadOnly(conflicts);
|
||||||
GroupAssignments = ReadOnly(groupAssignments);
|
GroupAssignments = ReadOnly(groupAssignments);
|
||||||
|
TargetGroupId = targetGroupId;
|
||||||
ExistingStudentsFingerprint = existingStudentsFingerprint;
|
ExistingStudentsFingerprint = existingStudentsFingerprint;
|
||||||
GroupStateFingerprint = groupStateFingerprint;
|
GroupStateFingerprint = groupStateFingerprint;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,77 +222,4 @@ public sealed class StudentMasterDataCsvImportHandler : IImportHandler<ImportedS
|
|||||||
private static string? NullIfWhiteSpace(string? value) =>
|
private static string? NullIfWhiteSpace(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,19 @@ public sealed class StudentImportService
|
|||||||
|
|
||||||
public async Task<StudentImportPreview> AnalyzeAsync(
|
public async Task<StudentImportPreview> AnalyzeAsync(
|
||||||
ImportFile file,
|
ImportFile file,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default) =>
|
||||||
|
await AnalyzeInternalAsync(file, null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
public async Task<StudentImportPreview> AnalyzeAsync(
|
||||||
|
ImportFile file,
|
||||||
|
Guid targetGroupId,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
await AnalyzeInternalAsync(file, targetGroupId, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
private async Task<StudentImportPreview> AnalyzeInternalAsync(
|
||||||
|
ImportFile file,
|
||||||
|
Guid? targetGroupId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(file);
|
ArgumentNullException.ThrowIfNull(file);
|
||||||
var detected = await _handlers.DetectAsync(file, cancellationToken).ConfigureAwait(false);
|
var detected = await _handlers.DetectAsync(file, cancellationToken).ConfigureAwait(false);
|
||||||
@@ -61,6 +73,15 @@ public sealed class StudentImportService
|
|||||||
|
|
||||||
var existing = _students.GetAll(includeInactive: true);
|
var existing = _students.GetAll(includeInactive: true);
|
||||||
var groups = _groups.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 currentSchoolYear = _schoolYears.CurrentSchoolYear();
|
||||||
var currentGroups = groups
|
var currentGroups = groups
|
||||||
.Where(group => group.IsActive && group.SchoolYear == currentSchoolYear)
|
.Where(group => group.IsActive && group.SchoolYear == currentSchoolYear)
|
||||||
@@ -107,6 +128,17 @@ public sealed class StudentImportService
|
|||||||
? sameName.Where(student => student.DateOfBirth == candidate.DateOfBirth).ToList()
|
? 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)
|
if (exact.Count == 1)
|
||||||
{
|
{
|
||||||
AddDifferenceWarning(candidate, exact[0], messages);
|
AddDifferenceWarning(candidate, exact[0], messages);
|
||||||
@@ -139,8 +171,11 @@ public sealed class StudentImportService
|
|||||||
entries.Add(new StudentImportEntry(entryId, candidate, null, conflictId, []));
|
entries.Add(new StudentImportEntry(entryId, candidate, null, conflictId, []));
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupAssignments = BuildGroupAssignments(imported, currentGroups, conflicts, messages);
|
var groupAssignments = targetGroup is { IsActive: true }
|
||||||
var groupMemberships = groups.SelectMany(group => _memberships.GetByGroup(group.Id)).ToList();
|
? [new StudentImportGroupAssignment(targetGroup.Name, targetGroup.Id, null)]
|
||||||
|
: targetGroupId is null
|
||||||
|
? BuildGroupAssignments(imported, currentGroups, conflicts, messages)
|
||||||
|
: [];
|
||||||
|
|
||||||
return new StudentImportPreview(
|
return new StudentImportPreview(
|
||||||
detected.Handler.FormatId,
|
detected.Handler.FormatId,
|
||||||
@@ -149,6 +184,7 @@ public sealed class StudentImportService
|
|||||||
messages,
|
messages,
|
||||||
conflicts,
|
conflicts,
|
||||||
groupAssignments,
|
groupAssignments,
|
||||||
|
targetGroupId,
|
||||||
Fingerprint(existing),
|
Fingerprint(existing),
|
||||||
GroupStateFingerprint(groups, groupMemberships, currentSchoolYear));
|
GroupStateFingerprint(groups, groupMemberships, currentSchoolYear));
|
||||||
}
|
}
|
||||||
@@ -235,10 +271,9 @@ public sealed class StudentImportService
|
|||||||
foreach (var entry in preview.Entries)
|
foreach (var entry in preview.Entries)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
if (entry.Student.GroupName is not { } sourceGroup
|
if (!studentIdsByEntry.TryGetValue(entry.Id, out var studentId)
|
||||||
|| !studentIdsByEntry.TryGetValue(entry.Id, out var studentId)
|
|
||||||
|| studentId is null
|
|| studentId is null
|
||||||
|| !groupResolutions.TryGetValue(sourceGroup, out var groupId)
|
|| !TryResolveTargetGroup(preview, entry, groupResolutions, out var groupId)
|
||||||
|| groupId is null
|
|| groupId is null
|
||||||
|| _memberships.GetByStudentAndGroup(studentId.Value, groupId.Value) is not null)
|
|| _memberships.GetByStudentAndGroup(studentId.Value, groupId.Value) is not null)
|
||||||
continue;
|
continue;
|
||||||
@@ -262,6 +297,24 @@ public sealed class StudentImportService
|
|||||||
createdIds.AsReadOnly()));
|
createdIds.AsReadOnly()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryResolveTargetGroup(
|
||||||
|
StudentImportPreview preview,
|
||||||
|
StudentImportEntry entry,
|
||||||
|
IReadOnlyDictionary<string, Guid?> 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
|
private static ImportedStudent Normalize(ImportedStudent student) => student with
|
||||||
{
|
{
|
||||||
FirstName = student.FirstName?.Trim() ?? "",
|
FirstName = student.FirstName?.Trim() ?? "",
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<AttendanceBalanceService>();
|
services.AddSingleton<AttendanceBalanceService>();
|
||||||
services.AddSingleton<PersonalDataExportService>();
|
services.AddSingleton<PersonalDataExportService>();
|
||||||
services.AddSingleton<IImportHandler<ImportedStudent>, StudentMasterDataCsvImportHandler>();
|
services.AddSingleton<IImportHandler<ImportedStudent>, StudentMasterDataCsvImportHandler>();
|
||||||
|
services.AddSingleton<IImportHandler<ImportedStudent>, LessonStudentListCsvImportHandler>();
|
||||||
|
services.AddSingleton<IImportHandler<ImportedStudent>, MarksPerLessonCsvImportHandler>();
|
||||||
services.AddSingleton<StudentImportService>();
|
services.AddSingleton<StudentImportService>();
|
||||||
|
|
||||||
// ── Datenbank ─────────────────────────────────────────────────────────
|
// ── Datenbank ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
<CheckBox Content="Ausgetretene anzeigen" IsChecked="{Binding ShowFormerStudents}"
|
<CheckBox Content="Ausgetretene anzeigen" IsChecked="{Binding ShowFormerStudents}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
<Button Content="+ Schüler" Command="{Binding AddStudentCommand}" IsEnabled="{Binding IsEditable}"/>
|
<Button Content="+ Schüler" Command="{Binding AddStudentCommand}" IsEnabled="{Binding IsEditable}"/>
|
||||||
|
<Button Content="⇩ Teilnehmer importieren…" Click="OnImportParticipantsClick"
|
||||||
|
IsEnabled="{Binding IsEditable}"/>
|
||||||
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
|
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
|
||||||
Command="{Binding WithdrawStudentCommand}"
|
Command="{Binding WithdrawStudentCommand}"
|
||||||
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using LehrerApp.Core.Importing;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.Views.Shared;
|
using LehrerApp.Desktop.Views.Shared;
|
||||||
|
using LehrerApp.Desktop.Views.Students;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views.Groups;
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
|
||||||
public partial class GroupDetailView : UserControl
|
public partial class GroupDetailView : UserControl
|
||||||
{
|
{
|
||||||
|
private const int MaximumImportFileSize = 20 * 1024 * 1024;
|
||||||
|
|
||||||
public GroupDetailView() => InitializeComponent();
|
public GroupDetailView() => InitializeComponent();
|
||||||
|
|
||||||
protected override void OnDataContextChanged(EventArgs e)
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
@@ -60,6 +68,61 @@ public partial class GroupDetailView : UserControl
|
|||||||
return await dialog.ShowDialog<bool>(owner);
|
return await dialog.ShowDialog<bool>(owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnImportParticipantsClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner is null || DataContext is not GroupDetailViewModel vm || vm.Group is null) return;
|
||||||
|
|
||||||
|
var service = App.Services.GetRequiredService<StudentImportService>();
|
||||||
|
var patterns = service.SupportedExtensions.Select(extension => $"*{extension}").ToArray();
|
||||||
|
var files = await owner.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = $"Teilnehmer für {vm.Group.Name} importieren",
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter =
|
||||||
|
[
|
||||||
|
new FilePickerFileType("Unterstützte Schülerlisten")
|
||||||
|
{
|
||||||
|
Patterns = patterns.Length > 0 ? patterns : ["*.csv"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (files.Count == 0) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var source = await files[0].OpenReadAsync();
|
||||||
|
if (source.CanSeek && source.Length > MaximumImportFileSize)
|
||||||
|
throw new InvalidDataException("Die Importdatei ist größer als 20 MB.");
|
||||||
|
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
await source.CopyToAsync(buffer);
|
||||||
|
if (buffer.Length > MaximumImportFileSize)
|
||||||
|
throw new InvalidDataException("Die Importdatei ist größer als 20 MB.");
|
||||||
|
|
||||||
|
var importFile = new ImportFile(files[0].Name, buffer.ToArray());
|
||||||
|
var preview = await Task.Run(async () =>
|
||||||
|
await service.AnalyzeAsync(importFile, vm.Group.Id).ConfigureAwait(false));
|
||||||
|
var dialogVm = new StudentImportDialogViewModel(service, preview, files[0].Name);
|
||||||
|
var dialog = new StudentImportDialog { DataContext = dialogVm };
|
||||||
|
if (!await dialog.ShowDialog<bool>(owner)) return;
|
||||||
|
|
||||||
|
vm.LoadStudents();
|
||||||
|
vm.ParticipationTab.RefreshCurrentGrid();
|
||||||
|
var result = dialogVm.Result!;
|
||||||
|
App.Services.GetRequiredService<NotificationService>().ShowSuccess(
|
||||||
|
$"Teilnehmerimport abgeschlossen: {result.CreatedStudents} neu, "
|
||||||
|
+ $"{result.UpdatedStudents} ergänzt, {result.CreatedMemberships} zugeordnet.");
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ImportFormatException
|
||||||
|
or InvalidDataException
|
||||||
|
or IOException
|
||||||
|
or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<bool> ShowWithdrawStudentDialog(StudentSummary student)
|
private async Task<bool> ShowWithdrawStudentDialog(StudentSummary student)
|
||||||
{
|
{
|
||||||
if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false;
|
if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Text;
|
||||||
|
using LehrerApp.Core.Importing;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Tests;
|
||||||
|
|
||||||
|
public sealed class LessonStudentListCsvImportHandlerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Parse_UebernimmtTeilnehmerUndOptionaleKontaktdaten()
|
||||||
|
{
|
||||||
|
var handler = new LessonStudentListCsvImportHandler();
|
||||||
|
var result = await handler.ParseAsync(File(LessonList()));
|
||||||
|
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
var student = Assert.Single(result.Items);
|
||||||
|
Assert.Equal("Max", student.FirstName);
|
||||||
|
Assert.Equal("Beispiel", student.LastName);
|
||||||
|
Assert.Equal("10c", student.GroupName);
|
||||||
|
Assert.Equal(Gender.M, student.Gender);
|
||||||
|
Assert.Equal("max@example.invalid", student.Contact?.Email);
|
||||||
|
Assert.Equal("0151 123", student.Contact?.MobilePhone);
|
||||||
|
Assert.Equal("0123 456", student.Contact?.Phone);
|
||||||
|
Assert.Equal("Zeile 2", student.SourceReference);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Detect_UnterscheidetTeilnehmerlisteVonStammdatenliste()
|
||||||
|
{
|
||||||
|
var handler = new LessonStudentListCsvImportHandler();
|
||||||
|
|
||||||
|
var detection = await handler.DetectAsync(File(LessonList()));
|
||||||
|
|
||||||
|
Assert.True(detection.IsMatch);
|
||||||
|
Assert.Equal(100, detection.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MarksExport_WirdErkanntUndErklaertFehlendeTeilnehmerdaten()
|
||||||
|
{
|
||||||
|
var handler = new MarksPerLessonCsvImportHandler();
|
||||||
|
const string header = "Datum\tName\tKlasse\tFach\tPrüfungsart\tNote\tBemerkung\tBenutzer\tSchlüssel (extern)\tGesamtnote\n";
|
||||||
|
|
||||||
|
var detection = await handler.DetectAsync(File(header));
|
||||||
|
var result = await handler.ParseAsync(File(header));
|
||||||
|
|
||||||
|
Assert.True(detection.IsMatch);
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Empty(result.Items);
|
||||||
|
Assert.Contains(result.Messages, message => message.Code == "marks-per-lesson.no-participants");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImportFile File(string text) => new("export.csv", Encoding.UTF8.GetBytes(text));
|
||||||
|
|
||||||
|
private static string LessonList() => string.Join('\n',
|
||||||
|
"Langname\tVorname\tKlasse\tGeschlecht\tEintrittsdatum\tAustrittsdatum\tE-Mail Adresse\tMobiltelefon\tTelefonnummer",
|
||||||
|
"Beispiel\tMax\t10c\tm\t01.08.2025\t\tmax@example.invalid\t0151 123\t0123 456") + "\n";
|
||||||
|
}
|
||||||
@@ -305,6 +305,67 @@ public sealed class StudentImportServiceTests
|
|||||||
Assert.Equal(ownClass.Id, Assert.Single(preview.GroupAssignments).AutomaticGroupId);
|
Assert.Equal(ownClass.Id, Assert.Single(preview.GroupAssignments).AutomaticGroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Apply_ExpliziteZielgruppeIgnoriertHeimatklasseUndVervollstaendigtTeilnehmerliste()
|
||||||
|
{
|
||||||
|
var repository = new InMemoryStudentRepository();
|
||||||
|
var target = new LearningGroup { Name = "Mathematik 10", IsActive = true };
|
||||||
|
var groups = new InMemoryGroupRepository([target]);
|
||||||
|
var memberships = new InMemoryGroupMembershipRepository();
|
||||||
|
var service = Service(repository,
|
||||||
|
[
|
||||||
|
new ImportedStudent
|
||||||
|
{
|
||||||
|
FirstName = "Max", LastName = "Beispiel", GroupName = "10c",
|
||||||
|
},
|
||||||
|
], groups, memberships);
|
||||||
|
|
||||||
|
var preview = await service.AnalyzeAsync(File(), target.Id);
|
||||||
|
var result = await service.ApplyAsync(preview);
|
||||||
|
|
||||||
|
Assert.True(preview.CanApply);
|
||||||
|
Assert.Equal(target.Id, preview.TargetGroupId);
|
||||||
|
Assert.Equal(target.Id, Assert.Single(preview.GroupAssignments).AutomaticGroupId);
|
||||||
|
Assert.Equal(1, result.CreatedStudents);
|
||||||
|
Assert.Equal(1, result.CreatedMemberships);
|
||||||
|
Assert.Equal(target.Id, Assert.Single(memberships.Memberships).GroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Analyze_ExpliziteZielgruppeErkenntBereitsZugeordnetenNamensgleichenSchueler()
|
||||||
|
{
|
||||||
|
var existing = new Student { FirstName = "Max", LastName = "Beispiel" };
|
||||||
|
var repository = new InMemoryStudentRepository([existing]);
|
||||||
|
var target = new LearningGroup { Name = "Mathematik 10", IsActive = true };
|
||||||
|
var groups = new InMemoryGroupRepository([target]);
|
||||||
|
var memberships = new InMemoryGroupMembershipRepository();
|
||||||
|
memberships.Save(new GroupMembership { StudentId = existing.Id, GroupId = target.Id });
|
||||||
|
var service = Service(repository,
|
||||||
|
[new ImportedStudent { FirstName = "Max", LastName = "Beispiel" }],
|
||||||
|
groups, memberships);
|
||||||
|
|
||||||
|
var preview = await service.AnalyzeAsync(File(), target.Id);
|
||||||
|
|
||||||
|
Assert.Empty(preview.Conflicts);
|
||||||
|
var resolution = Assert.Single(preview.Entries).AutomaticResolution;
|
||||||
|
Assert.Equal(StudentImportResolutionKind.UseExisting, resolution?.Kind);
|
||||||
|
Assert.Equal(existing.Id, resolution?.ExistingStudentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Analyze_ArchivierteZielgruppeVerhindertImport()
|
||||||
|
{
|
||||||
|
var target = new LearningGroup { Name = "Alter Kurs", IsActive = false };
|
||||||
|
var service = Service(new InMemoryStudentRepository(),
|
||||||
|
[new ImportedStudent { FirstName = "Max", LastName = "Beispiel" }],
|
||||||
|
new InMemoryGroupRepository([target]));
|
||||||
|
|
||||||
|
var preview = await service.AnalyzeAsync(File(), target.Id);
|
||||||
|
|
||||||
|
Assert.False(preview.CanApply);
|
||||||
|
Assert.Contains(preview.Messages, message => message.Code == "student.target-group.unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
private static StudentImportService Service(
|
private static StudentImportService Service(
|
||||||
InMemoryStudentRepository repository,
|
InMemoryStudentRepository repository,
|
||||||
IReadOnlyList<ImportedStudent> imported,
|
IReadOnlyList<ImportedStudent> imported,
|
||||||
|
|||||||
Reference in New Issue
Block a user