From e86125e5f98634c7d50945a348aaf47864cc9636 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Tue, 8 Sep 2026 18:01:42 +0200 Subject: [PATCH] Hausaufgaben feature und Stundeplan fix --- .../WebUntisHomeworkComparisonViewModel.cs | 229 ++++++++++++++++++ .../ViewModels/MainWindowViewModel.cs | 5 +- .../Views/Dashboard/DashboardView.axaml | 2 + .../Views/Dashboard/DashboardView.axaml.cs | 18 ++ .../WebUntisHomeworkComparisonDialog.axaml | 58 +++++ .../WebUntisHomeworkComparisonDialog.axaml.cs | 11 + TODO.md | 67 ++++- 7 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs create mode 100644 LehrerApp.Desktop/Views/Groups/WebUntisHomeworkComparisonDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/WebUntisHomeworkComparisonDialog.axaml.cs diff --git a/LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs new file mode 100644 index 0000000..73f303b --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs @@ -0,0 +1,229 @@ +using System.Collections.ObjectModel; +using System.Text.RegularExpressions; +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.Groups; + +/// Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar - +/// kann manuell per Auswahlliste gesetzt werden, gleiches Muster wie +/// und +/// . +public partial class WebUntisHomeworkRow : ObservableObject +{ + public required string ClassName { get; init; } + public required DateOnly Date { get; init; } + public required string UntisStudentName { get; init; } + public string? SubjectLabel { get; init; } + public string? Text { get; init; } + public required IReadOnlyList Candidates { get; init; } + internal Guid? MatchedSubjectId { get; init; } + internal Action? OnAssignmentChanged { get; init; } + public string DateLabel => Date.ToString("dd.MM.yyyy"); + public bool CanApply => SessionId is not null; + + [ObservableProperty] private Student? _assignedStudent; + [ObservableProperty] private string _localStatus = "ohne Zuordnung"; + [ObservableProperty] private Guid? _sessionId; + [ObservableProperty] private bool _selected; + + partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this); +} + +/// Abgleich negativer Klassenbucheinträge zum Stichwort "Hausaufgabe" (eigene, siehe +/// ) gegen +/// - Dashboard-weit statt pro Lerngruppe, aus demselben +/// Grund wie beim Dokumentations-Abgleich (der WebUntis-"-alle-"-Bericht ist klassenübergreifend, +/// siehe TODO.md). Nutzer-Feedback: WebUntis liefert für Klassenbucheinträge weder eine externe +/// Schülerkennung noch eine feste Fach-/Lerngruppenzuordnung, aber der Lehrkraft-eigene Text enthält +/// praktisch immer das Untis-Fachkürzel - darüber wird die passende Lerngruppe (SubjectId + aktive +/// Mitgliedschaft am Eintragsdatum) aufgelöst, mehrdeutige Treffer bleiben unaufgelöst statt zu raten. +/// Ein Eintrag setzt lokal ausschließlich vor, und auch nur, +/// wenn dort noch gar kein Status hinterlegt ist - bereits vorhandene, feinere Erfassungen ("Teilweise +/// angefertigt", "nachgereicht" usw.) werden nie automatisch überschrieben, sondern nur zum Vergleich +/// danebengestellt (Nutzerwunsch). +public partial class WebUntisHomeworkComparisonViewModel : ObservableObject +{ + private readonly WebUntisIntegrationService _untis; + private readonly IStudentRepository _students; + private readonly IGroupRepository _groups; + private readonly ISubjectRepository _subjects; + private readonly IGroupMembershipRepository _memberships; + private readonly IParticipationSessionRepository _sessions; + private readonly IParticipationRepository _participation; + private readonly SchoolYearService _schoolYears; + + public ObservableCollection Rows { 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 WebUntisHomeworkComparisonViewModel(WebUntisIntegrationService untis, IStudentRepository students, + IGroupRepository groups, ISubjectRepository subjects, IGroupMembershipRepository memberships, + IParticipationSessionRepository sessions, IParticipationRepository participation, + SchoolYearService schoolYears) + { + _untis = untis; _students = students; _groups = groups; _subjects = subjects; + _memberships = memberships; _sessions = sessions; _participation = participation; + _schoolYears = schoolYears; + } + + [RelayCommand] + private async Task Load() + { + var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime); + var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime); + if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; } + Busy = true; Rows.Clear(); + try + { + var ownStudents = _students.GetAll(); + var globalIndex = BuildNameIndex(ownStudents); + // Wie beim Dokumentations-Abgleich zusätzlich pro Klasse indiziert, um gleiche Namen in + // verschiedenen Klassen unterscheiden zu können. + var classIndexes = _groups.GetAll() + .Where(g => g.Type == GroupType.Class) + .GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()), + StringComparer.OrdinalIgnoreCase); + var shortNameIndex = _subjects.GetAll() + .Where(s => !string.IsNullOrWhiteSpace(s.ShortName)) + .GroupBy(s => s.ShortName.Trim().ToUpperInvariant()) + .Where(g => g.Count() == 1) // mehrdeutiges Kürzel lieber nicht zuordnen als raten + .ToDictionary(g => g.Key, g => g.First()); + + var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end); + var ordered = entries + .Where(IsMissingHomeworkEntry) + .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); + + void ResolveLocalMatch(WebUntisHomeworkRow row) + { + if (row.AssignedStudent is not { } student) + { + row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false; + return; + } + var group = ResolveGroup(student, row.MatchedSubjectId, row.Date); + var session = group is null ? null + : _sessions.GetByGroup(group.Id).FirstOrDefault(s => s.Date == row.Date); + var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id); + var current = entry is null ? null : HomeworkDisplay.Effective(entry); + row.SessionId = session?.Id; + row.LocalStatus = session is null + ? row.MatchedSubjectId is null ? "Fachkürzel nicht erkannt" + : group is null ? "kein passender Kurs gefunden" : "keine lokale Stunde" + : HomeworkDisplay.Label(current); + // Nur vorbelegen, wenn lokal noch überhaupt nichts erfasst ist - jeder vorhandene + // Status (auch ein bereits gesetztes "fehlt") bleibt unangetastet, siehe Klassenkommentar. + row.Selected = session is not null && current is null; + } + + 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 subject = MatchSubject(entry.Text, shortNameIndex) ?? MatchSubject(entry.CategoryName, shortNameIndex); + + var row = new WebUntisHomeworkRow + { + ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName, + SubjectLabel = subject?.ShortName, Text = entry.Text, Candidates = ownStudents, + MatchedSubjectId = subject?.Id, OnAssignmentChanged = ResolveLocalMatch, + }; + Rows.Add(row); + row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected + } + + var unresolvedStudent = Rows.Count(x => x.AssignedStudent is null); + var unresolvedSubject = Rows.Count(x => x.MatchedSubjectId is null); + Status = $"{Rows.Count} Einträge \"fehlende Hausaufgabe\" erhalten" + + (unresolvedStudent > 0 ? $", {unresolvedStudent} bitte manuell zuordnen" : "") + + (unresolvedSubject > 0 ? $", bei {unresolvedSubject} kein Fachkürzel im Text erkannt" : "") + + $". {Rows.Count(x => x.CanApply)} einer lokalen Stunde zuordenbar."; + } + catch (WebUntisIntegrationException ex) { Status = ex.Message; } + finally { Busy = false; } + } + + [RelayCommand] + private void Apply() + { + var selected = Rows.Where(x => x.Selected && x.SessionId is not null && x.AssignedStudent is not null).ToList(); + foreach (var row in selected) + { + var studentId = row.AssignedStudent!.Id; + var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId) + ?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId }; + entry.Homework = HomeworkStatus.MissingOpen; + entry.HomeworkMissing = true; + entry.UpdatedAt = DateTime.UtcNow; + _participation.Save(entry); + row.LocalStatus = HomeworkDisplay.Label(HomeworkStatus.MissingOpen); + row.Selected = false; + } + Status = $"{selected.Count} Hausaufgaben-Status übernommen."; + } + + /// Nur Lerngruppen (nicht die Klasse selbst), bei denen der/die Schüler*in am Eintragsdatum aktiv + /// Mitglied ist und deren Fach zum erkannten Kürzel passt - mehr als ein Treffer bleibt bewusst + /// unaufgelöst statt irgendeinen davon zu wählen. + private LearningGroup? ResolveGroup(Student student, Guid? subjectId, DateOnly date) + { + if (subjectId is null) return null; + var schoolYear = _schoolYears.CurrentSchoolYear(date); + var candidates = _memberships.GetByStudent(student.Id) + .Where(m => GroupMembershipService.IsActiveOn(m, date)) + .Select(m => _groups.GetById(m.GroupId)) + .Where(g => g is not null && g.SubjectId == subjectId && g.SchoolYear == schoolYear) + .Cast() + .ToList(); + return candidates.Count == 1 ? candidates[0] : null; + } + + // Gleiche Heuristik wie ClassTeacherDetailsViewModel.ContainsHomework, zusätzlich auf negative + // Einträge eingeschränkt (eine positive "Hausaufgabe"-Kategorie wäre kein Fehlen-Signal). + private static bool IsMissingHomeworkEntry(UntisClassRegisterEventDto entry) => + string.Equals(entry.CategoryGroup, "Negativ", StringComparison.OrdinalIgnoreCase) && + (entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true || + entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true); + + private static Subject? MatchSubject(string? text, IReadOnlyDictionary shortNameIndex) + { + if (string.IsNullOrWhiteSpace(text)) return null; + foreach (var token in Regex.Split(text, @"[^\p{L}\p{Nd}]+")) + if (token.Length > 0 && shortNameIndex.TryGetValue(token.ToUpperInvariant(), out var subject)) + return subject; + return null; + } + + private static string NameKey(string value) => value.Trim().ToLowerInvariant(); + + // Wie bei den übrigen WebUntis-Abgleichen: beide Namensreihenfolgen 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/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs index 1790690..f328767 100644 --- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -150,8 +150,11 @@ public partial class MainWindowViewModel : ObservableObject private TimetableViewModel GetTimetable() { + // WeekOffset bewusst nicht zurücksetzen: TimetableViewModel ist ein DI-Singleton, die + // zuletzt angezeigte Woche bleibt also automatisch über Tab-Wechsel hinweg erhalten + // (Nutzer-Feedback: beim Planen der nächsten Woche nervt es, nach jedem kurzen Abstecher + // in einen anderen Bereich wieder manuell dorthin zurückblättern zu müssen). var timetable = _services.GetRequiredService(); - timetable.WeekOffset = 0; timetable.Load(); timetable.ActiveTabIndex = 0; return timetable; diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index 997b313..3c4122f 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -28,6 +28,8 @@