diff --git a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs index ae2fc05..ae6b444 100644 --- a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs +++ b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs @@ -30,6 +30,8 @@ public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, I public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int AbsentPeriods, int UnexcusedAbsentPeriods, int AbsentMinutes, int UnexcusedAbsentMinutes, int? StartTime, int? EndTime, string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts); +public sealed record UntisClassRegisterEventDto(string ClassName, int Date, string? Subject, + string StudentName, string? CategoryName, string? CategoryGroup, string? Text); /// Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der /// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server. @@ -117,6 +119,22 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings x.HandledOn, x.Counts)).ToList(); }, token); + // "-alle-"-Bericht, hier auf eigene Einträge gefiltert (Benutzer == eigener WebUntis-Login) - Einträge + // anderer Lehrkräfte zu Schülern der eigenen Klasse gehören zu einem eigenständigen, noch nicht + // gebauten "Klassenlehrer"-Feature (siehe TODO.md), nicht zum reinen Dokumentations-Abgleich. + public Task> GetOwnClassRegisterEventsAsync(DateOnly start, + DateOnly end, CancellationToken token = default) => ExecuteAsync(async client => + { + var ownUsername = settings.GetApiCredentials()?.Username; + var entries = await client.GetClassRegisterEventsReportAsync(Date(start), Date(end), token); + return (IReadOnlyList)entries + .Where(x => ownUsername is not null + && string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase)) + .Select(x => new UntisClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName, + x.CategoryName, x.CategoryGroup, x.Text)) + .ToList(); + }, token); + private async Task ExecuteAsync(Func> operation, CancellationToken token) { try { return await operation(await GetClientAsync(token)); } diff --git a/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs index 0d65aa4..41ea98a 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs @@ -43,11 +43,16 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject private readonly IParticipationSessionRepository _sessions; private readonly IParticipationRepository _participation; + private IReadOnlyList _loadedStudents = []; + private IReadOnlyDictionary _loadedSessions = + new Dictionary(); + public ObservableCollection Rows { get; } = []; [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2); [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; [ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden."; [ObservableProperty] private bool _busy; + [ObservableProperty] private bool _markUnknownAsPresent; public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis, IStudentRepository students, IParticipationSessionRepository sessions, @@ -78,6 +83,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject var localSessions = _sessions.GetByGroup(_group.Id) .Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date) .ToDictionary(x => x.Key, x => x.First()); + _loadedStudents = courseStudents; + _loadedSessions = localSessions; // Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt // per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb // der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten. @@ -152,10 +159,39 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject entry.UpdatedAt = DateTime.UtcNow; _participation.Save(entry); } - Status = $"{selected.Count} Anwesenheitsstatus übernommen."; + + var presentCount = MarkUnknownAsPresent ? FillUnknownAsPresent() : 0; + Status = $"{selected.Count} Anwesenheitsstatus übernommen." + + (MarkUnknownAsPresent ? $" {presentCount} unbekannte Status auf anwesend gesetzt." : ""); foreach (var row in selected) row.Selected = false; } + // "Identifiziert" heißt hier: WebUntis hat für diesen Schüler an diesem Tag überhaupt eine Zeile + // gemeldet - unabhängig davon, ob die Zeile markiert/übernommen wurde. Nur wer für den geladenen + // Zeitraum weder von WebUntis gemeldet noch lokal schon kontrolliert wurde, gilt als "unbekannt" + // und wird auf anwesend gesetzt; bereits erfasste Einträge (auch ohne Anwesenheitsstatus, z.B. nur + // mit Notiz) werden nicht überschrieben, wenn ihr Anwesenheitsstatus schon gesetzt ist. + private int FillUnknownAsPresent() + { + var identified = Rows.Where(x => x.AssignedStudent is not null) + .Select(x => (x.Date, StudentId: x.AssignedStudent!.Id)) + .ToHashSet(); + var filled = 0; + foreach (var session in _loadedSessions.Values) + foreach (var student in _loadedStudents) + { + if (identified.Contains((session.Date, student.Id))) continue; + var entry = _participation.GetBySessionAndStudent(session.Id, student.Id); + if (entry?.Attendance is not null) continue; + entry ??= new ParticipationEntry { SessionId = session.Id, StudentId = student.Id }; + entry.Attendance = AttendanceStatus.Present; + entry.UpdatedAt = DateTime.UtcNow; + _participation.Save(entry); + filled++; + } + return filled; + } + private static int? StudentKey(Student student) { student.ExternalIds ??= []; diff --git a/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs new file mode 100644 index 0000000..7e3a385 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs @@ -0,0 +1,191 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.Services; + +namespace LehrerApp.Desktop.ViewModels.Students; + +/// Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar - +/// kann manuell per Auswahlliste gesetzt werden, wenn der Name nicht eindeutig auf ein/e Schüler*in +/// passt (siehe WebUntisLessonAbsenceComparisonViewModel für dasselbe Muster). +public partial class WebUntisDocumentationRow : ObservableObject +{ + public required string ClassName { get; init; } + public required DateOnly Date { get; init; } + public required string UntisStudentName { get; init; } + public string? Subject { get; init; } + public string? CategoryName { get; init; } + public string? CategoryGroup { get; init; } + public string? Text { get; init; } + public required IReadOnlyList Candidates { get; init; } + public string DateLabel => Date.ToString("dd.MM.yyyy"); + public bool CanApply => AssignedStudent is not null; + + [ObservableProperty] private Student? _assignedStudent; + /// Titel/Text eines lokalen Eintrags, der am selben Tag für diese/n Schüler*in schon + /// existiert - nur nach Datum+Schüler*in erkannt, nicht nach Wortlaut (der unterscheidet sich oft + /// von der WebUntis-Kategorie). Deshalb Anzeige zum Vergleichen statt automatischem Ausblenden. + [ObservableProperty] private string? _existingLocalEntry; + [ObservableProperty] private bool _selected; +} + +/// Lokaler Dokumentationseintrag im geladenen Zeitraum ohne passenden WebUntis-Eintrag - kann +/// nicht automatisch nach WebUntis geschrieben werden (bewusst keine schreibenden Aufrufe gegen die +/// undokumentierte API), deshalb nur als Kopiervorlage für die manuelle Nacherfassung dort. +public sealed record LocalOnlyDocumentationRow( + string StudentName, DateOnly Date, string? GroupName, string Title, string Content) +{ + public string DateLabel => Date.ToString("dd.MM.yyyy"); + public string ClipboardText => + $"{DateLabel} – {StudentName}" + (GroupName is null ? "" : $" ({GroupName})") + + $"\n{Title}\n{Content}"; +} + +/// Abgleich der WebUntis-Klassenbucheinträge (eigene, siehe +/// ) gegen die lokale +/// - Dashboard-weit statt pro Lerngruppe, weil der WebUntis-"-alle-"- +/// Bericht ebenfalls klassenübergreifend ist (siehe TODO.md, Nachtrag zu 4.3). +public partial class WebUntisDocumentationComparisonViewModel : ObservableObject +{ + private readonly WebUntisIntegrationService _untis; + private readonly IStudentRepository _students; + private readonly IGroupRepository _groups; + private readonly IDocumentationRepository _documentation; + private readonly SchoolYearService _schoolYears; + + public ObservableCollection Rows { get; } = []; + public ObservableCollection LocalOnlyRows { get; } = []; + [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-7); + [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; + [ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden."; + [ObservableProperty] private bool _busy; + + public WebUntisDocumentationComparisonViewModel(WebUntisIntegrationService untis, + IStudentRepository students, IGroupRepository groups, IDocumentationRepository documentation, + SchoolYearService schoolYears) + { + _untis = untis; _students = students; _groups = groups; _documentation = documentation; + _schoolYears = schoolYears; + } + + [RelayCommand] + private async Task Load() + { + var start = DateOnly.FromDateTime(StartDate.LocalDateTime); + var end = DateOnly.FromDateTime(EndDate.LocalDateTime); + if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; } + Busy = true; Rows.Clear(); LocalOnlyRows.Clear(); + try + { + var ownStudents = _students.GetAll(); + var globalIndex = BuildNameIndex(ownStudents); + // Zusätzlich pro Klasse (aktuelles Schuljahr) indiziert: löst den Fall "gleicher Name in + // verschiedenen Klassen" auf, den ein rein globaler Namensabgleich nicht unterscheiden könnte. + var currentSchoolYear = _schoolYears.CurrentSchoolYear(); + var classIndexes = _groups.GetAll() + .Where(g => g.Type == GroupType.Class && g.SchoolYear == currentSchoolYear) + .GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()), + StringComparer.OrdinalIgnoreCase); + + var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end); + var localDocs = _documentation.GetAll().Where(d => d.Date >= start && d.Date <= end).ToList(); + + var ordered = entries + .Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null)) + .Where(x => x.Date is not null) + .OrderBy(x => x.Date).ThenBy(x => x.Entry.StudentName); + + // Nur nach Schüler*in+Datum erkannt, nicht nach Wortlaut: ein lokaler Eintrag von vor dieser + // Funktion (oder frei formuliert) hat selten denselben Titel wie die WebUntis-Kategorie. + List LocalDocsFor(Guid studentId, DateOnly date) => + localDocs.Where(d => d.StudentId == studentId && d.Date == date).ToList(); + + foreach (var (entry, date) in ordered) + { + var nameKey = NameKey(entry.StudentName); + var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex) + ? classIndex.GetValueOrDefault(nameKey) + : null) + ?? globalIndex.GetValueOrDefault(nameKey); + var existing = match is null ? [] : LocalDocsFor(match.Id, date!.Value); + + Rows.Add(new WebUntisDocumentationRow + { + ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName, + Subject = entry.Subject, CategoryName = entry.CategoryName, CategoryGroup = entry.CategoryGroup, + Text = entry.Text, Candidates = ownStudents, AssignedStudent = match, + ExistingLocalEntry = existing.Count == 0 ? null + : string.Join(" | ", existing.Select(d => $"{d.Title}: {d.Content}")), + Selected = match is not null && existing.Count == 0, + }); + } + + foreach (var doc in localDocs) + { + var coveredByReport = Rows.Any(r => r.AssignedStudent?.Id == doc.StudentId && r.Date == doc.Date); + if (coveredByReport) continue; + var student = ownStudents.FirstOrDefault(s => s.Id == doc.StudentId); + if (student is null) continue; + LocalOnlyRows.Add(new LocalOnlyDocumentationRow(student.FullName, doc.Date, + doc.GroupId is { } groupId ? _groups.GetById(groupId)?.Name : null, doc.Title, doc.Content)); + } + + var unresolved = Rows.Count(x => x.AssignedStudent is null); + var possibleDuplicates = Rows.Count(x => x.ExistingLocalEntry is not null); + Status = $"{Rows.Count} WebUntis-Einträge erhalten, {possibleDuplicates} mit lokalem Eintrag am " + + "selben Tag (bitte vergleichen)" + + (unresolved > 0 ? $", {unresolved} bitte manuell zuordnen" : "") + + $". {LocalOnlyRows.Count} lokale Einträge ohne WebUntis-Gegenstück."; + } + catch (WebUntisIntegrationException ex) { Status = ex.Message; } + finally { Busy = false; } + } + + [RelayCommand] + private void Apply() + { + var selected = Rows.Where(x => x.Selected && x.CanApply).ToList(); + foreach (var row in selected) + { + _documentation.Save(new Documentation + { + StudentId = row.AssignedStudent!.Id, + Date = row.Date, + Type = DocumentationType.Incident, + Title = row.CategoryName ?? "WebUntis-Klassenbucheintrag", + Content = row.Text ?? "", + Tags = row.CategoryGroup is { Length: > 0 } group ? [group] : [], + }); + } + foreach (var row in selected) + { + row.ExistingLocalEntry = $"{row.CategoryName}: {row.Text}"; + row.Selected = false; + } + Status = $"{selected.Count} Einträge aus WebUntis übernommen."; + } + + private static string NameKey(string value) => value.Trim().ToLowerInvariant(); + + // Wie beim Fehlzeiten-Abgleich: WebUntis liefert Namen nicht einheitlich in einer Reihenfolge, + // deshalb werden beide Reihenfolgen registriert, aber nur falls innerhalb der Kandidaten eindeutig. + private static Dictionary BuildNameIndex(IReadOnlyList candidates) => + candidates + .SelectMany(student => new[] + { + NameKey($"{student.LastName} {student.FirstName}"), + NameKey($"{student.FirstName} {student.LastName}"), + }.Select(key => (Key: key, Student: student))) + .GroupBy(x => x.Key) + .Where(group => group.Select(x => x.Student).Distinct().Count() == 1) + .ToDictionary(group => group.Key, group => group.First().Student); + + private static bool TryDate(int value, out DateOnly date) => + DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); +} diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index 673a171..476e631 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -25,8 +25,12 @@ -