279 lines
16 KiB
C#
279 lines
16 KiB
C#
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);
|
||
}
|