diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index 0accca5..b418758 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -103,7 +103,7 @@ public enum UnitStatus { Planned, Active, Completed } // Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen: // LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit // vorhandene Daten ohne Migration weiterhin korrekt gelesen werden. -public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3 } +public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3, Cancelled = 4 } /// /// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei diff --git a/LehrerApp.Core/Services/LessonSchedulingService.cs b/LehrerApp.Core/Services/LessonSchedulingService.cs index 0e90202..c2a982d 100644 --- a/LehrerApp.Core/Services/LessonSchedulingService.cs +++ b/LehrerApp.Core/Services/LessonSchedulingService.cs @@ -16,7 +16,7 @@ public sealed class LessonSchedulingService(ILessonRepository lessons) { foreach (var other in lessons.GetByUnit(lesson.UnitId)) { - if (other.Id == lesson.Id || other.Status == LessonStatus.Conducted || other.Date <= oldDate) + if (other.Id == lesson.Id || other.Status is LessonStatus.Conducted or LessonStatus.Cancelled || other.Date <= oldDate) continue; other.Date = other.Date.AddDays(delta); lessons.Save(other); @@ -67,7 +67,7 @@ public sealed class LessonSchedulingService(ILessonRepository lessons) HomeworkChecked = source.HomeworkChecked, HomeworkCheckDismissed = source.HomeworkCheckDismissed, Reflection = source.Reflection, - Status = source.Status == LessonStatus.Conducted ? LessonStatus.Draft : source.Status, + Status = source.Status is LessonStatus.Conducted or LessonStatus.Cancelled ? LessonStatus.Draft : source.Status, }; source.Homework = null; source.HomeworkChecked = false; diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 4d5987c..06d8dc9 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -610,11 +610,15 @@ public class FakeMcpConfirmation : IMcpConfirmationService public string? LastMessage { get; private set; } public int CallCount { get; private set; } - public Task ConfirmAsync(string title, string message, CancellationToken ct) + public string? LastOperationKey { get; private set; } + + public Task ConfirmAsync(string title, string message, CancellationToken ct, + [System.Runtime.CompilerServices.CallerMemberName] string operationKey = "") { CallCount++; LastTitle = title; LastMessage = message; + LastOperationKey = operationKey; return Task.FromResult(Response); } } diff --git a/LehrerApp.Desktop.Tests/LessonSchedulingServiceTests.cs b/LehrerApp.Desktop.Tests/LessonSchedulingServiceTests.cs index 17d8f81..d8f89d7 100644 --- a/LehrerApp.Desktop.Tests/LessonSchedulingServiceTests.cs +++ b/LehrerApp.Desktop.Tests/LessonSchedulingServiceTests.cs @@ -45,6 +45,39 @@ public sealed class LessonSchedulingServiceTests Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date); } + [Fact] + public void Move_RuecktAuchAusgefalleneFolgestundenNichtNach() + { + var unitId = Guid.NewGuid(); + var groupId = Guid.NewGuid(); + var moved = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 1) }; + var cancelledFollowing = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 4), Status = LessonStatus.Cancelled }; + var repo = new FakeLessons(); + repo.Add(moved); repo.Add(cancelledFollowing); + + new LessonSchedulingService(repo).Move(moved, new(2026, 9, 8), null, shiftFollowing: true); + + Assert.Equal(new DateOnly(2026, 9, 4), cancelledFollowing.Date); + } + + [Fact] + public void SplitAndMoveSecondPart_AusgefalleneQuellstundeSetztFortsetzungAufEntwurfZurueck() + { + var source = new Lesson + { + UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new(2026, 9, 1), + Status = LessonStatus.Cancelled, + Phases = [new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 60 }], + }; + var repo = new FakeLessons(); + repo.Add(source); + + var continuation = new LessonSchedulingService(repo).SplitAndMoveSecondPart(source, 30, + new(2026, 9, 3), 1, null); + + Assert.Equal(LessonStatus.Draft, continuation.Status); + } + [Fact] public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase() { diff --git a/LehrerApp.Desktop.Tests/LessonStatusDisplayTests.cs b/LehrerApp.Desktop.Tests/LessonStatusDisplayTests.cs new file mode 100644 index 0000000..3c08489 --- /dev/null +++ b/LehrerApp.Desktop.Tests/LessonStatusDisplayTests.cs @@ -0,0 +1,38 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class LessonStatusDisplayTests +{ + [Theory] + [InlineData(LessonStatus.Draft, "Entwurf")] + [InlineData(LessonStatus.Ready, "Bereit")] + [InlineData(LessonStatus.Conducted, "Durchgeführt")] + [InlineData(LessonStatus.Cancelled, "Ausgefallen")] + [InlineData(LessonStatus.Planned, "Geplant")] + public void ToName_FromName_RoundTrip(LessonStatus status, string expectedName) + { + Assert.Equal(expectedName, LessonStatusDisplay.ToName(status)); + Assert.Equal(status, LessonStatusDisplay.FromName(expectedName)); + } + + [Fact] + public void UnitSummary_AusgefalleneStundenZaehlenWederAlsGehaltenNochAlsPensum() + { + var unit = new Unit { Title = "Optik" }; + var lessons = new List + { + new() { Status = LessonStatus.Conducted }, + new() { Status = LessonStatus.Cancelled }, + new() { Status = LessonStatus.Ready }, + }; + + var summary = new UnitSummary(unit, lessons); + + Assert.Equal(2, summary.TotalCount); + Assert.Equal(1, summary.ConductedCount); + Assert.Equal(0.5, summary.ProgressFraction); + } +} diff --git a/LehrerApp.Desktop.Tests/McpToolsTests.cs b/LehrerApp.Desktop.Tests/McpToolsTests.cs index dea423b..f8217f1 100644 --- a/LehrerApp.Desktop.Tests/McpToolsTests.cs +++ b/LehrerApp.Desktop.Tests/McpToolsTests.cs @@ -340,6 +340,25 @@ public sealed class McpToolsTests Assert.Equal(result.Id, phase.Id); } + [Fact] + public async Task AddLessonPhase_UebergibtEigenenMethodennamenAlsOperationKey() + { + // Belegt, dass IMcpConfirmationService.ConfirmAsync den [CallerMemberName]-Mechanismus + // tatsächlich nutzt (Grundlage für die Sitzungsfreigabe "diese Aktion nicht mehr + // nachfragen" in AvaloniaMcpConfirmationService) - ohne echtes UI testbar, weil der Name + // vom Compiler an der Aufrufstelle in AddLessonPhase eingesetzt wird, unabhängig von der + // konkreten IMcpConfirmationService-Implementierung. + var lesson = new Lesson { Topic = "Brechung" }; + var lessons = new FakeLessons(); + lessons.Add(lesson); + var confirmation = new FakeMcpConfirmation(); + var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation); + + await tool.AddLessonPhase(lesson.Id, "Einstieg", 10); + + Assert.Equal(nameof(LessonPlanTools.AddLessonPhase), confirmation.LastOperationKey); + } + [Fact] public async Task UpdateLessonPhase_AendertNurAngegebeneFelder() { diff --git a/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs b/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs index b0b36bd..5366ed8 100644 --- a/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs +++ b/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs @@ -1,7 +1,9 @@ +using System.Runtime.CompilerServices; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Threading; +using LehrerApp.Core.Services; using LehrerApp.Desktop.Views.Mcp; namespace LehrerApp.Desktop.Services.Mcp; @@ -17,16 +19,38 @@ namespace LehrerApp.Desktop.Services.Mcp; /// vor dem Anzeigen aus einer möglichen Minimierung geholt und aktiviert, und der Dialog selbst /// läuft `Topmost`. /// +/// Nutzer-Feedback (Nachtrag): bei vielen gleichartigen Vorschlägen in Folge (z.B. 17x +/// "add_lesson_phase" für eine neu generierte Unterrichtseinheit) einzeln nachfragen zu müssen, ist +/// unzumutbar. Der Dialog bietet deshalb zwei Sitzungsfreigaben an ("diese Aktion" / "alle +/// Aktionen"), die als reines In-Memory-Bookkeeping auf dieser Singleton-Instanz leben — sie gelten +/// bis zum Beenden der App (neuer Prozess = neue Instanz = wieder alles ungetraut) und werden nie +/// persistiert. ist der Name der aufrufenden Tool-Methode (siehe +/// ), nicht der MCP-Wire-Name. +/// /// Ohne Reaktion des Nutzers würde die Pipe-Session (und damit der wartende KI-Client) unbegrenzt /// hängen bleiben — nach wird der Dialog automatisch geschlossen und die /// Änderung als abgelehnt gewertet. /// -public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService +public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfirmationService { private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2); - public async Task ConfirmAsync(string title, string message, CancellationToken ct) + private readonly object _trustLock = new(); + private readonly HashSet _trustedOperations = []; + private bool _trustAll; + + public async Task ConfirmAsync(string title, string message, CancellationToken ct, + [CallerMemberName] string operationKey = "") { + bool alreadyTrusted; + lock (_trustLock) alreadyTrusted = _trustAll || _trustedOperations.Contains(operationKey); + if (alreadyTrusted) + { + logger.Info($"MCP: „{title}“ automatisch bestätigt (Sitzungsfreigabe für " + + $"{(_trustAll ? "alle Aktionen" : operationKey)})."); + return true; + } + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) return false; @@ -34,7 +58,7 @@ public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService // Avalonias Dispatcher-Synchronisationskontext sorgt dafür, dass die Fortsetzung nach // "await Task.WhenAny(...)" wieder auf dem UI-Thread läuft, sodass dialog.Close() dort // sicher aufgerufen werden kann. - return await Dispatcher.UIThread.InvokeAsync(async () => + var result = await Dispatcher.UIThread.InvokeAsync(async () => { if (owner.WindowState == WindowState.Minimized) owner.WindowState = WindowState.Normal; owner.Activate(); @@ -43,15 +67,25 @@ public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService { DataContext = new McpConfirmDialogInfo { Title = title, Message = message, ConfirmText = "Übernehmen" }, }; - var dialogTask = dialog.ShowDialog(owner); + var dialogTask = dialog.ShowDialog(owner); var timeoutTask = Task.Delay(Timeout, ct); var completed = await Task.WhenAny(dialogTask, timeoutTask); if (completed != dialogTask) { - dialog.Close(false); - return false; + dialog.Close(McpConfirmDialogResult.Rejected); + return McpConfirmDialogResult.Rejected; } return await dialogTask; }); + + if (result.Approved) + { + lock (_trustLock) + { + if (result.TrustAll) _trustAll = true; + else if (result.TrustOperation) _trustedOperations.Add(operationKey); + } + } + return result.Approved; } } diff --git a/LehrerApp.Desktop/Services/Mcp/IMcpConfirmationService.cs b/LehrerApp.Desktop/Services/Mcp/IMcpConfirmationService.cs index 3a2fbb9..c0e864f 100644 --- a/LehrerApp.Desktop/Services/Mcp/IMcpConfirmationService.cs +++ b/LehrerApp.Desktop/Services/Mcp/IMcpConfirmationService.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; + namespace LehrerApp.Desktop.Services.Mcp; /// @@ -9,7 +11,14 @@ namespace LehrerApp.Desktop.Services.Mcp; /// public interface IMcpConfirmationService { - /// true, wenn der Nutzer bestätigt hat; false bei Ablehnung, Timeout oder falls kein - /// Hauptfenster verfügbar ist (z.B. während des DB-Passwort-Prompts beim Start). - Task ConfirmAsync(string title, string message, CancellationToken ct); + /// Identifiziert die Art der Operation für eine mögliche + /// Sitzungsfreigabe ("diese Aktion für den Rest der Sitzung nicht mehr nachfragen") — bewusst + /// per automatisch befüllt (der Name der aufrufenden + /// Tool-Methode, z.B. "AddLessonPhase"), damit kein Aufrufer diesen Parameter selbst pflegen + /// muss. Nicht Teil des MCP-Wire-Protokolls, rein internes Bestätigungs-Bookkeeping. + /// true, wenn der Nutzer bestätigt hat (direkt oder über eine bereits erteilte + /// Sitzungsfreigabe); false bei Ablehnung, Timeout oder falls kein Hauptfenster verfügbar ist + /// (z.B. während des DB-Passwort-Prompts beim Start). + Task ConfirmAsync(string title, string message, CancellationToken ct, + [CallerMemberName] string operationKey = ""); } diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs index 0f33600..d971a15 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs @@ -193,7 +193,7 @@ public class LessonPlanTools( [Description("ID der Einzelstunde.")] Guid lessonId, [Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null, [Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = null, - [Description("Neuer Status: Planned, Conducted, Draft oder Ready. Unverändert lassen: weglassen.")] LessonStatus? status = null, + [Description("Neuer Status: Planned, Conducted, Draft, Ready oder Cancelled (ausgefallen, z.B. Exkursion/Feiertag - Alternative zu delete_lesson, wenn die Stunde als Ereignis dokumentiert bleiben soll). Unverändert lassen: weglassen.")] LessonStatus? status = null, [Description("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly? startTime = null, [Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null, CancellationToken ct = default) @@ -314,8 +314,11 @@ public class LessonPlanTools( if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); var oldDate = lesson.Date; + // Muss exakt dieselbe Schutzbedingung wie LessonSchedulingService.Move verwenden, sonst + // weicht die Vorschau-Zahl im Bestätigungstext von dem ab, was tatsächlich verschoben wird. var affectedCount = shiftFollowingLessons - ? lessons.GetByUnit(lesson.UnitId).Count(l => l.Id != lesson.Id && l.Status != LessonStatus.Conducted && l.Date > oldDate) + ? lessons.GetByUnit(lesson.UnitId).Count(l => + l.Id != lesson.Id && l.Status is not (LessonStatus.Conducted or LessonStatus.Cancelled) && l.Date > oldDate) : 0; var message = $"„{lesson.Topic}“ von {oldDate:dd.MM.yyyy} auf {newDate:dd.MM.yyyy} verschieben?" + @@ -340,7 +343,7 @@ public class LessonPlanTools( return new WriteResultDto(true, lesson.Id, "Stunde verschoben."); } - [Description("Löscht eine Einzelstunde endgültig, inklusive ihrer Anhänge. Anders als die meisten anderen Löschvorgänge in LehrerApp landet eine gelöschte Stunde NICHT im Papierkorb — nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")] + [Description("Löscht eine Einzelstunde endgültig, inklusive ihrer Anhänge. Anders als die meisten anderen Löschvorgänge in LehrerApp landet eine gelöschte Stunde NICHT im Papierkorb — nicht rückgängig zu machen. Für eine Stunde, die nur ausgefallen ist (Exkursion, Feiertag, Vertretung ohne Ersatztermin) aber als Ereignis dokumentiert bleiben soll, ist update_lesson mit status=Cancelled meist die bessere Wahl. Muss der Nutzer erst bestätigen.")] public async Task DeleteLesson( [Description("ID der zu löschenden Einzelstunde.")] Guid lessonId, CancellationToken ct = default) diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs index 9962ae2..e31b560 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs @@ -145,7 +145,7 @@ public partial class GroupOverviewViewModel : ObservableObject private void LoadNextLesson(DateOnly today) { var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays)) - .Where(l => l.Status != LessonStatus.Conducted) + .Where(l => l.Status is not (LessonStatus.Conducted or LessonStatus.Cancelled)) .OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0) .FirstOrDefault(); @@ -220,6 +220,7 @@ public partial class GroupOverviewViewModel : ObservableObject private void LoadOpenHomeworkCheck(DateOnly today) { var previous = _lessons.GetByGroupAndRange(_groupId, today.AddDays(-HomeworkCheckLookbackDays), today.AddDays(-1)) + .Where(l => l.Status != LessonStatus.Cancelled) .OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0) .FirstOrDefault(); diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index ad9e615..417f93b 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -376,7 +376,7 @@ public partial class PlanningTabViewModel : ObservableObject [RelayCommand(CanExecute = nameof(HasSelectedLesson))] private void AdvanceLessonStatus() { - if (SelectedLesson is null || SelectedLesson.Model.Status == LessonStatus.Conducted) return; + if (SelectedLesson is null || SelectedLesson.Model.Status is LessonStatus.Conducted or LessonStatus.Cancelled) return; var lesson = SelectedLesson.Model; lesson.Status = lesson.Status == LessonStatus.Ready ? LessonStatus.Conducted @@ -467,8 +467,11 @@ public class UnitSummary }; CompetencyCountLabel = u.Competencies.Count == 0 ? "–" : $"{u.Competencies.Count} Kompetenz(en)"; - TotalCount = lessons.Count; - ConductedCount = lessons.Count(l => l.Status == LessonStatus.Conducted); + // Ausgefallene Stunden zählen weder als gehalten noch als noch zu haltendes Pensum - + // sie fallen komplett aus dem Fortschritt heraus, statt den Nenner künstlich zu erhöhen. + var countableLessons = lessons.Where(l => l.Status != LessonStatus.Cancelled).ToList(); + TotalCount = countableLessons.Count; + ConductedCount = countableLessons.Count(l => l.Status == LessonStatus.Conducted); ProgressFraction = TotalCount == 0 ? 0 : (double)ConductedCount / TotalCount; ProgressText = TotalCount == 0 ? "Keine Stunden" : $"{ConductedCount} / {TotalCount} Stunden gehalten"; } @@ -507,6 +510,7 @@ public class LessonSummary LessonStatus.Draft => "#78909C", LessonStatus.Ready => "#1976D2", LessonStatus.Conducted => "#43A047", + LessonStatus.Cancelled => "#C62828", _ => "#9E9E9E", }; PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen"; @@ -543,13 +547,14 @@ public static class UnitStatusDisplay public static class LessonStatusDisplay { - public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt"]; + public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt", "Ausgefallen"]; public static string ToName(LessonStatus s) => s switch { LessonStatus.Draft => "Entwurf", LessonStatus.Ready => "Bereit", LessonStatus.Conducted => "Durchgeführt", + LessonStatus.Cancelled => "Ausgefallen", _ => "Geplant", }; @@ -558,6 +563,7 @@ public static class LessonStatusDisplay "Entwurf" => LessonStatus.Draft, "Bereit" => LessonStatus.Ready, "Durchgeführt" => LessonStatus.Conducted, + "Ausgefallen" => LessonStatus.Cancelled, _ => LessonStatus.Planned, }; } diff --git a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs index 1fa95aa..17578c5 100644 --- a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs @@ -612,6 +612,7 @@ public partial class TimetableViewModel : ObservableObject private bool HasUnhandledHomework(Guid groupId, DateOnly date) { var previousLesson = _lessons.GetByGroupAndRange(groupId, date.AddDays(-120), date.AddDays(-1)) + .Where(l => l.Status != LessonStatus.Cancelled) .OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0) .FirstOrDefault(); if (previousLesson is null || string.IsNullOrWhiteSpace(previousLesson.Homework)) return false; diff --git a/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml b/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml index 3bac1f1..5d59a3f 100644 --- a/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml +++ b/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml @@ -6,7 +6,7 @@ Width="520" MinHeight="240" SizeToContent="Height" CanResize="True" WindowStartupLocation="CenterOwner" Topmost="True" ShowInTaskbar="True"> - + @@ -16,7 +16,13 @@ - + + + + +