Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs
T

279 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// <summary>Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar - <see cref="AssignedStudent"/>
/// kann manuell per Auswahlliste gesetzt werden, gleiches Muster wie
/// <see cref="WebUntisLessonAbsenceComparisonViewModel"/> und
/// <see cref="LehrerApp.Desktop.ViewModels.Students.WebUntisDocumentationComparisonViewModel"/>.</summary>
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? CategoryName { get; init; }
public string? SubjectLabel { get; init; }
public string? Text { get; init; }
public required IReadOnlyList<Student> Candidates { get; init; }
internal Guid? MatchedSubjectId { get; init; }
internal Action<WebUntisHomeworkRow>? OnAssignmentChanged { get; init; }
public string DateLabel => Date.ToString("dd.MM.yyyy");
public bool CanApply => SessionId is not null;
private static readonly HomeworkStatus[] SelectableStatuses =
[
HomeworkStatus.MissingOpen, HomeworkStatus.MissingOverdue,
HomeworkStatus.PartiallyCompleted, HomeworkStatus.PartialMissingOverdue,
HomeworkStatus.PartialSubmittedLate, HomeworkStatus.SubmittedLate,
HomeworkStatus.Completed,
];
public static string[] StatusOptions { get; } = SelectableStatuses.Select(s => HomeworkDisplay.Label(s)).ToArray();
public HomeworkStatus SelectedStatus =>
SelectableStatuses.FirstOrDefault(s => HomeworkDisplay.Label(s) == SelectedStatusName,
HomeworkStatus.MissingOpen);
[ObservableProperty] private Student? _assignedStudent;
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
[ObservableProperty] private Guid? _sessionId;
[ObservableProperty] private bool _selected;
[ObservableProperty] private string _selectedStatusName = HomeworkDisplay.Label(HomeworkStatus.MissingOpen);
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
}
/// <summary>Lokal erfasste fehlende Hausaufgabe ohne zugeordnete WebUntis-Zeile. Dient nur als
/// Kopiervorlage; WebUntis wird aus dem Dialog heraus nicht beschrieben.</summary>
public sealed record LocalOnlyHomeworkRow(string StudentName, DateOnly Date, string GroupName,
string? SubjectName, HomeworkStatus HomeworkStatus, bool PossibleUntisMatch)
{
public string DateLabel => Date.ToString("dd.MM.yyyy");
public string StatusLabel => HomeworkDisplay.Label(HomeworkStatus);
public string? Warning => PossibleUntisMatch ? "WebUntis-Eintrag am selben Tag ohne Fachzuordnung prüfen" : null;
public string ClipboardText => $"{DateLabel} – {StudentName} ({GroupName})\n" +
$"Fehlende HA{(string.IsNullOrWhiteSpace(SubjectName) ? "" : $" ({SubjectName})")}\n{StatusLabel}";
}
/// <summary>Abgleich negativer Klassenbucheinträge zum Stichwort "Hausaufgabe" (eigene, siehe
/// <see cref="WebUntisIntegrationService.GetOwnClassRegisterEventsAsync"/>) gegen
/// <see cref="ParticipationEntry.Homework"/> - 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.
/// Als Ziel ist <see cref="HomeworkStatus.MissingOpen"/> vorbelegt und vor der Übernahme änderbar.
/// Bereits vorhandene lokale Stati werden nicht automatisch zur Übernahme markiert.</summary>
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<WebUntisHomeworkRow> Rows { get; } = [];
public ObservableCollection<LocalOnlyHomeworkRow> 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 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(); LocalOnlyRows.Clear();
try
{
var ownStudents = _students.GetAll();
var globalIndex = UntisNameMatching.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 => UntisNameMatching.BuildClassNameIndex(ownStudents,
g.SelectMany(x => _students.GetByGroup(x.Id))),
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);
var rowsLoaded = false;
void RefreshLocalOnlyRows()
{
LocalOnlyRows.Clear();
var covered = Rows.Where(r => r.AssignedStudent is not null && r.SessionId is not null)
.Select(r => (StudentId: r.AssignedStudent!.Id, SessionId: r.SessionId!.Value))
.ToHashSet();
foreach (var student in _students.GetAll(includeInactive: true))
foreach (var localEntry in _participation.GetByStudent(student.Id))
{
var homework = HomeworkDisplay.Effective(localEntry);
if (homework is null || !HomeworkDisplay.CountsAsMissing(homework)) continue;
var session = _sessions.GetById(localEntry.SessionId);
if (session is null || session.Date < start || session.Date > end ||
covered.Contains((student.Id, session.Id))) continue;
var group = _groups.GetById(session.GroupId);
if (group is null) continue;
var subjectName = group.SubjectId is { } subjectId ? _subjects.GetById(subjectId)?.ShortName : null;
var possibleMatch = Rows.Any(r => r.Date == session.Date && r.SessionId is null &&
(r.AssignedStudent?.Id == student.Id ||
UntisNameMatching.NamesMatch(r.UntisStudentName, student.FullName)));
LocalOnlyRows.Add(new LocalOnlyHomeworkRow(student.FullName, session.Date, group.Name,
subjectName, homework.Value, possibleMatch));
}
}
void ResolveLocalMatch(WebUntisHomeworkRow row)
{
if (row.AssignedStudent is not { } student)
{
row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false;
if (rowsLoaded) RefreshLocalOnlyRows();
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"
: current is null ? "Noch kein Status erfasst" : 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;
if (rowsLoaded) RefreshLocalOnlyRows();
}
foreach (var (entry, date) in ordered)
{
var nameKey = UntisNameMatching.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,
CategoryName = entry.CategoryName,
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
}
rowsLoaded = true;
RefreshLocalOnlyRows();
var unresolvedStudent = Rows.Count(x => x.AssignedStudent is null);
var unresolvedSubject = Rows.Count(x => x.MatchedSubjectId is null);
Status = $"{Rows.Count} passende Einträge von {entries.Count} eigenen Klassenbucheinträgen" +
(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." +
$" {LocalOnlyRows.Count} lokale fehlende Hausaufgaben ohne zugeordneten WebUntis-Eintrag.";
}
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 = row.SelectedStatus;
entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(row.SelectedStatus);
entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry);
row.LocalStatus = HomeworkDisplay.Label(row.SelectedStatus);
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<LearningGroup>()
.ToList();
return candidates.Count == 1 ? candidates[0] : null;
}
// Die Kategorie ist das verlässliche Signal. Der freie Text kann andere Hinweise enthalten und
// wird nur verwendet, wenn WebUntis gar keine Kategorie geliefert hat. "HA" muss ein Wort sein.
public static bool IsMissingHomeworkEntry(UntisClassRegisterEventDto entry) =>
string.Equals(entry.CategoryGroup?.Trim(), "Negativ", StringComparison.OrdinalIgnoreCase) &&
MentionsHomework(string.IsNullOrWhiteSpace(entry.CategoryName) ? entry.Text : entry.CategoryName);
private static bool MentionsHomework(string? value) =>
value?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true ||
(value is not null && Regex.IsMatch(value, @"(?<![\p{L}\p{Nd}])HA(?![\p{L}\p{Nd}])",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant));
private static Subject? MatchSubject(string? text, IReadOnlyDictionary<string, Subject> 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 bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
}