diff --git a/LehrerApp.Desktop.Tests/McpToolsTests.cs b/LehrerApp.Desktop.Tests/McpToolsTests.cs index d12204f..dea423b 100644 --- a/LehrerApp.Desktop.Tests/McpToolsTests.cs +++ b/LehrerApp.Desktop.Tests/McpToolsTests.cs @@ -29,12 +29,18 @@ public sealed class McpToolsTests new[] { "add_lesson_attachment", "add_lesson_phase", "create_grade_entry", "create_lesson", - "create_time_entry", "create_unit", "remove_lesson_phase", "update_lesson", - "update_lesson_phase", "update_student_group_assignment", "update_unit", + "create_time_entry", "create_unit", "move_lesson", "remove_lesson_phase", + "update_lesson", "update_lesson_phase", "update_student_group_assignment", "update_unit", }, McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal)); } + [Fact] + public void AllowedDestructiveWriteTools_EnthaeltNurDeleteLesson() + { + Assert.Equal(["delete_lesson"], McpToolScope.AllowedDestructiveWriteTools); + } + [Fact] public void AllowedTools_EnthaeltKeineDokumentationstypen() { @@ -463,6 +469,110 @@ public sealed class McpToolsTests Assert.Equal(0, confirmation.CallCount); } + [Fact] + public async Task MoveLesson_NutzerBestaetigt_VerschiebtDatum() + { + var lesson = new Lesson { Topic = "Brechung", Date = new DateOnly(2026, 1, 5) }; + var lessons = new FakeLessons(); + lessons.Add(lesson); + var tool = BuildLessonPlanTools(lessons: lessons); + + var result = await tool.MoveLesson(lesson.Id, new DateOnly(2026, 1, 12)); + + Assert.True(result.Applied); + Assert.Equal(new DateOnly(2026, 1, 12), lessons.GetById(lesson.Id)!.Date); + } + + [Fact] + public async Task MoveLesson_ShiftFollowing_VerschiebtSpaetereStundenDerselbenEinheitMit() + { + var unitId = Guid.NewGuid(); + var moved = new Lesson { UnitId = unitId, Topic = "Brechung", Date = new DateOnly(2026, 1, 5) }; + var later = new Lesson { UnitId = unitId, Topic = "Reflexion", Date = new DateOnly(2026, 1, 7) }; + var earlier = new Lesson { UnitId = unitId, Topic = "Einstieg", Date = new DateOnly(2026, 1, 1) }; + var lessons = new FakeLessons(); + lessons.Add(moved); lessons.Add(later); lessons.Add(earlier); + var tool = BuildLessonPlanTools(lessons: lessons); + + // moved: 5.1. -> 12.1. (Delta +7 Tage), later (7.1., danach) muss mitwandern, earlier (1.1., davor) nicht. + var result = await tool.MoveLesson(moved.Id, new DateOnly(2026, 1, 12), shiftFollowingLessons: true); + + Assert.True(result.Applied); + Assert.Equal(new DateOnly(2026, 1, 14), lessons.GetById(later.Id)!.Date); + Assert.Equal(new DateOnly(2026, 1, 1), lessons.GetById(earlier.Id)!.Date); + } + + [Fact] + public async Task MoveLesson_ZielStundeBelegt_LiefertFehlerNachBestaetigung() + { + var groupId = Guid.NewGuid(); + var moved = new Lesson { GroupId = groupId, Topic = "Brechung", Date = new DateOnly(2026, 1, 5), LessonNumber = 1 }; + var occupying = new Lesson { GroupId = groupId, Topic = "Anderer Kurs", Date = new DateOnly(2026, 1, 12), LessonNumber = 1 }; + var lessons = new FakeLessons(); + lessons.Add(moved); lessons.Add(occupying); + var confirmation = new FakeMcpConfirmation(); + var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation); + + var result = await tool.MoveLesson(moved.Id, new DateOnly(2026, 1, 12), newPeriod: 1); + + Assert.False(result.Applied); + Assert.Equal(1, confirmation.CallCount); // Bestätigung lief bereits, Konflikt zeigt sich erst beim Speichern. + Assert.Equal(new DateOnly(2026, 1, 5), lessons.GetById(moved.Id)!.Date); // unverändert + } + + [Fact] + public async Task MoveLesson_UnbekannteStunde_LiefertFehlerOhneNachfrage() + { + var confirmation = new FakeMcpConfirmation(); + var tool = BuildLessonPlanTools(confirmation: confirmation); + + var result = await tool.MoveLesson(Guid.NewGuid(), new DateOnly(2026, 1, 12)); + + Assert.False(result.Applied); + Assert.Equal(0, confirmation.CallCount); + } + + [Fact] + public async Task DeleteLesson_NutzerBestaetigt_LoeschtEndgueltig() + { + var lesson = new Lesson { Topic = "Brechung" }; + var lessons = new FakeLessons(); + lessons.Add(lesson); + var tool = BuildLessonPlanTools(lessons: lessons); + + var result = await tool.DeleteLesson(lesson.Id); + + Assert.True(result.Applied); + Assert.Null(lessons.GetById(lesson.Id)); + } + + [Fact] + public async Task DeleteLesson_NutzerLehntAb_BleibtErhalten() + { + var lesson = new Lesson { Topic = "Brechung" }; + var lessons = new FakeLessons(); + lessons.Add(lesson); + var confirmation = new FakeMcpConfirmation { Response = false }; + var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation); + + var result = await tool.DeleteLesson(lesson.Id); + + Assert.False(result.Applied); + Assert.NotNull(lessons.GetById(lesson.Id)); + } + + [Fact] + public async Task DeleteLesson_UnbekannteStunde_LiefertFehlerOhneNachfrage() + { + var confirmation = new FakeMcpConfirmation(); + var tool = BuildLessonPlanTools(confirmation: confirmation); + + var result = await tool.DeleteLesson(Guid.NewGuid()); + + Assert.False(result.Applied); + Assert.Equal(0, confirmation.CallCount); + } + // ── GroupMembershipTools ───────────────────────────────────────────────────────────────── [Fact] diff --git a/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs b/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs index 6d0434e..b0b36bd 100644 --- a/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs +++ b/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs @@ -2,15 +2,20 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Threading; -using LehrerApp.Desktop.Views.Shared; +using LehrerApp.Desktop.Views.Mcp; namespace LehrerApp.Desktop.Services.Mcp; /// -/// Produktive -Implementierung: zeigt den bestehenden -/// (Views/Shared) über dem Hauptfenster an. Der aufrufende Tool-Handler -/// läuft auf einem Hintergrund-Thread (MCP-Pipe-Session in ), -/// deshalb Marshalling über . +/// Produktive -Implementierung: zeigt +/// über dem Hauptfenster an. Der aufrufende Tool-Handler läuft auf einem Hintergrund-Thread +/// (MCP-Pipe-Session in ), deshalb Marshalling über +/// . +/// +/// Nutzer-Feedback: der Dialog fiel zu wenig auf, wenn LehrerApp im Hintergrund lief (naheliegend, +/// da der Anstoß von einem KI-Client in einem anderen Fenster kommt) — deshalb wird das Hauptfenster +/// vor dem Anzeigen aus einer möglichen Minimierung geholt und aktiviert, und der Dialog selbst +/// läuft `Topmost`. /// /// 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 @@ -31,9 +36,12 @@ public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService // sicher aufgerufen werden kann. return await Dispatcher.UIThread.InvokeAsync(async () => { - var dialog = new ConfirmDialog + if (owner.WindowState == WindowState.Minimized) owner.WindowState = WindowState.Normal; + owner.Activate(); + + var dialog = new McpConfirmDialog { - DataContext = new ConfirmDialogInfo { Title = title, Message = message, ConfirmText = "Übernehmen" }, + DataContext = new McpConfirmDialogInfo { Title = title, Message = message, ConfirmText = "Übernehmen" }, }; var dialogTask = dialog.ShowDialog(owner); var timeoutTask = Task.Delay(Timeout, ct); diff --git a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs index 772c163..045cd1e 100644 --- a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs +++ b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs @@ -8,7 +8,7 @@ using ModelContextProtocol.Server; namespace LehrerApp.Desktop.Services.Mcp; /// -/// In-Process-MCP-Server (Phase 1–3, siehe Planungsdokument). Lauscht auf der Named Pipe +/// In-Process-MCP-Server (siehe Planungsdokument und TODO.md 4.5.25ff.). Lauscht auf der Named Pipe /// und bedient jede eingehende Verbindung (eine je /// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über — /// ein ist ein normaler und kann direkt @@ -129,7 +129,8 @@ public sealed class McpServerHostedService : IAsyncDisposable // Write-Tools schreiben nie direkt - jede Handler-Methode ruft selbst erst // IMcpConfirmationService auf (siehe die jeweilige Tool-Klasse). ReadOnly bewusst false, - // Destructive bewusst false (keine der Phase-2-Schreiboperationen löscht etwas). + // Destructive bewusst false (keine dieser Schreiboperationen löscht etwas) - für die + // Ausnahme siehe AddDestructiveWriteTool/McpToolScope.AllowedDestructiveWriteTools. void AddWriteTool(Delegate handler, string name, string description) { toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions @@ -141,6 +142,17 @@ public sealed class McpServerHostedService : IAsyncDisposable })); } + void AddDestructiveWriteTool(Delegate handler, string name, string description) + { + toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions + { + Name = name, + Description = description, + ReadOnly = false, + Destructive = true, + })); + } + AddReadTool(studentTools.GetStudents, "get_students", "Listet Schüler, optional gefiltert nach Lerngruppe."); AddReadTool(examTools.GetExams, "get_exams", @@ -182,10 +194,16 @@ public sealed class McpServerHostedService : IAsyncDisposable "Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde (Bestätigung durch den Nutzer nötig)."); AddWriteTool(lessonPlanTools.AddLessonAttachment, "add_lesson_attachment", "Fügt einer Einzelstunde ein neues Material als Base64-kodierten Anhang hinzu (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(lessonPlanTools.MoveLesson, "move_lesson", + "Verschiebt eine Einzelstunde auf ein neues Datum, optional mit Mitverschieben späterer Stunden derselben Einheit (Bestätigung durch den Nutzer nötig)."); + + AddDestructiveWriteTool(lessonPlanTools.DeleteLesson, "delete_lesson", + "Löscht eine Einzelstunde endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig)."); System.Diagnostics.Debug.Assert( toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n) - .SequenceEqual(McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools).OrderBy(n => n)), + .SequenceEqual(McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools) + .Concat(McpToolScope.AllowedDestructiveWriteTools).OrderBy(n => n)), "Registrierte MCP-Tools weichen von McpToolScope ab."); return new McpServerOptions diff --git a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs index cc14022..ddf1735 100644 --- a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs +++ b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs @@ -41,5 +41,16 @@ public static class McpToolScope "update_lesson_phase", "remove_lesson_phase", "add_lesson_attachment", + "move_lesson", + ]; + + /// Löschende Write-Tools — eine bewusste, gezielte Ausnahme von der sonst geltenden + /// "v1 ohne Lösch-Tools"-Regel (siehe Planungsdokument), nicht deren Aufhebung. Getrennt von + /// aufgeführt, damit diese Ausnahme beim Lesen sofort auffällt. + /// registriert diese Tools zusätzlich mit + /// Destructive = true. + public static readonly IReadOnlyCollection AllowedDestructiveWriteTools = + [ + "delete_lesson", ]; } diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs index 870d311..0f33600 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using System.Text; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; +using LehrerApp.Core.Services; namespace LehrerApp.Desktop.Services.Mcp.Tools; @@ -16,7 +17,12 @@ namespace LehrerApp.Desktop.Services.Mcp.Tools; /// Stunde) und liefern einen für den Bestätigungsdialog tatsächlich lesbaren Diff statt eines /// kompletten Objekt-Dumps. Ein Tool, das die ganze Stunde überschreibt, ist bewusst NICHT /// vorgesehen; wo es fehlt, ist die Kombination aus update_lesson (Metadaten) + -/// add/update/remove_lesson_phase (je eine Phase) der vorgesehene Weg. +/// add/update/remove_lesson_phase (je eine Phase) der vorgesehene Weg. +/// +/// "delete_lesson" ist (Stand Nutzer-Nachtrag) das einzige Lösch-Tool im gesamten MCP-Katalog — +/// eine bewusste, gezielte Ausnahme von der sonst geltenden "v1 ohne Lösch-Tools"-Regel (siehe +/// Planungsdokument), nicht deren Aufhebung. Entsprechend als "Destructive" annotiert +/// (siehe McpServerHostedService) und mit besonders deutlicher Bestätigungsnachricht. public class LessonPlanTools( IUnitRepository units, ILessonRepository lessons, IGroupRepository groups, IAttachmentStorage attachments, IMcpConfirmationService confirmation) @@ -294,6 +300,63 @@ public class LessonPlanTools( return new WriteResultDto(true, phase.Id, "Phase entfernt."); } + // ── Einzelstunden (Lesson) — Verschieben & Löschen ─────────────────────────────────────── + + [Description("Verschiebt eine Einzelstunde auf ein neues Datum (und optional eine neue Stundennummer). Mit shiftFollowingLessons=true verschieben sich alle noch nicht durchgeführten, späteren Stunden derselben Einheit um denselben Tages-Versatz mit — so lässt sich eine Lücke für eine neue Stunde öffnen: diese Stunde auf den Termin der übernächsten verschieben (mit shiftFollowingLessons), dann create_lesson auf das dadurch freigewordene ursprüngliche Datum. Muss der Nutzer erst bestätigen.")] + public async Task MoveLesson( + [Description("ID der zu verschiebenden Einzelstunde.")] Guid lessonId, + [Description("Neues Datum, Format YYYY-MM-DD.")] DateOnly newDate, + [Description("Neue Stundennummer im Tagesraster. Unverändert lassen: weglassen.")] int? newPeriod = null, + [Description("Alle späteren, noch nicht durchgeführten Stunden derselben Einheit um denselben Tages-Versatz mitverschieben.")] bool shiftFollowingLessons = false, + CancellationToken ct = default) + { + var lesson = lessons.GetById(lessonId); + if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); + + var oldDate = lesson.Date; + var affectedCount = shiftFollowingLessons + ? lessons.GetByUnit(lesson.UnitId).Count(l => l.Id != lesson.Id && l.Status != LessonStatus.Conducted && l.Date > oldDate) + : 0; + + var message = $"„{lesson.Topic}“ von {oldDate:dd.MM.yyyy} auf {newDate:dd.MM.yyyy} verschieben?" + + (newPeriod is not null ? $"\nNeue Stundennummer: {newPeriod}." : "") + + (shiftFollowingLessons + ? affectedCount > 0 + ? $"\n{affectedCount} spätere Stunde(n) derselben Einheit verschieben sich um denselben Versatz mit." + : "\nKeine späteren, noch offenen Stunden derselben Einheit betroffen." + : ""); + + if (!await confirmation.ConfirmAsync("Stunde verschieben?", message, ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + try + { + new LessonSchedulingService(lessons).Move(lesson, newDate, newPeriod, shiftFollowingLessons); + } + catch (InvalidOperationException ex) + { + return new WriteResultDto(false, null, ex.Message); + } + 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.")] + public async Task DeleteLesson( + [Description("ID der zu löschenden Einzelstunde.")] Guid lessonId, + CancellationToken ct = default) + { + var lesson = lessons.GetById(lessonId); + if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); + + var message = $"Die Stunde „{lesson.Topic}“ vom {lesson.Date:dd.MM.yyyy} wird endgültig gelöscht. " + + "Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Stunden)."; + if (!await confirmation.ConfirmAsync("Stunde endgültig löschen?", message, ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + lessons.Delete(lessonId); + return new WriteResultDto(true, lessonId, "Stunde gelöscht."); + } + private static LessonDto ToDto(Lesson l) => new( l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.Status, l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand)).ToList(), diff --git a/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml b/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml new file mode 100644 index 0000000..3bac1f1 --- /dev/null +++ b/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + +