feat: Sitzungsfreigabe für MCP-Bestätigungen + LessonStatus.Cancelled
Sitzungsfreigabe: McpConfirmDialog bietet zwei Checkboxen ("diese Art
von Aktion" / "alle KI-Aktionen für den Rest der Sitzung nicht mehr
nachfragen"), nur beim Bestätigen wirksam. IMcpConfirmationService.
ConfirmAsync bekommt einen per [CallerMemberName] automatisch
befüllten operationKey - kein bestehender Aufruf musste geändert
werden. Freigaben leben als In-Memory-Bookkeeping auf der
Confirmation-Service-Instanz, gelten bis App-Ende, werden aber trotzdem
geloggt, damit stillschweigende Bestätigungen nicht spurlos bleiben.
Grund: 17 identische Bestätigungen in Folge für eine neu generierte
Unterrichtseinheit sind unzumutbar.
LessonStatus.Cancelled ("Ausgefallen") als Alternative zu
delete_lesson für Stunden, die nur ausgefallen sind (Exkursion,
Feiertag), aber als Ereignis dokumentiert bleiben sollen. Als
Cancelled=4 angehängt (bestehende LiteDB-Werte 0/1 sind fix). Konsistent
wie "bereits durchgeführt" behandelt an allen Stellen, die bisher nur
auf Conducted prüften: Terminverschiebung, Fortschrittsanzeige,
Hausaufgabe-kontrollieren-Erinnerung, "nächste Stunde"-Vorschlag.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -103,7 +103,7 @@ public enum UnitStatus { Planned, Active, Completed }
|
|||||||
// Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen:
|
// 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
|
// LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit
|
||||||
// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden.
|
// 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 }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
|
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public sealed class LessonSchedulingService(ILessonRepository lessons)
|
|||||||
{
|
{
|
||||||
foreach (var other in lessons.GetByUnit(lesson.UnitId))
|
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;
|
continue;
|
||||||
other.Date = other.Date.AddDays(delta);
|
other.Date = other.Date.AddDays(delta);
|
||||||
lessons.Save(other);
|
lessons.Save(other);
|
||||||
@@ -67,7 +67,7 @@ public sealed class LessonSchedulingService(ILessonRepository lessons)
|
|||||||
HomeworkChecked = source.HomeworkChecked,
|
HomeworkChecked = source.HomeworkChecked,
|
||||||
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
|
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
|
||||||
Reflection = source.Reflection,
|
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.Homework = null;
|
||||||
source.HomeworkChecked = false;
|
source.HomeworkChecked = false;
|
||||||
|
|||||||
@@ -610,11 +610,15 @@ public class FakeMcpConfirmation : IMcpConfirmationService
|
|||||||
public string? LastMessage { get; private set; }
|
public string? LastMessage { get; private set; }
|
||||||
public int CallCount { get; private set; }
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
public Task<bool> ConfirmAsync(string title, string message, CancellationToken ct)
|
public string? LastOperationKey { get; private set; }
|
||||||
|
|
||||||
|
public Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||||
|
[System.Runtime.CompilerServices.CallerMemberName] string operationKey = "")
|
||||||
{
|
{
|
||||||
CallCount++;
|
CallCount++;
|
||||||
LastTitle = title;
|
LastTitle = title;
|
||||||
LastMessage = message;
|
LastMessage = message;
|
||||||
|
LastOperationKey = operationKey;
|
||||||
return Task.FromResult(Response);
|
return Task.FromResult(Response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,39 @@ public sealed class LessonSchedulingServiceTests
|
|||||||
Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date);
|
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]
|
[Fact]
|
||||||
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
|
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<Lesson>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -340,6 +340,25 @@ public sealed class McpToolsTests
|
|||||||
Assert.Equal(result.Id, phase.Id);
|
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]
|
[Fact]
|
||||||
public async Task UpdateLessonPhase_AendertNurAngegebeneFelder()
|
public async Task UpdateLessonPhase_AendertNurAngegebeneFelder()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.Views.Mcp;
|
using LehrerApp.Desktop.Views.Mcp;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Services.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
|
/// vor dem Anzeigen aus einer möglichen Minimierung geholt und aktiviert, und der Dialog selbst
|
||||||
/// läuft `Topmost`.
|
/// 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. <paramref name="operationKey"/> ist der Name der aufrufenden Tool-Methode (siehe
|
||||||
|
/// <see cref="IMcpConfirmationService.ConfirmAsync"/>), nicht der MCP-Wire-Name.
|
||||||
|
///
|
||||||
/// Ohne Reaktion des Nutzers würde die Pipe-Session (und damit der wartende KI-Client) unbegrenzt
|
/// Ohne Reaktion des Nutzers würde die Pipe-Session (und damit der wartende KI-Client) unbegrenzt
|
||||||
/// hängen bleiben — nach <see cref="Timeout"/> wird der Dialog automatisch geschlossen und die
|
/// hängen bleiben — nach <see cref="Timeout"/> wird der Dialog automatisch geschlossen und die
|
||||||
/// Änderung als abgelehnt gewertet.
|
/// Änderung als abgelehnt gewertet.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService
|
public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfirmationService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
|
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken ct)
|
private readonly object _trustLock = new();
|
||||||
|
private readonly HashSet<string> _trustedOperations = [];
|
||||||
|
private bool _trustAll;
|
||||||
|
|
||||||
|
public async Task<bool> 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 })
|
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -34,7 +58,7 @@ public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService
|
|||||||
// Avalonias Dispatcher-Synchronisationskontext sorgt dafür, dass die Fortsetzung nach
|
// Avalonias Dispatcher-Synchronisationskontext sorgt dafür, dass die Fortsetzung nach
|
||||||
// "await Task.WhenAny(...)" wieder auf dem UI-Thread läuft, sodass dialog.Close() dort
|
// "await Task.WhenAny(...)" wieder auf dem UI-Thread läuft, sodass dialog.Close() dort
|
||||||
// sicher aufgerufen werden kann.
|
// 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;
|
if (owner.WindowState == WindowState.Minimized) owner.WindowState = WindowState.Normal;
|
||||||
owner.Activate();
|
owner.Activate();
|
||||||
@@ -43,15 +67,25 @@ public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService
|
|||||||
{
|
{
|
||||||
DataContext = new McpConfirmDialogInfo { Title = title, Message = message, ConfirmText = "Übernehmen" },
|
DataContext = new McpConfirmDialogInfo { Title = title, Message = message, ConfirmText = "Übernehmen" },
|
||||||
};
|
};
|
||||||
var dialogTask = dialog.ShowDialog<bool>(owner);
|
var dialogTask = dialog.ShowDialog<McpConfirmDialogResult>(owner);
|
||||||
var timeoutTask = Task.Delay(Timeout, ct);
|
var timeoutTask = Task.Delay(Timeout, ct);
|
||||||
var completed = await Task.WhenAny(dialogTask, timeoutTask);
|
var completed = await Task.WhenAny(dialogTask, timeoutTask);
|
||||||
if (completed != dialogTask)
|
if (completed != dialogTask)
|
||||||
{
|
{
|
||||||
dialog.Close(false);
|
dialog.Close(McpConfirmDialogResult.Rejected);
|
||||||
return false;
|
return McpConfirmDialogResult.Rejected;
|
||||||
}
|
}
|
||||||
return await dialogTask;
|
return await dialogTask;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.Approved)
|
||||||
|
{
|
||||||
|
lock (_trustLock)
|
||||||
|
{
|
||||||
|
if (result.TrustAll) _trustAll = true;
|
||||||
|
else if (result.TrustOperation) _trustedOperations.Add(operationKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.Approved;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Services.Mcp;
|
namespace LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -9,7 +11,14 @@ namespace LehrerApp.Desktop.Services.Mcp;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IMcpConfirmationService
|
public interface IMcpConfirmationService
|
||||||
{
|
{
|
||||||
/// <returns>true, wenn der Nutzer bestätigt hat; false bei Ablehnung, Timeout oder falls kein
|
/// <param name="operationKey">Identifiziert die Art der Operation für eine mögliche
|
||||||
/// Hauptfenster verfügbar ist (z.B. während des DB-Passwort-Prompts beim Start).</returns>
|
/// Sitzungsfreigabe ("diese Aktion für den Rest der Sitzung nicht mehr nachfragen") — bewusst
|
||||||
Task<bool> ConfirmAsync(string title, string message, CancellationToken ct);
|
/// per <see cref="CallerMemberNameAttribute"/> 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.</param>
|
||||||
|
/// <returns>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).</returns>
|
||||||
|
Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||||
|
[CallerMemberName] string operationKey = "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ public class LessonPlanTools(
|
|||||||
[Description("ID der Einzelstunde.")] Guid lessonId,
|
[Description("ID der Einzelstunde.")] Guid lessonId,
|
||||||
[Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null,
|
[Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null,
|
||||||
[Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = 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("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly? startTime = null,
|
||||||
[Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null,
|
[Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
@@ -314,8 +314,11 @@ public class LessonPlanTools(
|
|||||||
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
|
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
|
||||||
|
|
||||||
var oldDate = lesson.Date;
|
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
|
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;
|
: 0;
|
||||||
|
|
||||||
var message = $"„{lesson.Topic}“ von {oldDate:dd.MM.yyyy} auf {newDate:dd.MM.yyyy} verschieben?" +
|
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.");
|
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<WriteResultDto> DeleteLesson(
|
public async Task<WriteResultDto> DeleteLesson(
|
||||||
[Description("ID der zu löschenden Einzelstunde.")] Guid lessonId,
|
[Description("ID der zu löschenden Einzelstunde.")] Guid lessonId,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
private void LoadNextLesson(DateOnly today)
|
private void LoadNextLesson(DateOnly today)
|
||||||
{
|
{
|
||||||
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
|
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)
|
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
@@ -220,6 +220,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
private void LoadOpenHomeworkCheck(DateOnly today)
|
private void LoadOpenHomeworkCheck(DateOnly today)
|
||||||
{
|
{
|
||||||
var previous = _lessons.GetByGroupAndRange(_groupId, today.AddDays(-HomeworkCheckLookbackDays), today.AddDays(-1))
|
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)
|
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||||
private void AdvanceLessonStatus()
|
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;
|
var lesson = SelectedLesson.Model;
|
||||||
lesson.Status = lesson.Status == LessonStatus.Ready
|
lesson.Status = lesson.Status == LessonStatus.Ready
|
||||||
? LessonStatus.Conducted
|
? LessonStatus.Conducted
|
||||||
@@ -467,8 +467,11 @@ public class UnitSummary
|
|||||||
};
|
};
|
||||||
CompetencyCountLabel = u.Competencies.Count == 0 ? "–" : $"{u.Competencies.Count} Kompetenz(en)";
|
CompetencyCountLabel = u.Competencies.Count == 0 ? "–" : $"{u.Competencies.Count} Kompetenz(en)";
|
||||||
|
|
||||||
TotalCount = lessons.Count;
|
// Ausgefallene Stunden zählen weder als gehalten noch als noch zu haltendes Pensum -
|
||||||
ConductedCount = lessons.Count(l => l.Status == LessonStatus.Conducted);
|
// 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;
|
ProgressFraction = TotalCount == 0 ? 0 : (double)ConductedCount / TotalCount;
|
||||||
ProgressText = TotalCount == 0 ? "Keine Stunden" : $"{ConductedCount} / {TotalCount} Stunden gehalten";
|
ProgressText = TotalCount == 0 ? "Keine Stunden" : $"{ConductedCount} / {TotalCount} Stunden gehalten";
|
||||||
}
|
}
|
||||||
@@ -507,6 +510,7 @@ public class LessonSummary
|
|||||||
LessonStatus.Draft => "#78909C",
|
LessonStatus.Draft => "#78909C",
|
||||||
LessonStatus.Ready => "#1976D2",
|
LessonStatus.Ready => "#1976D2",
|
||||||
LessonStatus.Conducted => "#43A047",
|
LessonStatus.Conducted => "#43A047",
|
||||||
|
LessonStatus.Cancelled => "#C62828",
|
||||||
_ => "#9E9E9E",
|
_ => "#9E9E9E",
|
||||||
};
|
};
|
||||||
PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen";
|
PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen";
|
||||||
@@ -543,13 +547,14 @@ public static class UnitStatusDisplay
|
|||||||
|
|
||||||
public static class LessonStatusDisplay
|
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
|
public static string ToName(LessonStatus s) => s switch
|
||||||
{
|
{
|
||||||
LessonStatus.Draft => "Entwurf",
|
LessonStatus.Draft => "Entwurf",
|
||||||
LessonStatus.Ready => "Bereit",
|
LessonStatus.Ready => "Bereit",
|
||||||
LessonStatus.Conducted => "Durchgeführt",
|
LessonStatus.Conducted => "Durchgeführt",
|
||||||
|
LessonStatus.Cancelled => "Ausgefallen",
|
||||||
_ => "Geplant",
|
_ => "Geplant",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -558,6 +563,7 @@ public static class LessonStatusDisplay
|
|||||||
"Entwurf" => LessonStatus.Draft,
|
"Entwurf" => LessonStatus.Draft,
|
||||||
"Bereit" => LessonStatus.Ready,
|
"Bereit" => LessonStatus.Ready,
|
||||||
"Durchgeführt" => LessonStatus.Conducted,
|
"Durchgeführt" => LessonStatus.Conducted,
|
||||||
|
"Ausgefallen" => LessonStatus.Cancelled,
|
||||||
_ => LessonStatus.Planned,
|
_ => LessonStatus.Planned,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -612,6 +612,7 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
private bool HasUnhandledHomework(Guid groupId, DateOnly date)
|
private bool HasUnhandledHomework(Guid groupId, DateOnly date)
|
||||||
{
|
{
|
||||||
var previousLesson = _lessons.GetByGroupAndRange(groupId, date.AddDays(-120), date.AddDays(-1))
|
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)
|
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
if (previousLesson is null || string.IsNullOrWhiteSpace(previousLesson.Homework)) return false;
|
if (previousLesson is null || string.IsNullOrWhiteSpace(previousLesson.Homework)) return false;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
Width="520" MinHeight="240" SizeToContent="Height"
|
Width="520" MinHeight="240" SizeToContent="Height"
|
||||||
CanResize="True" WindowStartupLocation="CenterOwner"
|
CanResize="True" WindowStartupLocation="CenterOwner"
|
||||||
Topmost="True" ShowInTaskbar="True">
|
Topmost="True" ShowInTaskbar="True">
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||||
<Border Grid.Row="0" Background="#FFF4E5" BorderBrush="#D97706" BorderThickness="0,0,0,3" Padding="24,16">
|
<Border Grid.Row="0" Background="#FFF4E5" BorderBrush="#D97706" BorderThickness="0,0,0,3" Padding="24,16">
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Vorschlag deines KI-Assistenten — bitte prüfen" FontSize="12" FontWeight="SemiBold" Foreground="#8A5A00"/>
|
<TextBlock Text="Vorschlag deines KI-Assistenten — bitte prüfen" FontSize="12" FontWeight="SemiBold" Foreground="#8A5A00"/>
|
||||||
@@ -16,7 +16,13 @@
|
|||||||
<ScrollViewer Grid.Row="1" MaxHeight="380" Margin="24,18">
|
<ScrollViewer Grid.Row="1" MaxHeight="380" Margin="24,18">
|
||||||
<TextBlock Text="{Binding Message}" TextWrapping="Wrap" FontSize="14" LineHeight="20"/>
|
<TextBlock Text="{Binding Message}" TextWrapping="Wrap" FontSize="14" LineHeight="20"/>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="*,10,*" Margin="24,0,24,20">
|
<StackPanel Grid.Row="2" Margin="24,0,24,14" Spacing="6">
|
||||||
|
<CheckBox x:Name="TrustOperationCheck" FontSize="12"
|
||||||
|
Content="Diese Art von Aktion für den Rest der Sitzung nicht mehr nachfragen"/>
|
||||||
|
<CheckBox x:Name="TrustAllCheck" FontSize="12"
|
||||||
|
Content="Alle KI-Aktionen für den Rest der Sitzung nicht mehr nachfragen"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Grid Grid.Row="3" ColumnDefinitions="*,10,*" Margin="24,0,24,20">
|
||||||
<Button Grid.Column="0" Content="Ablehnen" HorizontalAlignment="Stretch" Padding="0,12" FontSize="14" Click="OnCancel"/>
|
<Button Grid.Column="0" Content="Ablehnen" HorizontalAlignment="Stretch" Padding="0,12" FontSize="14" Click="OnCancel"/>
|
||||||
<Button Grid.Column="2" Content="{Binding ConfirmText}" HorizontalAlignment="Stretch" Padding="0,12" FontSize="14"
|
<Button Grid.Column="2" Content="{Binding ConfirmText}" HorizontalAlignment="Stretch" Padding="0,12" FontSize="14"
|
||||||
FontWeight="SemiBold" Background="#D97706" Foreground="White" Click="OnConfirm"/>
|
FontWeight="SemiBold" Background="#D97706" Foreground="White" Click="OnConfirm"/>
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ public partial class McpConfirmDialog : Window
|
|||||||
{
|
{
|
||||||
public McpConfirmDialog() => InitializeComponent();
|
public McpConfirmDialog() => InitializeComponent();
|
||||||
|
|
||||||
private void OnConfirm(object? sender, RoutedEventArgs e) => Close(true);
|
private void OnConfirm(object? sender, RoutedEventArgs e) =>
|
||||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
Close(new McpConfirmDialogResult(true, TrustOperationCheck.IsChecked == true, TrustAllCheck.IsChecked == true));
|
||||||
|
|
||||||
|
// Checkbox-Zustand wird beim Ablehnen bewusst ignoriert (siehe McpConfirmDialogResult).
|
||||||
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(McpConfirmDialogResult.Rejected);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace LehrerApp.Desktop.Views.Mcp;
|
||||||
|
|
||||||
|
/// <summary>Ergebnis von <see cref="McpConfirmDialog"/>. <see cref="TrustOperation"/>/
|
||||||
|
/// <see cref="TrustAll"/> sind nur bei <see cref="Approved"/>=true aussagekräftig — beim Ablehnen
|
||||||
|
/// werden sie ignoriert (siehe McpConfirmDialog.axaml.cs), Ablehnen + "nicht mehr nachfragen" wäre
|
||||||
|
/// widersprüchlich.</summary>
|
||||||
|
public record McpConfirmDialogResult(bool Approved, bool TrustOperation, bool TrustAll)
|
||||||
|
{
|
||||||
|
public static McpConfirmDialogResult Rejected { get; } = new(false, false, false);
|
||||||
|
}
|
||||||
@@ -2684,6 +2684,45 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
|
|||||||
stillschweigend zu überschreiben (die bestehende `EnsureTargetIsFree`-Prüfung in
|
stillschweigend zu überschreiben (die bestehende `EnsureTargetIsFree`-Prüfung in
|
||||||
`LessonSchedulingService` greift unverändert).
|
`LessonSchedulingService` greift unverändert).
|
||||||
|
|
||||||
|
- [x] **4.5.32** Sitzungsfreigabe für den Bestätigungsdialog + `LessonStatus.Cancelled`
|
||||||
|
(2026-09-12, Nutzer-Feedback: 17x "Phase hinzufügen" einzeln bestätigen müssen ist
|
||||||
|
unzumutbar).
|
||||||
|
- **Sitzungsfreigabe:** `McpConfirmDialog` bietet jetzt zwei Checkboxen ("diese Art von
|
||||||
|
Aktion" / "alle KI-Aktionen für den Rest der Sitzung nicht mehr nachfragen"), nur beim
|
||||||
|
Bestätigen wirksam (Ablehnen + Checkbox wäre widersprüchlich, wird ignoriert — siehe
|
||||||
|
`McpConfirmDialog.axaml.cs`). `IMcpConfirmationService.ConfirmAsync` bekommt einen neuen
|
||||||
|
`operationKey`-Parameter, bewusst per `[CallerMemberName]` automatisch befüllt (Name der
|
||||||
|
aufrufenden Tool-Methode, z.B. "AddLessonPhase") — kein bestehender Aufruf in den
|
||||||
|
Tool-Klassen musste dafür geändert werden. Freigaben leben als reines In-Memory-Bookkeeping
|
||||||
|
auf der `AvaloniaMcpConfirmationService`-Singleton-Instanz, gelten bis zum Beenden der App
|
||||||
|
(neuer Prozess = neue Instanz = wieder alles ungetraut), nie persistiert. Automatisch
|
||||||
|
bestätigte Aufrufe werden trotzdem geloggt (`AppLogger.Info`), damit stillschweigende
|
||||||
|
Bestätigungen nicht spurlos bleiben.
|
||||||
|
- **`LessonStatus.Cancelled`** ("Ausgefallen") als Alternative zu `delete_lesson`, wenn eine
|
||||||
|
Stunde nur ausgefallen ist (Exkursion, Feiertag, Vertretung), aber als Ereignis
|
||||||
|
dokumentiert bleiben soll — als `Cancelled = 4` angehängt (nicht eingefügt, siehe
|
||||||
|
Kommentar auf dem Enum: bestehende LiteDB-Werte 0/1 sind fix). `delete_lesson`s
|
||||||
|
Tool-Beschreibung weist jetzt aktiv auf diese Alternative hin.
|
||||||
|
`LessonStatusDisplay.Options`/`ToName`/`FromName` erweitert — der bestehende
|
||||||
|
Status-Auswahl-`ComboBox` in `LessonDialog.axaml` (bindet bereits an `StatusOptions`)
|
||||||
|
zeigt "Ausgefallen" dadurch automatisch an, ohne XAML-Änderung.
|
||||||
|
- Konsistente Behandlung wie bereits durchgeführte Stunden an allen Stellen, die bisher
|
||||||
|
nur auf `Conducted` prüften (eine ausgefallene Stunde ist ebenfalls "resolved", nicht
|
||||||
|
mehr offen): `LessonSchedulingService.Move`/`SplitAndMoveSecondPart` (nicht mitverschoben
|
||||||
|
bzw. Fortsetzung auf Entwurf zurückgesetzt), `GroupOverviewViewModel.LoadNextLesson`
|
||||||
|
(nicht als nächste Stunde vorgeschlagen), `AdvanceLessonStatus` (Endzustand, kein
|
||||||
|
Weiterschalten). Fortschrittsanzeige (`UnitSummary`) zählt ausgefallene Stunden weder
|
||||||
|
zum Pensum noch zu den gehaltenen Stunden (Nenner sinkt statt künstlich zu wachsen).
|
||||||
|
- `LoadOpenHomeworkCheck`/`HasUnhandledHomework` (Hausaufgabe-kontrollieren-Erinnerung)
|
||||||
|
überspringen ausgefallene Stunden bei der Suche nach der "letzten Stunde" — sonst hätte
|
||||||
|
eine ausgefallene Stunde mit liegengebliebenem Hausaufgabentext fälschlich weiter genagt.
|
||||||
|
- 9 neue Tests: 2 in
|
||||||
|
[LessonSchedulingServiceTests.cs](LehrerApp.Desktop.Tests/LessonSchedulingServiceTests.cs),
|
||||||
|
neues [LessonStatusDisplayTests.cs](LehrerApp.Desktop.Tests/LessonStatusDisplayTests.cs)
|
||||||
|
(Round-Trip aller Status-Namen + Fortschrittsberechnung), 1 in
|
||||||
|
[McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (belegt den
|
||||||
|
`[CallerMemberName]`-Mechanismus ohne echtes UI).
|
||||||
|
|
||||||
**Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich,
|
**Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich,
|
||||||
dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem
|
dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem
|
||||||
bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`,
|
bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`,
|
||||||
|
|||||||
Reference in New Issue
Block a user