4 Commits
Author SHA1 Message Date
adminandClaude Sonnet 5 762c8236fe fix: Sync-Statusanzeige zeigt nach Neustart fälschlich "nicht konfiguriert"
SyncEngine feuert sein erstes StatusChanged bereits im eigenen
Konstruktor. Da SyncEngine und SyncStatusViewModel beide DI-Singletons
sind und Letzterer Ersteren erst innerhalb der eigenen Factory aus dem
Container holt, lief dieser erste Broadcast ab, bevor
SyncStatusViewModel überhaupt abonniert hatte - der Status ging
verloren, StatusText blieb bis zum nächsten Auto-Sync oder manuellen
Sync beim hartcodierten Default "Kein Server konfiguriert".

Fix: Konstruktor ruft nach dem Abonnieren zusätzlich einmal
OnStatus(engine.Status) mit dem bereits vorhandenen aktuellen Zustand
auf. Regressionstest ergänzt - dabei fehlte LehrerApp.Desktop.Tests
das DisableTestParallelization-Attribut (gleicher bekannter
LiteDB-BsonMapper.Global-Bug wie in den anderen Testprojekten, sobald
zwei Testklassen parallel LiteDbContext konstruieren).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 01:14:22 +02:00
adminandClaude Sonnet 5 f8058b537b feat: Geräte-Pairing-UI + Einstellungen-Neustrukturierung
- Sync-Settings-Tab: neuer Bereich "Gerät koppeln" (10.3.1), nutzt den
  bereits vorhandenen, bisher nirgends verdrahteten SnapshotService.
  Ein Gerät erzeugt per CreatePairingCode einen verschlüsselten
  DB-Snapshot + Einmal-Code, das zweite Gerät übernimmt per
  RedeemPairingCode Datenbank und Sync-Schlüssel. Bestätigungsdialog
  vor dem Einlösen, da die lokale Datenbank dabei vollständig ersetzt
  wird (alter Stand wird automatisch als Backup gesichert).
- Einstellungen: TabPlacement von Top auf Left umgestellt (vertikale
  Liste statt langem horizontalem Balken) und die 13 Tabs in drei
  logische Gruppen sortiert (Fachliches / Zeitplanung / System).
- Neuer SettingsTab-Enum ersetzt rohe int-Tab-Indizes bei
  MainWindowViewModel.NavigateToSettings/
  TimetableViewModel.OnNavigateToSettings. Dabei einen bestehenden Bug
  gefunden und mitbehoben: das Zahnrad im Stundenplan öffnete über den
  hartcodierten Index 7 tatsächlich "Datenschutz" statt des laut
  Kommentar/Tooltip beabsichtigten "Ferien & Feiertage".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 01:09:25 +02:00
