Automate overdue homework and show outstanding work in teaching mode
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
admin committed 2026-09-25 22:02:00 +02:00
1 parent f3e23ad207
commit 4b73d2bea2
10 files changed
+314 -2

No files matched your search

@@ -0,0 +1,40 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class HomeworkOverdueServiceTests
{
[Fact]
public void Advance_Nach14Kalendertagen_NurOffeneNachreichungen()
{
var today = new DateOnly(2026, 9, 25);
var group = new LearningGroup { IsActive = true };
var archived = new LearningGroup { IsActive = false };
var dueSession = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-14) };
var recentSession = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-13) };
var archivedSession = new ParticipationSession { GroupId = archived.Id, Date = today.AddDays(-30) };
var open = new ParticipationEntry { SessionId = dueSession.Id, Homework = HomeworkStatus.MissingOpen };
var legacy = new ParticipationEntry { SessionId = dueSession.Id, HomeworkMissing = true };
var submitted = new ParticipationEntry { SessionId = dueSession.Id, Homework = HomeworkStatus.SubmittedLate };
var partial = new ParticipationEntry { SessionId = dueSession.Id, Homework = HomeworkStatus.PartiallyCompleted };
var recent = new ParticipationEntry { SessionId = recentSession.Id, Homework = HomeworkStatus.MissingOpen };
var oldArchived = new ParticipationEntry { SessionId = archivedSession.Id, Homework = HomeworkStatus.MissingOpen };
var entries = new FakeEntries();
foreach (var entry in new[] { open, legacy, submitted, partial, recent, oldArchived }) entries.Add(entry);
var service = new HomeworkOverdueService(new FakeGroups([group, archived]),
new FakeSessions([dueSession, recentSession, archivedSession]), entries);
Assert.Equal(2, service.Advance(today));
Assert.Equal(HomeworkStatus.MissingOverdue, open.Homework);
Assert.Equal(HomeworkStatus.MissingOverdue, legacy.Homework);
Assert.True(open.HomeworkMissing);
Assert.True(legacy.HomeworkMissing);
Assert.Equal(HomeworkStatus.SubmittedLate, submitted.Homework);
Assert.Equal(HomeworkStatus.PartiallyCompleted, partial.Homework);
Assert.Equal(HomeworkStatus.MissingOpen, recent.Homework);
Assert.Equal(HomeworkStatus.MissingOpen, oldArchived.Homework);
Assert.Equal(0, service.Advance(today));
}
}
@@ -0,0 +1,70 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class TeachingModeOutstandingHomeworkViewModelTests
{
[Fact]
public void Refresh_ZeigtJungeOffeneNachreichungenUndFiltertAktuellAbwesende()
{
var date = new DateOnly(2026, 9, 25);
var group = new LearningGroup { Name = "10c Chemie" };
var amelia = new Student { FirstName = "Amelia", LastName = "Gerste" };
var ben = new Student { FirstName = "Ben", LastName = "Muster" };
var carl = new Student { FirstName = "Carl", LastName = "Beispiel" };
var oldMember = new Student { FirstName = "Dana", LastName = "Alt" };
var previous = new ParticipationSession { GroupId = group.Id, Date = date.AddDays(-5) };
var older = new ParticipationSession { GroupId = group.Id, Date = date.AddDays(-130) };
var current = new ParticipationSession { GroupId = group.Id, Date = date };
var memberships = new FakeMemberships([
new GroupMembership { StudentId = amelia.Id, GroupId = group.Id },
new GroupMembership { StudentId = ben.Id, GroupId = group.Id },
new GroupMembership { StudentId = carl.Id, GroupId = group.Id },
new GroupMembership { StudentId = oldMember.Id, GroupId = group.Id,
Period = MembershipPeriod.Custom, JoinedAt = date.AddDays(-30), LeftAt = date.AddDays(-1) },
]);
var entries = new FakeEntries();
entries.Add(new ParticipationEntry { SessionId = previous.Id, StudentId = amelia.Id,
Homework = HomeworkStatus.MissingOverdue });
var benEntry = new ParticipationEntry { SessionId = previous.Id, StudentId = ben.Id,
Homework = HomeworkStatus.MissingOpen };
entries.Add(benEntry);
entries.Add(new ParticipationEntry { SessionId = previous.Id, StudentId = carl.Id,
Homework = HomeworkStatus.SubmittedLate });
entries.Add(new ParticipationEntry { SessionId = previous.Id, StudentId = oldMember.Id,
Homework = HomeworkStatus.MissingOverdue });
entries.Add(new ParticipationEntry { SessionId = older.Id, StudentId = ben.Id,
Homework = HomeworkStatus.MissingOverdue });
entries.Add(new ParticipationEntry { SessionId = current.Id, StudentId = ben.Id,
Attendance = AttendanceStatus.Present });
entries.Add(new ParticipationEntry { SessionId = current.Id, StudentId = amelia.Id,
Attendance = AttendanceStatus.ExcusePending });
var vm = new TeachingModeOutstandingHomeworkViewModel(group.Id, date,
new FakeStudents([amelia, ben, carl, oldMember]), memberships,
new FakeSessions([previous, older, current]), entries);
vm.SetCurrentSessionId(current.Id);
vm.IsExpanded = true;
Assert.Equal(ben.FullName, Assert.Single(vm.Rows).StudentName);
Assert.Equal("Offene Nachreichungen (1)", vm.Header);
vm.IncludeAbsentStudents = true;
Assert.Equal(2, vm.Rows.Count);
Assert.Contains(vm.Rows, r => r.StudentName == amelia.FullName &&
r.Status == HomeworkStatus.MissingOverdue);
vm.IncludeAbsentStudents = false;
var currentAmelia = entries.GetBySessionAndStudent(current.Id, amelia.Id)!;
currentAmelia.Attendance = AttendanceStatus.Present;
entries.Save(currentAmelia);
vm.Refresh();
Assert.Equal(2, vm.Rows.Count);
benEntry.Homework = HomeworkStatus.SubmittedLate;
entries.Save(benEntry);
vm.Refresh();
Assert.Equal(amelia.FullName, Assert.Single(vm.Rows).StudentName);
}
}
+28
View File
@@ -28,6 +28,7 @@ public class App : Application
private static bool _exitHandlerAttached;
private static Task _initialPolls = Task.CompletedTask;
private static readonly CancellationTokenSource StartupCancellation = new();
private static DispatcherTimer? _homeworkOverdueTimer;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
@@ -100,6 +101,7 @@ public class App : Application
splash?.SetProgress(60, "Papierkorb aufräumen …");
await Task.Run(() => Services.GetRequiredService<ITrashRepository>()
.PurgeOlderThan(DateTime.UtcNow.AddDays(-30)));
await AdvanceHomeworkAsync();
if (!_exitHandlerAttached)
{
@@ -126,6 +128,7 @@ public class App : Application
main.EnableFinalSync(syncEngine);
desktop.MainWindow = main;
main.Show();
StartHomeworkOverdueTimer();
splash?.SetProgress(100, "Bereit");
windowToClose?.Close();
AppBootstrapper.Logger.Info($"Start: Hauptfenster nach {timer.ElapsedMilliseconds} ms geöffnet.");
@@ -152,6 +155,30 @@ public class App : Application
});
}
private static async Task AdvanceHomeworkAsync()
{
try
{
var changed = await Task.Run(() => Services.GetRequiredService<HomeworkOverdueService>()
.Advance(DateOnly.FromDateTime(DateTime.Today)));
if (changed > 0) AppBootstrapper.Logger.Info($"{changed} offene Hausaufgaben als nicht nachgereicht markiert.");
}
catch (Exception ex) { AppBootstrapper.Logger.Error("Hausaufgaben-Fristprüfung fehlgeschlagen.", ex); }
}
private static void StartHomeworkOverdueTimer()
{
var timer = new DispatcherTimer { Interval = TimeSpan.FromHours(1) };
timer.Tick += async (_, _) =>
{
timer.Stop();
await AdvanceHomeworkAsync();
if (_serviceProvider is not null) timer.Start();
};
_homeworkOverdueTimer = timer;
timer.Start();
}
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
/// gesamten sichtbaren Baum automatisch neu, sobald sich <see cref="ThemeVariant"/>
/// ändert.</summary>
@@ -165,6 +192,7 @@ public class App : Application
private static void DisposeServices()
{
_homeworkOverdueTimer?.Stop();
var serviceProvider = Interlocked.Exchange(ref _serviceProvider, null);
if (serviceProvider is null) return;
+1
View File
@@ -197,6 +197,7 @@ public static class AppBootstrapper
services.AddSingleton<GradingService>();
services.AddSingleton<CompetencyAnalysisService>();
services.AddSingleton<SchoolYearService>();
services.AddSingleton<HomeworkOverdueService>();
services.AddSingleton<GroupRolloverService>();
services.AddSingleton<PublicHolidayService>();
services.AddSingleton(_ => new SchoolCalendarSettingsService(appData));
@@ -0,0 +1,33 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services;
/// <summary>Schließt nicht nachgereichte Hausaufgaben 14 Kalendertage nach der Unterrichtsstunde.
/// Nur noch offene Nachreichungen werden geändert; andere Stati und archivierte Gruppen bleiben
/// unangetastet.</summary>
public sealed class HomeworkOverdueService(
IGroupRepository groups, IParticipationSessionRepository sessions,
IParticipationRepository participation)
{
public const int GracePeriodDays = 14;
public int Advance(DateOnly today)
{
var cutoff = today.AddDays(-GracePeriodDays);
var activeGroupIds = groups.GetAll().Where(g => g.IsActive).Select(g => g.Id).ToHashSet();
var changed = 0;
foreach (var session in sessions.GetAll()
.Where(s => s.Date <= cutoff && activeGroupIds.Contains(s.GroupId)))
foreach (var entry in participation.GetBySession(session.Id))
{
if (entry.Homework != HomeworkStatus.MissingOpen &&
!(entry.Homework is null && entry.HomeworkMissing)) continue;
entry.Homework = HomeworkStatus.MissingOverdue;
entry.HomeworkMissing = true;
participation.Save(entry);
changed++;
}
return changed;
}
}
@@ -0,0 +1,77 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
public sealed record OutstandingHomeworkRow(string StudentName, DateOnly Date, HomeworkStatus Status)
{
public string DateLabel => Date.ToString("dd.MM.yyyy");
public string StatusLabel => HomeworkDisplay.Label(Status);
}
/// <summary>Jüngste noch fehlende Hausaufgaben der aktuellen Lerngruppe. Liest bei jedem Öffnen
/// und auf Wunsch erneut, damit die Anwesenheit der laufenden Stunde berücksichtigt wird.</summary>
public partial class TeachingModeOutstandingHomeworkViewModel(
Guid groupId, DateOnly lessonDate,
IStudentRepository students, IGroupMembershipRepository memberships,
IParticipationSessionRepository sessions, IParticipationRepository participation) : ObservableObject
{
private const int LookbackDays = 120;
private const int MaxRows = 20;
private Guid? _currentSessionId;
public ObservableCollection<OutstandingHomeworkRow> Rows { get; } = [];
public string Header => $"Offene Nachreichungen ({Rows.Count})";
public bool HasRows => Rows.Count > 0;
[ObservableProperty] private bool _isExpanded;
[ObservableProperty] private bool _includeAbsentStudents;
partial void OnIsExpandedChanged(bool value)
{
if (value) Refresh();
}
partial void OnIncludeAbsentStudentsChanged(bool value) => Refresh();
public void SetCurrentSessionId(Guid? sessionId) => _currentSessionId = sessionId;
[RelayCommand]
public void Refresh()
{
Rows.Clear();
var groupMemberships = memberships.GetByGroup(groupId);
var roster = students.GetByGroup(groupId)
.Where(s => groupMemberships.FirstOrDefault(m => m.StudentId == s.Id) is not { } membership ||
GroupMembershipService.IsActiveOn(membership, lessonDate))
.ToDictionary(s => s.Id);
var absentIds = _currentSessionId is { } sessionId
? participation.GetBySession(sessionId)
.Where(e => e.Attendance is not null and not AttendanceStatus.Present
and not AttendanceStatus.Late and not AttendanceStatus.SignificantlyLate)
.Select(e => e.StudentId).ToHashSet()
: [];
var rows = sessions.GetByGroup(groupId)
.Where(s => s.Date < lessonDate && s.Date >= lessonDate.AddDays(-LookbackDays))
.SelectMany(session => participation.GetBySession(session.Id)
.Select(entry => (session.Date, Entry: entry)))
.Where(x => roster.ContainsKey(x.Entry.StudentId) &&
(IncludeAbsentStudents || !absentIds.Contains(x.Entry.StudentId)))
.Select(x => (x.Date, x.Entry.StudentId, Status: HomeworkDisplay.Effective(x.Entry)))
.Where(x => x.Status is { } status && HomeworkDisplay.CountsAsMissing(status))
.OrderByDescending(x => x.Date)
.ThenBy(x => roster[x.StudentId].FullName)
.Take(MaxRows);
foreach (var row in rows)
Rows.Add(new OutstandingHomeworkRow(roster[row.StudentId].FullName, row.Date, row.Status!.Value));
OnPropertyChanged(nameof(Header));
OnPropertyChanged(nameof(HasRows));
}
}
@@ -33,10 +33,12 @@ public class TeachingModeViewModel
/// der richtigen Sitzung starten.</summary>
public ParticipationTabViewModel Participation { get; }
public TeachingModeHomeworkViewModel Homework { get; }
public TeachingModeOutstandingHomeworkViewModel? OutstandingHomework { get; }
public TeachingModeViewModel(Lesson lesson, LearningGroup group,
IAlternativeLessonPathRepository alternativePaths, ILessonRepository lessons,
SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation)
SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation,
TeachingModeOutstandingHomeworkViewModel? outstandingHomework = null)
{
GroupName = group.Name;
Timeline = new TeachingTimelineViewModel(lesson, lessons, !group.IsActive);
@@ -52,6 +54,16 @@ public class TeachingModeViewModel
if (SeatingPlan.SelectedSession is { } linkedSession)
Participation.SelectedSession =
Participation.Sessions.FirstOrDefault(s => s.Id == linkedSession.Id);
OutstandingHomework = outstandingHomework;
OutstandingHomework?.SetCurrentSessionId(SeatingPlan.SelectedSession?.Id);
OutstandingHomework?.Refresh();
SeatingPlan.OnAssessmentChanged = () => OutstandingHomework?.Refresh();
SeatingPlan.PropertyChanged += (_, args) =>
{
if (args.PropertyName != nameof(SeatingPlan.SelectedSession)) return;
OutstandingHomework?.SetCurrentSessionId(SeatingPlan.SelectedSession?.Id);
OutstandingHomework?.Refresh();
};
// 4.5.4: dieselbe "letzte Stunde vor dieser"-Suche wie das Stundenplan-Badge
// "Hausaufgabe kontrollieren" (TimetableViewModel.HasUnhandledHomework) - 120 Tage
@@ -157,6 +157,35 @@
</StackPanel>
</Border>
<Expander Header="{Binding OutstandingHomework.Header}"
IsExpanded="{Binding OutstandingHomework.IsExpanded}">
<StackPanel Spacing="8" Margin="0,6,0,0">
<TextBlock Text="Jüngste fehlende Hausaufgaben der Lerngruppe (letzte 120 Tage)."
FontSize="11" Opacity="0.65" TextWrapping="Wrap"/>
<CheckBox Content="Abwesende dieser Stunde anzeigen"
IsChecked="{Binding OutstandingHomework.IncludeAbsentStudents}" FontSize="12"/>
<Button Content="Aktualisieren" Command="{Binding OutstandingHomework.RefreshCommand}"
HorizontalAlignment="Left" FontSize="11" Padding="6,4"/>
<TextBlock Text="Keine offenen Nachreichungen für die angezeigten Schüler."
IsVisible="{Binding !OutstandingHomework.HasRows}" FontSize="12" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding OutstandingHomework.Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:OutstandingHomeworkRow">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="5" Padding="8,6" Margin="0,0,0,5">
<StackPanel Spacing="2">
<TextBlock Text="{Binding StudentName}" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock FontSize="11" Opacity="0.75" TextWrapping="Wrap">
<Run Text="{Binding DateLabel}"/><Run Text=" · "/><Run Text="{Binding StatusLabel}"/>
</TextBlock>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Expander>
<StackPanel Spacing="4">
<TextBlock Text="Hausaufgabe (diese Stunde)" FontSize="12" FontWeight="SemiBold" Opacity="0.6"/>
<TextBox Text="{Binding Homework.CurrentHomework, Mode=TwoWay}" AcceptsReturn="True"
@@ -26,13 +26,19 @@ public partial class TeachingModeWindow : Window
}
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(lesson.GroupId);
if (group is null) return;
var outstandingHomework = new TeachingModeOutstandingHomeworkViewModel(group.Id, lesson.Date,
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>());
var window = new TeachingModeWindow
{
DataContext = new TeachingModeViewModel(lesson, group,
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
App.Services.GetRequiredService<ILessonRepository>(),
App.Services.GetRequiredService<SeatingPlanTabViewModel>(),
App.Services.GetRequiredService<ParticipationTabViewModel>())
App.Services.GetRequiredService<ParticipationTabViewModel>(),
outstandingHomework)
};
OpenWindows.Add(lesson.Id, window);
window.Closed += (_, _) => OpenWindows.Remove(lesson.Id);
@@ -100,6 +106,7 @@ public partial class TeachingModeWindow : Window
var dialog = new AttendanceHomeworkQuickInputDialog { DataContext = quickVm };
await dialog.ShowDialog(this);
(DataContext as TeachingModeViewModel)?.SeatingPlan.ReloadSeatBadgesFromRepository();
(DataContext as TeachingModeViewModel)?.OutstandingHomework?.Refresh();
}
private void OnClose(object? sender, RoutedEventArgs e) => Close();
+15
View File
@@ -2017,6 +2017,21 @@ Hausaufgabe selbst (`Lesson.Homework`), das bleibt ein offener, separater Punkt.
WebUntis-Gegenstück mit synthetischen WebUntis-Klassenbuchberichten in
`WebUntisHomeworkComparisonViewModelTests`.
**Nachtrag zu 4.3/4.5, automatische Nachreichfrist und Unterrichtsansicht (September 2026):**
- `HomeworkOverdueService` wandelt `MissingOpen` 14 Kalendertage nach dem Datum der lokalen
`ParticipationSession` in `MissingOverdue` um, auch für ältere Einträge mit ausschließlich
`HomeworkMissing=true`. Andere Stati und archivierte Gruppen bleiben unverändert. Die Prüfung
läuft beim Programmstart sowie stündlich bei geöffneter App und ist wiederholbar ohne weitere
Änderungen.
- Der Unterrichtsmodus enthält links ein ausklappbares Panel für bis zu 20 jüngste noch fehlende
Hausaufgaben der Lerngruppe aus den letzten 120 Tagen. Bereits nachgereichte Hausaufgaben und
nicht mehr aktive Mitglieder werden ausgeblendet. Schülerinnen und Schüler mit einer als abwesend
erfassten Anwesenheit in der aktuellen Sitzung sind standardmäßig ausgeblendet und können über
eine Checkbox eingeblendet werden. Das Panel lädt beim Öffnen und per Aktualisieren-Button neu;
nach dem Schnelldialog für Anwesenheit/Hausaufgaben ebenfalls.
- Regressionstests prüfen die 14-Tage-Grenze, unveränderte andere Stati und archivierte Gruppen
sowie die Panel-Filterung nach aktueller Anwesenheit, Mitgliedschaft und Nachreichstatus.
**Nachtrag zu 4.3, Untis-Hub (September 2026):** Nutzer-Feedback: die vier bestehenden WebUntis-
Abgleiche (Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-, Hausaufgabenabgleich) liefen
bislang alle rein manuell und ohne jede Übersicht, wann welcher zuletzt lief — bei bewusst fehlendem