using System.ComponentModel; using System.Text; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; namespace LehrerApp.Desktop.Services.Mcp.Tools; /// MCP-Tools rund um Unterrichtseinheiten () und Einzelstunden /// () — Phase 1 (get_lesson_plans) und Phase 3 (siehe Planungsdokument). /// /// Bewusst kleinteilig statt eines einzelnen "update_lesson", das die komplette Stunde inkl. /// Verlaufsplan als ein großes JSON-Objekt tauscht: ist zwar technisch /// eine eingebettete Liste im selben LiteDB-Dokument (keine echte Sub-Collection, also kein /// Zeilen-Locking auf DB-Ebene) — kleinteilige Tools verkürzen aber das Zeitfenster zwischen Lesen /// und Schreiben je Operation drastisch (ein Tool-Aufruf ändert nur eine Phase, nicht die ganze /// 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. /// /// "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) { /// Deckelt die Antwortgröße von "download_lesson_attachment" (Base64 bläht ca. um /// Faktor 1,33 auf). Kleiner als (App-weites /// Limit), damit ein einzelner MCP-Tool-Aufruf nicht unnötig groß wird — siehe Planungsdokument /// zum offenen Punkt "Ressourcen statt Inline-Base64 für große Dateien". public const long MaxInlineAttachmentBytes = 3 * 1024 * 1024; // ── Lesen ──────────────────────────────────────────────────────────────────────────────── [Description("Listet Unterrichtseinheiten und -stunden einer Lerngruppe; die Einzelstunden werden auf den angegebenen Zeitraum gefiltert.")] public LessonPlanResultDto GetLessonPlans( [Description("Lerngruppen-ID.")] Guid groupId, [Description("Startdatum (einschließlich) für die Einzelstunden, Format YYYY-MM-DD.")] DateOnly from, [Description("Enddatum (einschließlich) für die Einzelstunden, Format YYYY-MM-DD.")] DateOnly to) { var unitDtos = units.GetByGroup(groupId) .Select(u => new UnitDto(u.Id, u.GroupId, u.Title, u.StartDate, u.EndDate, u.Status, u.Competencies)) .ToList(); var lessonDtos = lessons.GetByGroupAndRange(groupId, from, to) .Select(ToDto) .ToList(); return new LessonPlanResultDto(unitDtos, lessonDtos); } [Description("Lädt den Inhalt eines an eine Einzelstunde angehängten Materials (z.B. Arbeitsblatt) Base64-kodiert herunter. Für die storageId siehe get_lesson_plans.")] public AttachmentContentDto DownloadLessonAttachment( [Description("ID der Einzelstunde.")] Guid lessonId, [Description("Speicher-ID des Anhangs, aus get_lesson_plans.")] string storageId) { var lesson = lessons.GetById(lessonId) ?? throw new InvalidOperationException("Unbekannte Stunden-ID."); var attachment = lesson.Attachments.FirstOrDefault(a => a.StorageId == storageId) ?? throw new InvalidOperationException("Kein Anhang mit dieser Speicher-ID an dieser Stunde."); if (attachment.SizeBytes > MaxInlineAttachmentBytes) throw new InvalidOperationException( $"Anhang ist mit {attachment.SizeBytes / 1024 / 1024} MB zu groß für eine Inline-Antwort (Limit {MaxInlineAttachmentBytes / 1024 / 1024} MB)."); using var stream = attachments.OpenRead(storageId) ?? throw new InvalidOperationException("Anhang-Inhalt nicht auffindbar (Speicher inkonsistent)."); using var buffer = new MemoryStream(); stream.CopyTo(buffer); return new AttachmentContentDto(attachment.FileName, attachment.SizeBytes, Convert.ToBase64String(buffer.ToArray())); } [Description("Fügt einer Einzelstunde ein neues Material (z.B. ein von der KI erzeugtes Arbeitsblatt) als Anhang hinzu, Base64-kodiert. Muss der Nutzer erst bestätigen.")] public async Task AddLessonAttachment( [Description("ID der Einzelstunde.")] Guid lessonId, [Description("Dateiname inkl. Endung, z.B. \"arbeitsblatt.pdf\".")] string fileName, [Description("Dateiinhalt, Base64-kodiert.")] string base64Content, CancellationToken ct = default) { var lesson = lessons.GetById(lessonId); if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); byte[] bytes; try { bytes = Convert.FromBase64String(base64Content); } catch (FormatException) { return new WriteResultDto(false, null, "Ungültiger Base64-Inhalt."); } if (bytes.Length == 0) return new WriteResultDto(false, null, "Leerer Dateiinhalt."); if (bytes.Length > IAttachmentStorage.MaxSizeBytes) return new WriteResultDto(false, null, $"Datei ist mit {bytes.Length / 1024 / 1024} MB zu groß (Limit {IAttachmentStorage.MaxSizeBytes / 1024 / 1024} MB)."); var sizeDisplay = bytes.Length >= 1024 * 1024 ? $"{bytes.Length / 1024 / 1024} MB" : $"{Math.Max(1, bytes.Length / 1024)} KB"; var message = $"„{fileName}“ ({sizeDisplay}) zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?"; if (!await confirmation.ConfirmAsync("Material anhängen?", message, ct)) return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); string storageId; using (var stream = new MemoryStream(bytes)) storageId = attachments.Upload(fileName, stream); lesson.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = fileName, SizeBytes = bytes.Length }); lessons.Save(lesson); // DocumentAttachment hat keine eigene Guid-Id (nur StorageId, ein string) - deshalb Id hier // null, storageId steht stattdessen in der Nachricht (für einen unmittelbaren Folgeaufruf, // z.B. download_lesson_attachment zur Bestätigung, ohne erst get_lesson_plans erneut aufzurufen). return new WriteResultDto(true, null, $"Anhang gespeichert (storageId={storageId})."); } // ── Unterrichtseinheiten (Unit) ────────────────────────────────────────────────────────── [Description("Legt eine neue Unterrichtseinheit an. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen.")] public async Task CreateUnit( [Description("Lerngruppen-ID.")] Guid groupId, [Description("Titel der Einheit.")] string title, [Description("Optionales Startdatum, Format YYYY-MM-DD.")] DateOnly? startDate = null, [Description("Optionales Enddatum, Format YYYY-MM-DD.")] DateOnly? endDate = null, CancellationToken ct = default) { var group = groups.GetById(groupId); if (group is null) return new WriteResultDto(false, null, "Unbekannte Gruppen-ID."); var message = $"Neue Unterrichtseinheit „{title}“ für {group.Name} anlegen?" + (startDate is not null || endDate is not null ? $"\nZeitraum: {startDate:dd.MM.yyyy} – {endDate:dd.MM.yyyy}" : ""); if (!await confirmation.ConfirmAsync("Unterrichtseinheit anlegen?", message, ct)) return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); var unit = new Unit { GroupId = groupId, Title = title, StartDate = startDate, EndDate = endDate }; units.Save(unit); return new WriteResultDto(true, unit.Id, "Unterrichtseinheit gespeichert."); } [Description("Ändert Titel/Zeitraum/Status einer bestehenden Unterrichtseinheit. Nur angegebene Felder werden geändert.")] public async Task UpdateUnit( [Description("ID der Unterrichtseinheit.")] Guid unitId, [Description("Neuer Titel. Unverändert lassen: weglassen.")] string? title = null, [Description("Neues Startdatum, Format YYYY-MM-DD. Unverändert lassen: weglassen.")] DateOnly? startDate = null, [Description("Neues Enddatum, Format YYYY-MM-DD. Unverändert lassen: weglassen.")] DateOnly? endDate = null, [Description("Neuer Status: Planned, Active oder Completed. Unverändert lassen: weglassen.")] UnitStatus? status = null, CancellationToken ct = default) { var unit = units.GetById(unitId); if (unit is null) return new WriteResultDto(false, null, "Unbekannte Einheiten-ID."); var changes = new StringBuilder(); if (title is not null && title != unit.Title) { changes.AppendLine($"Titel: „{unit.Title}“ → „{title}“"); unit.Title = title; } if (startDate is not null && startDate != unit.StartDate) { changes.AppendLine($"Start: {unit.StartDate:dd.MM.yyyy} → {startDate:dd.MM.yyyy}"); unit.StartDate = startDate; } if (endDate is not null && endDate != unit.EndDate) { changes.AppendLine($"Ende: {unit.EndDate:dd.MM.yyyy} → {endDate:dd.MM.yyyy}"); unit.EndDate = endDate; } if (status is not null && status != unit.Status) { changes.AppendLine($"Status: {unit.Status} → {status}"); unit.Status = status.Value; } if (changes.Length == 0) return new WriteResultDto(true, unit.Id, "Keine Änderung nötig."); if (!await confirmation.ConfirmAsync("Unterrichtseinheit ändern?", $"„{unit.Title}“\n{changes}".TrimEnd(), ct)) return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); units.Save(unit); return new WriteResultDto(true, unit.Id, "Unterrichtseinheit gespeichert."); } // ── Einzelstunden (Lesson) — Metadaten ─────────────────────────────────────────────────── [Description("Legt eine neue Einzelstunde ohne Verlaufsplan-Phasen an. Phasen danach einzeln über add_lesson_phase hinzufügen. Muss der Nutzer erst bestätigen.")] public async Task CreateLesson( [Description("ID der übergeordneten Unterrichtseinheit.")] Guid unitId, [Description("Datum, Format YYYY-MM-DD.")] DateOnly date, [Description("Thema der Stunde.")] string topic, [Description("Optionale Stundennummer im Tagesraster.")] int? lessonNumber = null, [Description("Optionaler Stundenbeginn, Format HH:mm.")] TimeOnly? startTime = null, CancellationToken ct = default) { var unit = units.GetById(unitId); if (unit is null) return new WriteResultDto(false, null, "Unbekannte Einheiten-ID."); var message = $"Neue Stunde „{topic}“ am {date:dd.MM.yyyy} in Einheit „{unit.Title}“ anlegen?"; if (!await confirmation.ConfirmAsync("Einzelstunde anlegen?", message, ct)) return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); var lesson = new Lesson { UnitId = unitId, GroupId = unit.GroupId, Date = date, Topic = topic, LessonNumber = lessonNumber, StartTime = startTime, }; lessons.Save(lesson); return new WriteResultDto(true, lesson.Id, "Einzelstunde gespeichert."); } [Description("Ändert Metadaten einer bestehenden Einzelstunde (Thema, Hausaufgabe, Planungsideen, Status, Beginn, Stundennummer) — der Verlaufsplan (Phasen) bleibt unverändert. Nur angegebene Felder werden geändert.")] public async Task UpdateLesson( [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("Neue Planungsideen (grober Entwurf vor der Feinplanung). Unverändert lassen: weglassen.")] string? planningIdeas = 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) { var lesson = lessons.GetById(lessonId); if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); var changes = new StringBuilder(); if (topic is not null && topic != lesson.Topic) { changes.AppendLine($"Thema: „{lesson.Topic}“ → „{topic}“"); lesson.Topic = topic; } if (homework is not null && homework != lesson.Homework) { changes.AppendLine($"Hausaufgabe: „{lesson.Homework}“ → „{homework}“"); lesson.Homework = homework; } if (planningIdeas is not null && planningIdeas != lesson.PlanningIdeas) { changes.AppendLine($"Planungsideen: „{lesson.PlanningIdeas}“ → „{planningIdeas}“"); lesson.PlanningIdeas = planningIdeas; } if (status is not null && status != lesson.Status) { changes.AppendLine($"Status: {lesson.Status} → {status}"); lesson.Status = status.Value; } if (startTime is not null && startTime != lesson.StartTime) { changes.AppendLine($"Beginn: {lesson.StartTime:HH\\:mm} → {startTime:HH\\:mm}"); lesson.StartTime = startTime; } if (lessonNumber is not null && lessonNumber != lesson.LessonNumber) { changes.AppendLine($"Nr.: {lesson.LessonNumber} → {lessonNumber}"); lesson.LessonNumber = lessonNumber; } if (changes.Length == 0) return new WriteResultDto(true, lesson.Id, "Keine Änderung nötig."); if (!await confirmation.ConfirmAsync("Einzelstunde ändern?", $"„{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy}\n{changes}".TrimEnd(), ct)) return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); lessons.Save(lesson); return new WriteResultDto(true, lesson.Id, "Einzelstunde gespeichert."); } // ── Einzelstunden (Lesson) — Verlaufsplan-Phasen ───────────────────────────────────────── [Description("Fügt einer Einzelstunde eine neue Verlaufsplan-Phase hinzu (ans Ende). Muss der Nutzer erst bestätigen.")] public async Task AddLessonPhase( [Description("ID der Einzelstunde.")] Guid lessonId, [Description("Name der Phase, z.B. \"Einstieg\", \"Erarbeitung\".")] string name, [Description("Dauer in Minuten.")] int durationMinutes, [Description("Tätigkeit/Sozialform.")] string activity = "", [Description("Material.")] string material = "", [Description("Kurzsymbol, z.B. \"AB001->S\".")] string shorthand = "", [Description("Optionaler, vollständiger Prompt zur Materialerstellung für diese Phase (siehe get_lesson_plans, LessonPhaseDto.MaterialPrompt) - z.B. wenn eine externe KI-Sitzung ihn selbst formuliert hat und er zur Wiederverwendung gespeichert werden soll.")] string? materialPrompt = null, CancellationToken ct = default) { var lesson = lessons.GetById(lessonId); if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); var message = $"Neue Phase „{name}“ ({durationMinutes} Min.) zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?"; if (!await confirmation.ConfirmAsync("Phase hinzufügen?", message, ct)) return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); var phase = new LessonPhaseStep { Name = name, DurationMinutes = durationMinutes, Activity = activity, Material = material, Shorthand = shorthand, MaterialPrompt = materialPrompt, }; lesson.Phases.Add(phase); lessons.Save(lesson); return new WriteResultDto(true, phase.Id, "Phase hinzugefügt."); } [Description("Ändert eine bestehende Verlaufsplan-Phase einer Einzelstunde. Nur angegebene Felder werden geändert.")] public async Task UpdateLessonPhase( [Description("ID der Einzelstunde.")] Guid lessonId, [Description("ID der Phase, aus get_lesson_plans.")] Guid phaseId, [Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null, [Description("Neue Dauer in Minuten. Unverändert lassen: weglassen.")] int? durationMinutes = null, [Description("Neue Tätigkeit. Unverändert lassen: weglassen.")] string? activity = null, [Description("Neues Material. Unverändert lassen: weglassen.")] string? material = null, [Description("Neues Kurzsymbol. Unverändert lassen: weglassen.")] string? shorthand = null, [Description("Neuer Prompt zur Materialerstellung (siehe get_lesson_plans, LessonPhaseDto.MaterialPrompt). Unverändert lassen: weglassen.")] string? materialPrompt = null, CancellationToken ct = default) { var lesson = lessons.GetById(lessonId); if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); var phase = lesson.Phases.FirstOrDefault(p => p.Id == phaseId); if (phase is null) return new WriteResultDto(false, null, "Unbekannte Phasen-ID an dieser Stunde."); var changes = new StringBuilder(); if (name is not null && name != phase.Name) { changes.AppendLine($"Name: „{phase.Name}“ → „{name}“"); phase.Name = name; } if (durationMinutes is not null && durationMinutes != phase.DurationMinutes) { changes.AppendLine($"Dauer: {phase.DurationMinutes} → {durationMinutes} Min."); phase.DurationMinutes = durationMinutes.Value; } if (activity is not null && activity != phase.Activity) { changes.AppendLine($"Tätigkeit: „{phase.Activity}“ → „{activity}“"); phase.Activity = activity; } if (material is not null && material != phase.Material) { changes.AppendLine($"Material: „{phase.Material}“ → „{material}“"); phase.Material = material; } if (shorthand is not null && shorthand != phase.Shorthand) { changes.AppendLine($"Kürzel: „{phase.Shorthand}“ → „{shorthand}“"); phase.Shorthand = shorthand; } if (materialPrompt is not null && materialPrompt != phase.MaterialPrompt) { changes.AppendLine("Materialerstellungs-Prompt geändert."); phase.MaterialPrompt = materialPrompt; } if (changes.Length == 0) return new WriteResultDto(true, phase.Id, "Keine Änderung nötig."); if (!await confirmation.ConfirmAsync("Phase ändern?", $"Phase „{phase.Name}“ in „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy}\n{changes}".TrimEnd(), ct)) return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); lessons.Save(lesson); return new WriteResultDto(true, phase.Id, "Phase gespeichert."); } [Description("Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde. Muss der Nutzer erst bestätigen.")] public async Task RemoveLessonPhase( [Description("ID der Einzelstunde.")] Guid lessonId, [Description("ID der Phase, aus get_lesson_plans.")] Guid phaseId, CancellationToken ct = default) { var lesson = lessons.GetById(lessonId); if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); var phase = lesson.Phases.FirstOrDefault(p => p.Id == phaseId); if (phase is null) return new WriteResultDto(false, null, "Unbekannte Phasen-ID an dieser Stunde."); var message = $"Phase „{phase.Name}“ ({phase.DurationMinutes} Min.) aus „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} entfernen?"; if (!await confirmation.ConfirmAsync("Phase entfernen?", message, ct)) return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); lesson.Phases.Remove(phase); lessons.Save(lesson); return new WriteResultDto(true, phase.Id, "Phase entfernt."); } // ── Einzelstunden (Lesson) — Kompetenzzuordnung ────────────────────────────────────────── // Auf Stundenebene (Lesson.Competencies), noch nicht je Phase - siehe TODO.md 4.5.8. Getrennte // add/remove-Tools statt eines "set_lesson_competencies", das die ganze Liste ersetzt: dieselbe // Race-Condition-Überlegung wie bei den Verlaufsplan-Phasen (add/update/remove_lesson_phase) - // ein Tool-Aufruf ändert nur einen Code, kein "letzter Schreiber gewinnt" über die ganze Liste. [Description("Ordnet einer Einzelstunde einen Kompetenzcode aus dem Katalog zu (siehe get_competency_catalog). Muss der Nutzer erst bestätigen.")] public async Task AddLessonCompetency( [Description("ID der Einzelstunde.")] Guid lessonId, [Description("Kompetenzcode, z.B. \"M.5.1\".")] string code, CancellationToken ct = default) { var lesson = lessons.GetById(lessonId); if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); code = code.Trim(); if (code.Length == 0) return new WriteResultDto(false, null, "Leerer Kompetenzcode."); if (lesson.Competencies.Contains(code, StringComparer.OrdinalIgnoreCase)) return new WriteResultDto(true, lesson.Id, "Kompetenzcode ist bereits zugeordnet."); var message = $"Kompetenz „{code}“ zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?"; if (!await confirmation.ConfirmAsync("Kompetenz zuordnen?", message, ct)) return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); lesson.Competencies.Add(code); lessons.Save(lesson); return new WriteResultDto(true, lesson.Id, "Kompetenz zugeordnet."); } [Description("Entfernt einen Kompetenzcode von einer Einzelstunde. Muss der Nutzer erst bestätigen.")] public async Task RemoveLessonCompetency( [Description("ID der Einzelstunde.")] Guid lessonId, [Description("Kompetenzcode, wie in get_lesson_plans hinterlegt.")] string code, CancellationToken ct = default) { var lesson = lessons.GetById(lessonId); if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); var existing = lesson.Competencies.FirstOrDefault(c => string.Equals(c, code, StringComparison.OrdinalIgnoreCase)); if (existing is null) return new WriteResultDto(false, null, "Dieser Kompetenzcode ist dieser Stunde nicht zugeordnet."); var message = $"Kompetenz „{existing}“ von „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} entfernen?"; if (!await confirmation.ConfirmAsync("Kompetenz entfernen?", message, ct)) return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); lesson.Competencies.Remove(existing); lessons.Save(lesson); return new WriteResultDto(true, lesson.Id, "Kompetenz 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; // 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 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?" + (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. 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) { 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.PlanningIdeas, l.Status, l.Competencies, l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand, p.MaterialPrompt)).ToList(), l.Attachments.Select(a => new LessonAttachmentDto(a.StorageId, a.FileName, a.SizeBytes)).ToList()); }