admin 65c2211dab feat: Importkonflikte gesammelt übernehmen 2026-08-18 00:52:05 +02:00
admin 99efa3a0fe feat: Teilnehmerlisten in Lerngruppen importieren 2026-08-18 00:44:07 +02:00
26 changed files with 1059 additions and 316 deletions
@@ -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() ?? "",
+7
View File
@@ -0,0 +1,7 @@
using Xunit;
// Gleicher Grund wie in LehrerApp.Data.Tests/AssemblyInfo.cs: LiteDBs geteilter, statischer
// BsonMapper.Global verträgt keine parallele Erstzuordnung von Typ-Metadaten über mehrere
// Testklassen hinweg (SettingsViewModelTests/SyncStatusViewModelTests konstruieren beide
// LiteDbContext).
[assembly: CollectionBehavior(DisableTestParallelization = true)]
@@ -56,6 +56,35 @@ public sealed class StudentImportDialogViewModelTests
Assert.Single(stored); Assert.Single(stored);
} }
[Fact]
public async Task UebernehmenFuerAlle_WaehltBeiJedemEindeutigenKonfliktDenVorhandenenSchueler()
{
var stored = new List<Student>
{
new() { FirstName = "Max", LastName = "Beispiel" },
new() { FirstName = "Erika", LastName = "Muster" },
};
var service = Service(stored,
[
new ImportedStudent { FirstName = "Max", LastName = "Beispiel" },
new ImportedStudent { FirstName = "Erika", LastName = "Muster" },
]);
var preview = await service.AnalyzeAsync(File());
var vm = new StudentImportDialogViewModel(service, preview, "students.csv");
Assert.True(vm.CanUseExistingForAll);
Assert.All(vm.Conflicts, conflict => Assert.Equal("skip", conflict.SelectedOption.Id));
vm.UseExistingForAll();
Assert.All(vm.Conflicts,
conflict => Assert.StartsWith("existing:", conflict.SelectedOption.Id));
Assert.True(await vm.TryApplyAsync());
Assert.Equal(2, vm.Result?.MatchedExistingStudents);
Assert.Equal(0, vm.Result?.SkippedStudents);
Assert.Equal(2, stored.Count);
}
private static StudentImportService Service( private static StudentImportService Service(
List<Student> stored, List<Student> stored,
IReadOnlyList<ImportedStudent> imported) => new( IReadOnlyList<ImportedStudent> imported) => new(
@@ -0,0 +1,62 @@
using LehrerApp.Data;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class SyncStatusViewModelTests
{
[Fact]
public void Konstruktor_EngineBereitsVorhanden_ZeigtNichtDenNichtKonfiguriertText()
{
// SyncEngine feuert sein erstes StatusChanged schon im eigenen Konstruktor - läuft der vor
// dem Abonnieren in SyncStatusViewModel (z.B. weil beide DI-Singletons sind und
// GetService<SyncEngine>() den Engine-Konstruktor erst innerhalb der VM-Factory auslöst),
// ginge dieser erste Status verloren und StatusText bliebe fälschlich beim Default.
using var temp = new TempSyncEngine();
var vm = new SyncStatusViewModel(temp.Engine);
Assert.True(vm.IsServerConfigured);
Assert.NotEqual("Kein Server konfiguriert", vm.StatusText);
Assert.Equal("Synchronisiert", vm.StatusText);
}
[Fact]
public void Konstruktor_KeineEngine_ZeigtNichtKonfiguriertText()
{
var vm = new SyncStatusViewModel(null);
Assert.False(vm.IsServerConfigured);
Assert.Equal("Kein Server konfiguriert", vm.StatusText);
}
private sealed class TempSyncEngine : IDisposable
{
private readonly string _queuePath = Path.Combine(
Path.GetTempPath(), $"lehrerapp-desktop-tests-{Guid.NewGuid():N}.db");
private readonly LiteDbContext _db = new(new MemoryStream());
private readonly HttpClient _http = new();
public SyncEngine Engine { get; }
public TempSyncEngine()
{
var queue = new EventQueue(_queuePath);
var key = SyncCrypto.GenerateKey();
Engine = new SyncEngine(
queue, new ConflictResolver(queue), new EventApplier(_db, key),
new AttachmentSyncer(_db, _http, key), _http,
new SyncConfig { AutoSyncIntervalMinutes = 5 });
}
public void Dispose()
{
Engine.Dispose(); // disposed auch die EventQueue
_db.Dispose();
_http.Dispose();
if (File.Exists(_queuePath)) File.Delete(_queuePath);
}
}
}
@@ -1,6 +1,7 @@
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
using Xunit; using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -151,12 +152,12 @@ public sealed class TimetableViewModelTests
public void OpenSettings_RuftOnNavigateToSettingsAuf() public void OpenSettings_RuftOnNavigateToSettingsAuf()
{ {
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([])); var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]));
var called = false; SettingsTab? navigatedTab = null;
vm.OnNavigateToSettings = () => called = true; vm.OnNavigateToSettings = tab => navigatedTab = tab;
vm.OpenSettingsCommand.Execute(null); vm.OpenSettingsCommand.Execute(null);
Assert.True(called); Assert.Equal(SettingsTab.Holidays, navigatedTab);
} }
// ── "Heute"-Ansicht: Wochenraster (Nutzer-Feedback, zweite Iteration) ──────────────────── // ── "Heute"-Ansicht: Wochenraster (Nutzer-Feedback, zweite Iteration) ────────────────────
+1 -1
View File
@@ -109,7 +109,7 @@ public class App : Application
// Stundenplan "Heute" → GroupDetail (Tab "Planung") / Einstellungen (Zahnrad, Tab "Ferien & Feiertage") // Stundenplan "Heute" → GroupDetail (Tab "Planung") / Einstellungen (Zahnrad, Tab "Ferien & Feiertage")
var timetable = Services.GetRequiredService<TimetableViewModel>(); var timetable = Services.GetRequiredService<TimetableViewModel>();
timetable.OnNavigateToSettings = () => main.NavigateToSettings(7); timetable.OnNavigateToSettings = tab => main.NavigateToSettings(tab);
timetable.OnNavigateToGroup = id => main.NavigateToGroupDetail(id, 6); timetable.OnNavigateToGroup = id => main.NavigateToGroupDetail(id, 6);
} }
+2
View File
@@ -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 ─────────────────────────────────────────────────────────
@@ -116,11 +116,11 @@ public partial class MainWindowViewModel : ObservableObject
vm.LoadGroup(groupId); // dann Daten laden vm.LoadGroup(groupId); // dann Daten laden
} }
public void NavigateToSettings(int initialTab = 0) public void NavigateToSettings(SettingsTab initialTab = SettingsTab.Subjects)
{ {
ActiveNavItem = NavItem.Settings; ActiveNavItem = NavItem.Settings;
var vm = _services.GetRequiredService<SettingsViewModel>(); var vm = _services.GetRequiredService<SettingsViewModel>();
vm.ActiveTabIndex = initialTab; vm.ActiveTabIndex = (int)initialTab;
CurrentPage = vm; CurrentPage = vm;
} }
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
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.Settings;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Planning; namespace LehrerApp.Desktop.ViewModels.Planning;
@@ -78,7 +79,7 @@ public partial class TimetableViewModel : ObservableObject
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; } public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; } public Action<Guid>? OnNavigateToGroup { get; set; }
public Func<Task>? OnAddSubstitution { get; set; } public Func<Task>? OnAddSubstitution { get; set; }
public Action? OnNavigateToSettings { get; set; } public Action<SettingsTab>? OnNavigateToSettings { get; set; }
public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups, public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams, ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
@@ -234,7 +235,7 @@ public partial class TimetableViewModel : ObservableObject
} }
[RelayCommand] [RelayCommand]
private void OpenSettings() => OnNavigateToSettings?.Invoke(); private void OpenSettings() => OnNavigateToSettings?.Invoke(SettingsTab.Holidays);
// ── "Heute": Wochenraster (Nutzer-Feedback) — wie das Bearbeiten-Raster, aber nur Anzeige ── // ── "Heute": Wochenraster (Nutzer-Feedback) — wie das Bearbeiten-Raster, aber nur Anzeige ──
@@ -14,6 +14,28 @@ using System.Text.Json.Serialization;
namespace LehrerApp.Desktop.ViewModels.Settings; namespace LehrerApp.Desktop.ViewModels.Settings;
/// <summary>
/// Reihenfolge MUSS exakt der Reihenfolge der ContentPage-Elemente in SettingsView.axaml
/// entsprechen (ActiveTabIndex bindet per Index, nicht per Name) — beim Umsortieren eines
/// Tabs in der XAML immer auch hier den Enum-Wert verschieben.
/// </summary>
public enum SettingsTab
{
Subjects = 0,
ShorthandCodes = 1,
Competencies = 2,
GradingKeyTemplates = 3,
GradingScheme = 4,
LetterTemplates = 5,
Holidays = 6,
PeriodSchedule = 7,
SupervisionDuties = 8,
Security = 9,
Privacy = 10,
Sync = 11,
Ai = 12,
}
// ── Haupt-ViewModel ─────────────────────────────────────────────────────────── // ── Haupt-ViewModel ───────────────────────────────────────────────────────────
public partial class SettingsViewModel : ObservableObject public partial class SettingsViewModel : ObservableObject
@@ -176,6 +198,20 @@ public partial class SettingsViewModel : ObservableObject
public ObservableCollection<SyncConflictListItem> SyncConflicts { get; } = []; public ObservableCollection<SyncConflictListItem> SyncConflicts { get; } = [];
// ── Geräte-Pairing (10.3.1: Schlüsselübertragung auf ein zweites Gerät) ───
//
// SnapshotService ist nur registriert, wenn bereits eine Server-URL konfiguriert ist (siehe
// AppBootstrapper) — daher optional/nullable statt eines Pflicht-Konstruktorparameters.
[ObservableProperty] private string _pairingCode = "";
[ObservableProperty] private string _pairingCodeInput = "";
[ObservableProperty] private string _pairingStatus = "";
[ObservableProperty] private bool _pairingBusy;
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog, bevor die lokale Datenbank durch
/// den Stand des anderen Geräts ersetzt wird.
public Func<Task<bool>>? OnConfirmPairingRestore { get; set; }
// ── Konstruktor ─────────────────────────────────────────────────────────── // ── Konstruktor ───────────────────────────────────────────────────────────
private readonly ISchoolHolidayRepository _schoolHolidays; private readonly ISchoolHolidayRepository _schoolHolidays;
@@ -187,6 +223,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly SyncSettingsService _syncSettings; private readonly SyncSettingsService _syncSettings;
private readonly SyncAuthService _syncAuth; private readonly SyncAuthService _syncAuth;
private readonly EventQueue _eventQueue; private readonly EventQueue _eventQueue;
private readonly SnapshotService? _snapshotService;
private readonly CompetencyCatalogImportService _catalogImport; private readonly CompetencyCatalogImportService _catalogImport;
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
@@ -198,7 +235,8 @@ public partial class SettingsViewModel : ObservableObject
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, AiSettingsService aiSettings, AiPlanningService aiPlanning,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue) SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
SnapshotService? snapshotService = null)
{ {
_subjects = subjects; _subjects = subjects;
_domainRepo = domainRepo; _domainRepo = domainRepo;
@@ -223,6 +261,7 @@ public partial class SettingsViewModel : ObservableObject
_syncSettings = syncSettings; _syncSettings = syncSettings;
_syncAuth = syncAuth; _syncAuth = syncAuth;
_eventQueue = eventQueue; _eventQueue = eventQueue;
_snapshotService = snapshotService;
_catalogImport = new CompetencyCatalogImportService(domainRepo); _catalogImport = new CompetencyCatalogImportService(domainRepo);
LoadSubjects(); LoadSubjects();
LoadShorthandCodes(); LoadShorthandCodes();
@@ -463,6 +502,72 @@ public partial class SettingsViewModel : ObservableObject
SyncConflicts.Remove(item); SyncConflicts.Remove(item);
} }
// ── Geräte-Pairing: Code erzeugen / einlösen ─────────────────────────────
//
// CreateAndUploadAsync lädt einen verschlüsselten Snapshot der lokalen Datenbank samt
// Sync-Schlüssel hoch, RestoreFromCodeAsync ersetzt auf dem ZWEITEN Gerät die dortige
// Datenbank vollständig durch diesen Snapshot (vorheriger Stand wird automatisch als
// .backup-Datei gesichert, siehe SnapshotService) — deshalb vor dem Einlösen ein
// Bestätigungsdialog wie bei RestoreBackup.
[RelayCommand]
private async Task CreatePairingCode()
{
if (_snapshotService is null) return;
PairingBusy = true;
PairingCode = "";
PairingStatus = "";
void OnProgress(SnapshotProgress p) => PairingStatus = p.Message;
_snapshotService.ProgressChanged += OnProgress;
try
{
var result = await _snapshotService.CreateAndUploadAsync();
PairingCode = result.Code;
PairingStatus = $"Gültig bis {result.ExpiresAt:dd.MM.yyyy HH:mm} — auf dem anderen Gerät eingeben.";
}
catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException)
{
PairingStatus = $"Fehlgeschlagen: {ex.Message}";
}
finally
{
_snapshotService.ProgressChanged -= OnProgress;
PairingBusy = false;
}
}
[RelayCommand]
private async Task RedeemPairingCode()
{
if (_snapshotService is null) return;
if (string.IsNullOrWhiteSpace(PairingCodeInput)) { PairingStatus = "Bitte Code eingeben."; return; }
if (OnConfirmPairingRestore is not null && !await OnConfirmPairingRestore()) return;
PairingBusy = true;
PairingStatus = "";
void OnProgress(SnapshotProgress p) => PairingStatus = p.Message;
_snapshotService.ProgressChanged += OnProgress;
// LiteDB hält beim Öffnen einen exklusiven Dateilock — muss vor dem Überschreiben
// geschlossen sein. Läuft danach ein Fehler (falscher Code, Server nicht erreichbar), ist
// _dbContext bereits disposed und die App nicht mehr sicher weiter benutzbar, ohne dass
// die Datenbankdatei selbst angefasst wurde (RestoreFromCodeAsync schreibt sie erst ganz
// am Ende) — deshalb in JEDEM Fall (Erfolg wie Fehlschlag) neu starten, nicht nur bei
// Erfolg. Ein Neustart öffnet dann wieder dieselbe, unveränderte Datenbank.
_dbContext.Dispose();
try
{
await _snapshotService.RestoreFromCodeAsync(PairingCodeInput.Trim(), AppBootstrapper.DbPath);
}
catch (Exception ex) when (ex is SnapshotNotFoundException or InvalidOperationException or HttpRequestException)
{
// _dbContext ist bereits disposed, PairingStatus ist aber ein reines ViewModel-Feld
// ohne DB-Zugriff - das Setzen ist unabhängig davon noch sicher.
PairingStatus = $"Fehlgeschlagen: {ex.Message}";
}
AppBootstrapper.RestartApplication();
}
// ── Stundenraster: Laden / Speichern ───────────────────────────────────── // ── Stundenraster: Laden / Speichern ─────────────────────────────────────
private void LoadPeriodTimes() private void LoadPeriodTimes()
@@ -30,6 +30,7 @@ public partial class StudentImportDialogViewModel : ObservableObject
public ObservableCollection<StudentImportConflictItem> Conflicts { get; } = []; public ObservableCollection<StudentImportConflictItem> Conflicts { get; } = [];
public bool HasMessages => Messages.Count > 0; public bool HasMessages => Messages.Count > 0;
public bool HasConflicts => Conflicts.Count > 0; public bool HasConflicts => Conflicts.Count > 0;
public bool CanUseExistingForAll => Conflicts.Any(conflict => conflict.HasSingleExistingStudentOption);
public bool IsReadyWithoutConflicts => !HasConflicts && _preview.CanApply; public bool IsReadyWithoutConflicts => !HasConflicts && _preview.CanApply;
public bool HasBlockingErrors => !_preview.CanApply; public bool HasBlockingErrors => !_preview.CanApply;
public bool CanApply => _preview.CanApply && !IsBusy; public bool CanApply => _preview.CanApply && !IsBusy;
@@ -65,6 +66,16 @@ public partial class StudentImportDialogViewModel : ObservableObject
.ToList() .ToList()
.AsReadOnly(); .AsReadOnly();
/// <summary>
/// Wählt für alle eindeutigen Schüler-Konflikte den jeweils vorhandenen Datensatz.
/// Mehrdeutige Treffer und Lerngruppen-Konflikte bleiben unverändert.
/// </summary>
public void UseExistingForAll()
{
foreach (var conflict in Conflicts)
conflict.TrySelectSingleExistingStudent();
}
public async Task<bool> TryApplyAsync() public async Task<bool> TryApplyAsync()
{ {
Error = ""; Error = "";
@@ -116,6 +127,8 @@ public sealed class StudentImportMessageItem
public partial class StudentImportConflictItem : ObservableObject public partial class StudentImportConflictItem : ObservableObject
{ {
private const string ExistingStudentOptionPrefix = "existing:";
[ObservableProperty] private StudentImportConflictOptionItem _selectedOption; [ObservableProperty] private StudentImportConflictOptionItem _selectedOption;
public string Id { get; } public string Id { get; }
@@ -125,6 +138,11 @@ public partial class StudentImportConflictItem : ObservableObject
public string ExistingValue { get; } public string ExistingValue { get; }
public string SourceReference { get; } public string SourceReference { get; }
public IReadOnlyList<StudentImportConflictOptionItem> Options { get; } public IReadOnlyList<StudentImportConflictOptionItem> Options { get; }
public bool HasSingleExistingStudentOption => ExistingStudentOptions.Count == 1;
private IReadOnlyList<StudentImportConflictOptionItem> ExistingStudentOptions => Options
.Where(option => option.Id.StartsWith(ExistingStudentOptionPrefix, StringComparison.Ordinal))
.ToList();
public StudentImportConflictItem(ImportConflict conflict) public StudentImportConflictItem(ImportConflict conflict)
{ {
@@ -141,6 +159,14 @@ public partial class StudentImportConflictItem : ObservableObject
.AsReadOnly(); .AsReadOnly();
_selectedOption = Options.First(option => option.Id == conflict.DefaultOptionId); _selectedOption = Options.First(option => option.Id == conflict.DefaultOptionId);
} }
public bool TrySelectSingleExistingStudent()
{
var options = ExistingStudentOptions;
if (options.Count != 1) return false;
SelectedOption = options[0];
return true;
}
} }
public sealed record StudentImportConflictOptionItem( public sealed record StudentImportConflictOptionItem(
@@ -19,7 +19,16 @@ public partial class SyncStatusViewModel : ObservableObject
{ {
_engine = engine; _engine = engine;
IsServerConfigured = engine is not null; IsServerConfigured = engine is not null;
if (_engine is not null) _engine.StatusChanged += OnStatus; if (_engine is not null)
{
_engine.StatusChanged += OnStatus;
// SyncEngine feuert sein erstes StatusChanged bereits im eigenen Konstruktor
// (UpdateStatus) - der läuft aber schon, während wir hier noch in GetService<SyncEngine>()
// stecken, also VOR dem obigen Abonnieren. Ohne diesen Nachhol-Aufruf bliebe StatusText
// nach jedem Neustart beim Default "Kein Server konfiguriert", bis der nächste
// Auto-Sync oder Klick auf "Jetzt synchronisieren" den Text erstmals aktualisiert.
OnStatus(_engine.Status);
}
} }
private void OnStatus(SyncStatus s) private void OnStatus(SyncStatus s)
@@ -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;
@@ -10,7 +10,9 @@
<shared:PageHeader Grid.Row="0" Title="Einstellungen" Margin="32,28,32,0"/> <shared:PageHeader Grid.Row="0" Title="Einstellungen" Margin="32,28,32,0"/>
<TabbedPage Grid.Row="1" TabPlacement="Top" SelectedIndex="{Binding ActiveTabIndex}"> <TabbedPage Grid.Row="1" TabPlacement="Left" SelectedIndex="{Binding ActiveTabIndex}">
<!-- ═══ Fachliches ═══ -->
<!-- Tab: Fächer --> <!-- Tab: Fächer -->
<ContentPage Header="Fächer"> <ContentPage Header="Fächer">
@@ -282,6 +284,50 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: Notenschema -->
<ContentPage Header="Notenschema">
<ContentPage.Resources>
<DataTemplate x:Key="GradingSchemeTemplate" DataType="vm:GradingSchemeEditItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="6" Padding="14,12">
<StackPanel Spacing="8">
<TextBlock Text="{Binding Label}" FontWeight="SemiBold" FontSize="14"/>
<Grid ColumnDefinitions="Auto,90,Auto,90,Auto,90" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="Klausuren %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="1" Value="{Binding ExamsPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
<TextBlock Grid.Column="2" Text="Mitarbeit %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="3" Value="{Binding ParticipationPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
<TextBlock Grid.Column="4" Text="Sonstige %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="5" Value="{Binding OtherPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
</Grid>
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" Foreground="Green" FontSize="12"
VerticalAlignment="Center"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Grid.Column="1" Content="Speichern" Command="{Binding SaveCommand}"/>
</Grid>
</StackPanel>
</Border>
</DataTemplate>
</ContentPage.Resources>
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Voreinstellung der Gewichtung für die Zeugnisnote (Klausuren / Mitarbeit / Sonstige), je Gruppentyp. Kann pro Lerngruppe überschrieben werden."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<ContentControl Content="{Binding ClassScheme}" ContentTemplate="{StaticResource GradingSchemeTemplate}"/>
<ContentControl Content="{Binding CourseScheme}" ContentTemplate="{StaticResource GradingSchemeTemplate}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Word-Briefvorlagen (7.1.4 / 11.5) --> <!-- Tab: Word-Briefvorlagen (7.1.4 / 11.5) -->
<ContentPage Header="Briefvorlagen"> <ContentPage Header="Briefvorlagen">
<ScrollViewer> <ScrollViewer>
@@ -362,50 +408,162 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: Notenschema --> <!-- ═══ Zeitplanung ═══ -->
<ContentPage Header="Notenschema">
<ContentPage.Resources> <!-- Tab: Ferien & Feiertage (4.3.5) -->
<DataTemplate x:Key="GradingSchemeTemplate" DataType="vm:GradingSchemeEditItem"> <ContentPage Header="Ferien &amp; Feiertage">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="6" Padding="14,12">
<StackPanel Spacing="8">
<TextBlock Text="{Binding Label}" FontWeight="SemiBold" FontSize="14"/>
<Grid ColumnDefinitions="Auto,90,Auto,90,Auto,90" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="Klausuren %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="1" Value="{Binding ExamsPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
<TextBlock Grid.Column="2" Text="Mitarbeit %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="3" Value="{Binding ParticipationPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
<TextBlock Grid.Column="4" Text="Sonstige %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="5" Value="{Binding OtherPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
</Grid>
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" Foreground="Green" FontSize="12"
VerticalAlignment="Center"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Grid.Column="1" Content="Speichern" Command="{Binding SaveCommand}"/>
</Grid>
</StackPanel>
</Border>
</DataTemplate>
</ContentPage.Resources>
<ScrollViewer> <ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480"> <StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Voreinstellung der Gewichtung für die Zeugnisnote (Klausuren / Mitarbeit / Sonstige), je Gruppentyp. Kann pro Lerngruppe überschrieben werden." <StackPanel Spacing="4">
<TextBlock Text="Bundesland (für Feiertage)" FontSize="13" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding StateOptions}" SelectedItem="{Binding SelectedStateName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<Separator Margin="0,4"/>
<TextBlock Text="Schulferien" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="Werden im Stundenplan als unterrichtsfreie Tage angezeigt."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/> FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<ContentControl Content="{Binding ClassScheme}" ContentTemplate="{StaticResource GradingSchemeTemplate}"/> <ItemsControl ItemsSource="{Binding SchoolHolidayEntries}">
<ContentControl Content="{Binding CourseScheme}" ContentTemplate="{StaticResource GradingSchemeTemplate}"/> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SchoolHolidayItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding RangeDisplay}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="×" FontSize="14" Padding="9,3"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveSchoolHolidayCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Schulferien hinterlegt." Classes="emptyhint"
IsVisible="{Binding !SchoolHolidayEntries.Count}"/>
<Separator Margin="0,4"/>
<StackPanel Spacing="4">
<TextBox Text="{Binding NewHolidayName}" PlaceholderText="Name (z.B. Sommerferien)"/>
<TextBlock Text="{Binding HolidayNameError}" Foreground="Red" FontSize="11"
IsVisible="{Binding HolidayNameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,8,*">
<TextBox Grid.Column="0" Text="{Binding NewHolidayStartText}" PlaceholderText="Beginn TT.MM.JJJJ"/>
<TextBox Grid.Column="2" Text="{Binding NewHolidayEndText}" PlaceholderText="Ende TT.MM.JJJJ"/>
</Grid>
<TextBlock Text="{Binding HolidayDateError}" Foreground="Red" FontSize="11"
IsVisible="{Binding HolidayDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content=" Schulferien hinzufügen" Command="{Binding AddSchoolHolidayCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: Stundenraster (4.2.2 Nachtrag) -->
<ContentPage Header="Stundenraster">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="Uhrzeiten der Einzelstunden" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Grundlage für die Zeitbedarf-Rückmeldung im Verlaufsplan-Editor. Nicht alle Stunden müssen eingetragen sein — für unkonfigurierte Stunden bleibt die Rückmeldung dort einfach aus."/>
<Grid ColumnDefinitions="70,*,8,*" Margin="0,4,0,0">
<TextBlock Grid.Column="1" Text="Beginn" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Grid.Column="3" Text="Ende" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
</Grid>
<ItemsControl ItemsSource="{Binding PeriodTimes}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:PeriodTimeEditItem">
<Grid ColumnDefinitions="70,*,8,*" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding PeriodLabel}" FontSize="13" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding StartText}" PlaceholderText="HH:MM"/>
<TextBox Grid.Column="3" Text="{Binding EndText}" PlaceholderText="HH:MM"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding PeriodTimesError}" Foreground="Red" FontSize="12"
IsVisible="{Binding PeriodTimesError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Speichern" Command="{Binding SavePeriodTimesCommand}" HorizontalAlignment="Left"/>
<TextBlock Text="{Binding PeriodTimesStatus}" Foreground="Green" FontSize="12"
IsVisible="{Binding PeriodTimesStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Aufsichten (4.3 Nachtrag) -->
<ContentPage Header="Aufsichten">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Wiederkehrende Pausenaufsicht" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Wird im Stundenplan zwischen den betroffenen Stunden angezeigt. Einmalige Vertretungsaufsichten trägst du direkt im Stundenplan (Heute-Ansicht) ein, nicht hier."/>
<ItemsControl ItemsSource="{Binding SupervisionDuties}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SupervisionDutyItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="{Binding WeekdayLabel}"/><Run Text=" — "/><Run Text="{Binding PeriodLabel}"/>
</TextBlock>
<TextBlock Text="{Binding Location}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="×" FontSize="14" Padding="9,3"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveSupervisionDutyCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Aufsicht hinterlegt." Classes="emptyhint"
IsVisible="{Binding !SupervisionDuties.Count}"/>
<Separator Margin="0,4"/>
<Grid ColumnDefinitions="*,8,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Wochentag" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding WeekdayOptions}" SelectedItem="{Binding NewDutyWeekdayName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Nach Stunde (0 = davor)" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding NewDutyAfterPeriod}" Minimum="0" Maximum="10" FormatString="0"
Width="140" ShowButtonSpinner="True"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Ort / Bezeichnung" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewDutyLocation}" PlaceholderText="z.B. Pausenhof"/>
</StackPanel>
<TextBlock Text="{Binding NewDutyError}" Foreground="Red" FontSize="12"
IsVisible="{Binding NewDutyError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content=" Aufsicht hinzufügen" Command="{Binding AddSupervisionDutyCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- ═══ System ═══ -->
<!-- Tab: Sicherheit (13.3) --> <!-- Tab: Sicherheit (13.3) -->
<ContentPage Header="Sicherheit"> <ContentPage Header="Sicherheit">
<ScrollViewer> <ScrollViewer>
@@ -570,195 +728,6 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: Ferien & Feiertage (4.3.5) -->
<ContentPage Header="Ferien &amp; Feiertage">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<StackPanel Spacing="4">
<TextBlock Text="Bundesland (für Feiertage)" FontSize="13" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding StateOptions}" SelectedItem="{Binding SelectedStateName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<Separator Margin="0,4"/>
<TextBlock Text="Schulferien" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="Werden im Stundenplan als unterrichtsfreie Tage angezeigt."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding SchoolHolidayEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SchoolHolidayItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding RangeDisplay}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="×" FontSize="14" Padding="9,3"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveSchoolHolidayCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Schulferien hinterlegt." Classes="emptyhint"
IsVisible="{Binding !SchoolHolidayEntries.Count}"/>
<Separator Margin="0,4"/>
<StackPanel Spacing="4">
<TextBox Text="{Binding NewHolidayName}" PlaceholderText="Name (z.B. Sommerferien)"/>
<TextBlock Text="{Binding HolidayNameError}" Foreground="Red" FontSize="11"
IsVisible="{Binding HolidayNameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,8,*">
<TextBox Grid.Column="0" Text="{Binding NewHolidayStartText}" PlaceholderText="Beginn TT.MM.JJJJ"/>
<TextBox Grid.Column="2" Text="{Binding NewHolidayEndText}" PlaceholderText="Ende TT.MM.JJJJ"/>
</Grid>
<TextBlock Text="{Binding HolidayDateError}" Foreground="Red" FontSize="11"
IsVisible="{Binding HolidayDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content=" Schulferien hinzufügen" Command="{Binding AddSchoolHolidayCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Stundenraster (4.2.2 Nachtrag) -->
<ContentPage Header="Stundenraster">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="Uhrzeiten der Einzelstunden" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Grundlage für die Zeitbedarf-Rückmeldung im Verlaufsplan-Editor. Nicht alle Stunden müssen eingetragen sein — für unkonfigurierte Stunden bleibt die Rückmeldung dort einfach aus."/>
<Grid ColumnDefinitions="70,*,8,*" Margin="0,4,0,0">
<TextBlock Grid.Column="1" Text="Beginn" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Grid.Column="3" Text="Ende" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
</Grid>
<ItemsControl ItemsSource="{Binding PeriodTimes}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:PeriodTimeEditItem">
<Grid ColumnDefinitions="70,*,8,*" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding PeriodLabel}" FontSize="13" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding StartText}" PlaceholderText="HH:MM"/>
<TextBox Grid.Column="3" Text="{Binding EndText}" PlaceholderText="HH:MM"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding PeriodTimesError}" Foreground="Red" FontSize="12"
IsVisible="{Binding PeriodTimesError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Speichern" Command="{Binding SavePeriodTimesCommand}" HorizontalAlignment="Left"/>
<TextBlock Text="{Binding PeriodTimesStatus}" Foreground="Green" FontSize="12"
IsVisible="{Binding PeriodTimesStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Aufsichten (4.3 Nachtrag) -->
<ContentPage Header="Aufsichten">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Wiederkehrende Pausenaufsicht" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Wird im Stundenplan zwischen den betroffenen Stunden angezeigt. Einmalige Vertretungsaufsichten trägst du direkt im Stundenplan (Heute-Ansicht) ein, nicht hier."/>
<ItemsControl ItemsSource="{Binding SupervisionDuties}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SupervisionDutyItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="{Binding WeekdayLabel}"/><Run Text=" — "/><Run Text="{Binding PeriodLabel}"/>
</TextBlock>
<TextBlock Text="{Binding Location}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="×" FontSize="14" Padding="9,3"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveSupervisionDutyCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Aufsicht hinterlegt." Classes="emptyhint"
IsVisible="{Binding !SupervisionDuties.Count}"/>
<Separator Margin="0,4"/>
<Grid ColumnDefinitions="*,8,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Wochentag" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding WeekdayOptions}" SelectedItem="{Binding NewDutyWeekdayName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Nach Stunde (0 = davor)" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding NewDutyAfterPeriod}" Minimum="0" Maximum="10" FormatString="0"
Width="140" ShowButtonSpinner="True"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Ort / Bezeichnung" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewDutyLocation}" PlaceholderText="z.B. Pausenhof"/>
</StackPanel>
<TextBlock Text="{Binding NewDutyError}" Foreground="Red" FontSize="12"
IsVisible="{Binding NewDutyError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content=" Aufsicht hinzufügen" Command="{Binding AddSupervisionDutyCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: KI-Unterstützung (4.5.9) -->
<ContentPage Header="KI-Unterstützung">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="KI-gestützte Planungsunterstützung" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Ermöglicht KI-Vorschläge für Unterrichtseinheiten über einen Zwischendienst auf dem eigenen Server (kein API-Schlüssel im Client). Jede Anfrage verbraucht Guthaben."/>
<CheckBox Content="KI-Unterstützung aktivieren" IsChecked="{Binding AiEnabled}"/>
<StackPanel Spacing="8" IsVisible="{Binding !AiIsLoggedIn}">
<StackPanel Spacing="4">
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AiUsername}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Passwort" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AiPassword}" PasswordChar="●"/>
</StackPanel>
<TextBlock Text="{Binding AiLoginError}" Foreground="Red" FontSize="12"
IsVisible="{Binding AiLoginError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Anmelden" Command="{Binding AiLoginCommand}" HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding AiIsLoggedIn}">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="Angemeldet als: "/><Run Text="{Binding AiUsername}"/>
</TextBlock>
<TextBlock Text="{Binding AiBalanceDisplay}" FontSize="13"/>
<Button Content="Abmelden" Command="{Binding AiLogoutCommand}" HorizontalAlignment="Left"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Synchronisation (Kapitel 10) --> <!-- Tab: Synchronisation (Kapitel 10) -->
<ContentPage Header="Synchronisation"> <ContentPage Header="Synchronisation">
<ScrollViewer> <ScrollViewer>
@@ -806,6 +775,35 @@
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap" <TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
Text="Speichern/Anmelden startet die App neu, damit die Änderung wirksam wird."/> Text="Speichern/Anmelden startet die App neu, damit die Änderung wirksam wird."/>
<StackPanel Spacing="10" Margin="0,10,0,0" IsVisible="{Binding SyncIsLoggedIn}">
<Separator/>
<TextBlock Text="Gerät koppeln" FontSize="14" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Login allein reicht nicht: Verschlüsselt wird lokal mit einem eigenen Schlüssel je Gerät, der nie über den Server läuft. Für ein zweites Gerät hier einen Code erzeugen — auf dem anderen Gerät eingeben, um Datenbank und Schlüssel zu übernehmen."/>
<StackPanel Spacing="6">
<TextBlock Text="Dieses Gerät als Quelle" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<Button Content="Code erzeugen" Command="{Binding CreatePairingCodeCommand}"
IsEnabled="{Binding !PairingBusy}" HorizontalAlignment="Left"/>
<TextBlock Text="{Binding PairingCode}" FontSize="20" FontWeight="Bold"
FontFamily="Monospace" IsVisible="{Binding PairingCode, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Code von einem anderen Gerät einlösen" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
Text="Ersetzt die Datenbank auf DIESEM Gerät vollständig — nur auf einem Gerät ohne eigene Daten verwenden."/>
<Grid ColumnDefinitions="*,8,Auto">
<TextBox Grid.Column="0" Text="{Binding PairingCodeInput}" PlaceholderText="TIGER-42-BLAU"/>
<Button Grid.Column="2" Content="Einlösen" Command="{Binding RedeemPairingCodeCommand}"
IsEnabled="{Binding !PairingBusy}"/>
</Grid>
</StackPanel>
<TextBlock Text="{Binding PairingStatus}" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding PairingStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<TextBlock Text="Konflikte" FontSize="14" FontWeight="SemiBold" Margin="0,10,0,0" <TextBlock Text="Konflikte" FontSize="14" FontWeight="SemiBold" Margin="0,10,0,0"
IsVisible="{Binding SyncConflicts.Count}"/> IsVisible="{Binding SyncConflicts.Count}"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap" <TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
@@ -836,6 +834,42 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: KI-Unterstützung (4.5.9) -->
<ContentPage Header="KI-Unterstützung">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="KI-gestützte Planungsunterstützung" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Ermöglicht KI-Vorschläge für Unterrichtseinheiten über einen Zwischendienst auf dem eigenen Server (kein API-Schlüssel im Client). Jede Anfrage verbraucht Guthaben."/>
<CheckBox Content="KI-Unterstützung aktivieren" IsChecked="{Binding AiEnabled}"/>
<StackPanel Spacing="8" IsVisible="{Binding !AiIsLoggedIn}">
<StackPanel Spacing="4">
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AiUsername}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Passwort" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AiPassword}" PasswordChar="●"/>
</StackPanel>
<TextBlock Text="{Binding AiLoginError}" Foreground="Red" FontSize="12"
IsVisible="{Binding AiLoginError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Anmelden" Command="{Binding AiLoginCommand}" HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding AiIsLoggedIn}">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="Angemeldet als: "/><Run Text="{Binding AiUsername}"/>
</TextBlock>
<TextBlock Text="{Binding AiBalanceDisplay}" FontSize="13"/>
<Button Content="Abmelden" Command="{Binding AiLogoutCommand}" HorizontalAlignment="Left"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
</TabbedPage> </TabbedPage>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -20,6 +20,7 @@ public partial class SettingsView : UserControl
vm.OnConfirmRestore = ShowRestoreConfirmDialog; vm.OnConfirmRestore = ShowRestoreConfirmDialog;
vm.OnAppLockChanged = () => App.Services.GetRequiredService<AppLockViewModel>().ApplyConfig(); vm.OnAppLockChanged = () => App.Services.GetRequiredService<AppLockViewModel>().ApplyConfig();
vm.OnConfirmHardDelete = ShowHardDeleteConfirmDialog; vm.OnConfirmHardDelete = ShowHardDeleteConfirmDialog;
vm.OnConfirmPairingRestore = ShowPairingRestoreConfirmDialog;
} }
} }
@@ -50,6 +51,21 @@ public partial class SettingsView : UserControl
return owner is not null && await dialog.ShowDialog<bool>(owner); return owner is not null && await dialog.ShowDialog<bool>(owner);
} }
private async Task<bool> ShowPairingRestoreConfirmDialog()
{
var info = new ConfirmDialogInfo
{
Title = "Gerät koppeln?",
Message = "Die lokale Datenbank auf diesem Gerät wird vollständig durch den Stand des anderen " +
"Geräts ersetzt. Der bisherige Stand wird automatisch als Backup gesichert. " +
"Die App wird danach automatisch neu gestartet.",
ConfirmText = "Ersetzen",
};
var dialog = new ConfirmDialog { DataContext = info };
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private async void OnImportClick(object? sender, RoutedEventArgs e) private async void OnImportClick(object? sender, RoutedEventArgs e)
{ {
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
@@ -34,8 +34,14 @@
</StackPanel> </StackPanel>
<Grid Grid.Row="1" RowDefinitions="Auto,*" Margin="0,16,0,0"> <Grid Grid.Row="1" RowDefinitions="Auto,*" Margin="0,16,0,0">
<TextBlock Grid.Row="0" Text="Entscheidungen" FontSize="15" FontWeight="SemiBold" <Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8"
Margin="0,0,0,8" IsVisible="{Binding HasConflicts}"/> IsVisible="{Binding HasConflicts}">
<TextBlock Grid.Column="0" Text="Entscheidungen" FontSize="15" FontWeight="SemiBold"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Übernehmen für alle" Click="OnUseExistingForAll"
IsVisible="{Binding CanUseExistingForAll}"
ToolTip.Tip="Wählt bei allen eindeutigen Treffern den jeweils vorhandenen Schüler aus."/>
</Grid>
<Border Grid.Row="1" IsVisible="{Binding IsReadyWithoutConflicts}" <Border Grid.Row="1" IsVisible="{Binding IsReadyWithoutConflicts}"
Background="#1422A06B" CornerRadius="6" Padding="14" Background="#1422A06B" CornerRadius="6" Padding="14"
@@ -14,5 +14,11 @@ public partial class StudentImportDialog : Window
Close(true); Close(true);
} }
private void OnUseExistingForAll(object? sender, RoutedEventArgs e)
{
if (DataContext is StudentImportDialogViewModel vm)
vm.UseExistingForAll();
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false); private void OnCancel(object? sender, RoutedEventArgs e) => Close(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,
+32 -1
View File
@@ -1317,6 +1317,17 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
RestartApplication`, gleiches Muster wie bei DB-Passwort/AppLock-Änderungen) — `SyncEngine`/ RestartApplication`, gleiches Muster wie bei DB-Passwort/AppLock-Änderungen) — `SyncEngine`/
`SnapshotService` werden nur einmalig beim Start registriert, es gibt keinen `SnapshotService` werden nur einmalig beim Start registriert, es gibt keinen
Live-Re-Registrierungspfad. Live-Re-Registrierungspfad.
**Nachtrag (Einstellungen-Reorg):** Der Tab-Balken war mit 13 Tabs oben (`TabPlacement="Top"`)
unübersichtlich lang geworden und wird mit weiteren Sync-Features noch länger. Umgestellt auf
`TabPlacement="Left"` (vertikale Liste, skaliert besser) und die Tabs in drei logische Gruppen
sortiert (Fachliches / Zeitplanung / System). Neuer Enum `SettingsTab` in
`SettingsViewModel.cs` ersetzt die bisherigen rohen `int`-Tab-Indizes bei
`MainWindowViewModel.NavigateToSettings`/`TimetableViewModel.OnNavigateToSettings` — beim
Umsortieren dabei ein bereits vorhandener Bug aufgefallen und mitbehoben: das Zahnrad-Symbol
im Stundenplan öffnete über den hartcodierten Index `7` tatsächlich den Tab "Datenschutz"
statt des in Kommentar/Tooltip beabsichtigten "Ferien & Feiertage" — mit benanntem Enum kann
diese Klasse von Fehler nicht mehr auftreten.
- [x] **10.1.2** Verbindungstest mit klarer Fehlermeldung (nicht erreichbar / Token ungültig). - [x] **10.1.2** Verbindungstest mit klarer Fehlermeldung (nicht erreichbar / Token ungültig).
**Umsetzung:** `SyncAuthService.TestConnectionAsync` unterscheidet drei Zustände (erreichbar **Umsetzung:** `SyncAuthService.TestConnectionAsync` unterscheidet drei Zustände (erreichbar
@@ -1337,6 +1348,17 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
**Umsetzung:** War bereits vorhanden (`SyncStatusViewModel`/`SyncStatusBar`), nur hier noch **Umsetzung:** War bereits vorhanden (`SyncStatusViewModel`/`SyncStatusBar`), nur hier noch
nicht abgehakt. nicht abgehakt.
**Nachtrag (Bug beim Nutzertest gefunden):** Nach jedem App-Neustart zeigte die Statusleiste
fälschlich "Kein Server konfiguriert", obwohl Sync korrekt eingerichtet war — bis zum
nächsten Auto-Sync oder Klick auf "Jetzt synchronisieren". Ursache: `SyncEngine` feuert sein
erstes `StatusChanged` bereits im eigenen Konstruktor; da `SyncEngine` und
`SyncStatusViewModel` beide DI-Singletons sind und Letzterer Ersteren erst innerhalb der
eigenen Factory aus dem Container holt, läuft dieser erste Broadcast ab, bevor
`SyncStatusViewModel` überhaupt abonniert hat — der Status ging verloren, `StatusText` blieb
beim hartcodierten Default. Fix: `SyncStatusViewModel`-Konstruktor ruft nach dem Abonnieren
zusätzlich einmal `OnStatus(engine.Status)` mit dem bereits vorhandenen aktuellen Zustand auf
(`SyncStatusViewModel.cs`). Regressionstest `SyncStatusViewModelTests.cs`.
- [x] **10.1.6** Lokale Schreibvorgänge atomar an die Outbox (`EventQueue`) anbinden — aktuell ist - [x] **10.1.6** Lokale Schreibvorgänge atomar an die Outbox (`EventQueue`) anbinden — aktuell ist
das Sync-Grundgerüst registriert, die Repositories erzeugen aber noch keine Sync-Ereignisse. das Sync-Grundgerüst registriert, die Repositories erzeugen aber noch keine Sync-Ereignisse.
@@ -1431,7 +1453,16 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
Pfad explizit außerhalb des von der Plattform verwalteten Checkouts sind sicher. Pfad explizit außerhalb des von der Plattform verwalteten Checkouts sind sicher.
### 10.3 Verschlüsselung ### 10.3 Verschlüsselung
- [ ] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase). - [x] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase).
**Umsetzung:** Der Backend-Mechanismus (`SnapshotService`/`SyncCrypto`, Einmal-Code-Pairing)
existierte bereits, war aber an keiner Stelle im Client verdrahtet. Neuer Bereich "Gerät
koppeln" im Sync-Settings-Tab: ein Gerät erzeugt per `CreatePairingCode` einen verschlüsselten
DB-Snapshot + Code (Format `WORT-ZZ-WORT`, 24h gültig), das zweite Gerät gibt den Code über
`RedeemPairingCode` ein und übernimmt Datenbank + Sync-Schlüssel. Bestätigungsdialog vor dem
Einlösen (überschreibt die lokale Datenbank vollständig, alter Stand wird automatisch als
Backup gesichert). Kein QR-Code (nur Passphrase-Code) — für zwei eigene Geräte per Hand
abtippen ausreichend, QR-Code wäre erst für eine Companion-App relevant.
- [ ] **10.3.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel. - [ ] **10.3.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel.
- [x] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen — - [x] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen —
personenbezogene Daten dürfen das nicht. `Grade`/`ExamResult` (beide mit `StudentId` plus personenbezogene Daten dürfen das nicht. `Grade`/`ExamResult` (beide mit `StudentId` plus