Compare commits
54
Commits
15463bc292
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77fe9b1c79 | ||
|
|
92e497b54f | ||
|
|
861f2c76ca | ||
|
|
ef3e9dbbb6 | ||
|
|
03a5e0dd9c | ||
|
|
6dccf96039 | ||
|
|
6e57bf407d | ||
|
|
0b5cfc5522 | ||
|
|
09cbbf1b77 | ||
|
|
1671796844 | ||
|
|
70dc78904c | ||
|
|
7b660c7152 | ||
|
|
3675b70004 | ||
|
|
eb1b2340b7 | ||
|
|
b8193be6ec | ||
|
|
0962845ead | ||
|
|
42c650518c | ||
|
|
2c791258a1 | ||
|
|
c528bd825f | ||
|
|
56ab5067e6 | ||
|
|
bfe214ecf2 | ||
|
|
d979a84e7b | ||
|
|
3f8813df51 | ||
|
|
19aa302487 | ||
|
|
a4733c156b | ||
|
|
e6c30b0bc7 | ||
|
|
55fba2cadb | ||
|
|
dd2e1e7c61 | ||
|
|
0c60a54c4d | ||
|
|
9567d8d616 | ||
|
|
98f5573999 | ||
|
|
455c61c946 | ||
|
|
6af4bee1f0 | ||
|
|
c3ce1a7204 | ||
|
|
ba52109eaa | ||
|
|
bd0d7e47ea | ||
|
|
2ec8adac61 | ||
|
|
e86125e5f9 | ||
|
|
86e114580f | ||
|
|
835cccadec | ||
|
|
709ea88c2a | ||
|
|
73453fd88d | ||
|
|
265190b3aa | ||
|
|
fedfceb81d | ||
|
|
7ef0c95a73 | ||
|
|
94946ffefb | ||
|
|
3cccb3226c | ||
|
|
988a293c42 | ||
|
|
92f9a75ec1 | ||
|
|
0a419c4c11 | ||
|
|
216708e879 | ||
|
|
0d5fb6d146 | ||
|
|
1d9521fc60 | ||
|
|
4af83c3e68 |
Vendored
+6
-5
@@ -47,11 +47,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Desktop + API (gleichzeitig)",
|
"name": "API Tests starten",
|
||||||
"configurations": [
|
"type": "coreclr",
|
||||||
"Desktop App starten",
|
"request": "launch",
|
||||||
"API Server starten"
|
"preLaunchTask": "API bauen",
|
||||||
]
|
"program": "${workspaceFolder}/LehrerApp.Api.Tests/bin/Debug/net10.0/LehrerApp.Api.Tests.dll",
|
||||||
|
"args": [],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"compounds": [
|
"compounds": [
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
<!-- API -->
|
<!-- API -->
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||||
|
|
||||||
|
<!-- MCP-Server (lokal, siehe TODO.md) -->
|
||||||
|
<PackageVersion Include="ModelContextProtocol.Core" Version="2.2.0" />
|
||||||
|
|
||||||
<!-- Tests -->
|
<!-- Tests -->
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ public class AiLesson
|
|||||||
public List<AiPhaseStep> Phases { get; set; } = [];
|
public List<AiPhaseStep> Phases { get; set; } = [];
|
||||||
public string? Homework { get; set; }
|
public string? Homework { get; set; }
|
||||||
public string? Reflection { get; set; }
|
public string? Reflection { get; set; }
|
||||||
|
/// Rohentwurf/erste Ideen der Lehrkraft vor der Feinplanung (siehe Lesson.PlanningIdeas) — reiner
|
||||||
|
/// Kontext für die KI wie Homework/Reflection, wird symmetrisch übernommen/zurückgegeben.
|
||||||
|
public string? PlanningIdeas { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AiPhaseStep
|
public class AiPhaseStep
|
||||||
@@ -100,6 +103,10 @@ public class AiPlanningResponse
|
|||||||
{
|
{
|
||||||
public List<AiLesson> Lessons { get; set; } = [];
|
public List<AiLesson> Lessons { get; set; } = [];
|
||||||
public string? Summary { get; set; }
|
public string? Summary { get; set; }
|
||||||
|
// Vom Backend zusätzlich mitgelieferte, unveränderte Modellantwort. Wird nur benötigt, falls
|
||||||
|
// die typisierte Deserialisierung scheitert; bei einer regulär verarbeiteten Antwort wird sie
|
||||||
|
// weder angezeigt noch gespeichert.
|
||||||
|
public string? RawResponse { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -174,3 +181,54 @@ public class AiSubstanceResearchResponse
|
|||||||
public string ActivityRestriction { get; set; } = "";
|
public string ActivityRestriction { get; set; } = "";
|
||||||
public string Source { get; set; } = "";
|
public string Source { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire-Vertrag für den Fehlzeiten-Statusvorschlag (ai-backend/untis-status.php, Nutzer-Feedback
|
||||||
|
/// zum Untis-Hub: der aus WebUntis abgeleitete Zielstatus war über den Anzeigetext oft nicht
|
||||||
|
/// nachvollziehbar). Bewusst OHNE jeden Personenbezug - <see cref="AiUntisStatusRow.Id"/> ist eine
|
||||||
|
/// rein technische, für die KI bedeutungslose Kennung, über die der Desktop-Client die Antwort
|
||||||
|
/// zurückordnet; kein Name, keine Klasse, kein Datum verlässt damit die App. Mehrere Zeilen eines
|
||||||
|
/// Abgleichslaufs werden in einer Anfrage gebündelt statt je Zeile einzeln (Kosten/Latenz).
|
||||||
|
/// </summary>
|
||||||
|
public class AiUntisStatusRequest
|
||||||
|
{
|
||||||
|
public List<AiUntisStatusRow> Rows { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AiUntisStatusRow
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public string ReasonText { get; set; } = "";
|
||||||
|
public int AbsentMinutes { get; set; }
|
||||||
|
/// True, wenn WebUntis für den Eintrag bereits ein Bearbeitungsdatum führt (unabhängig vom
|
||||||
|
/// tatsächlichen Datum, das aus Datenschutzgründen nicht mitgeschickt wird).
|
||||||
|
public bool HandledOn { get; set; }
|
||||||
|
/// Schulinterne Konvention: Klammerung der Entschuldigungsnummer bedeutet unentschuldigt, ohne
|
||||||
|
/// Klammern entschuldigt; null, wenn keine Nummer hinterlegt ist (siehe UntisDiffService-Analog
|
||||||
|
/// in WebUntisLessonAbsenceComparisonViewModel.MapStatus).
|
||||||
|
public bool? ExternKeyInParentheses { get; set; }
|
||||||
|
/// Bereits regelbasiert ermittelter Status (siehe MapStatus) - der Systemprompt bittet die KI,
|
||||||
|
/// nur bei eindeutigem Widerspruch im Freitext davon abzuweichen, statt bei Unsicherheit zu raten.
|
||||||
|
public string CurrentGuess { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enthält absichtlich GENAU eine Zeile je gesendeter <see cref="AiUntisStatusRow.Id"/> - der
|
||||||
|
/// Client verwirft die gesamte Antwort, wenn die zurückgegebene Id-Menge nicht exakt der
|
||||||
|
/// gesendeten entspricht (siehe AiPlanningService.RequestUntisStatusSuggestionsAsync), statt sich
|
||||||
|
/// auf die Reihenfolge zu verlassen. So bleibt eine Verwechslung zwischen Vorschlag und Zeile
|
||||||
|
/// strukturell ausgeschlossen, nicht nur im Regelfall vermieden.
|
||||||
|
/// </summary>
|
||||||
|
public class AiUntisStatusResponse
|
||||||
|
{
|
||||||
|
public List<AiUntisStatusSuggestion> Suggestions { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AiUntisStatusSuggestion
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
/// Einer von "Present"/"Late"/"LeftDuringClass"/"ExcusePending"/"Excused"/"Unexcused" (siehe
|
||||||
|
/// Systemprompt in ai-backend/untis-status.php) - wird client-seitig gegen genau diese Menge
|
||||||
|
/// geprüft, bevor er als AttendanceStatus interpretiert wird.
|
||||||
|
public string Status { get; set; } = "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ public interface IUnitRepository
|
|||||||
}
|
}
|
||||||
public interface ILessonRepository
|
public interface ILessonRepository
|
||||||
{
|
{
|
||||||
|
Lesson? GetById(Guid id);
|
||||||
List<Lesson> GetByUnit(Guid unitId);
|
List<Lesson> GetByUnit(Guid unitId);
|
||||||
List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date);
|
List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date);
|
||||||
List<Lesson> GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to);
|
List<Lesson> GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to);
|
||||||
@@ -170,6 +171,14 @@ public interface IUntisCacheFetchStateRepository
|
|||||||
UntisCacheFetchState? Get(string className, UntisCacheKind kind);
|
UntisCacheFetchState? Get(string className, UntisCacheKind kind);
|
||||||
void Save(UntisCacheFetchState state);
|
void Save(UntisCacheFetchState state);
|
||||||
}
|
}
|
||||||
|
/// Zuletzt-Lauf-Status der Untis-Hub-Jobs (siehe TODO.md) - je (Kind, GroupId), GroupId null bei
|
||||||
|
/// den dashboard-weiten Jobs.
|
||||||
|
public interface IUntisHubJobStateRepository
|
||||||
|
{
|
||||||
|
UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId);
|
||||||
|
List<UntisHubJobState> GetAll();
|
||||||
|
void Save(UntisHubJobState state);
|
||||||
|
}
|
||||||
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
|
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
|
||||||
public interface IUntisSlotMappingRepository
|
public interface IUntisSlotMappingRepository
|
||||||
{
|
{
|
||||||
@@ -276,6 +285,10 @@ public interface ISubjectRepository
|
|||||||
public interface ICompetencyDomainRepository
|
public interface ICompetencyDomainRepository
|
||||||
{
|
{
|
||||||
List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel);
|
List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel);
|
||||||
|
// Über alle Klassenstufen eines Fachs hinweg - für einen Überblick über den gesamten Katalog
|
||||||
|
// eines Fachs (z.B. MCP-Tool get_competency_catalog ohne gradeLevel-Filter), ohne dass der
|
||||||
|
// Aufrufer erst jede vorkommende Klassenstufe einzeln erraten/abfragen müsste.
|
||||||
|
List<CompetencyDomain> GetBySubject(Guid subjectId);
|
||||||
CompetencyDomain? GetById(Guid id);
|
CompetencyDomain? GetById(Guid id);
|
||||||
void Save(CompetencyDomain domain);
|
void Save(CompetencyDomain domain);
|
||||||
void Delete(Guid id);
|
void Delete(Guid id);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace LehrerApp.Core.Mcp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Named-Pipe-Konvention zwischen dem Avalonia-Hauptprozess (Pipe-Server, siehe
|
||||||
|
/// LehrerApp.Desktop/Services/Mcp) und LehrerApp.McpBridge (Pipe-Client). Bewusst als einzelne
|
||||||
|
/// geteilte Konstante statt eigener Bibliothek — die Bridge reicht JSON-RPC-Nachrichten unverändert
|
||||||
|
/// durch und braucht sonst keine gemeinsamen Typen mit der App (siehe Planungsdokument).
|
||||||
|
/// </summary>
|
||||||
|
public static class McpPipeConstants
|
||||||
|
{
|
||||||
|
public const string PipeName = "LehrerApp.Mcp";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unter macOS/Linux emuliert .NET Named Pipes über eine Socket-Datei unter
|
||||||
|
/// <see cref="Path.GetTempPath"/> (intern "CoreFxPipe_" + Name), und die liest dafür die
|
||||||
|
/// TMPDIR-Umgebungsvariable des jeweiligen Prozesses. LehrerApp.Desktop (von Finder/Dock
|
||||||
|
/// gestartet) bekommt das reguläre per-Login-Session-TMPDIR unter /var/folders/.../T,
|
||||||
|
/// während LehrerApp.McpBridge als Kindprozess von Claude Desktop oft eine reduzierte
|
||||||
|
/// Umgebung mit TMPDIR=/tmp erbt - beide Prozesse suchen die Pipe-Datei dann an
|
||||||
|
/// unterschiedlichen Orten und finden sich nie ("MCP nicht erreichbar, obwohl LehrerApp
|
||||||
|
/// läuft", auch wenn beide Prozesse laufen und die Pipe grundsätzlich offen ist). Fix: TMPDIR
|
||||||
|
/// für beide Prozesse hart auf denselben Ordner setzen, bevor die erste
|
||||||
|
/// NamedPipeServerStream/-ClientStream-Instanz entsteht - dieselbe ApplicationData-Basis wie
|
||||||
|
/// AppBootstrapper (siehe dort) wird anders als TMPDIR zuverlässig an Kindprozesse
|
||||||
|
/// weitergereicht. Muss als eine der ersten Anweisungen in Main aufgerufen werden (Desktop wie
|
||||||
|
/// Bridge), auf Windows ein No-op (dort nutzen Named Pipes den Kernel-Namespace, keine
|
||||||
|
/// Socket-Datei).
|
||||||
|
/// </summary>
|
||||||
|
public static void EnsureStableUnixSocketDirectory()
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows()) return;
|
||||||
|
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"LehrerApp", "ipc");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
Environment.SetEnvironmentVariable("TMPDIR", dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,14 @@ public class LearningGroup
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int? WebUntisLessonId { get; set; }
|
public int? WebUntisLessonId { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Blendet diese Lerngruppe im UntisHub (siehe <see cref="Services"/>-Layer im Desktop-Projekt:
|
||||||
|
/// UntisHubService) aus, auch wenn <see cref="WebUntisLessonId"/> gesetzt ist - z.B. für
|
||||||
|
/// Klassenrat/AGs, die zwar eine WebUntis-Unterrichtsnummer haben, aber nicht auf
|
||||||
|
/// Fehlzeiten/offene Periods hin überwacht werden sollen. Default false (alte Datensätze
|
||||||
|
/// verhalten sich unverändert wie bisher).
|
||||||
|
/// </summary>
|
||||||
|
public bool ExcludedFromUntisHub { get; set; }
|
||||||
|
/// <summary>
|
||||||
/// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) —
|
/// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) —
|
||||||
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
|
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
|
||||||
/// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
|
/// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
|
||||||
|
|||||||
@@ -54,7 +54,15 @@ public class Lesson : IHasAttachments
|
|||||||
/// Optionaler Stundenbeginn — solange kein Stundenplan (Kapitel 4.3) existiert, manuell
|
/// Optionaler Stundenbeginn — solange kein Stundenplan (Kapitel 4.3) existiert, manuell
|
||||||
/// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in <see cref="Phases"/>.
|
/// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in <see cref="Phases"/>.
|
||||||
public TimeOnly? StartTime { get; set; }
|
public TimeOnly? StartTime { get; set; }
|
||||||
|
/// Grobe Ideen/erster Entwurf, festgehalten lange bevor der Verlaufsplan (<see cref="Phases"/>)
|
||||||
|
/// feingeplant wird (Nutzer-Feedback: die eigentliche Ideenfindung liegt zeitlich oft weit vor
|
||||||
|
/// der Feinplanung) — bewusst ein eigenes Feld statt Zweckentfremdung von <see cref="Reflection"/>
|
||||||
|
/// (die ist nach der Stunde) oder <see cref="Unit.Notes"/> (die ist auf Einheitenebene, nicht je
|
||||||
|
/// Stunde). Wird auch der KI-Planungsunterstützung (4.5.9) als Kontext mitgegeben, damit erste
|
||||||
|
/// eigene Ideen bei der KI-gestützten Weiterplanung nicht erneut abgetippt werden müssen.
|
||||||
|
public string? PlanningIdeas { get; set; }
|
||||||
public List<LessonPhaseStep> Phases { get; set; } = [];
|
public List<LessonPhaseStep> Phases { get; set; } = [];
|
||||||
|
public TeachingTimelineState? TeachingTimeline { get; set; }
|
||||||
public string? Homework { get; set; }
|
public string? Homework { get; set; }
|
||||||
/// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert
|
/// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert
|
||||||
/// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4).
|
/// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4).
|
||||||
@@ -63,6 +71,11 @@ public class Lesson : IHasAttachments
|
|||||||
/// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird).
|
/// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird).
|
||||||
public bool HomeworkCheckDismissed { get; set; }
|
public bool HomeworkCheckDismissed { get; set; }
|
||||||
public string? Reflection { get; set; }
|
public string? Reflection { get; set; }
|
||||||
|
/// Kompetenzcodes (<see cref="CompetencyItem.Code"/>) aus dem Katalog, die dieser Stunde als
|
||||||
|
/// Ganzes zugeordnet sind — analog zu <see cref="Unit.Competencies"/>, aber auf Stundenebene statt
|
||||||
|
/// Einheitenebene. Bewusst noch keine Verknüpfung je einzelner <see cref="LessonPhaseStep"/>
|
||||||
|
/// (siehe TODO.md 4.5.8, weiterhin offen); diese Liste deckt nur die gröbere Zuordnung ab.
|
||||||
|
public List<string> Competencies { get; set; } = [];
|
||||||
public LessonStatus Status { get; set; } = LessonStatus.Planned;
|
public LessonStatus Status { get; set; } = LessonStatus.Planned;
|
||||||
/// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
|
/// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
|
||||||
/// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie
|
/// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie
|
||||||
@@ -89,6 +102,14 @@ public class LessonPhaseStep
|
|||||||
public string Activity { get; set; } = "";
|
public string Activity { get; set; } = "";
|
||||||
public string Material { get; set; } = "";
|
public string Material { get; set; } = "";
|
||||||
public string Shorthand { get; set; } = "";
|
public string Shorthand { get; set; } = "";
|
||||||
|
/// Der über <see cref="AiPlanning.AiPlanningService.BuildMaterialPrompt"/> erzeugte, vollständige
|
||||||
|
/// Prompt für diese Phase (4.5.20), sofern die KI beim letzten "Übernehmen" einen Medienvorschlag
|
||||||
|
/// gemacht hatte — anders als der reine Vorschlagstext (<c>AiPhaseStep.MaterialSuggestion</c>,
|
||||||
|
/// nur transient während der Review) bewusst hier persistiert (Nutzer-Feedback: der Prompt soll
|
||||||
|
/// auch nach dem Übernehmen noch abrufbar/erneut kopierbar bleiben, statt nur einmalig im
|
||||||
|
/// Review-Dialog verfügbar zu sein). Rein informativ, kein Datenmodell-Bezug zu einem separaten
|
||||||
|
/// "Material"-Konzept, das es weiterhin nicht gibt — siehe TODO.md 4.5.26.
|
||||||
|
public string? MaterialPrompt { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// null = Hauptweg. Sonst Verweis auf einen benannten <see cref="AlternativeLessonPath"/> aus
|
/// null = Hauptweg. Sonst Verweis auf einen benannten <see cref="AlternativeLessonPath"/> aus
|
||||||
/// dem Katalog, über den Phasen alternativer Unterrichtsverläufe zusammengehören —
|
/// dem Katalog, über den Phasen alternativer Unterrichtsverläufe zusammengehören —
|
||||||
@@ -103,7 +124,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
|
||||||
@@ -239,3 +260,21 @@ public class ReportGrade
|
|||||||
public bool IsLocked { get; set; }
|
public bool IsLocked { get; set; }
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Live timing is separate from the original lesson plan.</summary>
|
||||||
|
public class TeachingTimelineState
|
||||||
|
{
|
||||||
|
public DateTime StartUtc { get; set; }
|
||||||
|
public DateTime EndUtc { get; set; }
|
||||||
|
public DateTime? HeldSinceUtc { get; set; }
|
||||||
|
public Guid? HeldPhaseId { get; set; }
|
||||||
|
public List<TeachingPhaseTiming> Phases { get; set; } = [];
|
||||||
|
public List<Guid> TransferredPhaseIds { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TeachingPhaseTiming
|
||||||
|
{
|
||||||
|
public Guid PhaseId { get; set; }
|
||||||
|
public double Minutes { get; set; }
|
||||||
|
public bool ExplicitlyStarted { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
public enum UntisHubJobKind
|
||||||
|
{
|
||||||
|
FehlzeitenKurz,
|
||||||
|
FehlzeitenLang,
|
||||||
|
OffenePeriods,
|
||||||
|
Klassenbuchabgleich,
|
||||||
|
Hausaufgabenabgleich,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wann welcher WebUntis-Abgleich ("Untis-Hub"-Job, siehe TODO.md) zuletzt lief und mit
|
||||||
|
/// welchem Ergebnis - ein Datensatz pro (<see cref="Kind"/>, <see cref="GroupId"/>). Bei den drei
|
||||||
|
/// dashboard-weiten Jobs (<see cref="UntisHubJobKind.OffenePeriods"/>,
|
||||||
|
/// <see cref="UntisHubJobKind.Klassenbuchabgleich"/>, <see cref="UntisHubJobKind.Hausaufgabenabgleich"/>)
|
||||||
|
/// ist <see cref="GroupId"/> null; die beiden Fehlzeiten-Kadenzen sind je Lerngruppe getrennt.</summary>
|
||||||
|
public class UntisHubJobState
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public UntisHubJobKind Kind { get; set; }
|
||||||
|
public Guid? GroupId { get; set; }
|
||||||
|
public DateTime? LastRunAt { get; set; }
|
||||||
|
public string? LastResultSummary { get; set; }
|
||||||
|
}
|
||||||
@@ -12,11 +12,13 @@ public sealed class DashboardCardSetting
|
|||||||
/// <summary>Speichert Sichtbarkeit und Reihenfolge der Dashboard-Kacheln lokal.</summary>
|
/// <summary>Speichert Sichtbarkeit und Reihenfolge der Dashboard-Kacheln lokal.</summary>
|
||||||
public sealed class DashboardSettingsService
|
public sealed class DashboardSettingsService
|
||||||
{
|
{
|
||||||
|
// "attention" fasst die frueheren sieben Kacheln missingteachingtime/excuses/corrections/
|
||||||
|
// unplanned/alerts/attendance/support zusammen (siehe AttentionItem.cs im Desktop-Projekt).
|
||||||
|
// Alte gespeicherte dashboardsettings.json-Dateien mit den frueheren Keys sind unproblematisch:
|
||||||
|
// Load() unten verwirft unbekannte Keys ohnehin stillschweigend und ergaenzt neue als sichtbar.
|
||||||
public static readonly string[] DefaultCardOrder =
|
public static readonly string[] DefaultCardOrder =
|
||||||
[
|
[
|
||||||
"today", "tasks", "calendar", "excuses", "upcoming",
|
"today", "tasks", "attention", "calendar", "upcoming", "groups", "examload",
|
||||||
"corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload",
|
|
||||||
"missingteachingtime",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly string _configPath;
|
private readonly string _configPath;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>Gewichte für ClassTeacherRosterRow.PatternScore (Klassenlehrer-Bereich, "Gesamtbild")
|
||||||
|
/// — Default-Werte entsprechen den ursprünglich fest verdrahteten Konstanten.</summary>
|
||||||
|
public sealed class PatternScoreWeights
|
||||||
|
{
|
||||||
|
public double UnexcusedDayWeight { get; set; } = 3.0;
|
||||||
|
public double ExcusedDayWeight { get; set; } = 1.0;
|
||||||
|
public double LateDayWeight { get; set; } = 0.5;
|
||||||
|
public double NegativeClassRegisterEntryWeight { get; set; } = 1.0;
|
||||||
|
public double ConcerningKeywordBonus { get; set; } = 1.5;
|
||||||
|
public double SuspensionKeywordWeight { get; set; } = 15.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Speichert die Gewichte lokal, gleiches Muster wie WorkloadSettingsService/
|
||||||
|
/// DashboardSettingsService.</summary>
|
||||||
|
public sealed class PatternScoreSettingsService
|
||||||
|
{
|
||||||
|
private readonly string _configPath;
|
||||||
|
|
||||||
|
public PatternScoreSettingsService(string appDataPath) =>
|
||||||
|
_configPath = Path.Combine(appDataPath, "patternscoreweights.json");
|
||||||
|
|
||||||
|
public PatternScoreWeights Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_configPath))
|
||||||
|
return JsonSerializer.Deserialize<PatternScoreWeights>(File.ReadAllText(_configPath))
|
||||||
|
?? new PatternScoreWeights();
|
||||||
|
}
|
||||||
|
catch { /* beschädigte Konfiguration -> Standardgewichte */ }
|
||||||
|
return new PatternScoreWeights();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(PatternScoreWeights weights) =>
|
||||||
|
File.WriteAllText(_configPath, JsonSerializer.Serialize(weights));
|
||||||
|
}
|
||||||
@@ -57,4 +57,24 @@ public sealed class ChangeHookTests
|
|||||||
|
|
||||||
Assert.Null(exception);
|
Assert.Null(exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kein eigener Fall in ChangeHookMatrixTests: UntisHubJobStateRepository kennt kein Delete
|
||||||
|
// (siehe IUntisHubJobStateRepository), passt also nicht in deren Save+Delete-Tabellenform.
|
||||||
|
[Fact]
|
||||||
|
public void UntisHubJobStateRepository_Save_LoestOnChangeAus()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>();
|
||||||
|
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload));
|
||||||
|
var repo = new UntisHubJobStateRepository(db);
|
||||||
|
var state = new UntisHubJobState { Kind = UntisHubJobKind.OffenePeriods, LastRunAt = DateTime.UtcNow };
|
||||||
|
|
||||||
|
repo.Save(state);
|
||||||
|
|
||||||
|
var call = Assert.Single(calls);
|
||||||
|
Assert.Equal(nameof(UntisHubJobState), call.EntityType);
|
||||||
|
Assert.Equal(state.Id.ToString(), call.EntityId);
|
||||||
|
Assert.Equal("Save", call.Operation);
|
||||||
|
Assert.Same(state, call.Payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ public class LiteDbContext : IDisposable
|
|||||||
public ILiteCollection<UntisAbsenceCacheEntry> UntisAbsenceCache => _db.GetCollection<UntisAbsenceCacheEntry>("untis_absence_cache");
|
public ILiteCollection<UntisAbsenceCacheEntry> UntisAbsenceCache => _db.GetCollection<UntisAbsenceCacheEntry>("untis_absence_cache");
|
||||||
public ILiteCollection<UntisClassRegisterCacheEntry> UntisClassRegisterCache => _db.GetCollection<UntisClassRegisterCacheEntry>("untis_classregister_cache");
|
public ILiteCollection<UntisClassRegisterCacheEntry> UntisClassRegisterCache => _db.GetCollection<UntisClassRegisterCacheEntry>("untis_classregister_cache");
|
||||||
public ILiteCollection<UntisCacheFetchState> UntisCacheFetchStates => _db.GetCollection<UntisCacheFetchState>("untis_cache_fetch_state");
|
public ILiteCollection<UntisCacheFetchState> UntisCacheFetchStates => _db.GetCollection<UntisCacheFetchState>("untis_cache_fetch_state");
|
||||||
|
public ILiteCollection<UntisHubJobState> UntisHubJobStates => _db.GetCollection<UntisHubJobState>("untis_hub_job_states");
|
||||||
public ILiteCollection<UntisStudentRosterCacheEntry> UntisStudentRosterCache => _db.GetCollection<UntisStudentRosterCacheEntry>("untis_student_roster_cache");
|
public ILiteCollection<UntisStudentRosterCacheEntry> UntisStudentRosterCache => _db.GetCollection<UntisStudentRosterCacheEntry>("untis_student_roster_cache");
|
||||||
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
|
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
|
||||||
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
|
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
|
||||||
|
|||||||
@@ -357,6 +357,20 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
|
|||||||
public void Save(Unit u)
|
public void Save(Unit u)
|
||||||
{
|
{
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId);
|
||||||
|
// Persistenz-Invariante: auch Importe und künftig hinzukommende Aufrufer dürfen keine
|
||||||
|
// zweite laufende Einheit in derselben Lerngruppe hinterlassen. Der Einheiten-Dialog
|
||||||
|
// kündigt diese automatische Ablösung vorher sichtbar an.
|
||||||
|
if (u.Status == UnitStatus.Active)
|
||||||
|
{
|
||||||
|
foreach (var previous in db.Units.Find(x => x.GroupId == u.GroupId &&
|
||||||
|
x.Status == UnitStatus.Active && x.Id != u.Id))
|
||||||
|
{
|
||||||
|
previous.Status = UnitStatus.Completed;
|
||||||
|
previous.UpdatedAt = DateTime.UtcNow;
|
||||||
|
db.Units.Upsert(previous);
|
||||||
|
db.OnChange?.Invoke(nameof(Unit), previous.Id.ToString(), "Save", previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
u.UpdatedAt = DateTime.UtcNow;
|
u.UpdatedAt = DateTime.UtcNow;
|
||||||
db.Units.Upsert(u);
|
db.Units.Upsert(u);
|
||||||
db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u);
|
db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u);
|
||||||
@@ -372,6 +386,7 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
|
|||||||
|
|
||||||
public class LessonRepository(LiteDbContext db) : ILessonRepository
|
public class LessonRepository(LiteDbContext db) : ILessonRepository
|
||||||
{
|
{
|
||||||
|
public Lesson? GetById(Guid id) => db.Lessons.FindById(id);
|
||||||
public List<Lesson> GetByUnit(Guid id) =>
|
public List<Lesson> GetByUnit(Guid id) =>
|
||||||
db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList();
|
db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList();
|
||||||
public List<Lesson> GetByGroupAndDate(Guid gid, DateOnly date) =>
|
public List<Lesson> GetByGroupAndDate(Guid gid, DateOnly date) =>
|
||||||
@@ -885,6 +900,20 @@ public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetch
|
|||||||
public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state);
|
public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRepository
|
||||||
|
{
|
||||||
|
public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) =>
|
||||||
|
db.UntisHubJobStates.FindOne(s => s.Kind == kind && s.GroupId == groupId);
|
||||||
|
|
||||||
|
public List<UntisHubJobState> GetAll() => db.UntisHubJobStates.FindAll().ToList();
|
||||||
|
|
||||||
|
public void Save(UntisHubJobState state)
|
||||||
|
{
|
||||||
|
db.UntisHubJobStates.Upsert(state);
|
||||||
|
db.OnChange?.Invoke(nameof(UntisHubJobState), state.Id.ToString(), "Save", state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
|
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
|
||||||
{
|
{
|
||||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||||
@@ -892,6 +921,11 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
|
|||||||
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
||||||
.OrderBy(d => d.SortOrder)
|
.OrderBy(d => d.SortOrder)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
|
||||||
|
db.CompetencyDomains
|
||||||
|
.Find(d => d.SubjectId == subjectId)
|
||||||
|
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder)
|
||||||
|
.ToList();
|
||||||
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
|
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
|
||||||
public void Save(CompetencyDomain d)
|
public void Save(CompetencyDomain d)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using LehrerApp.Core.AiPlanning;
|
using LehrerApp.Core.AiPlanning;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
@@ -15,6 +18,196 @@ public sealed class AiPlanningServiceTests
|
|||||||
FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths) =>
|
FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths) =>
|
||||||
new(new HttpClient(), lessons, groups, subjects, competencyDomains, altPaths);
|
new(new HttpClient(), lessons, groups, subjects, competencyDomains, altPaths);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePlanningLessons_AkzeptiertNormaleAntwortUndDeutschesDatum()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{"lessons":[{"id":null,"date":"15.09.2026","lessonNumber":2,"topic":"Redox","startTime":"08:35","phases":[]}],"summary":"ok"}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var lesson = Assert.Single(AiPlanningService.ParsePlanningLessons(json));
|
||||||
|
|
||||||
|
Assert.Equal("Redox", lesson.Topic);
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 15), lesson.Date);
|
||||||
|
Assert.Equal(new TimeOnly(8, 35), lesson.StartTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePlanningLessons_AkzeptiertEinzelneMarkierteStunde()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{"topic":"Nur diese Stunde","phases":[{"name":"Einstieg","durationMinutes":5,"activity":"Impuls","material":"Bild","shorthand":"UG"}]}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var lesson = Assert.Single(AiPlanningService.ParsePlanningLessons(json));
|
||||||
|
|
||||||
|
Assert.Equal("Nur diese Stunde", lesson.Topic);
|
||||||
|
Assert.Single(lesson.Phases);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePlanningLessons_UngueltigeMarkierung_LiefertVerstaendlichenFehler()
|
||||||
|
{
|
||||||
|
var error = Assert.Throws<AiBackendException>(() =>
|
||||||
|
AiPlanningService.ParsePlanningLessons("Hier kommt das JSON: { kaputt }"));
|
||||||
|
|
||||||
|
Assert.Contains("noch kein gültiges Stunden-JSON", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestPlanAsync_Backendfehler_BewahrtOriginalantwortFuerRettung()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
{"error":"Kein gültiges JSON.","rawResponse":"Vorspann\n{kaputt}"}
|
||||||
|
""";
|
||||||
|
var service = BuildWithResponse(HttpStatusCode.BadGateway, body);
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
|
||||||
|
service.RequestPlanAsync(new Unit(), "", "token"));
|
||||||
|
|
||||||
|
Assert.Equal("Vorspann\n{kaputt}", error.RawResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestPlanAsync_Typfehler_BewahrtEingebetteteOriginalantwortFuerRettung()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
{"lessons":[{"topic":"Test","date":"kein Datum"}],"rawResponse":"DIE ORIGINALANTWORT"}
|
||||||
|
""";
|
||||||
|
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
|
||||||
|
service.RequestPlanAsync(new Unit(), "", "token"));
|
||||||
|
|
||||||
|
Assert.Equal("DIE ORIGINALANTWORT", error.RawResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestPlanAsync_Zeitueberschreitung_LiefertKonkreteProxyMeldung()
|
||||||
|
{
|
||||||
|
var http = new HttpClient(new CanceledResponseHandler())
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("https://example.invalid/"),
|
||||||
|
};
|
||||||
|
var service = new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||||
|
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
|
||||||
|
service.RequestPlanAsync(new Unit(), "", "token"));
|
||||||
|
|
||||||
|
Assert.Contains("3½ Minuten", error.Message);
|
||||||
|
Assert.Contains("Webserver-Proxys", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rettungsdialog_ImportiertMarkierteStundeAlsNeu()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup();
|
||||||
|
var unit = new Unit { GroupId = group.Id, Title = "T" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
|
||||||
|
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||||
|
var vm = new AiResponseRescueDialogViewModel(service, lessons, unit, "Rohantwort");
|
||||||
|
|
||||||
|
vm.ParseSelection("""{"topic":"Gerettete Stunde","phases":[]}""");
|
||||||
|
vm.ImportAsNewCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.Result);
|
||||||
|
Assert.Equal("Gerettete Stunde", Assert.Single(lessons.GetByUnit(unit.Id)).Topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestUntisStatusSuggestionsAsync_PassendeIds_LiefertZuordnung()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
{"suggestions":[{"id":"0","status":"Excused"},{"id":"1","status":"Unexcused"}]}
|
||||||
|
""";
|
||||||
|
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||||
|
var rows = new[]
|
||||||
|
{
|
||||||
|
new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" },
|
||||||
|
new AiUntisStatusRow { Id = "1", CurrentGuess = "ExcusePending" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
|
||||||
|
|
||||||
|
Assert.Equal("Excused", suggestions["0"]);
|
||||||
|
Assert.Equal("Unexcused", suggestions["1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kernabsicherung gegen Verwechslung (Nutzer-Feedback zum Untis-Hub, siehe
|
||||||
|
// AiPlanningService.RequestUntisStatusSuggestionsAsync): weicht die zurückgegebene Id-Menge
|
||||||
|
// auch nur minimal von der gesendeten ab (hier: eine erfundene Id "2" statt "1"), wird die
|
||||||
|
// gesamte Antwort verworfen statt sich auf eine möglicherweise vermischte Zuordnung zu verlassen.
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestUntisStatusSuggestionsAsync_AbweichendeIdMenge_VerwirftKomplett()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
{"suggestions":[{"id":"0","status":"Excused"},{"id":"2","status":"Unexcused"}]}
|
||||||
|
""";
|
||||||
|
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||||
|
var rows = new[]
|
||||||
|
{
|
||||||
|
new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" },
|
||||||
|
new AiUntisStatusRow { Id = "1", CurrentGuess = "ExcusePending" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
|
||||||
|
|
||||||
|
Assert.Empty(suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestUntisStatusSuggestionsAsync_DoppelteId_VerwirftKomplett()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
{"suggestions":[{"id":"0","status":"Excused"},{"id":"0","status":"Unexcused"}]}
|
||||||
|
""";
|
||||||
|
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||||
|
var rows = new[] { new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" } };
|
||||||
|
|
||||||
|
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
|
||||||
|
|
||||||
|
Assert.Empty(suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestUntisStatusSuggestionsAsync_KeineZeilen_KeinNetzwerkaufruf()
|
||||||
|
{
|
||||||
|
var service = Build(new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||||
|
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||||
|
|
||||||
|
var suggestions = await service.RequestUntisStatusSuggestionsAsync([], "token");
|
||||||
|
|
||||||
|
Assert.Empty(suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AiPlanningService BuildWithResponse(HttpStatusCode status, string body)
|
||||||
|
{
|
||||||
|
var http = new HttpClient(new StaticResponseHandler(status, body))
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("https://example.invalid/"),
|
||||||
|
};
|
||||||
|
return new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||||
|
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StaticResponseHandler(HttpStatusCode status, string body) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(status)
|
||||||
|
{
|
||||||
|
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CanceledResponseHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken) => Task.FromCanceled<HttpResponseMessage>(
|
||||||
|
new CancellationToken(canceled: true));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildContext_FuelltGruppenUndFachKontext()
|
public void BuildContext_FuelltGruppenUndFachKontext()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,12 +1,158 @@
|
|||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
|
using LehrerApp.Templating;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
public sealed class ClassTeacherViewModelsTests
|
public sealed class ClassTeacherViewModelsTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Monatskalender_KodiertFehlzeitenUndBlendetHausaufgabenStandardmaessigAus()
|
||||||
|
{
|
||||||
|
var month = new DateOnly(2026, 9, 1);
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 2), "Müller Ada", 1, 0, 12,
|
||||||
|
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Schmidt Ben", 2, 2, 90,
|
||||||
|
["Mathe"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
};
|
||||||
|
var register = new[]
|
||||||
|
{
|
||||||
|
new ClassTeacherClassRegisterRow(new DateOnly(2026, 9, 3), "Deu", "Schmidt Ben", "test",
|
||||||
|
"Störung", "Negativ", "Unterricht massiv gestört"),
|
||||||
|
new ClassTeacherClassRegisterRow(new DateOnly(2026, 9, 4), "Mathe", "Müller Ada", "test",
|
||||||
|
"Hausaufgaben", "Negativ", "Hausaufgaben fehlen"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var days = ClassTeacherDetailsViewModel.BuildCalendarDays(month, absences, register, includeHomework: false);
|
||||||
|
|
||||||
|
Assert.Equal(0, days.Count % 5);
|
||||||
|
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "V");
|
||||||
|
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "U");
|
||||||
|
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "!");
|
||||||
|
Assert.DoesNotContain(days.SelectMany(d => d.Events), e => e.Code == "H");
|
||||||
|
|
||||||
|
var withHomework = ClassTeacherDetailsViewModel.BuildCalendarDays(month, absences, register, includeHomework: true);
|
||||||
|
Assert.Contains(withHomework.SelectMany(d => d.Events), e => e.Code == "H");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Kompaktkalender_ZeigtNurMonatstageUndJeTagDasStaerksteSignal()
|
||||||
|
{
|
||||||
|
var month = new DateOnly(2026, 9, 1);
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
|
||||||
|
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
|
||||||
|
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
|
||||||
|
};
|
||||||
|
var register = new[]
|
||||||
|
{
|
||||||
|
new UntisForeignClassRegisterEventDto("6a", 20260903, "Deu", "Müller Ada", "test",
|
||||||
|
"Störung", "Negativ", "Unterricht gestört"),
|
||||||
|
new UntisForeignClassRegisterEventDto("6a", 20260905, "Mathe", "Schmidt Ben", "test",
|
||||||
|
"Hausaufgaben", "Negativ", "Hausaufgaben fehlen"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, register,
|
||||||
|
new DateOnly(2026, 9, 3));
|
||||||
|
|
||||||
|
Assert.Equal(30, days.Count);
|
||||||
|
Assert.False(days[0].HasSignal);
|
||||||
|
Assert.Equal("1", days[0].DayNumber);
|
||||||
|
Assert.Equal("U", days[2].SignalCode); // stärker als der parallele Klassenbucheintrag
|
||||||
|
Assert.True(days[2].IsToday);
|
||||||
|
Assert.Contains("Ada Müller", days[2].Tooltip);
|
||||||
|
Assert.Equal("E", days[3].SignalCode);
|
||||||
|
Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Kompaktkalender_KannAufEinzelnenSchuelerEingeschraenktWerden()
|
||||||
|
{
|
||||||
|
var month = new DateOnly(2026, 9, 1);
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
|
||||||
|
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
|
||||||
|
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, [], month,
|
||||||
|
"Ada Müller");
|
||||||
|
|
||||||
|
Assert.Equal("U", days[2].SignalCode);
|
||||||
|
Assert.False(days[3].HasSignal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ElternbriefKalender_LiefertPortablenDrawingPlatzhalterFuerSchueler()
|
||||||
|
{
|
||||||
|
var month = new DateOnly(2026, 9, 1);
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
|
||||||
|
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
|
||||||
|
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller", month, absences, []);
|
||||||
|
|
||||||
|
Assert.True(drawing.ContentHeight > 0);
|
||||||
|
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "Ada Müller");
|
||||||
|
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "U");
|
||||||
|
Assert.DoesNotContain(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "E");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ElternbriefKalender_UnterstuetztEinBisDreiMonateUndGroessen()
|
||||||
|
{
|
||||||
|
var start = new DateOnly(2026, 9, 18);
|
||||||
|
var small = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
|
||||||
|
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Small), [], []);
|
||||||
|
var large = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
|
||||||
|
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Large), [], []);
|
||||||
|
var labels = large.Commands.OfType<DrawStringEx>().Select(c => c.Text).ToList();
|
||||||
|
|
||||||
|
Assert.Contains("September 2026", labels);
|
||||||
|
Assert.Contains("Oktober 2026", labels);
|
||||||
|
Assert.Contains("November 2026", labels);
|
||||||
|
Assert.True(large.ContentHeight > small.ContentHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ElternbriefFehltage_ListetDatumUmfangUndEntschuldigungsstatus()
|
||||||
|
{
|
||||||
|
var options = new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1);
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 6, 270,
|
||||||
|
["Deu"], [1, 2, 3, 4, 5, 6], ["entsch."], ["Krank"], null, null, true),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 8), "Müller Ada", 1, 2, 90,
|
||||||
|
["Mathe"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 9), "Schmidt Ben", 2, 4, 180,
|
||||||
|
["Eng"], [1, 2, 3, 4], ["entsch."], ["Krank"], null, null, true),
|
||||||
|
};
|
||||||
|
|
||||||
|
var drawing = StudentAbsenceDayListDrawingBuilder.Build("Ada Müller", options, absences);
|
||||||
|
var labels = drawing.Commands.OfType<DrawStringEx>().Select(c => c.Text).ToList();
|
||||||
|
|
||||||
|
Assert.Contains("03.09.2026", labels);
|
||||||
|
Assert.Contains("Ganzer Fehltag", labels);
|
||||||
|
Assert.Contains("08.09.2026", labels);
|
||||||
|
Assert.Contains("Fehlzeit · 2 Std.", labels);
|
||||||
|
Assert.Contains("Entschuldigt", labels);
|
||||||
|
Assert.Contains("Unentschuldigt", labels);
|
||||||
|
Assert.DoesNotContain("09.09.2026", labels);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
|
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
|
||||||
{
|
{
|
||||||
@@ -841,6 +987,25 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
Assert.Equal(["Ada"], result.Select(d => d.Title));
|
Assert.Equal(["Ada"], result.Select(d => d.Title));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FilterOwnDocumentation_ZeigtVerwaistenKlassenEintragUndFindetTeilnehmerPerVorname()
|
||||||
|
{
|
||||||
|
var rosterMatches = new List<(Guid StudentId, string DisplayName)>
|
||||||
|
{
|
||||||
|
(Guid.NewGuid(), "Karim Alshurbaji"), (Guid.NewGuid(), "Saleh Al-Anezi"),
|
||||||
|
};
|
||||||
|
var orphan = new Documentation
|
||||||
|
{
|
||||||
|
StudentId = Guid.Empty, GroupId = null, Date = new DateOnly(2026, 9, 7),
|
||||||
|
Title = "Vorfall", Participants = ["Karim", "Saleh"],
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation([orphan], rosterMatches,
|
||||||
|
new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 30), "Karim");
|
||||||
|
|
||||||
|
Assert.Same(orphan, Assert.Single(result));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Vorgang: Fallmappe für Klassenbuch- und Dokumentationseinträge ───────────────────────────
|
// ── Vorgang: Fallmappe für Klassenbuch- und Dokumentationseinträge ───────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -889,4 +1054,193 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
|
|
||||||
Assert.Empty(result);
|
Assert.Empty(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Konfigurierbare Gewichtung + Dämpfung/Rückfall-Warnung nach geschlossenem Vorgang ─────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MatchDomains_ErkenntBereicheAnSchluesselwoertern()
|
||||||
|
{
|
||||||
|
Assert.Equal([VorgangScoreDomain.Attendance], ClassTeacherRosterRow.MatchDomains(["Absentismus"]));
|
||||||
|
Assert.Equal([VorgangScoreDomain.Lateness], ClassTeacherRosterRow.MatchDomains(["Verspätungen"]));
|
||||||
|
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Konflikte"]));
|
||||||
|
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Mitarbeit"]));
|
||||||
|
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Hausaufgaben"]));
|
||||||
|
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Eskalation"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MatchDomains_OhneErkanntesSchlagwortDaemptAlleBereiche()
|
||||||
|
{
|
||||||
|
// Nutzer-Entscheidung: ein Vorgang darf beim Schließen nie folgenlos bleiben, auch wenn sein
|
||||||
|
// Schlagwort (z.B. "Elternkontakt") keinem bekannten Score-Bereich zugeordnet werden kann.
|
||||||
|
var domains = ClassTeacherRosterRow.MatchDomains(["Elternkontakt"]);
|
||||||
|
|
||||||
|
Assert.Equal(3, domains.Count);
|
||||||
|
Assert.Contains(VorgangScoreDomain.Attendance, domains);
|
||||||
|
Assert.Contains(VorgangScoreDomain.Lateness, domains);
|
||||||
|
Assert.Contains(VorgangScoreDomain.Classbook, domains);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeDampeningCutoffs_NimmtJeBereichDasJuengsteSchliessDatum()
|
||||||
|
{
|
||||||
|
var closed = new List<Vorgang>
|
||||||
|
{
|
||||||
|
new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 10) },
|
||||||
|
new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 20) },
|
||||||
|
new() { Tags = ["Verspätungen"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 5) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed);
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(2026, 8, 20), cutoffs[VorgangScoreDomain.Attendance]);
|
||||||
|
Assert.Equal(new DateOnly(2026, 8, 5), cutoffs[VorgangScoreDomain.Lateness]);
|
||||||
|
Assert.False(cutoffs.ContainsKey(VorgangScoreDomain.Classbook));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeDampeningCutoffs_IgnoriertVorgaengeOhneSchliessDatum()
|
||||||
|
{
|
||||||
|
var closed = new List<Vorgang> { new() { Tags = ["Absentismus"], Status = VorgangStatus.Open } };
|
||||||
|
|
||||||
|
var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed);
|
||||||
|
|
||||||
|
Assert.Empty(cutoffs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PatternScore_BenutztUebergebeneGewichteStattFesterKonstanten()
|
||||||
|
{
|
||||||
|
var student = Student(1001, "Ada Müller");
|
||||||
|
var yearAbsences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 6, 270,
|
||||||
|
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||||
|
};
|
||||||
|
var customWeights = new PatternScoreWeights { UnexcusedDayWeight = 10.0 };
|
||||||
|
|
||||||
|
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||||
|
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||||
|
weights: customWeights));
|
||||||
|
|
||||||
|
Assert.Equal(10.0, row.PatternScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PatternScore_DaempftNurDenZumVorgangPassendenBereichNachAbschluss()
|
||||||
|
{
|
||||||
|
var student = Student(1001, "Ada Müller");
|
||||||
|
var yearAbsences = new[]
|
||||||
|
{
|
||||||
|
// Vor dem Abschluss: darf für den Score nicht mehr zählen.
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
|
||||||
|
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||||
|
// Nach dem Abschluss: zählt weiter.
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270,
|
||||||
|
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||||
|
// Verspätung, anderer Bereich: bleibt von der auf "Absentismus" beschränkten Dämpfung unberührt.
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 1), "Müller Ada", 1001, 0, 5,
|
||||||
|
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||||
|
};
|
||||||
|
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
|
||||||
|
{
|
||||||
|
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
|
||||||
|
{
|
||||||
|
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
|
||||||
|
ClosedAt = new DateTime(2026, 8, 12),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||||
|
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||||
|
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
|
||||||
|
|
||||||
|
// 1 unentschuldigter Tag nach Abschluss (3,0) + 1 unbeeinflusster Verspätungstag (0,5) = 3,5.
|
||||||
|
Assert.Equal(3.5, row.PatternScore);
|
||||||
|
// Die Quote bleibt von der Dämpfung unberührt: beide unentschuldigten Tage zählen weiter.
|
||||||
|
Assert.Equal(2, row.YearUnexcusedDayCount);
|
||||||
|
Assert.Equal(2, row.YearAbsenceDayCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PatternScore_VorgangOhneErkennbaresSchlagwortDaemptAlleBereiche()
|
||||||
|
{
|
||||||
|
var student = Student(1001, "Ada Müller");
|
||||||
|
var yearAbsences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 0, 5,
|
||||||
|
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||||
|
};
|
||||||
|
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
|
||||||
|
{
|
||||||
|
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
|
||||||
|
{
|
||||||
|
Title = "Elterngespräch", Tags = ["Elternkontakt"], Status = VorgangStatus.Closed,
|
||||||
|
ClosedAt = new DateTime(2026, 8, 12),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||||
|
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||||
|
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
|
||||||
|
|
||||||
|
Assert.Equal(0.0, row.PatternScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegressionScore_IstNullOhneNeueEreignisseNachAbschluss()
|
||||||
|
{
|
||||||
|
var student = Student(1001, "Ada Müller");
|
||||||
|
var yearAbsences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
|
||||||
|
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||||
|
};
|
||||||
|
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
|
||||||
|
{
|
||||||
|
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
|
||||||
|
{
|
||||||
|
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
|
||||||
|
ClosedAt = new DateTime(2026, 8, 12),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||||
|
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||||
|
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
|
||||||
|
|
||||||
|
Assert.Equal(0.0, row.RegressionScore);
|
||||||
|
Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegressionScore_ErkenntNeueUnentschuldigteTageNachAbschluss()
|
||||||
|
{
|
||||||
|
var student = Student(1001, "Ada Müller");
|
||||||
|
var yearAbsences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
|
||||||
|
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||||
|
// Nach Abschluss erneut unentschuldigt gefehlt.
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270,
|
||||||
|
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||||
|
};
|
||||||
|
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
|
||||||
|
{
|
||||||
|
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
|
||||||
|
{
|
||||||
|
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
|
||||||
|
ClosedAt = new DateTime(2026, 8, 12),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||||
|
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||||
|
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
|
||||||
|
|
||||||
|
Assert.Equal(3.0, row.RegressionScore);
|
||||||
|
Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle);
|
||||||
|
// Quote weiterhin unangetastet.
|
||||||
|
Assert.Equal(2, row.YearUnexcusedDayCount);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Students;
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
using LehrerApp.Templating;
|
using LehrerApp.Templating;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -54,6 +55,185 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
|||||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AdvancedContent_Anwesenheitskalender_WirdAlsDrawingGerendert()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition(
|
||||||
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
||||||
|
var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Lena Beispiel", new DateOnly(2026, 9, 1), [], []);
|
||||||
|
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||||
|
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), _ => drawing);
|
||||||
|
var output = Path.Combine(_directory, "Kalender.pdf");
|
||||||
|
|
||||||
|
Assert.True(vm.UsesAttendanceCalendar);
|
||||||
|
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
|
||||||
|
new DateOnly(2026, 8, 19), 3, AttendanceCalendarSize.Large));
|
||||||
|
Assert.True(vm.AttendanceCalendarConfigured);
|
||||||
|
Assert.Contains("August 2026", vm.AttendanceCalendarSummary);
|
||||||
|
Assert.Contains("3 Monate", vm.AttendanceCalendarSummary);
|
||||||
|
Assert.Contains("Groß", vm.AttendanceCalendarSummary);
|
||||||
|
Assert.True(vm.Generate(output));
|
||||||
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AttendanceKalenderKonfigurieren_LoestGezieltenDatenAbrufFuerDenZeitraumAus()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition(
|
||||||
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
||||||
|
AttendanceCalendarOptions? requested = null;
|
||||||
|
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||||
|
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||||
|
_ => new DrawingValue([], 0), attendanceDataRefresher: (options, _) =>
|
||||||
|
{ requested = options; return Task.CompletedTask; });
|
||||||
|
|
||||||
|
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
|
||||||
|
new DateOnly(2026, 9, 1), 2, AttendanceCalendarSize.Medium));
|
||||||
|
|
||||||
|
Assert.NotNull(requested);
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 1), requested!.NormalizedStartMonth);
|
||||||
|
Assert.Equal(2, requested.NormalizedMonthCount);
|
||||||
|
Assert.False(vm.IsRefreshingAttendanceData);
|
||||||
|
Assert.Equal("", vm.AttendanceRefreshError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AttendanceKalenderKonfigurieren_ZeigtFehlerBeiFehlgeschlagenemAbrufAnStattZuBlockieren()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition(
|
||||||
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
||||||
|
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||||
|
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||||
|
_ => new DrawingValue([], 0),
|
||||||
|
attendanceDataRefresher: (_, _) => throw new WebUntisIntegrationException("Keine Verbindung."));
|
||||||
|
|
||||||
|
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
|
||||||
|
new DateOnly(2026, 9, 1), 1, AttendanceCalendarSize.Medium));
|
||||||
|
|
||||||
|
Assert.True(vm.AttendanceCalendarConfigured);
|
||||||
|
Assert.Contains("Keine Verbindung.", vm.AttendanceRefreshError);
|
||||||
|
Assert.False(vm.IsRefreshingAttendanceData);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvancedContent_Fehlzeitenliste_AktiviertKonfigurationsschritt()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition(
|
||||||
|
StudentAbsenceDayListDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
||||||
|
var drawing = StudentAbsenceDayListDrawingBuilder.Build("Lena Beispiel",
|
||||||
|
new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1), []);
|
||||||
|
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||||
|
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||||
|
attendanceCalendarFactory: null, absenceDayListFactory: _ => drawing);
|
||||||
|
|
||||||
|
Assert.False(vm.UsesAttendanceCalendar);
|
||||||
|
Assert.True(vm.UsesAbsenceDayList);
|
||||||
|
Assert.True(vm.UsesAttendanceAdvancedContent);
|
||||||
|
Assert.True(vm.CanGenerate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EigenerPlatzhalter_KannImDialogEingegebenWerden()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||||
|
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||||
|
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
|
||||||
|
new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
|
||||||
|
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||||
|
vm.LetterText = "Dies ist der Inhalt.";
|
||||||
|
|
||||||
|
var betreff = Assert.Single(vm.CustomPlaceholders);
|
||||||
|
Assert.Equal("Betreff", betreff.Name);
|
||||||
|
Assert.False(vm.CanGenerate);
|
||||||
|
Assert.Contains(vm.Issues, i => i.Message.Contains("Betreff", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
betreff.TextValue = "Wichtiger Termin";
|
||||||
|
var output = Path.Combine(_directory, "MitBetreff.pdf");
|
||||||
|
|
||||||
|
Assert.True(vm.CanGenerate);
|
||||||
|
Assert.True(vm.Generate(output));
|
||||||
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StudentName_WirdAutomatischMitVollemNamenBefuellt()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||||
|
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||||
|
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
|
||||||
|
new PlaceholderDefinition("Student.Name", PlaceholderType.Text, true));
|
||||||
|
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||||
|
vm.LetterText = "Dies ist der Inhalt.";
|
||||||
|
|
||||||
|
Assert.Empty(vm.CustomPlaceholders);
|
||||||
|
Assert.True(vm.CanGenerate);
|
||||||
|
var output = Path.Combine(_directory, "StudentName.pdf");
|
||||||
|
Assert.True(vm.Generate(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Anrede_WirdAusKontaktVorbelegtUndBleibtEditierbar()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||||
|
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||||
|
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
|
||||||
|
var vm = Build(StudentWithContact(null), store);
|
||||||
|
vm.LetterText = "Dies ist der Inhalt.";
|
||||||
|
|
||||||
|
Assert.Equal("", vm.Anrede);
|
||||||
|
Assert.False(vm.CanGenerate);
|
||||||
|
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
vm.Anrede = "Sehr geehrte Familie Beispiel,";
|
||||||
|
var output = Path.Combine(_directory, "AnredeManuell.pdf");
|
||||||
|
|
||||||
|
Assert.True(vm.CanGenerate);
|
||||||
|
Assert.True(vm.Generate(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddressPreview_FolgtDemGewaehltenKontaktUndAktualisiertSichBeimWechsel()
|
||||||
|
{
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
FirstName = "Lena", LastName = "Beispiel",
|
||||||
|
Contacts =
|
||||||
|
[
|
||||||
|
new Contact { Name = "Frau Beispiel", Relation = "Mutter", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" },
|
||||||
|
new Contact { Name = "Herr Beispiel", Relation = "Vater", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var vm = Build(student, StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
|
||||||
|
|
||||||
|
Assert.Equal(2, vm.Contacts.Count);
|
||||||
|
Assert.Contains("Frau Beispiel", vm.AddressPreview);
|
||||||
|
Assert.Contains("Erste Str. 1", vm.AddressPreview);
|
||||||
|
|
||||||
|
vm.SelectedContact = vm.Contacts.Single(c => c.Model.Name == "Herr Beispiel");
|
||||||
|
|
||||||
|
Assert.Contains("Herr Beispiel", vm.AddressPreview);
|
||||||
|
Assert.Contains("Zweite Str. 2", vm.AddressPreview);
|
||||||
|
Assert.DoesNotContain("Frau Beispiel", vm.AddressPreview);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectContactDialog_StartetBeimAktuellenKontaktUndAktualisiertVorschau()
|
||||||
|
{
|
||||||
|
var contacts = new List<LetterContactChoice>
|
||||||
|
{
|
||||||
|
new(new Contact { Name = "Frau Beispiel", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" }),
|
||||||
|
new(new Contact { Name = "Herr Beispiel", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" }),
|
||||||
|
};
|
||||||
|
var dialogVm = new SelectContactDialogViewModel(contacts, contacts[1]);
|
||||||
|
|
||||||
|
Assert.Same(contacts[1], dialogVm.SelectedContact);
|
||||||
|
Assert.Contains("Zweite Str. 2", dialogVm.AddressPreview);
|
||||||
|
|
||||||
|
dialogVm.SelectedContact = contacts[0];
|
||||||
|
|
||||||
|
Assert.Contains("Erste Str. 1", dialogVm.AddressPreview);
|
||||||
|
}
|
||||||
|
|
||||||
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
|
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
|
||||||
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
|
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
|
||||||
|
|
||||||
@@ -65,8 +245,18 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
|||||||
var y = 20;
|
var y = 20;
|
||||||
foreach (var definition in definitions)
|
foreach (var definition in definitions)
|
||||||
{
|
{
|
||||||
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
|
var element = definition.Type switch
|
||||||
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
|
{
|
||||||
|
PlaceholderType.Multiline => "TEXTBOX",
|
||||||
|
PlaceholderType.Drawing => "DRAWBOX",
|
||||||
|
_ => "TEXT",
|
||||||
|
};
|
||||||
|
lines.Add(element switch
|
||||||
|
{
|
||||||
|
"TEXTBOX" => $"TEXTBOX 20 {y} 170 80 ${definition.Name}",
|
||||||
|
"DRAWBOX" => $"DRAWBOX 20 {y} 170 80 ${definition.Name}",
|
||||||
|
_ => $"TEXT 20 {y} ${definition.Name}",
|
||||||
|
});
|
||||||
y += 20;
|
y += 20;
|
||||||
}
|
}
|
||||||
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
|
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
|
||||||
|
|||||||
@@ -20,14 +20,75 @@ public sealed class DashboardViewModelTests
|
|||||||
|
|
||||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
|
||||||
|
|
||||||
Assert.False(vm.ExcusesCard.EffectiveIsVisible);
|
Assert.False(vm.AttentionCard.EffectiveIsVisible);
|
||||||
Assert.False(vm.CorrectionsCard.EffectiveIsVisible);
|
|
||||||
Assert.False(vm.AlertsCard.EffectiveIsVisible);
|
|
||||||
Assert.Equal("0 offene Punkte", vm.AttentionSummary);
|
Assert.Equal("0 offene Punkte", vm.AttentionSummary);
|
||||||
Assert.True(vm.TodayCard.EffectiveIsVisible);
|
Assert.True(vm.TodayCard.EffectiveIsVisible);
|
||||||
Assert.True(vm.CalendarCard.EffectiveIsVisible);
|
Assert.True(vm.CalendarCard.EffectiveIsVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression: ApplyCardLayout zaehlte frueher IsVisible statt EffectiveIsVisible. Eine
|
||||||
|
/// eingeschaltete, aber leere HideWhenEmpty-Kachel belegte damit einen Rasterplatz, den das
|
||||||
|
/// Grid nie fuellt — im Alltag der Normalfall, weil meist mehrere Hinweiskacheln leer sind.
|
||||||
|
[Fact]
|
||||||
|
public void Kachelraster_LeereAusgeblendeteKacheln_HinterlassenKeineLuecke()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
|
||||||
|
|
||||||
|
// Ohne mindestens eine leer ausgeblendete Kachel wuerde der Test nichts pruefen.
|
||||||
|
Assert.False(vm.AttentionCard.EffectiveIsVisible);
|
||||||
|
|
||||||
|
var belegtePlaetze = vm.DashboardCards.Where(c => c.EffectiveIsVisible)
|
||||||
|
.Select(c => c.Row * 2 + c.Column).OrderBy(slot => slot).ToList();
|
||||||
|
Assert.Equal(Enumerable.Range(0, belegtePlaetze.Count), belegtePlaetze);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void KachelMargin_FolgtDerBerechnetenSpalte()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
|
||||||
|
|
||||||
|
foreach (var card in vm.DashboardCards.Where(c => c.EffectiveIsVisible))
|
||||||
|
Assert.Equal(card.Column == 0
|
||||||
|
? new Avalonia.Thickness(0, 0, 8, 8)
|
||||||
|
: new Avalonia.Thickness(8, 0, 0, 8), card.Margin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression: der Margin hing fest im XAML an der Kachel. Wandert sie durch Aus-/Einblenden
|
||||||
|
/// einer vorherigen Kachel in die andere Spalte, sass der Rinnstein auf der falschen Seite.
|
||||||
|
[Fact]
|
||||||
|
public void KachelAusblenden_DrehtDenMarginDerNachfolgendenKachel()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
|
||||||
|
Assert.Equal(1, vm.TasksCard.Column);
|
||||||
|
Assert.Equal(new Avalonia.Thickness(8, 0, 0, 8), vm.TasksCard.Margin);
|
||||||
|
|
||||||
|
vm.TodayCard.IsVisible = false;
|
||||||
|
|
||||||
|
Assert.Equal(0, vm.TasksCard.Column);
|
||||||
|
Assert.Equal(new Avalonia.Thickness(0, 0, 8, 8), vm.TasksCard.Margin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die frueheren sieben eigenen Listen (OpenExcuses, AttendanceWarnings, ...) sind zur
|
||||||
|
/// zusammengefassten Attention-Karte verschmolzen (siehe AttentionItem.cs) — Tests greifen
|
||||||
|
/// seither ueber Kind gefiltert zu statt ueber eine eigene Collection je Art.
|
||||||
|
private static IEnumerable<AttentionItem> Items(DashboardViewModel vm, AttentionKind kind) =>
|
||||||
|
vm.Attention.Where(g => g.Kind == kind).SelectMany(g => g.Items);
|
||||||
|
|
||||||
|
/// AttentionItem traegt fuer MissingTeachingTime kein eigenes Date-Feld (Title ist bereits der
|
||||||
|
/// formatierte Anzeigetext) — das genaue Datum steckt im mitgegebenen Action-Parameter
|
||||||
|
/// (derselbe MissingTeachingTimeItem, den auch der "Erfassen"-Dialog erhaelt).
|
||||||
|
private static DateOnly MissingTimeDate(AttentionItem item) =>
|
||||||
|
((MissingTeachingTimeItem)item.Actions.Single().Parameter!).Date;
|
||||||
|
|
||||||
private static PeriodScheduleService NewPeriodSchedule()
|
private static PeriodScheduleService NewPeriodSchedule()
|
||||||
{
|
{
|
||||||
var tempPath = System.IO.Path.Combine(
|
var tempPath = System.IO.Path.Combine(
|
||||||
@@ -61,7 +122,7 @@ public sealed class DashboardViewModelTests
|
|||||||
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
|
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
|
||||||
FakeSessions? sessions = null, FakeEntries? entries = null,
|
FakeSessions? sessions = null, FakeEntries? entries = null,
|
||||||
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
|
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
|
||||||
FakeTimeEntries? timeEntries = null)
|
FakeTimeEntries? timeEntries = null, Func<DateTime>? now = null)
|
||||||
{
|
{
|
||||||
lessons ??= new FakeLessons();
|
lessons ??= new FakeLessons();
|
||||||
lessons.Add(lesson);
|
lessons.Add(lesson);
|
||||||
@@ -74,7 +135,9 @@ public sealed class DashboardViewModelTests
|
|||||||
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
||||||
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
|
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
|
||||||
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
|
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
|
||||||
substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), annualPlanEvents);
|
substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(),
|
||||||
|
TestSupport.BuildUntisHubService(), TestSupport.BuildWebUntisIntegrationService(), annualPlanEvents,
|
||||||
|
annualPlanSync: null, schoolWeather: null, now: now);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der
|
/// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der
|
||||||
@@ -152,8 +215,8 @@ public sealed class DashboardViewModelTests
|
|||||||
|
|
||||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
|
||||||
|
|
||||||
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
|
||||||
Assert.True(vm.MissingTeachingTimeCard.EffectiveIsVisible);
|
Assert.True(vm.AttentionCard.EffectiveIsVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -173,7 +236,7 @@ public sealed class DashboardViewModelTests
|
|||||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||||
slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries);
|
slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries);
|
||||||
|
|
||||||
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -193,7 +256,7 @@ public sealed class DashboardViewModelTests
|
|||||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||||
slots: slots, periodSchedule: periodSchedule, substitutions: substitutions);
|
slots: slots, periodSchedule: periodSchedule, substitutions: substitutions);
|
||||||
|
|
||||||
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -213,43 +276,55 @@ public sealed class DashboardViewModelTests
|
|||||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||||
slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays);
|
slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays);
|
||||||
|
|
||||||
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fixer Referenzzeitpunkt fuer die beiden "heute, kurz vor/nach Ablauf der Wartezeit"-Tests
|
||||||
|
/// unten. Regression: die Tests bauten "Unterrichtsende" bisher aus TimeOnly.FromDateTime(
|
||||||
|
/// DateTime.Now).AddHours(±2) — TimeOnly wickelt bei Mitternacht um, ausgefuehrt zwischen ca.
|
||||||
|
/// 22:00 und 02:00 Uhr wurde dadurch aus "+2h" ein Ende VOR "jetzt" (oder umgekehrt), je nach
|
||||||
|
/// Tageszeit zufaellig rot. September ist bewusst gewaehlt: keiner der bundesweiten oder
|
||||||
|
/// laenderspezifischen Feiertage (PublicHolidayService) faellt in diesen Monat, ein Dienstag
|
||||||
|
/// ist garantiert kein Wochenende — die injizierte Uhr (DashboardViewModel now:-Parameter)
|
||||||
|
/// macht "jetzt" fuer den Test unabhaengig von der tatsaechlichen Ausfuehrungsuhrzeit.
|
||||||
|
private static readonly DateTime FixedNow = new(2026, 9, 8, 10, 0, 0);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
|
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
|
||||||
{
|
{
|
||||||
var group = new LearningGroup { Name = "9c" };
|
var group = new LearningGroup { Name = "9c" };
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
var today = DateOnly.FromDateTime(FixedNow);
|
||||||
var slots = new FakeTimetableSlots();
|
var slots = new FakeTimetableSlots();
|
||||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||||
var periodSchedule = NewPeriodSchedule();
|
var periodSchedule = NewPeriodSchedule();
|
||||||
// Unterrichtsende liegt garantiert noch keine 30 Minuten zurück.
|
// Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) noch keine 30 Minuten zurück.
|
||||||
var futureEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(2);
|
var futureEnd = TimeOnly.FromDateTime(FixedNow).AddHours(2);
|
||||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
{ PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]);
|
{ PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]);
|
||||||
|
|
||||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots,
|
||||||
|
periodSchedule: periodSchedule, now: () => FixedNow);
|
||||||
|
|
||||||
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == today);
|
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
|
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
|
||||||
{
|
{
|
||||||
var group = new LearningGroup { Name = "9c" };
|
var group = new LearningGroup { Name = "9c" };
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
var today = DateOnly.FromDateTime(FixedNow);
|
||||||
var slots = new FakeTimetableSlots();
|
var slots = new FakeTimetableSlots();
|
||||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||||
var periodSchedule = NewPeriodSchedule();
|
var periodSchedule = NewPeriodSchedule();
|
||||||
// Unterrichtsende liegt garantiert mehr als 30 Minuten zurück.
|
// Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) mehr als 30 Minuten zurück.
|
||||||
var pastEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(-2);
|
var pastEnd = TimeOnly.FromDateTime(FixedNow).AddHours(-2);
|
||||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
{ PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]);
|
{ PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]);
|
||||||
|
|
||||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots,
|
||||||
|
periodSchedule: periodSchedule, now: () => FixedNow);
|
||||||
|
|
||||||
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == today);
|
Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -376,10 +451,9 @@ public sealed class DashboardViewModelTests
|
|||||||
exams: new FakeExams([exam]), results: results, memberships: memberships,
|
exams: new FakeExams([exam]), results: results, memberships: memberships,
|
||||||
students: new FakeStudents([anna, ben]));
|
students: new FakeStudents([anna, ben]));
|
||||||
|
|
||||||
var correction = Assert.Single(vm.OpenCorrections);
|
var correction = Assert.Single(Items(vm, AttentionKind.Correction));
|
||||||
Assert.Equal(1, correction.Completed);
|
Assert.Equal(50, correction.ProgressPercent);
|
||||||
Assert.Equal(2, correction.Total);
|
Assert.Equal("1 von 2 Arbeiten bewertet", correction.ProgressLabel);
|
||||||
Assert.Equal(50, correction.Percent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -400,7 +474,7 @@ public sealed class DashboardViewModelTests
|
|||||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades,
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades,
|
||||||
memberships: memberships, students: new FakeStudents([student]));
|
memberships: memberships, students: new FakeStudents([student]));
|
||||||
|
|
||||||
Assert.Contains(vm.Alerts, a => a.StudentId == student.Id && a.KindLabel == "Notenabfall");
|
Assert.Contains(Items(vm, AttentionKind.Alert), a => a.Title == student.FullName && a.TrailingText == "Notenabfall");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -559,9 +633,8 @@ public sealed class DashboardViewModelTests
|
|||||||
|
|
||||||
var vm = BuildVm(group, pastLesson, slots: slots);
|
var vm = BuildVm(group, pastLesson, slots: slots);
|
||||||
|
|
||||||
var item = Assert.Single(vm.UnplannedLessons);
|
var item = Assert.Single(Items(vm, AttentionKind.Unplanned));
|
||||||
Assert.Equal(group.Id, item.GroupId);
|
Assert.Equal($"{group.Name} · 1. Stunde", item.Title);
|
||||||
Assert.Equal(1, item.PeriodNumber);
|
|
||||||
Assert.Equal("Heute", item.DateDisplay);
|
Assert.Equal("Heute", item.DateDisplay);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +649,7 @@ public sealed class DashboardViewModelTests
|
|||||||
|
|
||||||
var vm = BuildVm(group, lesson, slots: slots);
|
var vm = BuildVm(group, lesson, slots: slots);
|
||||||
|
|
||||||
Assert.Empty(vm.UnplannedLessons);
|
Assert.Empty(Items(vm, AttentionKind.Unplanned));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -590,7 +663,7 @@ public sealed class DashboardViewModelTests
|
|||||||
|
|
||||||
var vm = BuildVm(group, pastLesson, slots: slots);
|
var vm = BuildVm(group, pastLesson, slots: slots);
|
||||||
|
|
||||||
Assert.Empty(vm.UnplannedLessons);
|
Assert.Empty(Items(vm, AttentionKind.Unplanned));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -606,7 +679,7 @@ public sealed class DashboardViewModelTests
|
|||||||
|
|
||||||
var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays);
|
var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays);
|
||||||
|
|
||||||
Assert.Empty(vm.UnplannedLessons);
|
Assert.Empty(Items(vm, AttentionKind.Unplanned));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -621,7 +694,7 @@ public sealed class DashboardViewModelTests
|
|||||||
|
|
||||||
var vm = BuildVm(group, lesson, slots: slots);
|
var vm = BuildVm(group, lesson, slots: slots);
|
||||||
|
|
||||||
Assert.Empty(vm.UnplannedLessons);
|
Assert.Empty(Items(vm, AttentionKind.Unplanned));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -636,9 +709,10 @@ public sealed class DashboardViewModelTests
|
|||||||
|
|
||||||
var vm = BuildVm(group, lesson, slots: slots);
|
var vm = BuildVm(group, lesson, slots: slots);
|
||||||
|
|
||||||
Assert.Equal(2, vm.UnplannedLessons.Count);
|
var unplanned = Items(vm, AttentionKind.Unplanned).ToList();
|
||||||
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3);
|
Assert.Equal(2, unplanned.Count);
|
||||||
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4);
|
Assert.Contains(unplanned, i => i.Title.Contains("3. Stunde"));
|
||||||
|
Assert.Contains(unplanned, i => i.Title.Contains("4. Stunde"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -656,7 +730,7 @@ public sealed class DashboardViewModelTests
|
|||||||
|
|
||||||
var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions);
|
var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions);
|
||||||
|
|
||||||
Assert.Empty(vm.UnplannedLessons);
|
Assert.Empty(Items(vm, AttentionKind.Unplanned));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -679,7 +753,7 @@ public sealed class DashboardViewModelTests
|
|||||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||||
students: new FakeStudents([student]), sessions: sessions, entries: entries);
|
students: new FakeStudents([student]), sessions: sessions, entries: entries);
|
||||||
|
|
||||||
Assert.Empty(vm.AttendanceWarnings);
|
Assert.Empty(Items(vm, AttentionKind.Attendance));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -703,9 +777,9 @@ public sealed class DashboardViewModelTests
|
|||||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||||
students: new FakeStudents([student]), sessions: sessions, entries: entries);
|
students: new FakeStudents([student]), sessions: sessions, entries: entries);
|
||||||
|
|
||||||
var item = Assert.Single(vm.AttendanceWarnings);
|
var item = Assert.Single(Items(vm, AttentionKind.Attendance));
|
||||||
Assert.Equal(student.FullName, item.StudentName);
|
Assert.Equal(student.FullName, item.Title);
|
||||||
Assert.Equal(30.0, item.AbsenceRatePercent);
|
Assert.Equal("30 %", item.TrailingText);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -228,6 +228,20 @@ public sealed class DocumentationDialogViewModelTests
|
|||||||
Assert.NotEqual("", vm.StudentError);
|
Assert.NotEqual("", vm.StudentError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OhneGeladeneSchuelerlisteUndOhneKontext_SaveErzeugtKeinenVerwaistenEintrag()
|
||||||
|
{
|
||||||
|
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage())
|
||||||
|
{
|
||||||
|
Title = "Vorfall", TypeName = "Vorkommnis",
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Null(vm.Result);
|
||||||
|
Assert.Contains("noch nicht geladen", vm.StudentError);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MitStudentOptions_AuswahlGetroffen_SaveUebernimmtSchuelerUndGruppe()
|
public void MitStudentOptions_AuswahlGetroffen_SaveUebernimmtSchuelerUndGruppe()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using LehrerApp.Core.Interfaces;
|
|||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.Services.Mcp;
|
||||||
using LehrerApp.Desktop.ViewModels.Settings;
|
using LehrerApp.Desktop.ViewModels.Settings;
|
||||||
using LehrerApp.Sync;
|
using LehrerApp.Sync;
|
||||||
using LehrerApp.Sync.Crypto;
|
using LehrerApp.Sync.Crypto;
|
||||||
@@ -31,6 +32,17 @@ public static class TestSupport
|
|||||||
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||||
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||||
|
|
||||||
|
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||||
|
public static McpSettingsService BuildMcpSettingsService()
|
||||||
|
{
|
||||||
|
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-mcpsettings-tests-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(tempPath);
|
||||||
|
return new McpSettingsService(tempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kein Konstruktorparameter - Pfade werden intern über Environment.SpecialFolder aufgelöst.
|
||||||
|
public static McpClientRegistrationService BuildMcpClientRegistrationService() => new();
|
||||||
|
|
||||||
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||||
public static WebUntisSettingsService BuildWebUntisSettingsService()
|
public static WebUntisSettingsService BuildWebUntisSettingsService()
|
||||||
{
|
{
|
||||||
@@ -39,6 +51,14 @@ public static class TestSupport
|
|||||||
return new WebUntisSettingsService(tempPath);
|
return new WebUntisSettingsService(tempPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nicht konfiguriert (kein API-Login hinterlegt) - genügt für Tests, die nur den Konstruktor
|
||||||
|
/// bedienen müssen und keinen echten WebUntis-Zugriff auslösen.
|
||||||
|
public static WebUntisIntegrationService BuildWebUntisIntegrationService() =>
|
||||||
|
new(new HttpClient(), BuildWebUntisSettingsService());
|
||||||
|
|
||||||
|
public static UntisHubService BuildUntisHubService(List<LearningGroup>? groups = null) =>
|
||||||
|
new(new FakeGroups(groups ?? []), new FakeUntisHubJobStates(), new SchoolYearService());
|
||||||
|
|
||||||
/// Analog zu den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis.
|
/// Analog zu den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis.
|
||||||
public static AnnualPlanSettingsService BuildAnnualPlanSettingsService()
|
public static AnnualPlanSettingsService BuildAnnualPlanSettingsService()
|
||||||
{
|
{
|
||||||
@@ -338,6 +358,7 @@ public class FakeLessons : ILessonRepository
|
|||||||
{
|
{
|
||||||
private readonly List<Lesson> _all = [];
|
private readonly List<Lesson> _all = [];
|
||||||
public void Add(Lesson l) => _all.Add(l);
|
public void Add(Lesson l) => _all.Add(l);
|
||||||
|
public Lesson? GetById(Guid id) => _all.FirstOrDefault(l => l.Id == id);
|
||||||
public List<Lesson> GetByUnit(Guid unitId) =>
|
public List<Lesson> GetByUnit(Guid unitId) =>
|
||||||
_all.Where(l => l.UnitId == unitId).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList();
|
_all.Where(l => l.UnitId == unitId).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList();
|
||||||
public List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date) =>
|
public List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date) =>
|
||||||
@@ -352,9 +373,18 @@ public class FakeSubjects(List<Subject> all) : ISubjectRepository
|
|||||||
{
|
{
|
||||||
public List<Subject> GetAll() => all;
|
public List<Subject> GetAll() => all;
|
||||||
public Subject? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
public Subject? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
||||||
public Subject? GetByName(string name) => all.FirstOrDefault(s => s.Name == name);
|
public Subject? GetByName(string name) => all.FirstOrDefault(s =>
|
||||||
public void Save(Subject subject) { }
|
string.Equals(s.Name.Trim(), name.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
public void Delete(Guid id) { }
|
public void Save(Subject subject)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(subject.Name)) throw new ArgumentException("Der Fachname darf nicht leer sein.");
|
||||||
|
var duplicate = GetByName(subject.Name);
|
||||||
|
if (duplicate is not null && duplicate.Id != subject.Id)
|
||||||
|
throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits.");
|
||||||
|
all.RemoveAll(s => s.Id == subject.Id);
|
||||||
|
all.Add(subject);
|
||||||
|
}
|
||||||
|
public void Delete(Guid id) => all.RemoveAll(s => s.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class FakeCompetencyDomains : ICompetencyDomainRepository
|
public class FakeCompetencyDomains : ICompetencyDomainRepository
|
||||||
@@ -364,6 +394,9 @@ public class FakeCompetencyDomains : ICompetencyDomainRepository
|
|||||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||||
_all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
_all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
||||||
.OrderBy(d => d.SortOrder).ToList();
|
.OrderBy(d => d.SortOrder).ToList();
|
||||||
|
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
|
||||||
|
_all.Where(d => d.SubjectId == subjectId)
|
||||||
|
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder).ToList();
|
||||||
public CompetencyDomain? GetById(Guid id) => _all.FirstOrDefault(d => d.Id == id);
|
public CompetencyDomain? GetById(Guid id) => _all.FirstOrDefault(d => d.Id == id);
|
||||||
public void Save(CompetencyDomain domain) { _all.RemoveAll(d => d.Id == domain.Id); _all.Add(domain); }
|
public void Save(CompetencyDomain domain) { _all.RemoveAll(d => d.Id == domain.Id); _all.Add(domain); }
|
||||||
public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id);
|
public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id);
|
||||||
@@ -525,6 +558,19 @@ public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class FakeUntisHubJobStates : IUntisHubJobStateRepository
|
||||||
|
{
|
||||||
|
private readonly List<UntisHubJobState> _all = [];
|
||||||
|
public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) =>
|
||||||
|
_all.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId);
|
||||||
|
public List<UntisHubJobState> GetAll() => _all.ToList();
|
||||||
|
public void Save(UntisHubJobState state)
|
||||||
|
{
|
||||||
|
_all.RemoveAll(s => s.Kind == state.Kind && s.GroupId == state.GroupId);
|
||||||
|
_all.Add(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public class FakeWorkTasks : IWorkTaskRepository
|
public class FakeWorkTasks : IWorkTaskRepository
|
||||||
{
|
{
|
||||||
private readonly List<WorkTask> _all = [];
|
private readonly List<WorkTask> _all = [];
|
||||||
@@ -565,3 +611,28 @@ public class FakeReportGrades : IReportGradeRepository
|
|||||||
}
|
}
|
||||||
public void Delete(Guid id) => _all.RemoveAll(r => r.Id == id);
|
public void Delete(Guid id) => _all.RemoveAll(r => r.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Fake für MCP-Write-Tool-Tests (Phase 2): antwortet ohne echtes UI, konfigurierbar über
|
||||||
|
/// <see cref="Response"/>, merkt sich Titel/Nachricht des letzten Aufrufs zur Prüfung, dass der
|
||||||
|
/// Bestätigungstext tatsächlich menschenlesbar ist (kein rohes JSON/GUID-Dump).</summary>
|
||||||
|
public class FakeMcpConfirmation : IMcpConfirmationService
|
||||||
|
{
|
||||||
|
public bool Response { get; set; } = true;
|
||||||
|
public string? LastTitle { get; private set; }
|
||||||
|
public string? LastMessage { get; private set; }
|
||||||
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
|
public string? LastOperationKey { get; private set; }
|
||||||
|
public bool? LastAllowSessionTrust { get; private set; }
|
||||||
|
|
||||||
|
public Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||||
|
[System.Runtime.CompilerServices.CallerMemberName] string operationKey = "", bool allowSessionTrust = true)
|
||||||
|
{
|
||||||
|
CallCount++;
|
||||||
|
LastTitle = title;
|
||||||
|
LastMessage = message;
|
||||||
|
LastOperationKey = operationKey;
|
||||||
|
LastAllowSessionTrust = allowSessionTrust;
|
||||||
|
return Task.FromResult(Response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,33 @@ namespace LehrerApp.Desktop.Tests;
|
|||||||
/// zusammen, was Planung/Klausuren/Mitarbeit/Dokumentation ohnehin schon verwalten.
|
/// zusammen, was Planung/Klausuren/Mitarbeit/Dokumentation ohnehin schon verwalten.
|
||||||
public sealed class GroupOverviewViewModelTests
|
public sealed class GroupOverviewViewModelTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void UnterrichtHeute_BietetNurHeutigeNichtAusgefalleneStundenUndOeffnetAuswahl()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { IsActive = true };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var first = new Lesson { GroupId = group.Id, Date = today, Topic = "Erste Stunde" };
|
||||||
|
var second = new Lesson { GroupId = group.Id, Date = today, Topic = "Zweite Stunde" };
|
||||||
|
lessons.Add(first);
|
||||||
|
lessons.Add(second);
|
||||||
|
lessons.Add(new Lesson { GroupId = group.Id, Date = today, Status = LessonStatus.Cancelled });
|
||||||
|
lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(1) });
|
||||||
|
lessons.Add(new Lesson { GroupId = Guid.NewGuid(), Date = today });
|
||||||
|
var vm = NewVm(lessons: lessons, groups: new FakeGroups([group]));
|
||||||
|
vm.Initialize(group.Id, group.Name);
|
||||||
|
Assert.True(vm.HasTodayLessons);
|
||||||
|
Assert.Equal(2, vm.TodayLessons.Count);
|
||||||
|
Lesson? opened = null;
|
||||||
|
vm.OnOpenTeachingMode = lesson => opened = lesson;
|
||||||
|
vm.SelectedTeachingLesson = second;
|
||||||
|
vm.StartTeachingModeCommand.Execute(null);
|
||||||
|
Assert.Same(second, opened);
|
||||||
|
group.IsActive = false;
|
||||||
|
vm.Refresh();
|
||||||
|
Assert.False(vm.HasTodayLessons);
|
||||||
|
}
|
||||||
|
|
||||||
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
|
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
|
||||||
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
|
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
|
||||||
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
|
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
/// Deckt die Kandidatensuche für den "Vorhandene Stunde verknüpfen"-Button im Stunden-anlegen-
|
||||||
|
/// Dialog ab (Nutzer-Feedback: per JSON-Import/KI ohne Stundennummer angelegte Stunden tauchen im
|
||||||
|
/// Stundenplan nicht auf, da der dort verwendete FindLessonForSlot per Datum+Stundennummer sucht).
|
||||||
|
public class LessonFixItSearchTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FindCandidates_FindetVerwaisteStundeOhneStundennummerAmSelbenTag()
|
||||||
|
{
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var date = new DateOnly(2026, 9, 8);
|
||||||
|
var orphan = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = null, Topic = "Reflexionsgesetz" };
|
||||||
|
lessons.Add(orphan);
|
||||||
|
|
||||||
|
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||||
|
|
||||||
|
var found = Assert.Single(candidates);
|
||||||
|
Assert.Equal(orphan.Id, found.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindCandidates_FindetStundeAmSelbenTagMitAbweichenderStundennummer()
|
||||||
|
{
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var date = new DateOnly(2026, 9, 8);
|
||||||
|
var wrongPeriod = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = 7, Topic = "Reflexionsgesetz" };
|
||||||
|
lessons.Add(wrongPeriod);
|
||||||
|
|
||||||
|
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||||
|
|
||||||
|
var found = Assert.Single(candidates);
|
||||||
|
Assert.Equal(wrongPeriod.Id, found.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindCandidates_SortiertExaktesDatumVorNahenTerminenOhneStundennummer()
|
||||||
|
{
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var date = new DateOnly(2026, 9, 8);
|
||||||
|
var near = new Lesson { UnitId = unitId, GroupId = groupId, Date = date.AddDays(-2), LessonNumber = null, Topic = "Nah" };
|
||||||
|
var exact = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = null, Topic = "Exakt" };
|
||||||
|
lessons.Add(near); lessons.Add(exact);
|
||||||
|
|
||||||
|
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||||
|
|
||||||
|
Assert.Equal(2, candidates.Count);
|
||||||
|
Assert.Equal(exact.Id, candidates[0].Id);
|
||||||
|
Assert.Equal(near.Id, candidates[1].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindCandidates_IgnoriertStundenAusserhalbDesZeitfenstersUndAnderenGruppen()
|
||||||
|
{
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var otherGroupId = Guid.NewGuid();
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var date = new DateOnly(2026, 9, 8);
|
||||||
|
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = date.AddDays(-30), LessonNumber = null, Topic = "Zu weit weg" });
|
||||||
|
lessons.Add(new Lesson { UnitId = unitId, GroupId = otherGroupId, Date = date, LessonNumber = null, Topic = "Andere Gruppe" });
|
||||||
|
// Gesetzte Stundennummer, aber exakt am gesuchten Datum — bewusst als Kandidat enthalten
|
||||||
|
// (z.B. falsch nummerierter Import); der Nutzer entscheidet im Bestätigungs-/
|
||||||
|
// Auswahldialog, ob sie passt.
|
||||||
|
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = 3, Topic = "Falsch nummeriert, aber am gesuchten Tag" });
|
||||||
|
|
||||||
|
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||||
|
|
||||||
|
var found = Assert.Single(candidates);
|
||||||
|
Assert.Equal("Falsch nummeriert, aber am gesuchten Tag", found.Topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindCandidates_LeerOhnePassendeStunde()
|
||||||
|
{
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var date = new DateOnly(2026, 9, 8);
|
||||||
|
|
||||||
|
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||||
|
|
||||||
|
Assert.Empty(candidates);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class LetterTemplateToolsTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-lettertools-tests-{Guid.NewGuid():N}");
|
||||||
|
public LetterTemplateToolsTests() => Directory.CreateDirectory(_directory);
|
||||||
|
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||||
|
|
||||||
|
private LetterTemplateTools BuildTool(TemplateStore store, FakeStudents? students = null, FakeGroups? groups = null) =>
|
||||||
|
new(store, new TemplateLoader(), new QuestTemplateRenderer(), students ?? new FakeStudents([]), groups ?? new FakeGroups([]));
|
||||||
|
|
||||||
|
private TemplateStore StoreWithTemplate(string name, params PlaceholderDefinition[] definitions)
|
||||||
|
{
|
||||||
|
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||||
|
var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = name, Placeholders = [.. definitions] };
|
||||||
|
var lines = new List<string> { "PAGE 210 297 mm" };
|
||||||
|
var y = 20;
|
||||||
|
foreach (var definition in definitions)
|
||||||
|
{
|
||||||
|
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
|
||||||
|
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
|
||||||
|
y += 20;
|
||||||
|
}
|
||||||
|
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
|
||||||
|
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
|
||||||
|
store.Import(source);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Student StudentWithContact(string? salutation = "Sehr geehrte Frau Muster,") => new()
|
||||||
|
{
|
||||||
|
FirstName = "Lena", LastName = "Beispiel",
|
||||||
|
Contacts = [new Contact
|
||||||
|
{
|
||||||
|
Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
|
||||||
|
Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt",
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ListLetterTemplates_ListetVorlageMitPlatzhaltern()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
|
||||||
|
var tool = BuildTool(store);
|
||||||
|
|
||||||
|
var result = Assert.Single(tool.ListLetterTemplates());
|
||||||
|
|
||||||
|
Assert.Equal("Elternbrief", result.Name);
|
||||||
|
Assert.Contains(result.Placeholders, p => p.Name == "Anrede" && p.Required);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderLetter_UnbekannteVorlage_LiefertFehler()
|
||||||
|
{
|
||||||
|
var student = StudentWithContact();
|
||||||
|
var tool = BuildTool(new TemplateStore(_directory), new FakeStudents([student]));
|
||||||
|
|
||||||
|
var result = tool.RenderLetter("nicht-vorhanden", student.Id, "Text", "Frau Lehrer");
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Null(result.Base64Pdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderLetter_UnbekannterSchueler_LiefertFehler()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
|
||||||
|
var installed = Assert.Single(store.GetTemplates());
|
||||||
|
var tool = BuildTool(store);
|
||||||
|
|
||||||
|
var result = tool.RenderLetter(installed.Id, Guid.NewGuid(), "Text", "Frau Lehrer");
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderLetter_PflichtplatzhalterFehlt_LiefertFehlerOhnePdf()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
|
||||||
|
var installed = Assert.Single(store.GetTemplates());
|
||||||
|
var student = StudentWithContact(salutation: null); // keine Anrede hinterlegt
|
||||||
|
var tool = BuildTool(store, new FakeStudents([student]));
|
||||||
|
|
||||||
|
var result = tool.RenderLetter(installed.Id, student.Id, "Text", "Frau Lehrer");
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Contains("Anrede", result.Message);
|
||||||
|
Assert.Null(result.Base64Pdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderLetter_VollstaendigeDaten_LiefertBase64Pdf()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate("Elternbrief",
|
||||||
|
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||||
|
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||||
|
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
|
||||||
|
var installed = Assert.Single(store.GetTemplates());
|
||||||
|
var student = StudentWithContact();
|
||||||
|
var tool = BuildTool(store, new FakeStudents([student]));
|
||||||
|
|
||||||
|
var result = tool.RenderLetter(installed.Id, student.Id, "Dies ist der Inhalt.", "Frau Lehrer");
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.NotNull(result.Base64Pdf);
|
||||||
|
var bytes = Convert.FromBase64String(result.Base64Pdf!);
|
||||||
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4));
|
||||||
|
Assert.Equal("Elternbrief_Beispiel_Lena.pdf", result.SuggestedFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderLetter_ExtraValues_FuelltZusaetzlichenPlatzhalter()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate("Elternbrief",
|
||||||
|
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||||
|
new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
|
||||||
|
var installed = Assert.Single(store.GetTemplates());
|
||||||
|
var student = StudentWithContact();
|
||||||
|
var tool = BuildTool(store, new FakeStudents([student]));
|
||||||
|
|
||||||
|
var result = tool.RenderLetter(installed.Id, student.Id, "Text", "Frau Lehrer",
|
||||||
|
extraValues: new Dictionary<string, string> { ["Betreff"] = "Elternabend" });
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.NotNull(result.Base64Pdf);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using LehrerApp.Desktop.Services.Mcp;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class McpClientRegistrationServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-mcpreg-tests-{Guid.NewGuid():N}");
|
||||||
|
public McpClientRegistrationServiceTests() => Directory.CreateDirectory(_directory);
|
||||||
|
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||||
|
|
||||||
|
private string ConfigPath => Path.Combine(_directory, "claude_desktop_config.json");
|
||||||
|
|
||||||
|
private string BuildFakeBridge()
|
||||||
|
{
|
||||||
|
var bridgePath = Path.Combine(_directory, "LehrerApp.McpBridge.exe");
|
||||||
|
File.WriteAllText(bridgePath, "fake");
|
||||||
|
return bridgePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Register_OhneBridgeDatei_LiefertFehlerUndSchreibtNichts()
|
||||||
|
{
|
||||||
|
var service = new McpClientRegistrationService(ConfigPath, Path.Combine(_directory, "fehlt.exe"));
|
||||||
|
|
||||||
|
var result = service.Register();
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.False(File.Exists(ConfigPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Register_OhneBestehendeKonfiguration_LegtDateiMitEintragAn()
|
||||||
|
{
|
||||||
|
var bridgePath = BuildFakeBridge();
|
||||||
|
var service = new McpClientRegistrationService(ConfigPath, bridgePath);
|
||||||
|
|
||||||
|
var result = service.Register();
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.True(service.IsRegistered());
|
||||||
|
var root = JsonNode.Parse(File.ReadAllText(ConfigPath))!;
|
||||||
|
Assert.Equal(bridgePath, root["mcpServers"]!["lehrerapp"]!["command"]!.GetValue<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Register_BestehendeKonfigurationMitAnderenServern_BleibtErhalten()
|
||||||
|
{
|
||||||
|
File.WriteAllText(ConfigPath, """{"mcpServers":{"andererServer":{"command":"foo"}},"globalShortcut":"Ctrl+X"}""");
|
||||||
|
var bridgePath = BuildFakeBridge();
|
||||||
|
var service = new McpClientRegistrationService(ConfigPath, bridgePath);
|
||||||
|
|
||||||
|
var result = service.Register();
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
var root = JsonNode.Parse(File.ReadAllText(ConfigPath))!;
|
||||||
|
Assert.Equal("foo", root["mcpServers"]!["andererServer"]!["command"]!.GetValue<string>());
|
||||||
|
Assert.Equal("Ctrl+X", root["globalShortcut"]!.GetValue<string>());
|
||||||
|
Assert.Equal(bridgePath, root["mcpServers"]!["lehrerapp"]!["command"]!.GetValue<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Register_KaputteBestehendeKonfiguration_WirdNichtUeberschrieben()
|
||||||
|
{
|
||||||
|
File.WriteAllText(ConfigPath, "{ das ist kein json");
|
||||||
|
var bridgePath = BuildFakeBridge();
|
||||||
|
var service = new McpClientRegistrationService(ConfigPath, bridgePath);
|
||||||
|
|
||||||
|
var result = service.Register();
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal("{ das ist kein json", File.ReadAllText(ConfigPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unregister_EntferntNurEigenenEintrag()
|
||||||
|
{
|
||||||
|
File.WriteAllText(ConfigPath, """{"mcpServers":{"andererServer":{"command":"foo"},"lehrerapp":{"command":"bar"}}}""");
|
||||||
|
var service = new McpClientRegistrationService(ConfigPath, BuildFakeBridge());
|
||||||
|
|
||||||
|
var result = service.Unregister();
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.False(service.IsRegistered());
|
||||||
|
Assert.Contains("andererServer", File.ReadAllText(ConfigPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsRegistered_OhneDatei_IstFalse()
|
||||||
|
{
|
||||||
|
var service = new McpClientRegistrationService(ConfigPath, BuildFakeBridge());
|
||||||
|
|
||||||
|
Assert.False(service.IsRegistered());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,901 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services.Mcp;
|
||||||
|
using LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class McpToolsTests
|
||||||
|
{
|
||||||
|
// ── McpToolScope ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllowedReadTools_EnthaeltGenauDieErwartetenReadTools()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"download_lesson_attachment", "get_competency_catalog", "get_exams", "get_grades",
|
||||||
|
"get_groups", "get_lesson_plans", "get_named_untis_absence_pattern", "get_schedule",
|
||||||
|
"get_students", "get_subjects", "get_time_entries", "get_untis_absence_rows",
|
||||||
|
"get_untis_hub_status", "list_letter_templates", "render_letter",
|
||||||
|
},
|
||||||
|
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllowedWriteTools_EnthaeltGenauDieErwartetenWriteTools()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"add_competency_item", "add_lesson_attachment", "add_lesson_competency", "add_lesson_phase",
|
||||||
|
"apply_untis_absence_status", "create_competency_domain", "create_grade_entry", "create_lesson",
|
||||||
|
"create_subject", "create_time_entry", "create_unit", "move_lesson", "remove_competency_item",
|
||||||
|
"remove_lesson_competency", "remove_lesson_phase", "update_competency_domain",
|
||||||
|
"update_competency_item", "update_lesson", "update_lesson_phase", "update_student_group_assignment",
|
||||||
|
"update_subject", "update_unit",
|
||||||
|
},
|
||||||
|
McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllowedDestructiveWriteTools_EnthaeltDeleteLessonSubjectUndCompetencyDomain()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "delete_competency_domain", "delete_lesson", "delete_subject" },
|
||||||
|
McpToolScope.AllowedDestructiveWriteTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllowedTools_EnthaeltKeineDokumentationstypen()
|
||||||
|
{
|
||||||
|
// Gesprächsnotizen/Vorfälle/Förderpläne dürfen technisch nie über MCP erreichbar sein
|
||||||
|
// (siehe Planungsdokument) - die Namenskonvention "documentation"/"vorgang" darf nie auftauchen.
|
||||||
|
var allNames = McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools);
|
||||||
|
Assert.DoesNotContain(allNames, n =>
|
||||||
|
n.Contains("documentation", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
n.Contains("vorgang", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
n.Contains("note", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GroupTools ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetGroups_LiefertIdNameUndSchuljahrJeGruppe()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup
|
||||||
|
{
|
||||||
|
Name = "9a", Type = GroupType.Class, SchoolYear = "2025/26", GradeLevel = 9, IsActive = true,
|
||||||
|
};
|
||||||
|
var tool = new GroupTools(new FakeGroups([group]));
|
||||||
|
|
||||||
|
var result = Assert.Single(tool.GetGroups());
|
||||||
|
|
||||||
|
Assert.Equal(group.Id, result.Id);
|
||||||
|
Assert.Equal("9a", result.Name);
|
||||||
|
Assert.Equal("2025/26", result.SchoolYear);
|
||||||
|
Assert.Equal(9, result.GradeLevel);
|
||||||
|
Assert.True(result.IsActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── StudentTools ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetStudents_OhneFilter_LiefertNurAktiveSchueler()
|
||||||
|
{
|
||||||
|
var active = new Student { FirstName = "Anna", LastName = "Aktiv", IsActive = true };
|
||||||
|
var inactive = new Student { FirstName = "Ida", LastName = "Inaktiv", IsActive = false };
|
||||||
|
var tool = new StudentTools(new FakeStudents([active, inactive]));
|
||||||
|
|
||||||
|
var result = tool.GetStudents();
|
||||||
|
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal(active.Id, result[0].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetStudents_IncludeInactive_LiefertAuchInaktive()
|
||||||
|
{
|
||||||
|
var active = new Student { FirstName = "Anna", LastName = "Aktiv", IsActive = true };
|
||||||
|
var inactive = new Student { FirstName = "Ida", LastName = "Inaktiv", IsActive = false };
|
||||||
|
var tool = new StudentTools(new FakeStudents([active, inactive]));
|
||||||
|
|
||||||
|
var result = tool.GetStudents(includeInactive: true);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ExamTools ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetExams_OhneIncludeResults_LiefertKeineErgebnisse()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var exam = new Exam { GroupId = groupId, Title = "Klausur 1" };
|
||||||
|
var results = new FakeResults();
|
||||||
|
results.Add(new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid(), TotalPoints = 10 });
|
||||||
|
var tool = new ExamTools(new FakeExams([exam]), results);
|
||||||
|
|
||||||
|
var dto = Assert.Single(tool.GetExams(groupId));
|
||||||
|
|
||||||
|
Assert.Null(dto.Results);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetExams_MitIncludeResults_LiefertErgebnisseJeSchueler()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var studentId = Guid.NewGuid();
|
||||||
|
var exam = new Exam { GroupId = groupId, Title = "Klausur 1" };
|
||||||
|
var results = new FakeResults();
|
||||||
|
results.Add(new ExamResult { ExamId = exam.Id, StudentId = studentId, TotalPoints = 12.5, Grade = "2+" });
|
||||||
|
var tool = new ExamTools(new FakeExams([exam]), results);
|
||||||
|
|
||||||
|
var dto = Assert.Single(tool.GetExams(groupId, includeResults: true));
|
||||||
|
|
||||||
|
var result = Assert.Single(dto.Results!);
|
||||||
|
Assert.Equal(studentId, result.StudentId);
|
||||||
|
Assert.Equal("2+", result.Grade);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GradeTools ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetGrades_MitStudentId_FiltertAufEinenSchueler()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var studentA = Guid.NewGuid();
|
||||||
|
var studentB = Guid.NewGuid();
|
||||||
|
var grades = new FakeGrades();
|
||||||
|
grades.Add(new Grade { GroupId = groupId, StudentId = studentA, Value = "2" });
|
||||||
|
grades.Add(new Grade { GroupId = groupId, StudentId = studentB, Value = "3" });
|
||||||
|
var tool = new GradeTools(grades, new FakeStudents([]), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var dto = Assert.Single(tool.GetGrades(groupId, studentA));
|
||||||
|
|
||||||
|
Assert.Equal(studentA, dto.StudentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateGradeEntry_NutzerBestaetigt_SpeichertNote()
|
||||||
|
{
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
|
||||||
|
var grades = new FakeGrades();
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = true };
|
||||||
|
var tool = new GradeTools(grades, new FakeStudents([student]), confirmation);
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var result = await tool.CreateGradeEntry(
|
||||||
|
student.Id, groupId, GradeCategory.Oral, "2+", new DateOnly(2026, 1, 10));
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.NotNull(result.Id);
|
||||||
|
Assert.Single(grades.GetByGroup(groupId));
|
||||||
|
// Bestätigungstext muss für einen Menschen lesbar sein (Name statt bloßer GUID).
|
||||||
|
Assert.Contains("Anna", confirmation.LastMessage);
|
||||||
|
Assert.DoesNotContain(student.Id.ToString(), confirmation.LastMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateGradeEntry_NutzerLehntAb_SpeichertNichts()
|
||||||
|
{
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
|
||||||
|
var grades = new FakeGrades();
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = false };
|
||||||
|
var tool = new GradeTools(grades, new FakeStudents([student]), confirmation);
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var result = await tool.CreateGradeEntry(
|
||||||
|
student.Id, groupId, GradeCategory.Oral, "2+", new DateOnly(2026, 1, 10));
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Empty(grades.GetByGroup(groupId));
|
||||||
|
Assert.Equal(1, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ScheduleTools ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSchedule_MitGroupId_FiltertNachGruppe()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||||
|
slots.Add(new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 2 });
|
||||||
|
var tool = new ScheduleTools(slots);
|
||||||
|
|
||||||
|
var dto = Assert.Single(tool.GetSchedule(groupId));
|
||||||
|
|
||||||
|
Assert.Equal(groupId, dto.GroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TimeEntryTools ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetTimeEntries_FiltertAufDenAngegebenenZeitraum()
|
||||||
|
{
|
||||||
|
var entries = new FakeTimeEntries();
|
||||||
|
entries.Add(new TimeEntry { Date = new DateOnly(2026, 1, 5), DurationMinutes = 30 });
|
||||||
|
entries.Add(new TimeEntry { Date = new DateOnly(2026, 2, 1), DurationMinutes = 45 });
|
||||||
|
var tool = new TimeEntryTools(entries, new FakeGroups([]), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var dto = Assert.Single(tool.GetTimeEntries(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
|
||||||
|
|
||||||
|
Assert.Equal(30, dto.DurationMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateTimeEntry_NutzerBestaetigt_SpeichertEintrag()
|
||||||
|
{
|
||||||
|
var entries = new FakeTimeEntries();
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = true };
|
||||||
|
var tool = new TimeEntryTools(entries, new FakeGroups([]), confirmation);
|
||||||
|
|
||||||
|
var result = await tool.CreateTimeEntry("Korrektur", new DateOnly(2026, 1, 10), 30);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Single(entries.GetByDateRange(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateTimeEntry_NutzerLehntAb_SpeichertNichts()
|
||||||
|
{
|
||||||
|
var entries = new FakeTimeEntries();
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = false };
|
||||||
|
var tool = new TimeEntryTools(entries, new FakeGroups([]), confirmation);
|
||||||
|
|
||||||
|
var result = await tool.CreateTimeEntry("Korrektur", new DateOnly(2026, 1, 10), 30);
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Empty(entries.GetByDateRange(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LessonPlanTools ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static LessonPlanTools BuildLessonPlanTools(
|
||||||
|
FakeUnits? units = null, FakeLessons? lessons = null, FakeGroups? groups = null,
|
||||||
|
FakeAttachmentStorage? attachments = null, FakeMcpConfirmation? confirmation = null) =>
|
||||||
|
new(units ?? new FakeUnits(), lessons ?? new FakeLessons(), groups ?? new FakeGroups([]),
|
||||||
|
attachments ?? new FakeAttachmentStorage(), confirmation ?? new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLessonPlans_LiefertEinheitenDerGruppeUndStundenImZeitraum()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var units = new FakeUnits();
|
||||||
|
units.Add(new Unit { GroupId = groupId, Title = "Optik" });
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(new Lesson { GroupId = groupId, Date = new DateOnly(2026, 1, 5), Topic = "Brechung" });
|
||||||
|
lessons.Add(new Lesson { GroupId = groupId, Date = new DateOnly(2026, 3, 1), Topic = "Später" });
|
||||||
|
var tool = BuildLessonPlanTools(units, lessons);
|
||||||
|
|
||||||
|
var result = tool.GetLessonPlans(groupId, new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31));
|
||||||
|
|
||||||
|
Assert.Single(result.Units);
|
||||||
|
var lesson = Assert.Single(result.Lessons);
|
||||||
|
Assert.Equal("Brechung", lesson.Topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateUnit_NutzerBestaetigt_SpeichertEinheit()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "7a" };
|
||||||
|
var units = new FakeUnits();
|
||||||
|
var tool = BuildLessonPlanTools(units: units, groups: new FakeGroups([group]));
|
||||||
|
|
||||||
|
var result = await tool.CreateUnit(group.Id, "Optik");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Single(units.GetByGroup(group.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateUnit_OhneAenderung_FragtNichtNach()
|
||||||
|
{
|
||||||
|
var unit = new Unit { Title = "Optik", Status = UnitStatus.Planned };
|
||||||
|
var units = new FakeUnits();
|
||||||
|
units.Add(unit);
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(units: units, confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.UpdateUnit(unit.Id, title: "Optik", status: UnitStatus.Planned);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateLesson_UnbekannteEinheit_LiefertFehlerOhneNachfrage()
|
||||||
|
{
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.CreateLesson(Guid.NewGuid(), new DateOnly(2026, 1, 5), "Brechung");
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateLesson_NutzerBestaetigt_UebernimmtGruppeVonDerEinheit()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var unit = new Unit { GroupId = groupId, Title = "Optik" };
|
||||||
|
var units = new FakeUnits();
|
||||||
|
units.Add(unit);
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var tool = BuildLessonPlanTools(units, lessons);
|
||||||
|
|
||||||
|
var result = await tool.CreateLesson(unit.Id, new DateOnly(2026, 1, 5), "Brechung");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var lesson = Assert.Single(lessons.GetByUnit(unit.Id));
|
||||||
|
Assert.Equal(groupId, lesson.GroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateLesson_AendertNurAngegebeneFelder()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung", Homework = "S. 12" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
var result = await tool.UpdateLesson(lesson.Id, topic: "Brechung II");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var updated = lessons.GetById(lesson.Id)!;
|
||||||
|
Assert.Equal("Brechung II", updated.Topic);
|
||||||
|
Assert.Equal("S. 12", updated.Homework);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLessonPhase_NutzerBestaetigt_HaengtPhaseAn()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
var result = await tool.AddLessonPhase(lesson.Id, "Einstieg", 10);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var phase = Assert.Single(lessons.GetById(lesson.Id)!.Phases);
|
||||||
|
Assert.Equal("Einstieg", phase.Name);
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
var phase = new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10, Material = "Folie" };
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
lesson.Phases.Add(phase);
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
var result = await tool.UpdateLessonPhase(lesson.Id, phase.Id, durationMinutes: 15);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var updated = lessons.GetById(lesson.Id)!.Phases.Single();
|
||||||
|
Assert.Equal(15, updated.DurationMinutes);
|
||||||
|
Assert.Equal("Folie", updated.Material);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveLessonPhase_NutzerBestaetigt_EntferntPhase()
|
||||||
|
{
|
||||||
|
var phase = new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10 };
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
lesson.Phases.Add(phase);
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
var result = await tool.RemoveLessonPhase(lesson.Id, phase.Id);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Empty(lessons.GetById(lesson.Id)!.Phases);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DownloadLessonAttachment_LiefertBase64Inhalt()
|
||||||
|
{
|
||||||
|
var storage = new FakeAttachmentStorage();
|
||||||
|
var storageId = storage.Upload("blatt.pdf", new MemoryStream("PDF-Inhalt"u8.ToArray()));
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
lesson.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "blatt.pdf", SizeBytes = 10 });
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons, attachments: storage);
|
||||||
|
|
||||||
|
var result = tool.DownloadLessonAttachment(lesson.Id, storageId);
|
||||||
|
|
||||||
|
Assert.Equal("blatt.pdf", result.FileName);
|
||||||
|
Assert.Equal("PDF-Inhalt", System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(result.Base64Content)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DownloadLessonAttachment_ZuGross_WirftFehler()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
lesson.Attachments.Add(new DocumentAttachment
|
||||||
|
{
|
||||||
|
StorageId = "big", FileName = "video.mp4", SizeBytes = LessonPlanTools.MaxInlineAttachmentBytes + 1,
|
||||||
|
});
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => tool.DownloadLessonAttachment(lesson.Id, "big"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLessonAttachment_NutzerBestaetigt_LaedtHochUndHaengtAn()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var storage = new FakeAttachmentStorage();
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons, attachments: storage);
|
||||||
|
var content = Convert.ToBase64String("Arbeitsblatt-Inhalt"u8.ToArray());
|
||||||
|
|
||||||
|
var result = await tool.AddLessonAttachment(lesson.Id, "arbeitsblatt.pdf", content);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var attachment = Assert.Single(lessons.GetById(lesson.Id)!.Attachments);
|
||||||
|
Assert.Equal("arbeitsblatt.pdf", attachment.FileName);
|
||||||
|
using var stream = storage.OpenRead(attachment.StorageId)!;
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
Assert.Equal("Arbeitsblatt-Inhalt", reader.ReadToEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLessonAttachment_NutzerLehntAb_SpeichertNichts()
|
||||||
|
{
|
||||||
|
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 content = Convert.ToBase64String("Inhalt"u8.ToArray());
|
||||||
|
|
||||||
|
var result = await tool.AddLessonAttachment(lesson.Id, "arbeitsblatt.pdf", content);
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Empty(lessons.GetById(lesson.Id)!.Attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLessonAttachment_UngueltigesBase64_LiefertFehlerOhneNachfrage()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.AddLessonAttachment(lesson.Id, "arbeitsblatt.pdf", "nicht-base64!!!");
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLessonAttachment_UnbekannteStunde_LiefertFehlerOhneNachfrage()
|
||||||
|
{
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.AddLessonAttachment(Guid.NewGuid(), "x.pdf", Convert.ToBase64String("x"u8.ToArray()));
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
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]
|
||||||
|
public async Task UpdateStudentGroupAssignment_KeineBestehendeMitgliedschaft_LegtNeueAn()
|
||||||
|
{
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
|
||||||
|
var group = new LearningGroup { Name = "7a" };
|
||||||
|
var memberships = new FakeMemberships([]);
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = true };
|
||||||
|
var tool = new GroupMembershipTools(memberships, new FakeStudents([student]), new FakeGroups([group]), confirmation);
|
||||||
|
|
||||||
|
var result = await tool.UpdateStudentGroupAssignment(student.Id, group.Id, niveau: Niveau.E);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var membership = Assert.Single(memberships.GetByStudent(student.Id));
|
||||||
|
Assert.Equal(Niveau.E, membership.Niveau);
|
||||||
|
Assert.Contains("Anna", confirmation.LastMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateStudentGroupAssignment_BestehendeMitgliedschaftUnveraendert_FragtNichtNochmalNach()
|
||||||
|
{
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
|
||||||
|
var group = new LearningGroup { Name = "7a" };
|
||||||
|
var existing = new GroupMembership { StudentId = student.Id, GroupId = group.Id, Niveau = Niveau.G };
|
||||||
|
var memberships = new FakeMemberships([existing]);
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = true };
|
||||||
|
var tool = new GroupMembershipTools(memberships, new FakeStudents([student]), new FakeGroups([group]), confirmation);
|
||||||
|
|
||||||
|
var result = await tool.UpdateStudentGroupAssignment(student.Id, group.Id, niveau: Niveau.G);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateStudentGroupAssignment_UnbekannterSchueler_LiefertFehlerOhneNachfrage()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "7a" };
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = true };
|
||||||
|
var tool = new GroupMembershipTools(new FakeMemberships([]), new FakeStudents([]), new FakeGroups([group]), confirmation);
|
||||||
|
|
||||||
|
var result = await tool.UpdateStudentGroupAssignment(Guid.NewGuid(), group.Id);
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LessonPlanTools — Kompetenzzuordnung ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLessonCompetency_NutzerBestaetigt_OrdnetCodeZu()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Contains("PH.9.1", lessons.GetById(lesson.Id)!.Competencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLessonCompetency_BereitsZugeordnet_FragtNichtNochmalNach()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
lesson.Competencies.Add("PH.9.1");
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
Assert.Single(lessons.GetById(lesson.Id)!.Competencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveLessonCompetency_NutzerBestaetigt_EntferntCode()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
lesson.Competencies.Add("PH.9.1");
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Empty(lessons.GetById(lesson.Id)!.Competencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveLessonCompetency_NichtZugeordnet_LiefertFehlerOhneNachfrage()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1");
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CompetencyTools ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSubjects_LiefertAlleFaecher()
|
||||||
|
{
|
||||||
|
var subject = new Subject { Name = "Mathematik", ShortName = "Ma" };
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([subject]), new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var dto = Assert.Single(tool.GetSubjects());
|
||||||
|
|
||||||
|
Assert.Equal("Mathematik", dto.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateSubject_NutzerBestaetigt_SpeichertFach()
|
||||||
|
{
|
||||||
|
var subjects = new FakeSubjects([]);
|
||||||
|
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.CreateSubject("Mathematik", "Ma");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var subject = Assert.Single(subjects.GetAll());
|
||||||
|
Assert.Equal("Mathematik", subject.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateSubject_NameBereitsVergeben_LiefertFehler()
|
||||||
|
{
|
||||||
|
var subjects = new FakeSubjects([new Subject { Name = "Mathematik" }]);
|
||||||
|
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.CreateSubject("Mathematik");
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Single(subjects.GetAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateSubject_AendertNurAngegebeneFelder()
|
||||||
|
{
|
||||||
|
var subject = new Subject { Name = "Mathematik", ShortName = "Ma" };
|
||||||
|
var subjects = new FakeSubjects([subject]);
|
||||||
|
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.UpdateSubject(subject.Id, shortName: "M");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var updated = subjects.GetById(subject.Id)!;
|
||||||
|
Assert.Equal("Mathematik", updated.Name);
|
||||||
|
Assert.Equal("M", updated.ShortName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteSubject_NutzerBestaetigt_LoeschtFach()
|
||||||
|
{
|
||||||
|
var subject = new Subject { Name = "Mathematik" };
|
||||||
|
var subjects = new FakeSubjects([subject]);
|
||||||
|
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.DeleteSubject(subject.Id);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Empty(subjects.GetAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteSubject_UnbekannteId_LiefertFehlerOhneNachfrage()
|
||||||
|
{
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), new FakeCompetencyDomains(), confirmation);
|
||||||
|
|
||||||
|
var result = await tool.DeleteSubject(Guid.NewGuid());
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetCompetencyCatalog_OhneKlassenstufe_LiefertAlleKlassenstufenDesFachs()
|
||||||
|
{
|
||||||
|
var subjectId = Guid.NewGuid();
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 5, Name = "Zahlen" });
|
||||||
|
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 9, Name = "Funktionen" });
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = tool.GetCompetencyCatalog(subjectId);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateCompetencyDomain_NutzerBestaetigt_SpeichertBereich()
|
||||||
|
{
|
||||||
|
var subject = new Subject { Name = "Mathematik" };
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([subject]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.CreateCompetencyDomain(subject.Id, 9, "Funktionen");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var domain = Assert.Single(domains.GetBySubjectAndGrade(subject.Id, 9));
|
||||||
|
Assert.Equal("Funktionen", domain.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteCompetencyDomain_NutzerBestaetigt_LoeschtBereichMitItems()
|
||||||
|
{
|
||||||
|
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||||
|
domain.Items.Add(new CompetencyItem { Code = "M.9.1", Description = "..." });
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
domains.Add(domain);
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.DeleteCompetencyDomain(domain.Id);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Null(domains.GetById(domain.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddCompetencyItem_NutzerBestaetigt_HaengtItemAn()
|
||||||
|
{
|
||||||
|
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
domains.Add(domain);
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.AddCompetencyItem(domain.Id, "M.9.1", "lineare Funktionen erkennen");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var item = Assert.Single(domains.GetById(domain.Id)!.Items);
|
||||||
|
Assert.Equal("M.9.1", item.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateCompetencyItem_AendertNurAngegebeneFelder()
|
||||||
|
{
|
||||||
|
var item = new CompetencyItem { Code = "M.9.1", Description = "alt" };
|
||||||
|
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||||
|
domain.Items.Add(item);
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
domains.Add(domain);
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.UpdateCompetencyItem(domain.Id, item.Id, description: "neu");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var updated = domains.GetById(domain.Id)!.Items.Single();
|
||||||
|
Assert.Equal("M.9.1", updated.Code);
|
||||||
|
Assert.Equal("neu", updated.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveCompetencyItem_NutzerBestaetigt_EntferntItem()
|
||||||
|
{
|
||||||
|
var item = new CompetencyItem { Code = "M.9.1", Description = "..." };
|
||||||
|
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||||
|
domain.Items.Add(item);
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
domains.Add(domain);
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.RemoveCompetencyItem(domain.Id, item.Id);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Empty(domains.GetById(domain.Id)!.Items);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class PersonalizeWorksheetDialogViewModelTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-worksheet-vm-tests-{Guid.NewGuid():N}");
|
||||||
|
public PersonalizeWorksheetDialogViewModelTests() => Directory.CreateDirectory(_directory);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_ErzeugtEinePdfJeAusgewaehltemSchueler()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
|
||||||
|
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
|
||||||
|
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
|
||||||
|
var vm = Build(group, [anna, ben], StoreWithTemplate());
|
||||||
|
var output = Path.Combine(_directory, "output");
|
||||||
|
|
||||||
|
vm.Generate(output);
|
||||||
|
|
||||||
|
Assert.Equal(2, vm.Results.Count);
|
||||||
|
Assert.All(vm.Results, r => Assert.True(r.Success));
|
||||||
|
Assert.Equal(2, Directory.GetFiles(output, "*.pdf").Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_AbgewaehlterSchueler_WirdUebersprungen()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
|
||||||
|
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
|
||||||
|
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
|
||||||
|
var vm = Build(group, [anna, ben], StoreWithTemplate());
|
||||||
|
vm.Students.Single(s => s.Model.Id == ben.Id).IsIncluded = false;
|
||||||
|
var output = Path.Combine(_directory, "output");
|
||||||
|
|
||||||
|
vm.Generate(output);
|
||||||
|
|
||||||
|
Assert.Single(vm.Results);
|
||||||
|
Assert.Equal(anna.FullName, vm.Results[0].StudentName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OhneVorlage_GenerateTutNichts()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
|
||||||
|
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
|
||||||
|
var vm = Build(group, [anna], new WorksheetTemplateStore(new TemplateStore(Path.Combine(_directory, "empty-store"))));
|
||||||
|
var output = Path.Combine(_directory, "output");
|
||||||
|
|
||||||
|
vm.Generate(output);
|
||||||
|
|
||||||
|
Assert.Empty(vm.Results);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonalizeWorksheetDialogViewModel Build(LearningGroup group, List<Student> students, WorksheetTemplateStore store) =>
|
||||||
|
new(group, [.. students.Select(s => s.Id)], new FakeStudents(students), store, new QuestTemplateRenderer());
|
||||||
|
|
||||||
|
private WorksheetTemplateStore StoreWithTemplate()
|
||||||
|
{
|
||||||
|
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||||
|
var manifest = new TemplateManifest
|
||||||
|
{
|
||||||
|
Id = $"arbeitsblatt-{Guid.NewGuid():N}", Name = "Arbeitsblatt",
|
||||||
|
Placeholders = [new PlaceholderDefinition("Student.FirstName", PlaceholderType.Text)],
|
||||||
|
};
|
||||||
|
TemplatePackage.Create(source, manifest, "PAGE 210 297 mm\nTEXT 20 20 $Student.FirstName", new Dictionary<string, byte[]>());
|
||||||
|
var store = new WorksheetTemplateStore(new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")));
|
||||||
|
store.Store.Import(source);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||||
|
}
|
||||||
@@ -45,6 +45,21 @@ public class PlanningTabViewModelTests
|
|||||||
Assert.Contains("2 / 3", summary.ProgressText);
|
Assert.Contains("2 / 3", summary.ProgressText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Initialize_WaehltLaufendeEinheitAlsArbeitskontext()
|
||||||
|
{
|
||||||
|
var (vm, units, _, groupId) = BuildScenario();
|
||||||
|
units.Add(new Unit { GroupId = groupId, Title = "Früher", Status = UnitStatus.Completed,
|
||||||
|
StartDate = new DateOnly(2025, 8, 1) });
|
||||||
|
var active = new Unit { GroupId = groupId, Title = "Aktuell", Status = UnitStatus.Active,
|
||||||
|
StartDate = new DateOnly(2025, 9, 1) };
|
||||||
|
units.Add(active);
|
||||||
|
|
||||||
|
vm.Initialize(groupId);
|
||||||
|
|
||||||
|
Assert.Equal(active.Id, vm.SelectedUnit?.Id);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MoveLesson_MitNachrueckenVerschiebtNurGeplanteFolgestundenNachDemStichtag()
|
public async Task MoveLesson_MitNachrueckenVerschiebtNurGeplanteFolgestundenNachDemStichtag()
|
||||||
{
|
{
|
||||||
@@ -93,6 +108,30 @@ public class PlanningTabViewModelTests
|
|||||||
Assert.Equal(new DateOnly(2025, 9, 8), byUnit[l2.Id].Date); // unverändert
|
Assert.Equal(new DateOnly(2025, 9, 8), byUnit[l2.Id].Date); // unverändert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangeLessonUnit_OrdnetStundeDerZieleinheitZuUndWaehltSieAus()
|
||||||
|
{
|
||||||
|
var (vm, units, lessons, groupId) = BuildScenario();
|
||||||
|
var unitA = new Unit { GroupId = groupId, Title = "Mechanik" };
|
||||||
|
var unitB = new Unit { GroupId = groupId, Title = "Optik" };
|
||||||
|
units.Add(unitA); units.Add(unitB);
|
||||||
|
var l1 = new Lesson { UnitId = unitA.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Status = LessonStatus.Planned, Topic = "1" };
|
||||||
|
lessons.Add(l1);
|
||||||
|
|
||||||
|
vm.Initialize(groupId);
|
||||||
|
vm.SelectedUnit = vm.Units.Single(u => u.Id == unitA.Id);
|
||||||
|
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == l1.Id);
|
||||||
|
vm.OnPickUnitChange = _ => Task.FromResult<Unit?>(unitB);
|
||||||
|
|
||||||
|
await vm.ChangeLessonUnitCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Empty(lessons.GetByUnit(unitA.Id));
|
||||||
|
var moved = Assert.Single(lessons.GetByUnit(unitB.Id));
|
||||||
|
Assert.Equal(l1.Id, moved.Id);
|
||||||
|
Assert.Equal(unitB.Id, vm.SelectedUnit?.Id);
|
||||||
|
Assert.Equal(l1.Id, vm.SelectedLesson?.Id);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CopyUnit_ErhaeltAbstaendeUndSetztGroupIdAufZielgruppe()
|
public async Task CopyUnit_ErhaeltAbstaendeUndSetztGroupIdAufZielgruppe()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class SeatingQuickCheckTests
|
||||||
|
{
|
||||||
|
private static (SeatingPlanTabViewModel Vm, FakeEntries Entries, ParticipationSession Session, Student Student) Build(bool readOnly = false)
|
||||||
|
{
|
||||||
|
var group = Guid.NewGuid();
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var session = new ParticipationSession { GroupId = group, Date = DateOnly.FromDateTime(DateTime.Today) };
|
||||||
|
var entries = new FakeEntries();
|
||||||
|
var plan = new SeatingPlan { GroupId = group, Rows = 1, Columns = 2,
|
||||||
|
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }] };
|
||||||
|
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||||
|
new FakeMemberships([new GroupMembership { GroupId = group, StudentId = student.Id }]),
|
||||||
|
new FakeSessions([session]), entries, new FakeAspects());
|
||||||
|
vm.Initialize(group, readOnly);
|
||||||
|
return (vm, entries, session, student);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void Attendance_UsesSeatAndPreservesExistingHomeworkAndCounters()
|
||||||
|
{
|
||||||
|
var (vm, entries, session, student) = Build();
|
||||||
|
entries.Save(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id,
|
||||||
|
Homework = HomeworkStatus.Completed, RaisedHandCount = 3, CalledOnCount = 2 });
|
||||||
|
vm.CheckAttendanceCommand.Execute(null);
|
||||||
|
var seat = vm.Seats[0];
|
||||||
|
Assert.True(seat.ShowQuickCheck);
|
||||||
|
Assert.False(vm.Seats[1].ShowQuickCheck);
|
||||||
|
seat.QuickNegativeCommand.Execute(null);
|
||||||
|
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
|
||||||
|
Assert.Equal(AttendanceStatus.ExcusePending, entry.Attendance);
|
||||||
|
Assert.Equal(HomeworkStatus.Completed, entry.Homework);
|
||||||
|
Assert.Equal(3, entry.RaisedHandCount);
|
||||||
|
Assert.Equal(2, entry.CalledOnCount);
|
||||||
|
Assert.Equal(1, seat.DisplayOpacity);
|
||||||
|
seat.QuickPositiveCommand.Execute(null);
|
||||||
|
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void Homework_UpdatesLegacyFlagAndPreservesAttendance()
|
||||||
|
{
|
||||||
|
var (vm, entries, session, student) = Build();
|
||||||
|
entries.Save(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Late });
|
||||||
|
vm.CheckHomeworkCommand.Execute(null);
|
||||||
|
vm.Seats[0].QuickNegativeCommand.Execute(null);
|
||||||
|
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
|
||||||
|
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
|
||||||
|
Assert.True(entry.HomeworkMissing);
|
||||||
|
Assert.Equal(AttendanceStatus.Late, entry.Attendance);
|
||||||
|
vm.Seats[0].QuickPositiveCommand.Execute(null);
|
||||||
|
Assert.Equal(HomeworkStatus.Completed, entry.Homework);
|
||||||
|
Assert.False(entry.HomeworkMissing);
|
||||||
|
vm.EndQuickCheckCommand.Execute(null);
|
||||||
|
Assert.False(vm.Seats[0].ShowQuickCheck);
|
||||||
|
Assert.True(vm.Seats[0].ShowNormalActions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public async Task SpecialCases_OpenAssessmentForClickedStudentAndSession()
|
||||||
|
{
|
||||||
|
var (vm, _, _, _) = Build();
|
||||||
|
var calls = 0;
|
||||||
|
vm.OnAssessStudent = _ => { calls++; return Task.CompletedTask; };
|
||||||
|
vm.CheckAttendanceCommand.Execute(null);
|
||||||
|
await vm.Seats[0].QuickSpecialCommand.ExecuteAsync(null);
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void ReadOnlyAndEmptySeats_CannotWriteQuickChecks()
|
||||||
|
{
|
||||||
|
var (vm, entries, session, _) = Build(readOnly: true);
|
||||||
|
vm.CheckHomeworkCommand.Execute(null);
|
||||||
|
vm.Seats[0].QuickNegativeCommand.Execute(null);
|
||||||
|
vm.Seats[1].QuickPositiveCommand.Execute(null);
|
||||||
|
Assert.False(vm.Seats[0].ShowQuickCheck);
|
||||||
|
Assert.Empty(entries.GetBySession(session.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,9 @@ public sealed class SettingsViewModelTests
|
|||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
||||||
new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||||
|
TestSupport.BuildMcpSettingsService(),
|
||||||
|
TestSupport.BuildMcpClientRegistrationService(),
|
||||||
TestSupport.BuildWebUntisSettingsService(),
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
TestSupport.BuildAnnualPlanSettingsService(),
|
TestSupport.BuildAnnualPlanSettingsService(),
|
||||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
||||||
@@ -219,6 +221,25 @@ public sealed class SettingsViewModelTests
|
|||||||
Assert.Empty(queue.GetUnreviewed());
|
Assert.Empty(queue.GetUnreviewed());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearSyncConflicts_LeertProtokollUndQueue()
|
||||||
|
{
|
||||||
|
var queue = TestSupport.BuildEventQueue();
|
||||||
|
foreach (var resolution in new[] { "LocalWon", "RemoteWon" })
|
||||||
|
queue.AddConflict(new ConflictEntry
|
||||||
|
{
|
||||||
|
LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() },
|
||||||
|
RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() },
|
||||||
|
Resolution = resolution,
|
||||||
|
});
|
||||||
|
var vm = BuildViewModel(eventQueue: queue);
|
||||||
|
|
||||||
|
vm.ClearSyncConflictsCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Empty(vm.SyncConflicts);
|
||||||
|
Assert.Empty(queue.GetUnreviewed());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddSchoolHoliday_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt()
|
public void AddSchoolHoliday_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt()
|
||||||
{
|
{
|
||||||
@@ -321,7 +342,9 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
||||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||||
|
TestSupport.BuildMcpSettingsService(),
|
||||||
|
TestSupport.BuildMcpClientRegistrationService(),
|
||||||
TestSupport.BuildWebUntisSettingsService(),
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
TestSupport.BuildAnnualPlanSettingsService(),
|
TestSupport.BuildAnnualPlanSettingsService(),
|
||||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||||
@@ -349,7 +372,9 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||||
|
TestSupport.BuildMcpSettingsService(),
|
||||||
|
TestSupport.BuildMcpClientRegistrationService(),
|
||||||
TestSupport.BuildWebUntisSettingsService(),
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
TestSupport.BuildAnnualPlanSettingsService(),
|
TestSupport.BuildAnnualPlanSettingsService(),
|
||||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||||
@@ -381,7 +406,9 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||||
|
TestSupport.BuildMcpSettingsService(),
|
||||||
|
TestSupport.BuildMcpClientRegistrationService(),
|
||||||
TestSupport.BuildWebUntisSettingsService(),
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
TestSupport.BuildAnnualPlanSettingsService(),
|
TestSupport.BuildAnnualPlanSettingsService(),
|
||||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class StudentPickerDialogViewModelTests
|
||||||
|
{
|
||||||
|
private static (StudentPickerDialogViewModel Vm, Student Anna, Student Ben) Build()
|
||||||
|
{
|
||||||
|
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
|
||||||
|
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
|
||||||
|
var vm = new StudentPickerDialogViewModel(new FakeStudents([anna, ben]));
|
||||||
|
return (vm, anna, ben);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SearchText_FiltertNachTeilstring()
|
||||||
|
{
|
||||||
|
var (vm, anna, _) = Build();
|
||||||
|
|
||||||
|
vm.SearchText = "ann";
|
||||||
|
|
||||||
|
Assert.Single(vm.Students);
|
||||||
|
Assert.Equal(anna.FullName, vm.Students[0].FullName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Select_OhneAuswahl_SetztValidationMessage()
|
||||||
|
{
|
||||||
|
var (vm, _, _) = Build();
|
||||||
|
|
||||||
|
vm.SelectCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Null(vm.Result);
|
||||||
|
Assert.NotEqual("", vm.ValidationMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Select_MitAuswahl_LiefertVollenSchueler()
|
||||||
|
{
|
||||||
|
var (vm, anna, _) = Build();
|
||||||
|
vm.SelectedStudent = vm.Students.Single(s => s.Id == anna.Id);
|
||||||
|
|
||||||
|
vm.SelectCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Equal(anna.Id, vm.Result?.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class TeachingTimelineViewModelTests
|
||||||
|
{
|
||||||
|
private readonly DateTime _start = new(2026, 9, 14, 8, 0, 0, DateTimeKind.Utc);
|
||||||
|
private DateTime _now;
|
||||||
|
private readonly FakeLessons _lessons = new();
|
||||||
|
|
||||||
|
private (Lesson Lesson, TeachingTimelineViewModel Vm) Build(bool scheduled = true)
|
||||||
|
{
|
||||||
|
_now = _start.AddMinutes(5);
|
||||||
|
var local = _start.ToLocalTime();
|
||||||
|
var lesson = new Lesson
|
||||||
|
{
|
||||||
|
Date = DateOnly.FromDateTime(local),
|
||||||
|
StartTime = scheduled ? TimeOnly.FromDateTime(local) : null,
|
||||||
|
Phases = [new() { Name = "Einstieg", DurationMinutes = 10 },
|
||||||
|
new() { Name = "Arbeit", DurationMinutes = 10 },
|
||||||
|
new() { Name = "Sicherung", DurationMinutes = 10 }]
|
||||||
|
};
|
||||||
|
_lessons.Add(lesson);
|
||||||
|
return (lesson, new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void Clock_HighlightsExactlyOnePhaseAtBoundary()
|
||||||
|
{
|
||||||
|
var (_, vm) = Build();
|
||||||
|
Assert.True(vm.Phases[0].IsActive);
|
||||||
|
Assert.Equal(50, vm.Phases[0].Progress);
|
||||||
|
_now = _start.AddMinutes(10);
|
||||||
|
vm.Refresh();
|
||||||
|
Assert.True(vm.Phases[0].IsCompleted);
|
||||||
|
Assert.True(vm.Phases[1].IsActive);
|
||||||
|
Assert.Single(vm.Phases, p => p.IsActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void NoStartTime_RequiresExplicitStart()
|
||||||
|
{
|
||||||
|
var (_, vm) = Build(scheduled: false);
|
||||||
|
Assert.True(vm.NeedsStart);
|
||||||
|
Assert.DoesNotContain(vm.Phases, p => p.IsActive);
|
||||||
|
vm.StartNowCommand.Execute(null);
|
||||||
|
Assert.False(vm.NeedsStart);
|
||||||
|
Assert.True(vm.Phases[0].IsActive);
|
||||||
|
Assert.Equal(_now, vm.Phases[0].StartUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void Extend_ShiftsLaterPhasesAndPreservesOriginalPlan()
|
||||||
|
{
|
||||||
|
var (lesson, vm) = Build();
|
||||||
|
vm.Phases[0].ExtendTenCommand.Execute(null);
|
||||||
|
Assert.Equal(_start.AddMinutes(20), vm.Phases[1].StartUtc);
|
||||||
|
Assert.True(vm.Phases[2].IsOverflow);
|
||||||
|
Assert.Equal(10, lesson.Phases[0].DurationMinutes);
|
||||||
|
Assert.NotNull(_lessons.GetById(lesson.Id)!.TeachingTimeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void FinishEarly_StartsNextPhaseNow()
|
||||||
|
{
|
||||||
|
var (_, vm) = Build();
|
||||||
|
vm.Phases[0].FinishCommand.Execute(null);
|
||||||
|
Assert.True(vm.Phases[0].IsCompleted);
|
||||||
|
Assert.True(vm.Phases[1].IsActive);
|
||||||
|
Assert.Equal(_now, vm.Phases[1].StartUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void Hold_SurvivesReopenAndContinuesOnlyOnNext()
|
||||||
|
{
|
||||||
|
var (lesson, vm) = Build();
|
||||||
|
vm.Phases[0].HoldCommand.Execute(null);
|
||||||
|
_now = _start.AddMinutes(65);
|
||||||
|
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
|
||||||
|
Assert.True(reopened.Phases[0].IsHeld);
|
||||||
|
Assert.True(reopened.Phases[0].IsActive);
|
||||||
|
Assert.Single(reopened.Phases, p => p.IsActive);
|
||||||
|
reopened.Phases[0].FinishCommand.Execute(null);
|
||||||
|
Assert.True(reopened.Phases[1].IsActive);
|
||||||
|
Assert.False(reopened.Phases[0].IsHeld);
|
||||||
|
Assert.Equal(_now, reopened.Phases[1].StartUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void BringForward_KeepsSkippedPendingPhasesBelowChosenPhase()
|
||||||
|
{
|
||||||
|
var (_, vm) = Build();
|
||||||
|
var chosen = vm.Phases[2];
|
||||||
|
chosen.BringForwardCommand.Execute(null);
|
||||||
|
Assert.Equal(new[] { "Einstieg", "Sicherung", "Arbeit" }, vm.Phases.Select(p => p.Source.Name));
|
||||||
|
Assert.True(chosen.IsActive);
|
||||||
|
Assert.Equal(_now, chosen.StartUtc);
|
||||||
|
Assert.False(vm.Phases[2].IsCompleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void Overflow_RemainsPendingTheNextDayAndCanBeStartedNow()
|
||||||
|
{
|
||||||
|
var (lesson, vm) = Build();
|
||||||
|
vm.Phases[0].ExtendTenCommand.Execute(null);
|
||||||
|
_now = _start.AddDays(1);
|
||||||
|
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
|
||||||
|
var remainder = reopened.Phases[2];
|
||||||
|
Assert.True(remainder.IsOverflow);
|
||||||
|
Assert.False(remainder.IsCompleted);
|
||||||
|
Assert.False(remainder.IsActive);
|
||||||
|
Assert.True(reopened.HasRemainder);
|
||||||
|
remainder.BringForwardCommand.Execute(null);
|
||||||
|
Assert.True(remainder.IsActive);
|
||||||
|
Assert.Equal(_now, remainder.StartUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void Transfer_CopiesIntoSelectedLessonOnlyOnceAndKeepsSource()
|
||||||
|
{
|
||||||
|
var (lesson, _) = Build();
|
||||||
|
var target = new Lesson { GroupId = lesson.GroupId, Date = lesson.Date.AddDays(1), Topic = "Folgestunde" };
|
||||||
|
_lessons.Add(target);
|
||||||
|
var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
|
||||||
|
vm.Phases[0].ExtendTenCommand.Execute(null);
|
||||||
|
vm.TransferRemainderCommand.Execute(null);
|
||||||
|
vm.TransferRemainderCommand.Execute(null);
|
||||||
|
var copy = Assert.Single(target.Phases);
|
||||||
|
Assert.Equal("Sicherung", copy.Name);
|
||||||
|
Assert.NotEqual(lesson.Phases[2].Id, copy.Id);
|
||||||
|
Assert.Equal(3, lesson.Phases.Count);
|
||||||
|
Assert.True(vm.Phases[2].IsTransferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void PartiallyOverflowingPhase_PreservesOnlyUnfinishedMinutesAfterLessonEnds()
|
||||||
|
{
|
||||||
|
var (lesson, vm) = Build();
|
||||||
|
vm.Phases[0].ExtendFiveCommand.Execute(null);
|
||||||
|
_now = _start.AddMinutes(28);
|
||||||
|
vm.Refresh();
|
||||||
|
Assert.True(vm.Phases[2].IsActive);
|
||||||
|
_now = _start.AddDays(1);
|
||||||
|
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
|
||||||
|
var remainder = reopened.Phases[2];
|
||||||
|
Assert.False(remainder.IsCompleted);
|
||||||
|
Assert.False(remainder.IsActive);
|
||||||
|
Assert.True(reopened.HasRemainder);
|
||||||
|
Assert.Equal(5, remainder.RemainingMinutes);
|
||||||
|
remainder.BringForwardCommand.Execute(null);
|
||||||
|
Assert.True(remainder.IsActive);
|
||||||
|
Assert.Equal(_now.AddMinutes(5), remainder.EndUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void ReadOnly_DoesNotChangeTimingOrStartClock()
|
||||||
|
{
|
||||||
|
var (lesson, _) = Build(scheduled: false);
|
||||||
|
var vm = new TeachingTimelineViewModel(lesson, _lessons, readOnly: true, utcNow: () => _now);
|
||||||
|
vm.StartNowCommand.Execute(null);
|
||||||
|
Assert.Null(lesson.TeachingTimeline);
|
||||||
|
Assert.True(vm.NeedsStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void AlternativePhases_AreNotRunAlongsideMainPath()
|
||||||
|
{
|
||||||
|
var (lesson, _) = Build();
|
||||||
|
lesson.Phases.Add(new LessonPhaseStep { AlternativePathId = Guid.NewGuid(), DurationMinutes = 30 });
|
||||||
|
var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
|
||||||
|
Assert.Equal(3, vm.Phases.Count);
|
||||||
|
Assert.Equal(_start.AddMinutes(30), vm.Phases[^1].EndUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void HeldTiming_RoundTripsThroughLiteDbWithoutTimezoneShift()
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
using var db = new LehrerApp.Data.LiteDbContext(stream);
|
||||||
|
var repository = new LehrerApp.Data.Repositories.LessonRepository(db);
|
||||||
|
var (lesson, _) = Build();
|
||||||
|
repository.Save(lesson);
|
||||||
|
var vm = new TeachingTimelineViewModel(lesson, repository, utcNow: () => _now);
|
||||||
|
vm.Phases[0].HoldCommand.Execute(null);
|
||||||
|
_now = _start.AddMinutes(40);
|
||||||
|
var loaded = repository.GetById(lesson.Id)!;
|
||||||
|
var reopened = new TeachingTimelineViewModel(loaded, repository, utcNow: () => _now);
|
||||||
|
Assert.True(reopened.Phases[0].IsActive);
|
||||||
|
Assert.True(reopened.Phases[0].IsHeld);
|
||||||
|
Assert.Equal(_start, reopened.Phases[0].StartUtc);
|
||||||
|
Assert.Equal(_start.AddMinutes(45), reopened.Phases[0].EndUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,4 +25,28 @@ public sealed class UnitDialogViewModelTests
|
|||||||
|
|
||||||
Assert.Equal("10c · kein Fach hinterlegt (siehe Lerngruppe)", vm.GroupSubjectDisplay);
|
Assert.Equal("10c · kein Fach hinterlegt (siehe Lerngruppe)", vm.GroupSubjectDisplay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Laufend_SetztBisherigeLaufendeEinheitAufAbgeschlossenUndWarntVorher()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var units = new FakeUnits();
|
||||||
|
var previous = new LehrerApp.Core.Models.Unit
|
||||||
|
{
|
||||||
|
GroupId = groupId, Title = "Bruchrechnung", Status = LehrerApp.Core.Models.UnitStatus.Active,
|
||||||
|
};
|
||||||
|
units.Add(previous);
|
||||||
|
var vm = new UnitDialogViewModel(units, new FakeCompetencyDomains(),
|
||||||
|
groupId, null, 6, "6a", "Mathematik", editingUnit: null)
|
||||||
|
{
|
||||||
|
Title = "Prozentrechnung",
|
||||||
|
StatusName = "Laufend",
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Contains("Bruchrechnung", vm.StatusNotice);
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Equal(LehrerApp.Core.Models.UnitStatus.Completed, previous.Status);
|
||||||
|
Assert.Single(units.GetByGroup(groupId), u => u.Status == LehrerApp.Core.Models.UnitStatus.Active);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
/// Aus Konsistenz mit den drei bestehenden WebUntis-Abgleichs-ViewModels bewusst ohne Tests für
|
||||||
|
/// GetUntisAbsenceRows/GetNamedUntisAbsencePattern gelassen (siehe TODO.md) - beide rufen
|
||||||
|
/// WebUntisIntegrationService.GetLessonAbsencesAsync auf, was einen echten WebUntis-JSON-RPC-
|
||||||
|
/// Handshake voraussetzt. Getestet werden die Teile, die keinen WebUntis-Zugriff brauchen:
|
||||||
|
/// GetUntisHubStatus (reine Delegation an UntisHubService) und die Validierungspfade von
|
||||||
|
/// ApplyUntisAbsenceStatus, die vor jedem WebUntis-/Datenbankzugriff greifen.
|
||||||
|
public sealed class UntisComparisonToolsTests
|
||||||
|
{
|
||||||
|
private static UntisComparisonTools Build(
|
||||||
|
List<LearningGroup>? groups = null, List<Student>? students = null,
|
||||||
|
List<ParticipationSession>? sessions = null, FakeMcpConfirmation? confirmation = null) =>
|
||||||
|
new(new FakeGroups(groups ?? []), new FakeStudents(students ?? []), new FakeSessions(sessions ?? []),
|
||||||
|
new FakeEntries(), TestSupport.BuildWebUntisIntegrationService(),
|
||||||
|
TestSupport.BuildUntisHubService(groups ?? []), confirmation ?? new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetUntisHubStatus_DelegiertAnUntisHubServiceUndMapptKindUndDueStateAlsText()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9a", WebUntisLessonId = 42 };
|
||||||
|
var tool = Build([group]);
|
||||||
|
|
||||||
|
var rows = tool.GetUntisHubStatus();
|
||||||
|
|
||||||
|
Assert.Contains(rows, r => r.Kind == nameof(UntisHubJobKind.FehlzeitenKurz) && r.GroupName == "9a"
|
||||||
|
&& r.DueState == nameof(UntisHubDueState.Overdue));
|
||||||
|
Assert.Contains(rows, r => r.Kind == nameof(UntisHubJobKind.OffenePeriods) && r.GroupName == "Offene Stunden");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyUntisAbsenceStatus_UnbekannterStatus_WirdOhneRowIdPruefungAbgelehnt()
|
||||||
|
{
|
||||||
|
var tool = Build();
|
||||||
|
|
||||||
|
var result = await tool.ApplyUntisAbsenceStatus("irgendeine-id", "Suspendiert");
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Contains("Status", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyUntisAbsenceStatus_NichtSelektierbarerAberGueltigerEnumWert_WirdAbgelehnt()
|
||||||
|
{
|
||||||
|
// "Truant"/"Suspended" etc. sind gültige AttendanceStatus-Werte, aber keine, die WebUntis
|
||||||
|
// hier je melden würde (siehe SelectableStatuses) - müssen trotzdem abgelehnt werden, damit
|
||||||
|
// ein KI-Client nicht versehentlich einen fachlich unpassenden Status setzen kann.
|
||||||
|
var tool = Build();
|
||||||
|
|
||||||
|
var result = await tool.ApplyUntisAbsenceStatus("irgendeine-id", nameof(AttendanceStatus.Truant));
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyUntisAbsenceStatus_UnbekannteRowId_WirdAbgelehntOhneBestaetigung()
|
||||||
|
{
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = Build(confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.ApplyUntisAbsenceStatus("nie-vergeben", nameof(AttendanceStatus.Excused));
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Contains("row-id", result.Message);
|
||||||
|
Assert.Equal(0, confirmation.CallCount); // erst gar nicht bis zur Bestätigung vorgedrungen
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class UntisHubServiceTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime UtcNow = new(2026, 9, 10, 8, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static LearningGroup Group(string name, int? webUntisLessonId, bool isActive = true) =>
|
||||||
|
new() { Name = name, WebUntisLessonId = webUntisLessonId, IsActive = isActive };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRows_ProGruppeMitLessonId_ZweiFehlzeitenzeilenPlusDreiGlobaleZeilen()
|
||||||
|
{
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
|
||||||
|
var rows = UntisHubService.BuildRows([group], [], UtcNow);
|
||||||
|
|
||||||
|
Assert.Equal(5, rows.Count);
|
||||||
|
Assert.Equal(2, rows.Count(r => r.GroupId == group.Id));
|
||||||
|
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.FehlzeitenKurz && r.GroupId == group.Id);
|
||||||
|
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.FehlzeitenLang && r.GroupId == group.Id);
|
||||||
|
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.OffenePeriods && r.GroupId == null);
|
||||||
|
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.Klassenbuchabgleich && r.GroupId == null);
|
||||||
|
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.Hausaufgabenabgleich && r.GroupId == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRows_NieGeprueft_GiltAlsUeberfaellig()
|
||||||
|
{
|
||||||
|
var rows = UntisHubService.BuildRows([], [], UtcNow);
|
||||||
|
|
||||||
|
Assert.All(rows, r => Assert.Equal(UntisHubDueState.Overdue, r.DueState));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRows_FehlzeitenKurzVorSechsTagen_GiltAlsOk()
|
||||||
|
{
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
var state = new UntisHubJobState
|
||||||
|
{
|
||||||
|
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-6),
|
||||||
|
};
|
||||||
|
|
||||||
|
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
|
||||||
|
|
||||||
|
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
|
||||||
|
Assert.Equal(UntisHubDueState.Ok, row.DueState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRows_FehlzeitenKurzVorSechzehnTagen_GiltAlsFaellig()
|
||||||
|
{
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
var state = new UntisHubJobState
|
||||||
|
{
|
||||||
|
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-16),
|
||||||
|
};
|
||||||
|
|
||||||
|
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
|
||||||
|
|
||||||
|
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
|
||||||
|
Assert.Equal(UntisHubDueState.Due, row.DueState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRows_FehlzeitenKurzVorZweiundzwanzigTagen_GiltAlsUeberfaellig()
|
||||||
|
{
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
var state = new UntisHubJobState
|
||||||
|
{
|
||||||
|
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-22),
|
||||||
|
};
|
||||||
|
|
||||||
|
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
|
||||||
|
|
||||||
|
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
|
||||||
|
Assert.Equal(UntisHubDueState.Overdue, row.DueState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRows_FehlzeitenLangHatDeutlichLaengereKadenzAlsKurz()
|
||||||
|
{
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
var lastRunAt = UtcNow.AddDays(-40);
|
||||||
|
var states = new List<UntisHubJobState>
|
||||||
|
{
|
||||||
|
new() { Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = lastRunAt },
|
||||||
|
new() { Kind = UntisHubJobKind.FehlzeitenLang, GroupId = group.Id, LastRunAt = lastRunAt },
|
||||||
|
};
|
||||||
|
|
||||||
|
var rows = UntisHubService.BuildRows([group], states, UtcNow);
|
||||||
|
|
||||||
|
Assert.Equal(UntisHubDueState.Overdue, rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz).DueState);
|
||||||
|
Assert.Equal(UntisHubDueState.Ok, rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenLang).DueState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRows_GruppeOhneWebUntisLessonId_WirdVonAufrufseiteAusgeschlossen()
|
||||||
|
{
|
||||||
|
// GetRows() filtert vorab auf WebUntisLessonId != null (siehe UntisHubService.GetRows) -
|
||||||
|
// BuildRows selbst bekommt bereits nur die eligible-Gruppen übergeben.
|
||||||
|
var eligible = new List<LearningGroup> { Group("9a", 42) };
|
||||||
|
|
||||||
|
var rows = UntisHubService.BuildRows(eligible, [], UtcNow);
|
||||||
|
|
||||||
|
Assert.Equal(2, rows.Count(r => r.GroupId != null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecordRun_FehlzeitenLang_SchliesstAuchFehlzeitenKurzDerselbenGruppeAb()
|
||||||
|
{
|
||||||
|
// Nutzer-Feedback: der Langzeit-Abgleich deckt das kurzfristige Zeitfenster als Teilmenge mit
|
||||||
|
// ab - ohne diese Kopplung bliebe die kurzfristige Kadenz trotz erledigtem Langzeit-Abgleich
|
||||||
|
// als fällig stehen.
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
var jobStates = new FakeUntisHubJobStates();
|
||||||
|
var hub = new UntisHubService(new FakeGroups([group]), jobStates, new SchoolYearService());
|
||||||
|
|
||||||
|
hub.RecordRun(UntisHubJobKind.FehlzeitenLang, group.Id, "3 Fehlzeiten übernommen");
|
||||||
|
|
||||||
|
var kurzState = jobStates.Get(UntisHubJobKind.FehlzeitenKurz, group.Id);
|
||||||
|
var langState = jobStates.Get(UntisHubJobKind.FehlzeitenLang, group.Id);
|
||||||
|
Assert.NotNull(kurzState?.LastRunAt);
|
||||||
|
Assert.Equal(langState!.LastRunAt, kurzState!.LastRunAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecordRun_FehlzeitenKurz_LaesstFehlzeitenLangUnangetastet()
|
||||||
|
{
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
var jobStates = new FakeUntisHubJobStates();
|
||||||
|
var hub = new UntisHubService(new FakeGroups([group]), jobStates, new SchoolYearService());
|
||||||
|
|
||||||
|
hub.RecordRun(UntisHubJobKind.FehlzeitenKurz, group.Id, "keine Abweichungen");
|
||||||
|
|
||||||
|
Assert.Null(jobStates.Get(UntisHubJobKind.FehlzeitenLang, group.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,4 +18,18 @@ public sealed class UntisNameMatchingTests
|
|||||||
[InlineData("Ben Schmidt", "")]
|
[InlineData("Ben Schmidt", "")]
|
||||||
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
|
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
|
||||||
Assert.False(UntisNameMatching.NamesMatch(a, b));
|
Assert.False(UntisNameMatching.NamesMatch(a, b));
|
||||||
|
|
||||||
|
// Regression: Student.FullName liefert "Nachname, Vorname" (mit Komma) für Anzeigezwecke.
|
||||||
|
// Wird dieser String direkt an NamesMatch übergeben, bleibt das Komma am Wort kleben
|
||||||
|
// ("gerste," != "gerste") und der Abgleich gegen WebUntis-Namen (immer ohne Komma) schlägt
|
||||||
|
// fehl - genau das ließ den Anwesenheitskalender/die Fehlzeitenliste im Elternbrief leer
|
||||||
|
// bleiben, obwohl echte Fehlzeiten vorlagen. Aufrufer müssen deshalb "Vorname Nachname" ohne
|
||||||
|
// Komma bilden (siehe StudentAttendanceCalendarDrawingBuilder.StudentAttendanceCalendarService
|
||||||
|
// und ClassTeacherOverviewViewModel.cs:1173), statt Student.FullName direkt zu verwenden.
|
||||||
|
[Fact]
|
||||||
|
public void NamesMatch_KommaGetrennterAnzeigename_PasstNichtOhneUmformung()
|
||||||
|
{
|
||||||
|
Assert.False(UntisNameMatching.NamesMatch("Gerste, Amelia", "Gerste Amelia"));
|
||||||
|
Assert.True(UntisNameMatching.NamesMatch("Amelia Gerste", "Gerste Amelia"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public sealed class UntisSyncServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndAktualisiertBeiWiederholung()
|
public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndUeberspringtUnveraenderteWiederholung()
|
||||||
{
|
{
|
||||||
var mappings = new FakeUntisSlotMappings();
|
var mappings = new FakeUntisSlotMappings();
|
||||||
mappings.Add(new UntisSlotMapping
|
mappings.Add(new UntisSlotMapping
|
||||||
@@ -81,7 +81,7 @@ public sealed class UntisSyncServiceTests
|
|||||||
// Eintrag erzeugen - Idempotenz über SubstitutionEntry.ExternalId.
|
// Eintrag erzeugen - Idempotenz über SubstitutionEntry.ExternalId.
|
||||||
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
|
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
|
||||||
|
|
||||||
Assert.Equal(1, second.SubstitutionCount);
|
Assert.Equal(0, second.SubstitutionCount);
|
||||||
Assert.Single(substitutions.GetAll());
|
Assert.Single(substitutions.GetAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ public sealed class UntisSyncServiceTests
|
|||||||
// Erneuter Poll mit demselben, weiterhin unverändert vorhandenen Termin darf keinen
|
// Erneuter Poll mit demselben, weiterhin unverändert vorhandenen Termin darf keinen
|
||||||
// zweiten Eintrag erzeugen (Idempotenz über ExternalId=Uid).
|
// zweiten Eintrag erzeugen (Idempotenz über ExternalId=Uid).
|
||||||
var second = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
|
var second = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
|
||||||
Assert.Equal(1, second.SubstitutionCount);
|
Assert.Equal(0, second.SubstitutionCount);
|
||||||
Assert.Single(substitutions.GetAll());
|
Assert.Single(substitutions.GetAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -572,6 +572,51 @@ public sealed class TimeTrackingViewModelTests
|
|||||||
|
|
||||||
Assert.False(called);
|
Assert.False(called);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingTeachingTime_VergangenerUnterrichtstag_WirdInZeiterfassungAngezeigt()
|
||||||
|
{
|
||||||
|
var pastDay = DateOnly.FromDateTime(DateTime.Today).AddDays(-1);
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var periodSchedule = TestSupport.BuildPeriodScheduleService();
|
||||||
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
|
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||||
|
|
||||||
|
var vm = new TimeTrackingViewModel(
|
||||||
|
new FakeTimeEntries(), new FakeWorkTasks(), slots, periodSchedule);
|
||||||
|
|
||||||
|
var gap = Assert.Single(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||||
|
Assert.Equal(new TimeOnly(7, 45), gap.WindowStart);
|
||||||
|
Assert.Equal(new TimeOnly(8, 55), gap.WindowEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMissingTeachingTime_SpeichertVorgefuelltenTagUndEntferntIhnAusOffenerListe()
|
||||||
|
{
|
||||||
|
var pastDay = DateOnly.FromDateTime(DateTime.Today).AddDays(-1);
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var periodSchedule = TestSupport.BuildPeriodScheduleService();
|
||||||
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
|
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||||
|
var entries = new FakeTimeEntries();
|
||||||
|
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks(), slots, periodSchedule);
|
||||||
|
var gap = Assert.Single(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||||
|
vm.OnAddMissingTeachingTime = item => Task.FromResult<TimeEntry?>(new TimeEntry
|
||||||
|
{
|
||||||
|
Date = item.Date,
|
||||||
|
Category = TaskCategoryDisplay.Label(TaskCategory.Teaching),
|
||||||
|
StartTime = item.WindowStart,
|
||||||
|
EndTime = item.WindowEnd,
|
||||||
|
DurationMinutes = (int)(item.WindowEnd - item.WindowStart).TotalMinutes,
|
||||||
|
});
|
||||||
|
|
||||||
|
await vm.AddMissingTeachingTimeCommand.ExecuteAsync(gap);
|
||||||
|
|
||||||
|
Assert.Single(entries.GetByDate(pastDay));
|
||||||
|
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AddTimeEntryDialogViewModelTests
|
public sealed class AddTimeEntryDialogViewModelTests
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using LehrerApp.Templating;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class _TempFlowCheck2
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void MarkerTest()
|
||||||
|
{
|
||||||
|
var outDir = @"C:\Users\SHedt\AppData\Local\Temp\claude\d--source-LehrerApp\fb3681df-caaf-4f2d-9996-e343e44f5554\scratchpad";
|
||||||
|
Directory.CreateDirectory(outDir);
|
||||||
|
|
||||||
|
var commands = new List<DrawingCommand>
|
||||||
|
{
|
||||||
|
new DrawStringEx(0, 0, 12, 170, "TOP-0", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
|
||||||
|
new DrawStringEx(0, 90, 12, 170, "NEARBOTTOM-90", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
|
||||||
|
new DrawStringEx(0, 105, 12, 170, "AFTERBOUNDARY-105", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
|
||||||
|
new DrawStringEx(0, 190, 12, 170, "BOTTOM-190", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
|
||||||
|
};
|
||||||
|
var drawing = new DrawingValue(commands, 200);
|
||||||
|
|
||||||
|
var manifest = new TemplateManifest
|
||||||
|
{
|
||||||
|
Id = "marker", Name = "Marker",
|
||||||
|
Placeholders = [new("Marker", PlaceholderType.Drawing, true)],
|
||||||
|
};
|
||||||
|
var layout = "PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Marker\n";
|
||||||
|
var loadedLayout = new LayoutParser().Parse(layout);
|
||||||
|
var loaded = new LoadedTemplate(manifest, loadedLayout, new Dictionary<string, byte[]>());
|
||||||
|
var provider = new FakeProvider(new Dictionary<string, PlaceholderValue> { ["Marker"] = drawing });
|
||||||
|
|
||||||
|
var pngs = new QuestTemplateRenderer().RenderPagesToPng(loaded, provider, dpi: 150);
|
||||||
|
for (var i = 0; i < pngs.Count; i++)
|
||||||
|
File.WriteAllBytes(Path.Combine(outDir, $"marker2-{i}.png"), pngs[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||||
|
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
<StreamGeometry x:Key="IconCalendar">M3,4 H21 V22 H3 Z M5,10 V20 H19 V10 Z M7,2 H9 V7 H7 Z M15,2 H17 V7 H15 Z M7,12 H10 V15 H7 Z M12,12 H15 V15 H12 Z M7,17 H10 V19 H7 Z M12,17 H15 V19 H12 Z</StreamGeometry>
|
<StreamGeometry x:Key="IconCalendar">M3,4 H21 V22 H3 Z M5,10 V20 H19 V10 Z M7,2 H9 V7 H7 Z M15,2 H17 V7 H15 Z M7,12 H10 V15 H7 Z M12,12 H15 V15 H12 Z M7,17 H10 V19 H7 Z M12,17 H15 V19 H12 Z</StreamGeometry>
|
||||||
<StreamGeometry x:Key="IconClock">M12,2 A10,10 0 1 0 12,22 A10,10 0 1 0 12,2 M12,5 A7,7 0 1 1 12,19 A7,7 0 1 1 12,5 M11,7 H13 V12 L17,14 L16,16 L11,13 Z</StreamGeometry>
|
<StreamGeometry x:Key="IconClock">M12,2 A10,10 0 1 0 12,22 A10,10 0 1 0 12,2 M12,5 A7,7 0 1 1 12,19 A7,7 0 1 1 12,5 M11,7 H13 V12 L17,14 L16,16 L11,13 Z</StreamGeometry>
|
||||||
<StreamGeometry x:Key="IconClassTeacher">M2,8 L12,3 L22,8 L12,13 Z M6,11 V16 C9,19 15,19 18,16 V11 M22,8 V15</StreamGeometry>
|
<StreamGeometry x:Key="IconClassTeacher">M2,8 L12,3 L22,8 L12,13 Z M6,11 V16 C9,19 15,19 18,16 V11 M22,8 V15</StreamGeometry>
|
||||||
|
<!-- Klassenbuch mit Ausrufezeichen für noch offene Einträge. -->
|
||||||
|
<StreamGeometry x:Key="IconOpenPeriods">M5,2 H19 A2,2 0 0 1 21,4 V20 A2,2 0 0 1 19,22 H5 A2,2 0 0 1 3,20 V4 A2,2 0 0 1 5,2 Z M7,4 V20 H19 V4 Z M11,7 H14 V13 H11 Z M11,15 H14 V18 H11 Z</StreamGeometry>
|
||||||
<StreamGeometry x:Key="IconSettings">M3,5 H21 V7 H3 Z M8,3 A3,3 0 1 0 8,9 A3,3 0 1 0 8,3 M3,11 H21 V13 H3 Z M16,9 A3,3 0 1 0 16,15 A3,3 0 1 0 16,9 M3,17 H21 V19 H3 Z M10,15 A3,3 0 1 0 10,21 A3,3 0 1 0 10,15</StreamGeometry>
|
<StreamGeometry x:Key="IconSettings">M3,5 H21 V7 H3 Z M8,3 A3,3 0 1 0 8,9 A3,3 0 1 0 8,3 M3,11 H21 V13 H3 Z M16,9 A3,3 0 1 0 16,15 A3,3 0 1 0 16,9 M3,17 H21 V19 H3 Z M10,15 A3,3 0 1 0 10,21 A3,3 0 1 0 10,15</StreamGeometry>
|
||||||
<StreamGeometry x:Key="IconRefresh">M12,3 A9,9 0 0 1 21,12 H18 A6,6 0 0 0 8,7 L11,10 H3 V2 L6,5 A9,9 0 0 1 12,3 M12,21 A9,9 0 0 1 3,12 H6 A6,6 0 0 0 16,17 L13,14 H21 V22 L18,19 A9,9 0 0 1 12,21</StreamGeometry>
|
<StreamGeometry x:Key="IconRefresh">M12,3 A9,9 0 0 1 21,12 H18 A6,6 0 0 0 8,7 L11,10 H3 V2 L6,5 A9,9 0 0 1 12,3 M12,21 A9,9 0 0 1 3,12 H6 A6,6 0 0 0 16,17 L13,14 H21 V22 L18,19 A9,9 0 0 1 12,21</StreamGeometry>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using Avalonia.Styling;
|
using Avalonia.Styling;
|
||||||
|
using Avalonia.Threading;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
@@ -24,6 +26,8 @@ public class App : Application
|
|||||||
public static IServiceProvider Services { get; private set; } = null!;
|
public static IServiceProvider Services { get; private set; } = null!;
|
||||||
private static ServiceProvider? _serviceProvider;
|
private static ServiceProvider? _serviceProvider;
|
||||||
private static bool _exitHandlerAttached;
|
private static bool _exitHandlerAttached;
|
||||||
|
private static Task _initialPolls = Task.CompletedTask;
|
||||||
|
private static readonly CancellationTokenSource StartupCancellation = new();
|
||||||
|
|
||||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
@@ -38,41 +42,64 @@ public class App : Application
|
|||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
|
// Erst den Splashscreen anzeigen und die synchrone Initialisierung in den ersten
|
||||||
// (und damit BuildServices, das sie öffnet) angefasst wird.
|
// Dispatcher-Durchlauf verschieben, damit das Ladebild vorher gezeichnet wird.
|
||||||
if (AppBootstrapper.IsDbEncrypted())
|
var splash = new SplashWindow();
|
||||||
{
|
desktop.MainWindow = splash;
|
||||||
var promptVm = new DbPasswordPromptViewModel(
|
Dispatcher.UIThread.Post(
|
||||||
new DatabaseEncryptionService(), AppBootstrapper.ResolveDbPath());
|
() => ContinueStartup(desktop, splash),
|
||||||
var promptWindow = new DbPasswordPromptWindow { DataContext = promptVm };
|
DispatcherPriority.Background);
|
||||||
promptVm.OnUnlocked = password =>
|
|
||||||
{
|
|
||||||
AppBootstrapper.DbPassword = password;
|
|
||||||
StartMainApp(desktop, showImmediately: true);
|
|
||||||
promptWindow.Close();
|
|
||||||
};
|
|
||||||
desktop.MainWindow = promptWindow;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
StartMainApp(desktop, showImmediately: false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void StartMainApp(
|
private static async void ContinueStartup(
|
||||||
IClassicDesktopStyleApplicationLifetime desktop, bool showImmediately)
|
IClassicDesktopStyleApplicationLifetime desktop, SplashWindow splash)
|
||||||
{
|
{
|
||||||
_serviceProvider = AppBootstrapper.BuildServices();
|
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
|
||||||
|
// (und damit BuildServices, das sie öffnet) angefasst wird.
|
||||||
|
if (AppBootstrapper.IsDbEncrypted())
|
||||||
|
{
|
||||||
|
var promptVm = new DbPasswordPromptViewModel(
|
||||||
|
new DatabaseEncryptionService(), AppBootstrapper.ResolveDbPath());
|
||||||
|
var promptWindow = new DbPasswordPromptWindow { DataContext = promptVm };
|
||||||
|
promptVm.OnUnlocked = async password =>
|
||||||
|
{
|
||||||
|
AppBootstrapper.DbPassword = password;
|
||||||
|
var unlockedSplash = new SplashWindow();
|
||||||
|
desktop.MainWindow = unlockedSplash;
|
||||||
|
unlockedSplash.Show();
|
||||||
|
promptWindow.Close();
|
||||||
|
await StartMainAppAsync(desktop, unlockedSplash);
|
||||||
|
};
|
||||||
|
desktop.MainWindow = promptWindow;
|
||||||
|
promptWindow.Show();
|
||||||
|
splash.Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await StartMainAppAsync(desktop, splash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task StartMainAppAsync(
|
||||||
|
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
|
||||||
|
{
|
||||||
|
var splash = windowToClose as SplashWindow;
|
||||||
|
var timer = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var progress = new Progress<(int Value, string Text)>(step =>
|
||||||
|
splash?.SetProgress(step.Value, step.Text));
|
||||||
|
_serviceProvider = await Task.Run(() => AppBootstrapper.BuildServices(
|
||||||
|
(value, text) => ((IProgress<(int, string)>)progress).Report((value, text))));
|
||||||
Services = _serviceProvider;
|
Services = _serviceProvider;
|
||||||
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
|
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
|
||||||
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
|
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
|
||||||
// Papierkorb (14.3): Einträge älter als 30 Tage endgültig entfernen. Beim Start statt per
|
// Papierkorb (14.3): Einträge älter als 30 Tage endgültig entfernen. Beim Start statt per
|
||||||
// Timer - reicht für ein Werkzeug, das ohnehin nur "Fehlklick eben rückgängig machen" sein
|
// Timer - reicht für ein Werkzeug, das ohnehin nur "Fehlklick eben rückgängig machen" sein
|
||||||
// soll, kein dauerhaftes Archiv.
|
// soll, kein dauerhaftes Archiv.
|
||||||
Services.GetRequiredService<ITrashRepository>().PurgeOlderThan(DateTime.UtcNow.AddDays(-30));
|
splash?.SetProgress(60, "Papierkorb aufräumen …");
|
||||||
|
await Task.Run(() => Services.GetRequiredService<ITrashRepository>()
|
||||||
|
.PurgeOlderThan(DateTime.UtcNow.AddDays(-30)));
|
||||||
|
|
||||||
if (!_exitHandlerAttached)
|
if (!_exitHandlerAttached)
|
||||||
{
|
{
|
||||||
@@ -80,23 +107,49 @@ public class App : Application
|
|||||||
_exitHandlerAttached = true;
|
_exitHandlerAttached = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MCP-Server (lokal, Phase 1): Start ist ohne Wirkung, falls in den Einstellungen nicht
|
||||||
|
// aktiviert (siehe McpServerHostedService.Start). Kein Live-Reload beim Umschalten des
|
||||||
|
// Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein.
|
||||||
|
Services.GetRequiredService<Services.Mcp.McpServerHostedService>().Start();
|
||||||
|
|
||||||
|
splash?.SetProgress(75, "Übersicht vorbereiten …");
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||||||
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
||||||
WireCallbacks(mainVm);
|
WireCallbacks(mainVm);
|
||||||
|
|
||||||
// DI-Singletons werden erst beim ersten Auflösen erzeugt. Ohne dieses explizite
|
splash?.SetProgress(90, "Hauptfenster öffnen …");
|
||||||
// Auflösen entstand der Timer des optionalen WebUntis-Abgleichs erst, wenn die
|
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||||||
// Einstellungen geöffnet oder ein manueller Abruf gestartet wurde. Den Dienst beim
|
|
||||||
// Anwendungsstart aktivieren und wie den Jahresplan sofort einmal abgleichen; danach
|
|
||||||
// übernimmt sein stündlicher Timer.
|
|
||||||
if (Services.GetService<UntisSyncService>() is { } untisSync)
|
|
||||||
_ = untisSync.PollAsync();
|
|
||||||
|
|
||||||
var main = new MainWindow { DataContext = mainVm };
|
var main = new MainWindow { DataContext = mainVm };
|
||||||
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
|
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
|
||||||
if (Services.GetService<SyncEngine>() is { } syncEngine)
|
if (Services.GetService<SyncEngine>() is { } syncEngine)
|
||||||
main.EnableFinalSync(syncEngine);
|
main.EnableFinalSync(syncEngine);
|
||||||
desktop.MainWindow = main;
|
desktop.MainWindow = main;
|
||||||
if (showImmediately) main.Show();
|
main.Show();
|
||||||
|
splash?.SetProgress(100, "Bereit");
|
||||||
|
windowToClose?.Close();
|
||||||
|
AppBootstrapper.Logger.Info($"Start: Hauptfenster nach {timer.ElapsedMilliseconds} ms geöffnet.");
|
||||||
|
|
||||||
|
// Vorhandene lokale Daten sind sofort nutzbar; Netzwerkzugriffe blockieren den Start nicht.
|
||||||
|
var untis = Services.GetService<UntisSyncService>();
|
||||||
|
var annualPlan = Services.GetService<AnnualPlanSyncService>();
|
||||||
|
if (untis is not null)
|
||||||
|
untis.DataChanged += () => Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
Services.GetRequiredService<TimetableViewModel>().Load();
|
||||||
|
Services.GetRequiredService<DashboardViewModel>().RefreshCommand.Execute(null);
|
||||||
|
});
|
||||||
|
_initialPolls = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(
|
||||||
|
untis?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask,
|
||||||
|
annualPlan?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (StartupCancellation.IsCancellationRequested) { }
|
||||||
|
catch (Exception ex) { AppBootstrapper.Logger.Error("Erstabgleich fehlgeschlagen.", ex); }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
|
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
|
||||||
@@ -117,6 +170,8 @@ public class App : Application
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
StartupCancellation.Cancel();
|
||||||
|
_initialPolls.GetAwaiter().GetResult();
|
||||||
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using LehrerApp.Core.Services;
|
|||||||
using LehrerApp.Data;
|
using LehrerApp.Data;
|
||||||
using LehrerApp.Data.Repositories;
|
using LehrerApp.Data.Repositories;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.Services.Mcp;
|
||||||
|
using LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
using LehrerApp.Desktop.ViewModels.Exams;
|
using LehrerApp.Desktop.ViewModels.Exams;
|
||||||
@@ -99,7 +101,7 @@ public static class AppBootstrapper
|
|||||||
Environment.Exit(0);
|
Environment.Exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ServiceProvider BuildServices()
|
public static ServiceProvider BuildServices(Action<int, string>? reportProgress = null)
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
|
|
||||||
@@ -121,13 +123,16 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<ITemplateLoader, TemplateLoader>();
|
services.AddSingleton<ITemplateLoader, TemplateLoader>();
|
||||||
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
|
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
|
||||||
services.AddSingleton(_ => new TemplateStore(appData));
|
services.AddSingleton(_ => new TemplateStore(appData));
|
||||||
|
services.AddSingleton(_ => new WorksheetTemplateStore(new TemplateStore(appData, subfolder: "worksheet-template-packages")));
|
||||||
|
|
||||||
// ── Datensicherheit (13.3) ───────────────────────────────────────────
|
// ── Datensicherheit (13.3) ───────────────────────────────────────────
|
||||||
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
|
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
|
||||||
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
|
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
|
||||||
var backupSettings = new BackupSettingsService(appData);
|
var backupSettings = new BackupSettingsService(appData);
|
||||||
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
|
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
|
||||||
|
reportProgress?.Invoke(10, "Sicherung erstellen …");
|
||||||
var backupPath = backup.CreateBackup(DbPath);
|
var backupPath = backup.CreateBackup(DbPath);
|
||||||
|
reportProgress?.Invoke(25, "Sicherung prüfen …");
|
||||||
// Best-effort-Prüfung des automatischen Startbackups: nur geloggt, kein Blocker für den
|
// Best-effort-Prüfung des automatischen Startbackups: nur geloggt, kein Blocker für den
|
||||||
// Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten.
|
// Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten.
|
||||||
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
|
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
|
||||||
@@ -185,6 +190,7 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
|
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
|
||||||
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
|
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
|
||||||
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
|
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
|
||||||
|
services.AddSingleton<IUntisHubJobStateRepository, UntisHubJobStateRepository>();
|
||||||
|
|
||||||
// ── Services ──────────────────────────────────────────────────────────
|
// ── Services ──────────────────────────────────────────────────────────
|
||||||
services.AddSingleton<GradingService>();
|
services.AddSingleton<GradingService>();
|
||||||
@@ -196,15 +202,40 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton(_ => new PeriodScheduleService(appData));
|
services.AddSingleton(_ => new PeriodScheduleService(appData));
|
||||||
services.AddSingleton(_ => new WorkloadSettingsService(appData));
|
services.AddSingleton(_ => new WorkloadSettingsService(appData));
|
||||||
services.AddSingleton(_ => new DashboardSettingsService(appData));
|
services.AddSingleton(_ => new DashboardSettingsService(appData));
|
||||||
|
services.AddSingleton(_ => new PatternScoreSettingsService(appData));
|
||||||
services.AddSingleton(_ => new WindowSettingsService(appData));
|
services.AddSingleton(_ => new WindowSettingsService(appData));
|
||||||
services.AddSingleton(_ => new AppearanceSettingsService(appData));
|
services.AddSingleton(_ => new AppearanceSettingsService(appData));
|
||||||
services.AddSingleton<PlanningExchangeService>();
|
services.AddSingleton<PlanningExchangeService>();
|
||||||
|
|
||||||
// ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ──────
|
// ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ──────
|
||||||
services.AddSingleton(_ => new AiSettingsService(appData));
|
services.AddSingleton(_ => new AiSettingsService(appData));
|
||||||
services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) });
|
services.AddSingleton(_ => new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(AiBackendUrl),
|
||||||
|
// Das PHP-Backend wartet höchstens 180 s auf umfangreiche KI-Antworten. Der Client
|
||||||
|
// bleibt bewusst etwas länger offen, damit dessen konkrete Fehlermeldung noch ankommt.
|
||||||
|
Timeout = TimeSpan.FromSeconds(210),
|
||||||
|
});
|
||||||
services.AddSingleton<AiPlanningService>();
|
services.AddSingleton<AiPlanningService>();
|
||||||
|
|
||||||
|
// ── MCP-Server (lokal, Phase 1 – siehe Planungsdokument, optional per Opt-in) ─────────
|
||||||
|
services.AddSingleton(_ => new McpSettingsService(appData));
|
||||||
|
services.AddSingleton<IMcpConfirmationService, AvaloniaMcpConfirmationService>();
|
||||||
|
services.AddSingleton<StudentTools>();
|
||||||
|
services.AddSingleton<ExamTools>();
|
||||||
|
services.AddSingleton<GradeTools>();
|
||||||
|
services.AddSingleton<ScheduleTools>();
|
||||||
|
services.AddSingleton<TimeEntryTools>();
|
||||||
|
services.AddSingleton<LessonPlanTools>();
|
||||||
|
services.AddSingleton<GroupMembershipTools>();
|
||||||
|
services.AddSingleton<LetterTemplateTools>();
|
||||||
|
services.AddSingleton<StudentAttendanceCalendarService>();
|
||||||
|
services.AddSingleton<CompetencyTools>();
|
||||||
|
services.AddSingleton<GroupTools>();
|
||||||
|
services.AddSingleton<UntisComparisonTools>();
|
||||||
|
services.AddSingleton<McpServerHostedService>();
|
||||||
|
services.AddSingleton<McpClientRegistrationService>();
|
||||||
|
|
||||||
// ── WebUntis-iCal-Abgleich (optional – nur wenn URL hinterlegt und aktiviert) ─────────
|
// ── WebUntis-iCal-Abgleich (optional – nur wenn URL hinterlegt und aktiviert) ─────────
|
||||||
var untisSettings = new WebUntisSettingsService(appData);
|
var untisSettings = new WebUntisSettingsService(appData);
|
||||||
services.AddSingleton(untisSettings);
|
services.AddSingleton(untisSettings);
|
||||||
@@ -241,6 +272,7 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
|
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
|
||||||
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
|
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
|
||||||
services.AddSingleton<UntisReportCacheService>();
|
services.AddSingleton<UntisReportCacheService>();
|
||||||
|
services.AddSingleton<UntisHubService>();
|
||||||
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
|
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
|
||||||
// stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN
|
// stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN
|
||||||
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
|
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
|
||||||
@@ -334,7 +366,9 @@ public static class AppBootstrapper
|
|||||||
services.AddTransient<TrashViewModel>();
|
services.AddTransient<TrashViewModel>();
|
||||||
services.AddTransient<SettingsViewModel>();
|
services.AddTransient<SettingsViewModel>();
|
||||||
|
|
||||||
|
reportProgress?.Invoke(40, "Datenbank öffnen und aktualisieren …");
|
||||||
var provider = services.BuildServiceProvider();
|
var provider = services.BuildServiceProvider();
|
||||||
|
provider.GetRequiredService<LiteDbContext>();
|
||||||
|
|
||||||
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
|
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
|
||||||
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden.
|
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
@@ -21,6 +21,7 @@
|
|||||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
<PackageReference Include="QuestPDF" />
|
<PackageReference Include="QuestPDF" />
|
||||||
|
<PackageReference Include="ModelContextProtocol.Core" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="Assets\**" />
|
<AvaloniaResource Include="Assets\**" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
using LehrerApp.Core.Mcp;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop;
|
namespace LehrerApp.Desktop;
|
||||||
@@ -8,6 +9,10 @@ class Program
|
|||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
|
// Muss vor der ersten NamedPipeServerStream-Instanz laufen (siehe
|
||||||
|
// McpServerHostedService), sonst löst TMPDIR-Auseinanderdriften zwischen diesem Prozess
|
||||||
|
// und LehrerApp.McpBridge auf macOS/Linux "MCP nicht erreichbar" aus - siehe Doku dort.
|
||||||
|
McpPipeConstants.EnsureStableUnixSocketDirectory();
|
||||||
var logger = AppBootstrapper.EnsureLogger();
|
var logger = AppBootstrapper.EnsureLogger();
|
||||||
GlobalExceptionHandler.Install(logger);
|
GlobalExceptionHandler.Install(logger);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ using System.Text.Json.Serialization;
|
|||||||
namespace LehrerApp.Desktop.Services;
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
/// <summary>Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet.</summary>
|
/// <summary>Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet.</summary>
|
||||||
public class AiBackendException(string userMessage) : Exception(userMessage);
|
public class AiBackendException(string userMessage, string? rawResponse = null) : Exception(userMessage)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unveränderte Modellantwort, sofern der Fehler beim Lesen einer Planungsantwort entstand.
|
||||||
|
/// Sie wird ausschließlich für den ausdrücklich vom Nutzer gestarteten Rettungsdialog gehalten.
|
||||||
|
/// </summary>
|
||||||
|
public string? RawResponse { get; } = rawResponse;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Der KI-Wire-Vertrag verwendet deutsches Datumsformat (siehe ai-backend/plan.php Systemprompt,
|
/// Der KI-Wire-Vertrag verwendet deutsches Datumsformat (siehe ai-backend/plan.php Systemprompt,
|
||||||
@@ -87,16 +94,19 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
private static async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp)
|
private static async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp)
|
||||||
{
|
{
|
||||||
string? backendReason = null;
|
string? backendReason = null;
|
||||||
|
string? rawResponse = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var body = await resp.Content.ReadFromJsonAsync<BackendErrorResult>(JsonOptions);
|
var responseText = await resp.Content.ReadAsStringAsync();
|
||||||
|
var body = JsonSerializer.Deserialize<BackendErrorResult>(responseText, JsonOptions);
|
||||||
backendReason = string.IsNullOrWhiteSpace(body?.Error) ? null : body!.Error;
|
backendReason = string.IsNullOrWhiteSpace(body?.Error) ? null : body!.Error;
|
||||||
|
rawResponse = string.IsNullOrWhiteSpace(body?.RawResponse) ? null : body!.RawResponse;
|
||||||
}
|
}
|
||||||
catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ }
|
catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ }
|
||||||
|
|
||||||
return new AiBackendException(backendReason is null
|
return new AiBackendException(backendReason is null
|
||||||
? "Die Anfrage an den KI-Dienst ist fehlgeschlagen."
|
? "Die Anfrage an den KI-Dienst ist fehlgeschlagen."
|
||||||
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}");
|
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}", rawResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> LoginAsync(string username, string password)
|
public async Task<string> LoginAsync(string username, string password)
|
||||||
@@ -178,6 +188,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
StartTime = l.StartTime,
|
StartTime = l.StartTime,
|
||||||
Homework = l.Homework,
|
Homework = l.Homework,
|
||||||
Reflection = l.Reflection,
|
Reflection = l.Reflection,
|
||||||
|
PlanningIdeas = l.PlanningIdeas,
|
||||||
Phases = ToAiPhases(l.Phases, pathNames),
|
Phases = ToAiPhases(l.Phases, pathNames),
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -260,6 +271,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal))
|
if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal))
|
||||||
diffs.Add("Reflexion geändert");
|
diffs.Add("Reflexion geändert");
|
||||||
|
|
||||||
|
if (!string.Equals(existing.PlanningIdeas, proposed.PlanningIdeas, StringComparison.Ordinal))
|
||||||
|
diffs.Add("Planungsideen geändert");
|
||||||
|
|
||||||
var pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name);
|
var pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name);
|
||||||
var existingPhases = ToAiPhases(existing.Phases, pathNames);
|
var existingPhases = ToAiPhases(existing.Phases, pathNames);
|
||||||
if (!PhasesEqual(existingPhases, proposed.Phases))
|
if (!PhasesEqual(existingPhases, proposed.Phases))
|
||||||
@@ -340,6 +354,12 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
|
|
||||||
HttpResponseMessage resp;
|
HttpResponseMessage resp;
|
||||||
try { resp = await http.SendAsync(req); }
|
try { resp = await http.SendAsync(req); }
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
throw new AiBackendException(
|
||||||
|
"Die KI-Anfrage hat länger als 3½ Minuten gedauert und wurde beendet. " +
|
||||||
|
"Falls das wiederholt passiert, bitte das Zeitlimit des Webserver-Proxys prüfen.");
|
||||||
|
}
|
||||||
catch (HttpRequestException)
|
catch (HttpRequestException)
|
||||||
{
|
{
|
||||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||||
@@ -352,14 +372,64 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
throw await BuildRequestFailedExceptionAsync(resp);
|
throw await BuildRequestFailedExceptionAsync(resp);
|
||||||
|
|
||||||
|
var responseText = await resp.Content.ReadAsStringAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await resp.Content.ReadFromJsonAsync<AiPlanningResponse>(JsonOptions);
|
var result = JsonSerializer.Deserialize<AiPlanningResponse>(responseText, JsonOptions);
|
||||||
return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is not AiBackendException)
|
catch (Exception ex) when (ex is not AiBackendException)
|
||||||
{
|
{
|
||||||
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
|
string? rawModelResponse = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var envelope = JsonDocument.Parse(responseText);
|
||||||
|
if (envelope.RootElement.TryGetProperty("rawResponse", out var rawElement)
|
||||||
|
&& rawElement.ValueKind == JsonValueKind.String)
|
||||||
|
rawModelResponse = rawElement.GetString();
|
||||||
|
}
|
||||||
|
catch (JsonException) { /* Die HTTP-Antwort selbst war ungültig; unten komplett zeigen. */ }
|
||||||
|
|
||||||
|
throw new AiBackendException(
|
||||||
|
"Die Antwort der KI konnte nicht verarbeitet werden. Du kannst die Antwort manuell retten.",
|
||||||
|
string.IsNullOrWhiteSpace(rawModelResponse) ? responseText : rawModelResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liest eine von Hand markierte Modellantwort. Akzeptiert die normale Antwort-Hülle, ein
|
||||||
|
/// einzelnes Lesson-Objekt oder ein Array von Lessons, damit auch nur der relevante Ausschnitt
|
||||||
|
/// der Rohantwort markiert werden kann.
|
||||||
|
/// </summary>
|
||||||
|
public static List<AiLesson> ParsePlanningLessons(string json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
throw new AiBackendException("Bitte markiere zuerst den JSON-Abschnitt der Antwort.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
var root = document.RootElement;
|
||||||
|
List<AiLesson>? parsed = root.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Object when root.TryGetProperty("lessons", out _) =>
|
||||||
|
JsonSerializer.Deserialize<AiPlanningResponse>(json, JsonOptions)?.Lessons,
|
||||||
|
JsonValueKind.Object =>
|
||||||
|
[JsonSerializer.Deserialize<AiLesson>(json, JsonOptions)
|
||||||
|
?? throw new JsonException("Leere Stunde")],
|
||||||
|
JsonValueKind.Array => JsonSerializer.Deserialize<List<AiLesson>>(json, JsonOptions),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parsed is null || parsed.Count == 0)
|
||||||
|
throw new JsonException("Keine Stunde enthalten");
|
||||||
|
if (parsed.Any(l => string.IsNullOrWhiteSpace(l.Topic)))
|
||||||
|
throw new JsonException("Mindestens einer Stunde fehlt das Feld topic");
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
throw new AiBackendException($"Der markierte Text ist noch kein gültiges Stunden-JSON: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,6 +590,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
StartTime = ai.StartTime,
|
StartTime = ai.StartTime,
|
||||||
Homework = ai.Homework,
|
Homework = ai.Homework,
|
||||||
Reflection = ai.Reflection,
|
Reflection = ai.Reflection,
|
||||||
|
PlanningIdeas = ai.PlanningIdeas,
|
||||||
// Status bleibt bei einer Änderung erhalten — sonst würde eine bereits
|
// Status bleibt bei einer Änderung erhalten — sonst würde eine bereits
|
||||||
// durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant"
|
// durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant"
|
||||||
// zurückgesetzt (die KI kennt/liefert diesen Status gar nicht).
|
// zurückgesetzt (die KI kennt/liefert diesen Status gar nicht).
|
||||||
@@ -531,6 +602,10 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
Activity = p.Activity,
|
Activity = p.Activity,
|
||||||
Material = p.Material,
|
Material = p.Material,
|
||||||
Shorthand = p.Shorthand,
|
Shorthand = p.Shorthand,
|
||||||
|
// Persistiert den Prompt (4.5.20/4.5.36), damit er nach dem Übernehmen weiterhin
|
||||||
|
// im Stundeneditor kopierbar bleibt statt nur einmalig im Review-Dialog.
|
||||||
|
MaterialPrompt = string.IsNullOrWhiteSpace(p.MaterialSuggestion)
|
||||||
|
? null : BuildMaterialPrompt(unit, ai, p),
|
||||||
AlternativePathId = p.AlternativePathName is { } name && pathIdsByName.TryGetValue(name, out var pathId)
|
AlternativePathId = p.AlternativePathName is { } name && pathIdsByName.TryGetValue(name, out var pathId)
|
||||||
? pathId : null,
|
? pathId : null,
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
@@ -539,7 +614,64 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fragt für eine Charge anonymisierter Fehlzeitenzeilen (ai-backend/untis-status.php,
|
||||||
|
/// Nutzer-Feedback zum Untis-Hub) einen Statusvorschlag ab - eine Anfrage für alle fraglichen
|
||||||
|
/// Zeilen eines Abgleichslaufs statt einer je Zeile (Kosten/Latenz-Überlegung aus der
|
||||||
|
/// Nutzerdiskussion). Liefert nur dann Vorschläge zurück, wenn die vom Backend gemeldete
|
||||||
|
/// Id-Menge exakt der gesendeten entspricht (keine fehlenden, zusätzlichen oder doppelten Ids) -
|
||||||
|
/// andernfalls eine leere Zuordnung, statt sich auf eine möglicherweise vermischte Reihenfolge
|
||||||
|
/// zu verlassen. Der Aufrufer behält für jede nicht zurückgelieferte Id den bisherigen
|
||||||
|
/// regelbasierten Status bei.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyDictionary<string, string>> RequestUntisStatusSuggestionsAsync(
|
||||||
|
IReadOnlyList<AiUntisStatusRow> rows, string token)
|
||||||
|
{
|
||||||
|
if (rows.Count == 0) return new Dictionary<string, string>();
|
||||||
|
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Post, "untis-status.php")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new AiUntisStatusRequest { Rows = rows.ToList() }, options: JsonOptions),
|
||||||
|
};
|
||||||
|
req.Headers.Authorization = new("Bearer", token);
|
||||||
|
|
||||||
|
HttpResponseMessage resp;
|
||||||
|
try { resp = await http.SendAsync(req); }
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
|
||||||
|
if (resp.StatusCode == (HttpStatusCode)402)
|
||||||
|
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
throw await BuildRequestFailedExceptionAsync(resp);
|
||||||
|
|
||||||
|
AiUntisStatusResponse? result;
|
||||||
|
try { result = await resp.Content.ReadFromJsonAsync<AiUntisStatusResponse>(JsonOptions); }
|
||||||
|
catch (Exception ex) when (ex is not AiBackendException)
|
||||||
|
{
|
||||||
|
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
|
||||||
|
}
|
||||||
|
if (result is null)
|
||||||
|
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||||||
|
|
||||||
|
var sentIds = rows.Select(r => r.Id).ToHashSet();
|
||||||
|
var receivedIds = result.Suggestions.Select(s => s.Id).ToList();
|
||||||
|
if (receivedIds.Count != sentIds.Count || receivedIds.Distinct().Count() != receivedIds.Count
|
||||||
|
|| !sentIds.SetEquals(receivedIds))
|
||||||
|
return new Dictionary<string, string>();
|
||||||
|
|
||||||
|
return result.Suggestions.ToDictionary(s => s.Id, s => s.Status);
|
||||||
|
}
|
||||||
|
|
||||||
private class LoginResult { public string Token { get; set; } = ""; }
|
private class LoginResult { public string Token { get; set; } = ""; }
|
||||||
private class BalanceResult { public decimal BalanceUsd { get; set; } }
|
private class BalanceResult { public decimal BalanceUsd { get; set; } }
|
||||||
private class BackendErrorResult { public string? Error { get; set; } }
|
private class BackendErrorResult
|
||||||
|
{
|
||||||
|
public string? Error { get; set; }
|
||||||
|
public string? RawResponse { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ public sealed class AnnualPlanSyncService : IDisposable
|
|||||||
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval);
|
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task PollAsync()
|
public async Task PollAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!await _gate.WaitAsync(0)) return;
|
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = _settings.GetIcalUrl();
|
var url = _settings.GetIcalUrl();
|
||||||
@@ -47,7 +47,7 @@ public sealed class AnnualPlanSyncService : IDisposable
|
|||||||
string icsText;
|
string icsText;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
icsText = await _http.GetStringAsync(url);
|
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -59,7 +59,7 @@ public sealed class AnnualPlanSyncService : IDisposable
|
|||||||
AnnualPlanPollResult result;
|
AnnualPlanPollResult result;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
result = ProcessIcsText(icsText);
|
result = await Task.Run(() => ProcessIcsText(icsText)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using LehrerApp.Desktop.Views.Students;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
/// <summary>Öffnet den bestehenden Elternbrief-Dialog für einen Schüler, egal ob der Aufruf aus
|
||||||
|
/// der Schülerdetailansicht (Student schon im DataContext) oder aus dem Formulare-Menü (Student
|
||||||
|
/// erst per Picker gewählt) kommt.</summary>
|
||||||
|
public static class LetterDialogs
|
||||||
|
{
|
||||||
|
public static async Task ShowCreateLetterDialogAsync(Window owner, Student student)
|
||||||
|
{
|
||||||
|
var vm = new CreateLetterDialogViewModel(student,
|
||||||
|
App.Services.GetRequiredService<TemplateStore>(),
|
||||||
|
App.Services.GetRequiredService<ITemplateRenderer>(),
|
||||||
|
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||||
|
App.Services.GetRequiredService<IGroupRepository>(),
|
||||||
|
options => App.Services.GetRequiredService<StudentAttendanceCalendarService>().Build(student, options),
|
||||||
|
options => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
|
||||||
|
.BuildAbsenceDayList(student, options),
|
||||||
|
RefreshAttendanceDataAsync);
|
||||||
|
var dialog = new CreateLetterDialog { DataContext = vm };
|
||||||
|
var path = await dialog.ShowDialog<string?>(owner);
|
||||||
|
if (!string.IsNullOrEmpty(path) && File.Exists(path))
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Holt genau den im Anwesenheitskalender-Dialog gewählten Zeitraum gezielt per
|
||||||
|
/// WebUntis nach (ausgelöst durch den expliziten "Konfigurieren…"-Klick, kein Hintergrundabruf
|
||||||
|
/// beim bloßen Öffnen des Briefdialogs). Damit sieht <see cref="StudentAttendanceCalendarService"/>
|
||||||
|
/// anschließend frische Daten im lokalen Cache, statt stillschweigend "keine Fehltage" zu
|
||||||
|
/// melden, nur weil die Klassenlehrer-Übersicht für diesen Zeitraum noch nie geöffnet wurde.</summary>
|
||||||
|
private static async Task RefreshAttendanceDataAsync(AttendanceCalendarOptions options, CancellationToken token)
|
||||||
|
{
|
||||||
|
var className = App.Services.GetRequiredService<WebUntisSettingsService>().HomeroomClassName;
|
||||||
|
if (string.IsNullOrWhiteSpace(className)) return;
|
||||||
|
var cache = App.Services.GetRequiredService<UntisReportCacheService>();
|
||||||
|
var start = options.NormalizedStartMonth;
|
||||||
|
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||||
|
await cache.GetAbsencesAsync(className, start, end, token: token);
|
||||||
|
await cache.GetClassRegisterEventsAsync(className, start, end, token: token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Baut die Standard-Platzhalterwerte für Elternbrief-Vorlagen (Datum, Anrede, Kontaktadresse,
|
||||||
|
/// Schülerdaten, Brieftext, ...). Ursprünglich Teil von
|
||||||
|
/// <see cref="ViewModels.Students.CreateLetterDialogViewModel"/>, hierher extrahiert, damit
|
||||||
|
/// <c>LetterTemplateTools</c> (MCP, siehe Planungsdokument) dieselbe Logik nutzt statt sie zu
|
||||||
|
/// duplizieren — beide Aufrufer müssen exakt dieselben Platzhalternamen befüllen, sonst driften
|
||||||
|
/// Dialog-generierte und KI-generierte Briefe unbemerkt auseinander.
|
||||||
|
/// </summary>
|
||||||
|
public static class LetterPlaceholderBuilder
|
||||||
|
{
|
||||||
|
public static Dictionary<string, PlaceholderValue> BuildStandardValues(
|
||||||
|
Student student, Contact? contact, LearningGroup? group, DateOnly date,
|
||||||
|
string letterText, string teacherName, DrawingValue? attendanceCalendar = null,
|
||||||
|
DrawingValue? absenceDays = null)
|
||||||
|
{
|
||||||
|
var address = FormatAddress(contact);
|
||||||
|
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
|
||||||
|
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||||
|
["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName),
|
||||||
|
["Student.FirstName"] = new TextValue(student.FirstName), ["Student.LastName"] = new TextValue(student.LastName),
|
||||||
|
["Student.Name"] = new TextValue(student.FullName),
|
||||||
|
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
|
||||||
|
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
|
||||||
|
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||||
|
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
|
||||||
|
[StudentAttendanceCalendarDrawingBuilder.PlaceholderName] = attendanceCalendar ?? new DrawingValue([], 0),
|
||||||
|
[StudentAbsenceDayListDrawingBuilder.PlaceholderName] = absenceDays ?? new DrawingValue([], 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mehrzeilige Anschrift (Name/Straße/PLZ Ort) für Adressvorschau im Dialog und
|
||||||
|
/// den <c>Contact.Address</c>-Platzhalter - eine Formatierung für beide Verwendungen.</summary>
|
||||||
|
public static string FormatAddress(Contact? contact)
|
||||||
|
{
|
||||||
|
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
return string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsEmpty(PlaceholderValue value) => value switch
|
||||||
|
{
|
||||||
|
TextValue x => string.IsNullOrWhiteSpace(x.Value),
|
||||||
|
MultilineValue x => string.IsNullOrWhiteSpace(x.Value),
|
||||||
|
DrawingValue x => x.ContentHeight <= 0 || x.Commands.Count == 0,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Produktive <see cref="IMcpConfirmationService"/>-Implementierung: zeigt <see cref="McpConfirmDialog"/>
|
||||||
|
/// über dem Hauptfenster an. Der aufrufende Tool-Handler läuft auf einem Hintergrund-Thread
|
||||||
|
/// (MCP-Pipe-Session in <see cref="McpServerHostedService"/>), deshalb Marshalling über
|
||||||
|
/// <see cref="Dispatcher.UIThread"/>.
|
||||||
|
///
|
||||||
|
/// 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`.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// hängen bleiben — nach <see cref="Timeout"/> wird der Dialog automatisch geschlossen und die
|
||||||
|
/// Änderung als abgelehnt gewertet.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfirmationService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
|
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 allowSessionTrust = true)
|
||||||
|
{
|
||||||
|
if (allowSessionTrust)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Die gesamte Warte-/Timeout-Logik läuft als ein Stück innerhalb des UI-Thread-Callbacks:
|
||||||
|
// 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.
|
||||||
|
var result = await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
if (owner.WindowState == WindowState.Minimized) owner.WindowState = WindowState.Normal;
|
||||||
|
owner.Activate();
|
||||||
|
|
||||||
|
var dialog = new McpConfirmDialog
|
||||||
|
{
|
||||||
|
DataContext = new McpConfirmDialogInfo
|
||||||
|
{
|
||||||
|
Title = title, Message = message, ConfirmText = "Übernehmen",
|
||||||
|
AllowSessionTrust = allowSessionTrust,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var dialogTask = dialog.ShowDialog<McpConfirmDialogResult>(owner);
|
||||||
|
var timeoutTask = Task.Delay(Timeout, ct);
|
||||||
|
var completed = await Task.WhenAny(dialogTask, timeoutTask);
|
||||||
|
if (completed != dialogTask)
|
||||||
|
{
|
||||||
|
dialog.Close(McpConfirmDialogResult.Rejected);
|
||||||
|
return McpConfirmDialogResult.Rejected;
|
||||||
|
}
|
||||||
|
return await dialogTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.Approved && allowSessionTrust)
|
||||||
|
{
|
||||||
|
lock (_trustLock)
|
||||||
|
{
|
||||||
|
if (result.TrustAll) _trustAll = true;
|
||||||
|
else if (result.TrustOperation) _trustedOperations.Add(operationKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.Approved;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zeigt dem Nutzer eine über MCP vorgeschlagene Änderung an, bevor sie tatsächlich geschrieben
|
||||||
|
/// wird — Sicherheitsmodell aus dem Planungsdokument: "Tool-Aufruf erzeugt einen Vorschlag/Diff,
|
||||||
|
/// der im Avalonia-Client als Bestätigungsdialog angezeigt wird, [...] kein 'silent write' durch
|
||||||
|
/// das Modell." Als Interface gehalten, damit Write-Tool-Tests ohne echtes UI laufen können (siehe
|
||||||
|
/// <see cref="AvaloniaMcpConfirmationService"/> für die produktive Implementierung).
|
||||||
|
/// </summary>
|
||||||
|
public interface IMcpConfirmationService
|
||||||
|
{
|
||||||
|
/// <param name="operationKey">Identifiziert die Art der Operation für eine mögliche
|
||||||
|
/// Sitzungsfreigabe ("diese Aktion für den Rest der Sitzung nicht mehr nachfragen") — bewusst
|
||||||
|
/// 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>
|
||||||
|
/// <param name="allowSessionTrust">False für die eine bewusste Ausnahme, bei der eine
|
||||||
|
/// Sitzungsfreigabe nicht angeboten werden soll (Nutzer-Entscheidung zu
|
||||||
|
/// <c>get_named_untis_absence_pattern</c>: eine namentliche Fehlzeitenauskunft muss JEDES MAL
|
||||||
|
/// einzeln bestätigt werden, nie pauschal für die restliche Sitzung) — weder die vorherige
|
||||||
|
/// Prüfung auf eine bereits bestehende Freigabe noch das Setzen einer neuen finden dann statt,
|
||||||
|
/// unabhängig davon, ob zuvor schon "alle Aktionen" freigegeben wurde.</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 = "", bool allowSessionTrust = true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
|
public record McpRegistrationResult(bool Success, string Message);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trägt LehrerApp.McpBridge in die MCP-Server-Konfiguration von Claude Desktop ein (siehe
|
||||||
|
/// Planungsdokument, "Registrierungs-Workflow"). Bewusst nur für Claude Desktop: das ist der einzige
|
||||||
|
/// KI-Client, den die Spec konkret benennt und der eine dokumentierte, stabile Config-Datei-Konvention
|
||||||
|
/// hat (<c>mcpServers</c>-Objekt in <c>claude_desktop_config.json</c>) — andere lokale Clients
|
||||||
|
/// (Ollama, LM Studio, ...) haben keine einheitliche Konvention, die sich hier ohne Rätselraten
|
||||||
|
/// unterstützen ließe.
|
||||||
|
///
|
||||||
|
/// Läuft nur auf explizite Nutzeraktion (Button in den Einstellungen), nie automatisch beim
|
||||||
|
/// App-Start — das Schreiben in die Konfigurationsdatei eines fremden Programms ist ein sichtbarer
|
||||||
|
/// externer Seiteneffekt und gehört nicht in den normalen Startpfad.
|
||||||
|
///
|
||||||
|
/// Setzt eine gepackte Installation voraus (Bridge liegt neben der Hauptapp-Executable, siehe
|
||||||
|
/// build-macos-app.sh) — bei einem lokalen "dotnet build/run" liegt die Bridge in ihrem eigenen
|
||||||
|
/// separaten bin-Ordner, <see cref="ResolveBridgePath"/> findet sie dann nicht.
|
||||||
|
/// </summary>
|
||||||
|
public class McpClientRegistrationService
|
||||||
|
{
|
||||||
|
private const string ServerName = "lehrerapp";
|
||||||
|
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
||||||
|
private readonly string? _bridgePath;
|
||||||
|
|
||||||
|
public McpClientRegistrationService() : this(ResolveConfigPath(), ResolveBridgePath()) { }
|
||||||
|
|
||||||
|
/// <summary>Test-Seam: erlaubt, Konfigurations- und Bridge-Pfad ohne echte
|
||||||
|
/// Sonderordner/Installation vorzugeben (siehe <see cref="ResolveConfigPath"/>/
|
||||||
|
/// <see cref="ResolveBridgePath"/> für das produktive Verhalten).</summary>
|
||||||
|
public McpClientRegistrationService(string? configPath, string? bridgePath)
|
||||||
|
{
|
||||||
|
ConfigPath = configPath;
|
||||||
|
_bridgePath = bridgePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? ConfigPath { get; }
|
||||||
|
|
||||||
|
/// <summary>Ob überhaupt ein Claude-Desktop-Konfigurationsordner existiert — ein Hinweis, ob die
|
||||||
|
/// Anwendung installiert ist, unabhängig davon, ob LehrerApp dort schon eingetragen ist.</summary>
|
||||||
|
public bool IsClaudeDesktopDetected => ConfigPath is not null && File.Exists(ConfigPath);
|
||||||
|
|
||||||
|
public bool IsRegistered() =>
|
||||||
|
TryLoadRoot(out var root, out _) && root["mcpServers"]?[ServerName] is not null;
|
||||||
|
|
||||||
|
public McpRegistrationResult Register()
|
||||||
|
{
|
||||||
|
if (ConfigPath is null)
|
||||||
|
return new(false, "Claude Desktop wird auf diesem Betriebssystem nicht unterstützt.");
|
||||||
|
|
||||||
|
var bridgePath = _bridgePath;
|
||||||
|
if (bridgePath is null || !File.Exists(bridgePath))
|
||||||
|
return new(false,
|
||||||
|
$"Bridge-Programm nicht gefunden ({bridgePath ?? "unbekannter Pfad"}). " +
|
||||||
|
"Diese Funktion setzt eine gepackte Installation voraus, nicht einen lokalen Entwicklungs-Build.");
|
||||||
|
|
||||||
|
if (!TryLoadRoot(out var root, out var error))
|
||||||
|
return new(false, error!);
|
||||||
|
|
||||||
|
if (root["mcpServers"] is not JsonObject servers)
|
||||||
|
{
|
||||||
|
servers = new JsonObject();
|
||||||
|
root["mcpServers"] = servers;
|
||||||
|
}
|
||||||
|
servers[ServerName] = new JsonObject
|
||||||
|
{
|
||||||
|
["command"] = bridgePath,
|
||||||
|
["args"] = new JsonArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
var detectedBefore = IsClaudeDesktopDetected;
|
||||||
|
var result = WriteRoot(root, "In Claude Desktop eingetragen. Claude Desktop neu starten, damit die Änderung wirkt.");
|
||||||
|
if (result.Success && !detectedBefore)
|
||||||
|
return result with
|
||||||
|
{
|
||||||
|
Message = result.Message +
|
||||||
|
" Hinweis: Es wurde keine bestehende Claude-Desktop-Installation erkannt — die Konfiguration " +
|
||||||
|
"greift erst, sobald Claude Desktop installiert ist.",
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public McpRegistrationResult Unregister()
|
||||||
|
{
|
||||||
|
if (ConfigPath is null || !File.Exists(ConfigPath))
|
||||||
|
return new(true, "Nichts einzutragen gefunden.");
|
||||||
|
if (!TryLoadRoot(out var root, out var error))
|
||||||
|
return new(false, error!);
|
||||||
|
if (root["mcpServers"] is not JsonObject servers || !servers.Remove(ServerName))
|
||||||
|
return new(true, "War nicht eingetragen.");
|
||||||
|
return WriteRoot(root, "Eintrag entfernt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryLoadRoot(out JsonObject root, out string? error)
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
if (ConfigPath is null || !File.Exists(ConfigPath)) { root = new JsonObject(); return true; }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
root = JsonNode.Parse(File.ReadAllText(ConfigPath)) as JsonObject ?? new JsonObject();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
// Nicht raten und überschreiben - eine kaputte fremde Konfigurationsdatei bleibt unangetastet,
|
||||||
|
// der Nutzer bekommt stattdessen eine klare Fehlermeldung mit Pfad.
|
||||||
|
root = new JsonObject();
|
||||||
|
error = $"Bestehende Claude-Desktop-Konfiguration ist kein gültiges JSON ({ex.Message}). " +
|
||||||
|
$"Bitte manuell prüfen: {ConfigPath}";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Z.B. gesperrt, weil Claude Desktop selbst gerade schreibt - ebenfalls nicht überschreiben.
|
||||||
|
root = new JsonObject();
|
||||||
|
error = $"Bestehende Claude-Desktop-Konfiguration konnte nicht gelesen werden: {ex.Message}";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpRegistrationResult WriteRoot(JsonObject root, string successMessage)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath!)!);
|
||||||
|
File.WriteAllText(ConfigPath!, root.ToJsonString(WriteOptions));
|
||||||
|
return new(true, successMessage);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return new(false, $"Konfiguration konnte nicht geschrieben werden: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveBridgePath()
|
||||||
|
{
|
||||||
|
var bridgeFile = OperatingSystem.IsWindows() ? "LehrerApp.McpBridge.exe" : "LehrerApp.McpBridge";
|
||||||
|
return Path.Combine(AppContext.BaseDirectory, bridgeFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveConfigPath()
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
return Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"Claude", "claude_desktop_config.json");
|
||||||
|
if (OperatingSystem.IsMacOS())
|
||||||
|
return Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
"Library", "Application Support", "Claude", "claude_desktop_config.json");
|
||||||
|
return null; // Linux: kein offizieller Claude-Desktop-Client bekannt.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
using System.IO.Pipes;
|
||||||
|
using LehrerApp.Core.Mcp;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-Process-MCP-Server (siehe Planungsdokument und TODO.md 4.5.25ff.). Lauscht auf der Named Pipe
|
||||||
|
/// <see cref="McpPipeConstants.PipeName"/> und bedient jede eingehende Verbindung (eine je
|
||||||
|
/// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über <see cref="StreamServerTransport"/> —
|
||||||
|
/// ein <see cref="NamedPipeServerStream"/> ist ein normaler <see cref="Stream"/> und kann direkt
|
||||||
|
/// als Ein-/Ausgabe der Session übergeben werden, ohne eigenes JSON-RPC-Parsing.
|
||||||
|
///
|
||||||
|
/// Nur aktiv, wenn <see cref="McpSettingsService.Enabled"/> — sonst tut <see cref="Start"/> nichts.
|
||||||
|
/// Repositories sind im DI-Container Singletons (siehe AppBootstrapper), deshalb reicht es, die
|
||||||
|
/// Tool-Instanzen und die daraus gebaute <see cref="McpServerOptions"/> einmalig zu bauen und für
|
||||||
|
/// alle Sessions zu teilen.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class McpServerHostedService : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly McpSettingsService _settings;
|
||||||
|
private readonly AppLogger _logger;
|
||||||
|
private readonly McpServerOptions _serverOptions;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private Task? _acceptLoop;
|
||||||
|
|
||||||
|
public McpServerHostedService(
|
||||||
|
McpSettingsService settings, AppLogger logger,
|
||||||
|
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||||
|
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||||
|
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
|
||||||
|
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_logger = logger;
|
||||||
|
_serverOptions = BuildServerOptions(
|
||||||
|
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
|
||||||
|
groupMembershipTools, letterTemplateTools, competencyTools, untisComparisonTools, groupTools);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls
|
||||||
|
/// bereits gestartet oder in den Einstellungen deaktiviert (dann bleibt keine Pipe offen).</summary>
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (!_settings.Enabled || _cts is not null) return;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
_acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
|
||||||
|
_logger.Info("MCP-Server gestartet, lauscht auf Pipe '" + McpPipeConstants.PipeName + "'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AcceptLoopAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var pipe = new NamedPipeServerStream(
|
||||||
|
McpPipeConstants.PipeName, PipeDirection.InOut,
|
||||||
|
NamedPipeServerStream.MaxAllowedServerInstances,
|
||||||
|
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await pipe.WaitForConnectionAsync(ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
await pipe.DisposeAsync();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Error("MCP: Fehler beim Warten auf eine Bridge-Verbindung.", ex);
|
||||||
|
await pipe.DisposeAsync();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nicht awaiten: die Accept-Loop muss sofort weiterlaufen, damit mehrere gleichzeitige
|
||||||
|
// Bridge-Instanzen (mehrere KI-Client-Sitzungen) unabhängig bedient werden.
|
||||||
|
_ = RunSessionAsync(pipe, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunSessionAsync(NamedPipeServerStream pipe, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var transport = new StreamServerTransport(pipe, pipe, "LehrerApp");
|
||||||
|
await using var server = McpServer.Create(transport, _serverOptions, loggerFactory: null, serviceProvider: null);
|
||||||
|
await server.RunAsync(ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.Warn($"MCP: Session beendet ({ex.Message}).");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await pipe.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_cts is null) return;
|
||||||
|
await _cts.CancelAsync();
|
||||||
|
if (_acceptLoop is not null)
|
||||||
|
{
|
||||||
|
try { await _acceptLoop; }
|
||||||
|
catch { /* Beenden über Cancellation ist der Normalfall hier */ }
|
||||||
|
}
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static McpServerOptions BuildServerOptions(
|
||||||
|
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||||
|
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||||
|
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
|
||||||
|
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools)
|
||||||
|
{
|
||||||
|
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||||
|
|
||||||
|
void AddReadTool(Delegate handler, string name, string description)
|
||||||
|
{
|
||||||
|
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Description = description,
|
||||||
|
ReadOnly = true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Description = description,
|
||||||
|
ReadOnly = false,
|
||||||
|
Destructive = false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(groupTools.GetGroups, "get_groups",
|
||||||
|
"Listet Lerngruppen (Klassen/Kurse) mit ihrer Id, optional gefiltert nach Schuljahr.");
|
||||||
|
AddReadTool(examTools.GetExams, "get_exams",
|
||||||
|
"Listet Klausuren, optional gefiltert nach Lerngruppe.");
|
||||||
|
AddReadTool(gradeTools.GetGrades, "get_grades",
|
||||||
|
"Listet Noten einer Lerngruppe, optional gefiltert auf einen Schüler.");
|
||||||
|
AddReadTool(scheduleTools.GetSchedule, "get_schedule",
|
||||||
|
"Listet Stundenplan-Einträge, optional gefiltert nach Lerngruppe.");
|
||||||
|
AddReadTool(timeEntryTools.GetTimeEntries, "get_time_entries",
|
||||||
|
"Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.");
|
||||||
|
AddReadTool(lessonPlanTools.GetLessonPlans, "get_lesson_plans",
|
||||||
|
"Listet Unterrichtseinheiten und -stunden einer Lerngruppe in einem Zeitraum.");
|
||||||
|
AddReadTool(lessonPlanTools.DownloadLessonAttachment, "download_lesson_attachment",
|
||||||
|
"Lädt den Inhalt eines an eine Einzelstunde angehängten Materials Base64-kodiert herunter.");
|
||||||
|
AddReadTool(letterTemplateTools.ListLetterTemplates, "list_letter_templates",
|
||||||
|
"Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern.");
|
||||||
|
AddReadTool(letterTemplateTools.RenderLetter, "render_letter",
|
||||||
|
"Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF.");
|
||||||
|
AddReadTool(competencyTools.GetSubjects, "get_subjects",
|
||||||
|
"Listet alle Fächer.");
|
||||||
|
AddReadTool(competencyTools.GetCompetencyCatalog, "get_competency_catalog",
|
||||||
|
"Listet den Kompetenzkatalog eines Fachs, optional gefiltert auf eine Klassenstufe.");
|
||||||
|
AddReadTool(untisComparisonTools.GetUntisHubStatus, "get_untis_hub_status",
|
||||||
|
"Listet die Fälligkeit der Untis-Hub-Abgleiche, ohne selbst WebUntis anzufragen.");
|
||||||
|
AddReadTool(untisComparisonTools.GetUntisAbsenceRows, "get_untis_absence_rows",
|
||||||
|
"Listet anonymisierte Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis (keine Schülernamen, nur eine row-id je Zeile).");
|
||||||
|
AddReadTool(untisComparisonTools.GetNamedUntisAbsencePattern, "get_named_untis_absence_pattern",
|
||||||
|
"Liefert Fehlzeiten MIT Schülername für explizit angegebene Schüler-IDs - Ausnahme von der sonstigen Anonymisierung, erfordert jedes Mal eine gesonderte Nutzerbestätigung.");
|
||||||
|
|
||||||
|
AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
|
||||||
|
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(gradeTools.CreateGradeEntry, "create_grade_entry",
|
||||||
|
"Schlägt eine neue Note für einen Schüler vor (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(groupMembershipTools.UpdateStudentGroupAssignment, "update_student_group_assignment",
|
||||||
|
"Legt eine Gruppenmitgliedschaft an oder ändert Niveau/Zeitraum (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(lessonPlanTools.CreateUnit, "create_unit",
|
||||||
|
"Legt eine neue Unterrichtseinheit an (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(lessonPlanTools.UpdateUnit, "update_unit",
|
||||||
|
"Ändert Titel/Zeitraum/Status einer Unterrichtseinheit (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(lessonPlanTools.CreateLesson, "create_lesson",
|
||||||
|
"Legt eine neue Einzelstunde ohne Verlaufsplan an (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(lessonPlanTools.UpdateLesson, "update_lesson",
|
||||||
|
"Ändert Metadaten einer Einzelstunde, ohne den Verlaufsplan anzufassen (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(lessonPlanTools.AddLessonPhase, "add_lesson_phase",
|
||||||
|
"Fügt einer Einzelstunde eine Verlaufsplan-Phase hinzu (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(lessonPlanTools.UpdateLessonPhase, "update_lesson_phase",
|
||||||
|
"Ändert eine Verlaufsplan-Phase einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(lessonPlanTools.RemoveLessonPhase, "remove_lesson_phase",
|
||||||
|
"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).");
|
||||||
|
AddWriteTool(lessonPlanTools.AddLessonCompetency, "add_lesson_competency",
|
||||||
|
"Ordnet einer Einzelstunde einen Kompetenzcode zu (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(lessonPlanTools.RemoveLessonCompetency, "remove_lesson_competency",
|
||||||
|
"Entfernt einen Kompetenzcode von einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.CreateSubject, "create_subject",
|
||||||
|
"Legt ein neues Fach an (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.UpdateSubject, "update_subject",
|
||||||
|
"Ändert Name/Kurzform eines Fachs (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.CreateCompetencyDomain, "create_competency_domain",
|
||||||
|
"Legt einen neuen Kompetenzbereich für ein Fach/eine Klassenstufe an (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.UpdateCompetencyDomain, "update_competency_domain",
|
||||||
|
"Ändert Name/Kürzel eines Kompetenzbereichs (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.AddCompetencyItem, "add_competency_item",
|
||||||
|
"Fügt einem Kompetenzbereich eine Einzelkompetenz hinzu (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.UpdateCompetencyItem, "update_competency_item",
|
||||||
|
"Ändert Code/Beschreibung einer Einzelkompetenz (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.RemoveCompetencyItem, "remove_competency_item",
|
||||||
|
"Entfernt eine Einzelkompetenz aus einem Kompetenzbereich (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(untisComparisonTools.ApplyUntisAbsenceStatus, "apply_untis_absence_status",
|
||||||
|
"Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id (Bestätigung durch den Nutzer nötig, nennt keinen Schülernamen).");
|
||||||
|
|
||||||
|
AddDestructiveWriteTool(lessonPlanTools.DeleteLesson, "delete_lesson",
|
||||||
|
"Löscht eine Einzelstunde endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddDestructiveWriteTool(competencyTools.DeleteSubject, "delete_subject",
|
||||||
|
"Löscht ein Fach endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddDestructiveWriteTool(competencyTools.DeleteCompetencyDomain, "delete_competency_domain",
|
||||||
|
"Löscht einen Kompetenzbereich endgültig samt Einzelkompetenzen, 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)
|
||||||
|
.Concat(McpToolScope.AllowedDestructiveWriteTools).OrderBy(n => n)),
|
||||||
|
"Registrierte MCP-Tools weichen von McpToolScope ab.");
|
||||||
|
|
||||||
|
return new McpServerOptions
|
||||||
|
{
|
||||||
|
ServerInfo = new Implementation { Name = "LehrerApp", Version = "1.0.0" },
|
||||||
|
Capabilities = new ServerCapabilities { Tools = new ToolsCapability() },
|
||||||
|
ToolCollection = toolCollection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
namespace LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allowlist der über MCP exponierten Tool-Namen. Dieselbe Absicherung wie
|
||||||
|
/// LehrerApp.Api/PlainEventStore.cs (dort für den Klartext-Sync-Kanal): Gesprächsnotizen, Vorfälle
|
||||||
|
/// und Förderpläne (Documentation/Vorgang) sind hier bewusst nie aufgeführt und werden von keiner
|
||||||
|
/// Tool-Klasse referenziert — ein KI-Client kann diese Daten technisch nicht erreichen, unabhängig
|
||||||
|
/// davon, wie vertrauenswürdig der lokale Modell-Client erscheint oder wie die Tool-Liste künftig
|
||||||
|
/// wächst. <see cref="McpServerHostedService"/> registriert nur exakt diese Namen.
|
||||||
|
///
|
||||||
|
/// "get_named_untis_absence_pattern" ist die eine bewusste, eng begrenzte Ausnahme von der oben
|
||||||
|
/// beschriebenen Regel (Nutzer-Entscheidung, siehe TODO.md): es verknüpft Schülername mit
|
||||||
|
/// Fehlzeitendaten, aber nur für explizit angegebene Schüler-IDs und nur nach jedes Mal gesonderter,
|
||||||
|
/// prominenter Bestätigung ohne Sitzungsfreigabe (siehe UntisComparisonTools). Documentation/
|
||||||
|
/// Vorgang bleibt davon unberührt weiterhin vollständig ausgeschlossen.
|
||||||
|
/// </summary>
|
||||||
|
public static class McpToolScope
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyCollection<string> AllowedReadTools =
|
||||||
|
[
|
||||||
|
"get_students",
|
||||||
|
"get_groups",
|
||||||
|
"get_exams",
|
||||||
|
"get_grades",
|
||||||
|
"get_schedule",
|
||||||
|
"get_time_entries",
|
||||||
|
"get_lesson_plans",
|
||||||
|
"download_lesson_attachment",
|
||||||
|
"list_letter_templates",
|
||||||
|
"render_letter",
|
||||||
|
"get_subjects",
|
||||||
|
"get_competency_catalog",
|
||||||
|
"get_untis_hub_status",
|
||||||
|
"get_untis_absence_rows",
|
||||||
|
"get_named_untis_absence_pattern",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>,
|
||||||
|
/// bevor irgendetwas geschrieben wird (siehe die jeweilige Tool-Klasse). Absichtlich kleinteilig
|
||||||
|
/// bei Unit/Lesson (create/update_unit, create/update_lesson, add/update/remove_lesson_phase)
|
||||||
|
/// statt eines einzelnen "update_lesson" für die ganze Stunde inkl. Verlaufsplan — siehe
|
||||||
|
/// Begründung in LessonPlanTools.</summary>
|
||||||
|
public static readonly IReadOnlyCollection<string> AllowedWriteTools =
|
||||||
|
[
|
||||||
|
"create_time_entry",
|
||||||
|
"create_grade_entry",
|
||||||
|
"update_student_group_assignment",
|
||||||
|
"create_unit",
|
||||||
|
"update_unit",
|
||||||
|
"create_lesson",
|
||||||
|
"update_lesson",
|
||||||
|
"add_lesson_phase",
|
||||||
|
"update_lesson_phase",
|
||||||
|
"remove_lesson_phase",
|
||||||
|
"add_lesson_attachment",
|
||||||
|
"move_lesson",
|
||||||
|
"add_lesson_competency",
|
||||||
|
"remove_lesson_competency",
|
||||||
|
"create_subject",
|
||||||
|
"update_subject",
|
||||||
|
"create_competency_domain",
|
||||||
|
"update_competency_domain",
|
||||||
|
"add_competency_item",
|
||||||
|
"update_competency_item",
|
||||||
|
"remove_competency_item",
|
||||||
|
"apply_untis_absence_status",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Löschende Write-Tools — ursprünglich eine bewusste, gezielte Ausnahme von der sonst
|
||||||
|
/// geltenden "v1 ohne Lösch-Tools"-Regel für "delete_lesson" (siehe Planungsdokument), inzwischen
|
||||||
|
/// (Nutzer-Nachtrag) um die endgültigen Löschungen für Fächer/Kompetenzbereiche erweitert — beide
|
||||||
|
/// ohne Papierkorb, wie delete_lesson. Getrennt von <see cref="AllowedWriteTools"/> aufgeführt,
|
||||||
|
/// damit diese Ausnahme beim Lesen sofort auffällt. <see cref="McpServerHostedService"/>
|
||||||
|
/// registriert diese Tools zusätzlich mit <c>Destructive = true</c>.</summary>
|
||||||
|
public static readonly IReadOnlyCollection<string> AllowedDestructiveWriteTools =
|
||||||
|
[
|
||||||
|
"delete_lesson",
|
||||||
|
"delete_subject",
|
||||||
|
"delete_competency_domain",
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>MCP-Tools für die Fach- und Kompetenzkatalog-Verwaltung (Nutzer-Nachtrag zu 4.5.28ff.):
|
||||||
|
/// <see cref="Subject"/> und <see cref="CompetencyDomain"/>/<see cref="CompetencyItem"/> vollständig
|
||||||
|
/// anlegen/ändern/löschen. Die Zuordnung von Kompetenzcodes zu einzelnen Stunden lebt bewusst in
|
||||||
|
/// <see cref="LessonPlanTools"/> (AddLessonCompetency/RemoveLessonCompetency), nicht hier - sie
|
||||||
|
/// gehört fachlich zur Stundenverwaltung, nicht zum Katalog selbst.
|
||||||
|
///
|
||||||
|
/// "delete_subject" und "delete_competency_domain" sind wie "delete_lesson" (LessonPlanTools)
|
||||||
|
/// endgültige Löschungen ohne Papierkorb (die Repository-Implementierungen löschen direkt in der
|
||||||
|
/// LiteDB-Collection statt über MoveToTrash) - deshalb als Destructive registriert (siehe
|
||||||
|
/// McpServerHostedService) und mit entsprechend deutlicher Bestätigungsnachricht.</summary>
|
||||||
|
public class CompetencyTools(
|
||||||
|
ISubjectRepository subjects, ICompetencyDomainRepository domains, IMcpConfirmationService confirmation)
|
||||||
|
{
|
||||||
|
// ── Lesen ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Description("Listet alle Fächer.")]
|
||||||
|
public List<SubjectDto> GetSubjects() =>
|
||||||
|
subjects.GetAll().Select(s => new SubjectDto(s.Id, s.Name, s.ShortName)).ToList();
|
||||||
|
|
||||||
|
[Description("Listet den Kompetenzkatalog (Bereiche mit Einzelkompetenzen) eines Fachs, optional gefiltert auf eine Klassenstufe.")]
|
||||||
|
public List<CompetencyDomainDto> GetCompetencyCatalog(
|
||||||
|
[Description("Fach-ID, aus get_subjects.")] Guid subjectId,
|
||||||
|
[Description("Optionale Klassenstufe. Weglassen: alle Klassenstufen dieses Fachs.")] int? gradeLevel = null)
|
||||||
|
{
|
||||||
|
var result = gradeLevel is not null
|
||||||
|
? domains.GetBySubjectAndGrade(subjectId, gradeLevel.Value)
|
||||||
|
: domains.GetBySubject(subjectId);
|
||||||
|
return result.Select(ToDto).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fächer (Subject) ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Description("Legt ein neues Fach an. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> CreateSubject(
|
||||||
|
[Description("Name des Fachs, z.B. \"Mathematik\".")] string name,
|
||||||
|
[Description("Kurzform, z.B. \"Ma\".")] string shortName = "",
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Fachname darf nicht leer sein.");
|
||||||
|
|
||||||
|
var message = $"Neues Fach „{name.Trim()}“" +
|
||||||
|
(string.IsNullOrWhiteSpace(shortName) ? "" : $" ({shortName.Trim()})") + " anlegen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Fach anlegen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var subject = new Subject { Name = name, ShortName = shortName };
|
||||||
|
try { subjects.Save(subject); }
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
return new WriteResultDto(false, null, ex.Message);
|
||||||
|
}
|
||||||
|
return new WriteResultDto(true, subject.Id, "Fach gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Ändert Name/Kurzform eines bestehenden Fachs. Nur angegebene Felder werden geändert.")]
|
||||||
|
public async Task<WriteResultDto> UpdateSubject(
|
||||||
|
[Description("Fach-ID.")] Guid subjectId,
|
||||||
|
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
|
||||||
|
[Description("Neue Kurzform. Unverändert lassen: weglassen.")] string? shortName = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var subject = subjects.GetById(subjectId);
|
||||||
|
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
|
||||||
|
|
||||||
|
var changes = new StringBuilder();
|
||||||
|
if (name is not null && name != subject.Name) { changes.AppendLine($"Name: „{subject.Name}“ → „{name}“"); subject.Name = name; }
|
||||||
|
if (shortName is not null && shortName != subject.ShortName) { changes.AppendLine($"Kurzform: „{subject.ShortName}“ → „{shortName}“"); subject.ShortName = shortName; }
|
||||||
|
|
||||||
|
if (changes.Length == 0)
|
||||||
|
return new WriteResultDto(true, subject.Id, "Keine Änderung nötig.");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync("Fach ändern?", $"„{subject.Name}“\n{changes}".TrimEnd(), ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
try { subjects.Save(subject); }
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
return new WriteResultDto(false, null, ex.Message);
|
||||||
|
}
|
||||||
|
return new WriteResultDto(true, subject.Id, "Fach gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Löscht ein Fach endgültig. Schlägt fehl, wenn noch eine Lerngruppe oder ein Kompetenzkatalog dieses Fach referenziert. Kein Papierkorb - nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> DeleteSubject(
|
||||||
|
[Description("Fach-ID.")] Guid subjectId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var subject = subjects.GetById(subjectId);
|
||||||
|
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
|
||||||
|
|
||||||
|
var message = $"Das Fach „{subject.Name}“ wird endgültig gelöscht. Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Fächer).";
|
||||||
|
if (!await confirmation.ConfirmAsync("Fach endgültig löschen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
try { subjects.Delete(subjectId); }
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return new WriteResultDto(false, null, ex.Message);
|
||||||
|
}
|
||||||
|
return new WriteResultDto(true, subjectId, "Fach gelöscht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kompetenzbereiche (CompetencyDomain) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Description("Legt einen neuen Kompetenzbereich (z.B. \"Zahlen und Operationen\") für ein Fach und eine Klassenstufe an, zunächst ohne Einzelkompetenzen. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> CreateCompetencyDomain(
|
||||||
|
[Description("Fach-ID, aus get_subjects.")] Guid subjectId,
|
||||||
|
[Description("Klassenstufe.")] int gradeLevel,
|
||||||
|
[Description("Name des Bereichs.")] string name,
|
||||||
|
[Description("Optionales Kürzel, z.B. \"ZO\".")] string code = "",
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var subject = subjects.GetById(subjectId);
|
||||||
|
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Bereichsname darf nicht leer sein.");
|
||||||
|
|
||||||
|
var sortOrder = domains.GetBySubjectAndGrade(subjectId, gradeLevel).Count;
|
||||||
|
var message = $"Neuen Kompetenzbereich „{name.Trim()}“ für {subject.Name}, Klasse {gradeLevel}, anlegen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenzbereich anlegen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var domain = new CompetencyDomain
|
||||||
|
{
|
||||||
|
SubjectId = subjectId, GradeLevel = gradeLevel, Name = name, Code = code, SortOrder = sortOrder,
|
||||||
|
};
|
||||||
|
domains.Save(domain);
|
||||||
|
return new WriteResultDto(true, domain.Id, "Kompetenzbereich gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Ändert Name/Kürzel eines bestehenden Kompetenzbereichs. Nur angegebene Felder werden geändert.")]
|
||||||
|
public async Task<WriteResultDto> UpdateCompetencyDomain(
|
||||||
|
[Description("ID des Kompetenzbereichs, aus get_competency_catalog.")] Guid domainId,
|
||||||
|
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
|
||||||
|
[Description("Neues Kürzel. Unverändert lassen: weglassen.")] string? code = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var domain = domains.GetById(domainId);
|
||||||
|
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||||
|
|
||||||
|
var changes = new StringBuilder();
|
||||||
|
if (name is not null && name != domain.Name) { changes.AppendLine($"Name: „{domain.Name}“ → „{name}“"); domain.Name = name; }
|
||||||
|
if (code is not null && code != domain.Code) { changes.AppendLine($"Kürzel: „{domain.Code}“ → „{code}“"); domain.Code = code; }
|
||||||
|
|
||||||
|
if (changes.Length == 0)
|
||||||
|
return new WriteResultDto(true, domain.Id, "Keine Änderung nötig.");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenzbereich ändern?", $"„{domain.Name}“\n{changes}".TrimEnd(), ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
domains.Save(domain);
|
||||||
|
return new WriteResultDto(true, domain.Id, "Kompetenzbereich gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Löscht einen Kompetenzbereich endgültig, inklusive aller darin enthaltenen Einzelkompetenzen. Bereits vergebene Kompetenzcodes an Einheiten/Stunden/Klausuraufgaben bleiben als Freitext bestehen, gelten aber danach als \"nicht im Katalog\". Kein Papierkorb - nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> DeleteCompetencyDomain(
|
||||||
|
[Description("ID des Kompetenzbereichs.")] Guid domainId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var domain = domains.GetById(domainId);
|
||||||
|
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||||
|
|
||||||
|
var message = $"Der Kompetenzbereich „{domain.Name}“ mit {domain.Items.Count} Einzelkompetenz(en) wird endgültig gelöscht. " +
|
||||||
|
"Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Kompetenzbereiche).";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenzbereich endgültig löschen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
domains.Delete(domainId);
|
||||||
|
return new WriteResultDto(true, domainId, "Kompetenzbereich gelöscht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Einzelkompetenzen (CompetencyItem) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Description("Fügt einem Kompetenzbereich eine neue Einzelkompetenz hinzu (ans Ende). Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> AddCompetencyItem(
|
||||||
|
[Description("ID des Kompetenzbereichs, aus get_competency_catalog.")] Guid domainId,
|
||||||
|
[Description("Kompetenzcode, z.B. \"M.5.1\".")] string code,
|
||||||
|
[Description("Beschreibung der Kompetenz.")] string description,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var domain = domains.GetById(domainId);
|
||||||
|
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||||
|
if (string.IsNullOrWhiteSpace(description)) return new WriteResultDto(false, null, "Beschreibung darf nicht leer sein.");
|
||||||
|
|
||||||
|
var message = $"Neue Kompetenz „{code}“ zu Bereich „{domain.Name}“ hinzufügen?\n{description}";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenz hinzufügen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var item = new CompetencyItem { Code = code.Trim(), Description = description.Trim(), SortOrder = domain.Items.Count };
|
||||||
|
domain.Items.Add(item);
|
||||||
|
domains.Save(domain);
|
||||||
|
return new WriteResultDto(true, item.Id, "Kompetenz hinzugefügt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Ändert Code/Beschreibung einer bestehenden Einzelkompetenz. Nur angegebene Felder werden geändert.")]
|
||||||
|
public async Task<WriteResultDto> UpdateCompetencyItem(
|
||||||
|
[Description("ID des Kompetenzbereichs.")] Guid domainId,
|
||||||
|
[Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId,
|
||||||
|
[Description("Neuer Kompetenzcode. Unverändert lassen: weglassen.")] string? code = null,
|
||||||
|
[Description("Neue Beschreibung. Unverändert lassen: weglassen.")] string? description = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var domain = domains.GetById(domainId);
|
||||||
|
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||||
|
var item = domain.Items.FirstOrDefault(i => i.Id == itemId);
|
||||||
|
if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID in diesem Bereich.");
|
||||||
|
|
||||||
|
var changes = new StringBuilder();
|
||||||
|
if (code is not null && code != item.Code) { changes.AppendLine($"Code: „{item.Code}“ → „{code}“"); item.Code = code; }
|
||||||
|
if (description is not null && description != item.Description) { changes.AppendLine($"Beschreibung: „{item.Description}“ → „{description}“"); item.Description = description; }
|
||||||
|
|
||||||
|
if (changes.Length == 0)
|
||||||
|
return new WriteResultDto(true, item.Id, "Keine Änderung nötig.");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenz ändern?", $"Kompetenz „{item.Code}“ in Bereich „{domain.Name}“\n{changes}".TrimEnd(), ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
domains.Save(domain);
|
||||||
|
return new WriteResultDto(true, item.Id, "Kompetenz gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Entfernt eine Einzelkompetenz aus einem Kompetenzbereich. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> RemoveCompetencyItem(
|
||||||
|
[Description("ID des Kompetenzbereichs.")] Guid domainId,
|
||||||
|
[Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var domain = domains.GetById(domainId);
|
||||||
|
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||||
|
var item = domain.Items.FirstOrDefault(i => i.Id == itemId);
|
||||||
|
if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID in diesem Bereich.");
|
||||||
|
|
||||||
|
var message = $"Kompetenz „{item.Code}“ ({item.Description}) aus Bereich „{domain.Name}“ entfernen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenz entfernen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
domain.Items.Remove(item);
|
||||||
|
domains.Save(domain);
|
||||||
|
return new WriteResultDto(true, item.Id, "Kompetenz entfernt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CompetencyDomainDto ToDto(CompetencyDomain d) => new(
|
||||||
|
d.Id, d.SubjectId, d.GradeLevel, d.Name, d.Code, d.SortOrder,
|
||||||
|
d.Items.OrderBy(i => i.SortOrder)
|
||||||
|
.Select(i => new CompetencyItemDto(i.Id, i.Code, i.Description, i.SortOrder)).ToList());
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
// Schlanke, bewusst nicht 1:1 zu den LiteDB-Entities gehaltene Rückgabetypen: verhindert, dass ein
|
||||||
|
// später zum Modell hinzugefügtes Feld (z.B. ein neues personenbezogenes Attribut) unbeabsichtigt
|
||||||
|
// über ein MCP-Tool nach außen dringt, nur weil es Teil der Entity-Klasse ist.
|
||||||
|
|
||||||
|
public record StudentDto(Guid Id, string FirstName, string LastName, bool IsActive);
|
||||||
|
|
||||||
|
public record ExamResultDto(Guid StudentId, double TotalPoints, string? Grade, bool Absent);
|
||||||
|
|
||||||
|
public record ExamDto(
|
||||||
|
Guid Id, Guid GroupId, string Title, DateOnly Date, ExamStatus Status, Niveau? Niveau,
|
||||||
|
List<ExamResultDto>? Results);
|
||||||
|
|
||||||
|
public record GradeDto(
|
||||||
|
Guid Id, Guid StudentId, Guid GroupId, GradeCategory Category, string Value, DateOnly Date,
|
||||||
|
double Weight, string? Note);
|
||||||
|
|
||||||
|
public record TimetableSlotDto(Guid Id, Guid GroupId, DayOfWeek Weekday, int PeriodNumber, string? Room);
|
||||||
|
|
||||||
|
public record TimeEntryDto(
|
||||||
|
Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date,
|
||||||
|
TimeOnly? StartTime, TimeOnly? EndTime, int DurationMinutes, string? Description);
|
||||||
|
|
||||||
|
/// <summary><see cref="MaterialPrompt"/> ist der beim letzten KI-"Übernehmen" gespeicherte
|
||||||
|
/// Materialerstellungs-Prompt (4.5.20/4.5.36) — nur gesetzt, wenn die KI für diese Phase einen
|
||||||
|
/// Medienvorschlag gemacht hatte. Ein MCP-Client kann ihn direkt zur Materialerzeugung nutzen, ohne
|
||||||
|
/// erst den Desktop-Dialog öffnen zu müssen.</summary>
|
||||||
|
public record LessonPhaseDto(
|
||||||
|
Guid Id, string Name, int DurationMinutes, string Activity, string Material, string Shorthand,
|
||||||
|
string? MaterialPrompt);
|
||||||
|
|
||||||
|
public record LessonAttachmentDto(string StorageId, string FileName, long SizeBytes);
|
||||||
|
|
||||||
|
public record LessonDto(
|
||||||
|
Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic,
|
||||||
|
string? Homework, string? PlanningIdeas, LessonStatus Status, List<string> Competencies,
|
||||||
|
List<LessonPhaseDto> Phases, List<LessonAttachmentDto> Attachments);
|
||||||
|
|
||||||
|
/// <summary>Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten
|
||||||
|
/// als JSON/Text übertragen werden. Bewusst kein Ressourcen-URI-Mechanismus (siehe Planungsdokument) -
|
||||||
|
/// dafür müsste der Server MCP-Resources anbieten, was über den Rahmen dieses Tools hinausgeht;
|
||||||
|
/// stattdessen deckelt <see cref="LessonPlanTools.MaxInlineAttachmentBytes"/> die Größe.</summary>
|
||||||
|
public record AttachmentContentDto(string FileName, long SizeBytes, string Base64Content);
|
||||||
|
|
||||||
|
public record UnitDto(
|
||||||
|
Guid Id, Guid GroupId, string Title, DateOnly? StartDate, DateOnly? EndDate,
|
||||||
|
UnitStatus Status, List<string> Competencies);
|
||||||
|
|
||||||
|
public record LessonPlanResultDto(List<UnitDto> Units, List<LessonDto> Lessons);
|
||||||
|
|
||||||
|
public record SubjectDto(Guid Id, string Name, string ShortName);
|
||||||
|
|
||||||
|
public record CompetencyItemDto(Guid Id, string Code, string Description, int SortOrder);
|
||||||
|
|
||||||
|
public record CompetencyDomainDto(
|
||||||
|
Guid Id, Guid SubjectId, int GradeLevel, string Name, string Code, int SortOrder,
|
||||||
|
List<CompetencyItemDto> Items);
|
||||||
|
|
||||||
|
public record GroupMembershipDto(
|
||||||
|
Guid Id, Guid StudentId, Guid GroupId, MembershipPeriod Period,
|
||||||
|
DateOnly? JoinedAt, DateOnly? LeftAt, Niveau? Niveau);
|
||||||
|
|
||||||
|
/// <summary>Ergebnis eines Write-Tools (Phase 2, siehe Planungsdokument): <see cref="Applied"/> ist
|
||||||
|
/// nur dann true, wenn der Nutzer die Änderung im Bestätigungsdialog angenommen hat.</summary>
|
||||||
|
public record WriteResultDto(bool Applied, Guid? Id, string Message);
|
||||||
|
|
||||||
|
public record PlaceholderInfoDto(string Name, string Type, bool Required, bool IsConstant);
|
||||||
|
|
||||||
|
/// <summary>"Worksheets" aus der ursprünglichen Spec entsprechen im tatsächlichen Datenmodell den
|
||||||
|
/// importierten Elternbrief-Vorlagen (<c>.lavorlage</c>, <c>LehrerApp.Templating</c>) — es gibt kein
|
||||||
|
/// separates "Arbeitsblatt"-Konzept mit Fach/Klassenstufe-Metadaten. Siehe LetterTemplateTools.</summary>
|
||||||
|
public record LetterTemplateDto(string Id, string Name, string Description, List<PlaceholderInfoDto> Placeholders);
|
||||||
|
|
||||||
|
public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName);
|
||||||
|
|
||||||
|
/// <summary>Eine Zeile des Untis-Hub (siehe UntisHubService) - "Kind"/"DueState" als Text statt
|
||||||
|
/// Enum-Wert, damit ein KI-Client sie ohne Kenntnis des internen Enums lesen kann. GroupId ist bei
|
||||||
|
/// den drei dashboard-weiten Zeilen (offene Stunden, Klassenbuch-/Hausaufgabenabgleich) null - siehe
|
||||||
|
/// UntisHubJobRow. Enthalten, damit ein KI-Client die Id nicht erst über get_groups nachschlagen
|
||||||
|
/// muss, um get_untis_absence_rows/get_named_untis_absence_pattern für eine hier gelistete Gruppe
|
||||||
|
/// aufzurufen (Nutzer-Feedback: die GroupId war nirgends exponiert).</summary>
|
||||||
|
public record UntisHubStatusRowDto(
|
||||||
|
string Kind, Guid? GroupId, string GroupName, string DueState, string DueLabel,
|
||||||
|
DateTime? LastRunAt, string? LastResultSummary);
|
||||||
|
|
||||||
|
/// <summary>Lerngruppe (Klasse oder Kurs) - siehe GroupTools.GetGroups. Bewusst kein Verweis auf
|
||||||
|
/// SubjectId->Name aufgelöst (dafür get_subjects), um nicht bei jeder Gruppe implizit einen ganzen
|
||||||
|
/// Fach-Datensatz mitzuschleppen.</summary>
|
||||||
|
public record GroupDto(
|
||||||
|
Guid Id, string Name, GroupType Type, string SchoolYear, int GradeLevel,
|
||||||
|
Guid? SubjectId, bool IsActive);
|
||||||
|
|
||||||
|
/// <summary>Anonymisierte Fehlzeiten-Diskrepanz (siehe UntisComparisonTools.GetUntisAbsenceRows):
|
||||||
|
/// bewusst KEIN Schülername/keine Klasse - <see cref="RowId"/> ist die einzige Kennung, über die
|
||||||
|
/// UntisComparisonTools.ApplyUntisAbsenceStatus später zurückordnet.</summary>
|
||||||
|
public record UntisAbsenceRowDto(
|
||||||
|
string RowId, DateOnly Date, string ReasonText, int AbsentMinutes, bool HandledOn,
|
||||||
|
bool? ExternKeyInParentheses, string CurrentLocalStatus, string CurrentGuessStatus);
|
||||||
|
|
||||||
|
/// <summary>Eine Zeile aus dem namentlichen Ausnahmeweg (get_named_untis_absence_pattern) - im
|
||||||
|
/// Unterschied zu <see cref="UntisAbsenceRowDto"/> bewusst MIT Schülername, da genau diese
|
||||||
|
/// Zusammenführung von Name und Fehlzeitendaten der Zweck des Aufrufs ist (z.B. Fehlmuster-Vergleich
|
||||||
|
/// zwischen zwei Schülern) und der Nutzer sie je Anfrage einzeln freigegeben hat.</summary>
|
||||||
|
public record NamedUntisAbsenceRowDto(
|
||||||
|
Guid StudentId, string StudentFullName, DateOnly Date, string ReasonText,
|
||||||
|
string CurrentLocalStatus, string CurrentGuessStatus);
|
||||||
|
|
||||||
|
/// <summary><see cref="Granted"/> ist false bei Ablehnung/Timeout oder wenn keine der angegebenen
|
||||||
|
/// Schüler-IDs bekannt war - <see cref="Rows"/> ist dann null, nicht nur leer, damit ein KI-Client
|
||||||
|
/// "abgelehnt" nicht mit "keine Fehlzeiten gefunden" verwechselt.</summary>
|
||||||
|
public record NamedUntisAbsenceResultDto(bool Granted, string Message, List<NamedUntisAbsenceRowDto>? Rows);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>MCP-Read-Tool "get_exams" (Phase 1, siehe Planungsdokument).</summary>
|
||||||
|
public class ExamTools(IExamRepository exams, IExamResultRepository examResults)
|
||||||
|
{
|
||||||
|
[Description("Listet Klausuren, optional gefiltert nach Lerngruppe.")]
|
||||||
|
public List<ExamDto> GetExams(
|
||||||
|
[Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null,
|
||||||
|
[Description("Ergebnisse je Schüler mitliefern (Standard: nein, hält die Antwort klein).")] bool includeResults = false)
|
||||||
|
{
|
||||||
|
var list = groupId is { } id ? exams.GetByGroup(id) : exams.GetAll();
|
||||||
|
return list.Select(e => new ExamDto(
|
||||||
|
e.Id, e.GroupId, e.Title, e.Date, e.Status, e.Niveau,
|
||||||
|
includeResults
|
||||||
|
? examResults.GetByExam(e.Id)
|
||||||
|
.Select(r => new ExamResultDto(r.StudentId, r.TotalPoints, r.Grade, r.Absent))
|
||||||
|
.ToList()
|
||||||
|
: null)).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>MCP-Tools "get_grades" (Phase 1) und "create_grade_entry" (Phase 2), siehe
|
||||||
|
/// Planungsdokument.</summary>
|
||||||
|
public class GradeTools(IGradeRepository grades, IStudentRepository students, IMcpConfirmationService confirmation)
|
||||||
|
{
|
||||||
|
[Description("Listet Noten einer Lerngruppe, optional gefiltert auf einen einzelnen Schüler.")]
|
||||||
|
public List<GradeDto> GetGrades(
|
||||||
|
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||||
|
[Description("Optionale Schüler-ID zum Filtern auf einen einzelnen Schüler.")] Guid? studentId = null)
|
||||||
|
{
|
||||||
|
var list = studentId is { } sid ? grades.GetByStudentAndGroup(sid, groupId) : grades.GetByGroup(groupId);
|
||||||
|
return list.Select(g => new GradeDto(
|
||||||
|
g.Id, g.StudentId, g.GroupId, g.Category, g.Value, g.Date, g.Weight, g.Note)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Schlägt eine neue Note für einen Schüler vor. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen, bevor sie gespeichert wird.")]
|
||||||
|
public async Task<WriteResultDto> CreateGradeEntry(
|
||||||
|
[Description("Schüler-ID.")] Guid studentId,
|
||||||
|
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||||
|
[Description("Kategorie: Oral, Homework, Participation, Project oder Other.")] GradeCategory category,
|
||||||
|
[Description("Notenwert als Text, z.B. \"2+\" oder \"gut\".")] string value,
|
||||||
|
[Description("Datum, Format YYYY-MM-DD.")] DateOnly date,
|
||||||
|
[Description("Gewichtung, Standard 1.0.")] double weight = 1.0,
|
||||||
|
[Description("Optionale Notiz.")] string? note = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var student = students.GetById(studentId);
|
||||||
|
if (student is null)
|
||||||
|
return new WriteResultDto(false, null, "Unbekannte Schüler-ID.");
|
||||||
|
|
||||||
|
var message =
|
||||||
|
$"Neue Note für {student.FullName}: {GradeCategoryDisplayName(category)} = {value}" +
|
||||||
|
(weight != 1.0 ? $" (Gewichtung {weight:0.##})" : "") +
|
||||||
|
$", am {date:dd.MM.yyyy}" +
|
||||||
|
(string.IsNullOrWhiteSpace(note) ? "" : $"\n„{note}“");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync("Note anlegen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var grade = new Grade
|
||||||
|
{
|
||||||
|
StudentId = studentId,
|
||||||
|
GroupId = groupId,
|
||||||
|
Category = category,
|
||||||
|
Value = value,
|
||||||
|
Date = date,
|
||||||
|
Weight = weight,
|
||||||
|
Note = note,
|
||||||
|
};
|
||||||
|
grades.Save(grade);
|
||||||
|
return new WriteResultDto(true, grade.Id, "Note gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eigene, schlanke Beschriftung statt Wiederverwendung von ViewModels.Groups.GradeCategoryDisplay:
|
||||||
|
// Tool-Klassen unter Services/Mcp sollen nicht von ViewModel-Klassen abhängen.
|
||||||
|
private static string GradeCategoryDisplayName(GradeCategory c) => c switch
|
||||||
|
{
|
||||||
|
GradeCategory.Oral => "Mündlich",
|
||||||
|
GradeCategory.Homework => "Hausaufgaben",
|
||||||
|
GradeCategory.Participation => "Mitarbeit",
|
||||||
|
GradeCategory.Project => "Projekt",
|
||||||
|
GradeCategory.Other => "Sonstiges",
|
||||||
|
_ => c.ToString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>MCP-Write-Tool "update_student_group_assignment" (Phase 2, siehe Planungsdokument).
|
||||||
|
/// Legt eine <see cref="GroupMembership"/> an, falls noch keine für Schüler+Gruppe existiert, sonst
|
||||||
|
/// werden nur die übergebenen (nicht-null) Felder überschrieben.</summary>
|
||||||
|
public class GroupMembershipTools(
|
||||||
|
IGroupMembershipRepository memberships, IStudentRepository students, IGroupRepository groups,
|
||||||
|
IMcpConfirmationService confirmation)
|
||||||
|
{
|
||||||
|
[Description("Legt eine Gruppenmitgliedschaft eines Schülers an oder ändert Niveau/Zeitraum einer bestehenden. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> UpdateStudentGroupAssignment(
|
||||||
|
[Description("Schüler-ID.")] Guid studentId,
|
||||||
|
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||||
|
[Description("Niveau: E, G oder Foerder. Unverändert lassen: weglassen.")] Niveau? niveau = null,
|
||||||
|
[Description("Zeitraum: FullYear, H1Only, H2Only oder Custom. Unverändert lassen: weglassen.")] MembershipPeriod? period = null,
|
||||||
|
[Description("Beitrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? joinedAt = null,
|
||||||
|
[Description("Austrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? leftAt = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var student = students.GetById(studentId);
|
||||||
|
var group = groups.GetById(groupId);
|
||||||
|
if (student is null || group is null)
|
||||||
|
return new WriteResultDto(false, null, "Unbekannte Schüler- oder Gruppen-ID.");
|
||||||
|
|
||||||
|
var existing = memberships.GetByStudentAndGroup(studentId, groupId);
|
||||||
|
var target = existing is null
|
||||||
|
? new GroupMembership { StudentId = studentId, GroupId = groupId }
|
||||||
|
: Clone(existing);
|
||||||
|
|
||||||
|
var changes = new StringBuilder();
|
||||||
|
if (niveau is not null && niveau != target.Niveau) { changes.AppendLine($"Niveau: {NiveauName(target.Niveau)} → {NiveauName(niveau)}"); target.Niveau = niveau; }
|
||||||
|
if (period is not null && period != target.Period) { changes.AppendLine($"Zeitraum: {target.Period} → {period}"); target.Period = period.Value; }
|
||||||
|
if (joinedAt is not null && joinedAt != target.JoinedAt) { changes.AppendLine($"Beitritt: {target.JoinedAt:dd.MM.yyyy} → {joinedAt:dd.MM.yyyy}"); target.JoinedAt = joinedAt; }
|
||||||
|
if (leftAt is not null && leftAt != target.LeftAt) { changes.AppendLine($"Austritt: {target.LeftAt:dd.MM.yyyy} → {leftAt:dd.MM.yyyy}"); target.LeftAt = leftAt; }
|
||||||
|
|
||||||
|
if (existing is not null && changes.Length == 0)
|
||||||
|
return new WriteResultDto(true, existing.Id, "Keine Änderung nötig, Mitgliedschaft besteht bereits unverändert.");
|
||||||
|
|
||||||
|
var title = existing is null ? "Gruppenmitgliedschaft anlegen?" : "Gruppenmitgliedschaft ändern?";
|
||||||
|
var message = $"{student.FullName} — {group.Name}" +
|
||||||
|
(existing is null ? "\nNeue Mitgliedschaft anlegen." : "") +
|
||||||
|
(changes.Length > 0 ? "\n" + changes.ToString().TrimEnd() : "");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync(title, message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
memberships.Save(target);
|
||||||
|
return new WriteResultDto(true, target.Id, "Gruppenmitgliedschaft gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GroupMembership Clone(GroupMembership m) => new()
|
||||||
|
{
|
||||||
|
Id = m.Id, StudentId = m.StudentId, GroupId = m.GroupId, AddedOn = m.AddedOn,
|
||||||
|
Period = m.Period, JoinedAt = m.JoinedAt, LeftAt = m.LeftAt, Niveau = m.Niveau,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string NiveauName(Niveau? n) => n switch
|
||||||
|
{
|
||||||
|
Core.Models.Niveau.E => "E-Niveau",
|
||||||
|
Core.Models.Niveau.G => "G-Niveau",
|
||||||
|
Core.Models.Niveau.Foerder => "Förderniveau",
|
||||||
|
_ => "–",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>MCP-Read-Tool "get_groups" (Nutzer-Nachtrag: die GroupId, die praktisch jedes andere
|
||||||
|
/// Tool als Pflichtparameter verlangt - get_grades, get_schedule, get_lesson_plans,
|
||||||
|
/// get_untis_absence_rows usw. - war nirgends über MCP auflösbar; ein KI-Client kannte bestenfalls
|
||||||
|
/// den Klarnamen einer Lerngruppe aus dem Gespräch, nie ihre Id). Reiner Lesezugriff auf das
|
||||||
|
/// bestehende Repository, keine eigene Datenzugriffslogik.</summary>
|
||||||
|
public class GroupTools(IGroupRepository groups)
|
||||||
|
{
|
||||||
|
[Description("Listet Lerngruppen (Klassen/Kurse) mit ihrer Id - Voraussetzung, um andere Tools (z.B. get_grades, get_schedule, get_untis_absence_rows) für eine bestimmte Gruppe aufzurufen, wenn nur ihr Name bekannt ist. Ohne schoolYear werden alle Schuljahre zurückgegeben.")]
|
||||||
|
public List<GroupDto> GetGroups(
|
||||||
|
[Description("Optionales Schuljahr zum Filtern, Format \"2025/26\". Ohne Angabe alle Schuljahre.")] string? schoolYear = null,
|
||||||
|
[Description("Auch inaktive/archivierte Gruppen einbeziehen.")] bool includeInactive = false)
|
||||||
|
{
|
||||||
|
var list = schoolYear is null
|
||||||
|
? groups.GetAll(includeInactive)
|
||||||
|
: groups.GetBySchoolYear(schoolYear, includeInactive);
|
||||||
|
return list.Select(g => new GroupDto(g.Id, g.Name, g.Type, g.SchoolYear, g.GradeLevel, g.SubjectId, g.IsActive)).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>MCP-Tools rund um Unterrichtseinheiten (<see cref="Unit"/>) und Einzelstunden
|
||||||
|
/// (<see cref="Lesson"/>) — 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: <see cref="Lesson.Phases"/> 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.</summary>
|
||||||
|
public class LessonPlanTools(
|
||||||
|
IUnitRepository units, ILessonRepository lessons, IGroupRepository groups,
|
||||||
|
IAttachmentStorage attachments, IMcpConfirmationService confirmation)
|
||||||
|
{
|
||||||
|
/// <summary>Deckelt die Antwortgröße von "download_lesson_attachment" (Base64 bläht ca. um
|
||||||
|
/// Faktor 1,33 auf). Kleiner als <see cref="IAttachmentStorage.MaxSizeBytes"/> (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".</summary>
|
||||||
|
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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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());
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>MCP-Tools "list_letter_templates"/"render_letter" (siehe Planungsdokument, Abschnitt
|
||||||
|
/// "Worksheets"). Die ursprüngliche Spec sah "Arbeitsblätter" mit Fach-/Klassenstufe-Metadaten vor —
|
||||||
|
/// das existiert im Datenmodell nicht. Was tatsächlich existiert: importierte
|
||||||
|
/// <c>.lavorlage</c>-Elternbrief-Vorlagen (<see cref="TemplateStore"/>), an Schüler+Kontakt
|
||||||
|
/// gebunden, exakt wie im bestehenden "Elternbrief"-Dialog
|
||||||
|
/// (<c>CreateLetterDialogViewModel</c>). Bewusst NICHT umgesetzt: "upload_worksheet"/
|
||||||
|
/// "update_worksheet" im Sinne von "eine neue Vorlage per KI entwerfen" — das Seitenlayout ist eine
|
||||||
|
/// eigene, positionsbasierte DSL (siehe LehrerApp.Templating/LayoutParser.cs), für die es absichtlich
|
||||||
|
/// einen eigenen visuellen Editor (LehrerApp.TemplateDesigner) gibt; ein LLM würde diese DSL blind
|
||||||
|
/// erzeugen müssen, mit hohem Risiko für unbrauchbare/kaputte Layouts. "render_letter" deckt den
|
||||||
|
/// tatsächlich nützlichen Fall ab: eine bestehende, vom Nutzer gestaltete Vorlage mit Werten füllen.</summary>
|
||||||
|
public class LetterTemplateTools(
|
||||||
|
TemplateStore templates, ITemplateLoader loader, ITemplateRenderer renderer,
|
||||||
|
IStudentRepository students, IGroupRepository groups,
|
||||||
|
StudentAttendanceCalendarService? attendanceCalendars = null)
|
||||||
|
{
|
||||||
|
[Description("Listet importierte Elternbrief-Vorlagen mit ihren deklarierten Platzhaltern (Name, Typ, Pflichtfeld, konstant).")]
|
||||||
|
public List<LetterTemplateDto> ListLetterTemplates()
|
||||||
|
{
|
||||||
|
var result = new List<LetterTemplateDto>();
|
||||||
|
foreach (var installed in templates.GetTemplates())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loaded = templates.Load(installed);
|
||||||
|
result.Add(new LetterTemplateDto(installed.Id, installed.Name, installed.Description,
|
||||||
|
loaded.Manifest.Placeholders
|
||||||
|
.Select(p => new PlaceholderInfoDto(p.Name, p.Type.ToString(), p.Required, p.IsConstant))
|
||||||
|
.ToList()));
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||||
|
{
|
||||||
|
// Beschädigtes/ungültiges Paket - wie TemplateStore.GetTemplates() selbst überspringt
|
||||||
|
// auch diese Auflistung es kommentarlos, statt die ganze Liste scheitern zu lassen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Erzeugt einen Elternbrief aus einer importierten Vorlage für einen Schüler und liefert ihn als Base64-PDF. Reiner Lesezugriff (kein Datenbank-Schreibzugriff), deshalb ohne Bestätigungsdialog.")]
|
||||||
|
public LetterRenderResultDto RenderLetter(
|
||||||
|
[Description("Vorlagen-ID, aus list_letter_templates.")] string templateId,
|
||||||
|
[Description("Schüler-ID.")] Guid studentId,
|
||||||
|
[Description("Brieftext (Fließtext).")] string letterText,
|
||||||
|
[Description("Name der unterschreibenden Lehrkraft.")] string teacherName,
|
||||||
|
[Description("Optionale Kontakt-ID des Schülers. Ohne Angabe wird der erste gültige Kontakt verwendet.")] Guid? contactId = null,
|
||||||
|
[Description("Optionale Lerngruppen-ID für Group.Name/SchoolYear-Platzhalter.")] Guid? groupId = null,
|
||||||
|
[Description("Briefdatum, Format YYYY-MM-DD. Standard: heute.")] DateOnly? letterDate = null,
|
||||||
|
[Description("Zusätzliche, von der Vorlage deklarierte Platzhalterwerte über die Standardfelder hinaus (Name -> Text/Zahl/Datum als Text).")]
|
||||||
|
Dictionary<string, string>? extraValues = null)
|
||||||
|
{
|
||||||
|
var student = students.GetById(studentId);
|
||||||
|
if (student is null) return new LetterRenderResultDto(false, "Unbekannte Schüler-ID.", null, null);
|
||||||
|
|
||||||
|
var installed = templates.GetTemplates().FirstOrDefault(t => t.Id == templateId);
|
||||||
|
if (installed is null) return new LetterRenderResultDto(false, "Unbekannte Vorlagen-ID.", null, null);
|
||||||
|
|
||||||
|
LoadedTemplate loaded;
|
||||||
|
try { loaded = templates.Load(installed); }
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||||
|
{ return new LetterRenderResultDto(false, $"Vorlage ist ungültig: {ex.Message}", null, null); }
|
||||||
|
|
||||||
|
var contact = contactId is { } cid
|
||||||
|
? student.Contacts.FirstOrDefault(c => c.Id == cid)
|
||||||
|
: student.Contacts.FirstOrDefault(c => !c.InvalidSince.HasValue);
|
||||||
|
var group = groupId is { } gid ? groups.GetById(gid) : null;
|
||||||
|
var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
|
||||||
|
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText,
|
||||||
|
teacherName, attendanceCalendars?.Build(student, date),
|
||||||
|
attendanceCalendars?.BuildAbsenceDayList(student, new AttendanceCalendarOptions(date, 1)));
|
||||||
|
foreach (var (name, raw) in extraValues ?? [])
|
||||||
|
{
|
||||||
|
var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name);
|
||||||
|
if (definition is null || definition.IsConstant) continue;
|
||||||
|
if (TryConvert(definition.Type, raw) is { } converted) values[name] = converted;
|
||||||
|
}
|
||||||
|
var resolved = TemplateDataResolver.Resolve(loaded.Manifest, values);
|
||||||
|
|
||||||
|
var validation = loader.Validate(loaded, resolved.ToDictionary(x => x.Key, x => x.Value.Type));
|
||||||
|
var missing = loaded.Manifest.Placeholders
|
||||||
|
.Where(p => !p.IsConstant && p.Required &&
|
||||||
|
(!resolved.TryGetValue(p.Name, out var v) || LetterPlaceholderBuilder.IsEmpty(v)))
|
||||||
|
.Select(p => p.Name).ToList();
|
||||||
|
if (!validation.IsValid || missing.Count > 0)
|
||||||
|
{
|
||||||
|
var issues = validation.Issues.Select(i => i.Message)
|
||||||
|
.Concat(missing.Select(m => $"Pflichtfeld „{m}“ fehlt."));
|
||||||
|
return new LetterRenderResultDto(false,
|
||||||
|
"Vorlage kann mit diesen Werten nicht gefüllt werden: " + string.Join(" ", issues), null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] pdf;
|
||||||
|
try { pdf = renderer.RenderToPdf(loaded, new DictionaryDataProvider(resolved)); }
|
||||||
|
catch (Exception ex) when (ex is InvalidDataException or TemplateValidationException)
|
||||||
|
{ return new LetterRenderResultDto(false, $"Brief konnte nicht erzeugt werden: {ex.Message}", null, null); }
|
||||||
|
|
||||||
|
var fileName = SanitizeFileName($"{installed.Name}_{student.LastName}_{student.FirstName}.pdf");
|
||||||
|
return new LetterRenderResultDto(true, "Brief erzeugt.", Convert.ToBase64String(pdf), fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlaceholderValue? TryConvert(PlaceholderType type, string raw) => type switch
|
||||||
|
{
|
||||||
|
PlaceholderType.Text => new TextValue(raw),
|
||||||
|
PlaceholderType.Multiline => new MultilineValue(raw),
|
||||||
|
PlaceholderType.Date => TryParseDate(raw) is { } date ? new DateValue(date) : null,
|
||||||
|
PlaceholderType.Number => TryParseNumber(raw) is { } number ? new NumberValue(number) : null,
|
||||||
|
// Image/Table/Chart/Drawing sind über MCP-Textparameter nicht sinnvoll setzbar.
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static DateOnly? TryParseDate(string raw) =>
|
||||||
|
DateOnly.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ||
|
||||||
|
DateOnly.TryParse(raw, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out d) ? d : null;
|
||||||
|
|
||||||
|
private static decimal? TryParseNumber(string raw) =>
|
||||||
|
decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out var n) ||
|
||||||
|
decimal.TryParse(raw, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out n) ? n : null;
|
||||||
|
|
||||||
|
private static string SanitizeFileName(string value)
|
||||||
|
{
|
||||||
|
foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DictionaryDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||||
|
{
|
||||||
|
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>MCP-Read-Tool "get_schedule" (Phase 1, siehe Planungsdokument).</summary>
|
||||||
|
public class ScheduleTools(ITimetableSlotRepository slots)
|
||||||
|
{
|
||||||
|
[Description("Listet Stundenplan-Einträge (Wochenraster), optional gefiltert nach Lerngruppe.")]
|
||||||
|
public List<TimetableSlotDto> GetSchedule(
|
||||||
|
[Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null)
|
||||||
|
{
|
||||||
|
var list = groupId is { } id ? slots.GetByGroup(id) : slots.GetAll();
|
||||||
|
return list.Select(s => new TimetableSlotDto(s.Id, s.GroupId, s.Weekday, s.PeriodNumber, s.Room)).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>MCP-Read-Tool "get_students" (Phase 1, siehe Planungsdokument). Reine Lesezugriffe auf
|
||||||
|
/// die bestehenden Repositories, keine eigene Datenzugriffslogik.</summary>
|
||||||
|
public class StudentTools(IStudentRepository students)
|
||||||
|
{
|
||||||
|
[Description("Listet Schüler, optional gefiltert nach Lerngruppe. Enthält standardmäßig nur aktive Schüler.")]
|
||||||
|
public List<StudentDto> GetStudents(
|
||||||
|
[Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null,
|
||||||
|
[Description("Auch inaktive/ausgeschiedene Schüler einbeziehen.")] bool includeInactive = false)
|
||||||
|
{
|
||||||
|
var list = groupId is { } id ? students.GetByGroup(id) : students.GetAll(includeInactive);
|
||||||
|
if (groupId is not null && !includeInactive)
|
||||||
|
list = list.Where(s => s.IsActive).ToList();
|
||||||
|
return list.Select(s => new StudentDto(s.Id, s.FirstName, s.LastName, s.IsActive)).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>MCP-Tools "get_time_entries" (Phase 1) und "create_time_entry" (Phase 2), siehe
|
||||||
|
/// Planungsdokument. Der Zeitraum bei "get_time_entries" ist Pflicht (nicht optional), damit eine
|
||||||
|
/// unbedachte Anfrage nicht die gesamte Zeiterfassungshistorie zurückgibt.</summary>
|
||||||
|
public class TimeEntryTools(ITimeEntryRepository timeEntries, IGroupRepository groups, IMcpConfirmationService confirmation)
|
||||||
|
{
|
||||||
|
[Description("Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.")]
|
||||||
|
public List<TimeEntryDto> GetTimeEntries(
|
||||||
|
[Description("Startdatum (einschließlich), Format YYYY-MM-DD.")] DateOnly from,
|
||||||
|
[Description("Enddatum (einschließlich), Format YYYY-MM-DD.")] DateOnly to) =>
|
||||||
|
timeEntries.GetByDateRange(from, to).Select(t => new TimeEntryDto(
|
||||||
|
t.Id, t.TaskId, t.Category, t.GroupId, t.Date, t.StartTime, t.EndTime,
|
||||||
|
t.DurationMinutes, t.Description)).ToList();
|
||||||
|
|
||||||
|
[Description("Schlägt einen neuen Zeiterfassungs-Eintrag vor. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen, bevor er gespeichert wird.")]
|
||||||
|
public async Task<WriteResultDto> CreateTimeEntry(
|
||||||
|
[Description("Kategorie, z.B. \"Unterricht\", \"Korrektur\", \"Vorbereitung\".")] string category,
|
||||||
|
[Description("Datum, Format YYYY-MM-DD.")] DateOnly date,
|
||||||
|
[Description("Dauer in Minuten.")] int durationMinutes,
|
||||||
|
[Description("Optionale Lerngruppen-ID.")] Guid? groupId = null,
|
||||||
|
[Description("Optionale Beschreibung.")] string? description = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var groupName = groupId is { } gid ? groups.GetById(gid)?.Name : null;
|
||||||
|
var message =
|
||||||
|
$"Neuer Zeiteintrag: {category}, {durationMinutes} Min. am {date:dd.MM.yyyy}" +
|
||||||
|
(groupName is not null ? $", Gruppe {groupName}" : "") +
|
||||||
|
(string.IsNullOrWhiteSpace(description) ? "" : $"\n„{description}“");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync("Zeiteintrag anlegen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var entry = new TimeEntry
|
||||||
|
{
|
||||||
|
Category = category,
|
||||||
|
Date = date,
|
||||||
|
DurationMinutes = durationMinutes,
|
||||||
|
GroupId = groupId,
|
||||||
|
Description = description,
|
||||||
|
};
|
||||||
|
timeEntries.Save(entry);
|
||||||
|
return new WriteResultDto(true, entry.Id, "Zeiteintrag gespeichert.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MCP-Tools für die WebUntis-Abgleiche (Nutzer-Nachtrag zum Untis-Hub, siehe TODO.md), zweigleisig
|
||||||
|
/// wie vom Nutzer entschieden:
|
||||||
|
///
|
||||||
|
/// - <see cref="GetUntisAbsenceRows"/>/<see cref="ApplyUntisAbsenceStatus"/> (bevorzugter Weg): eine
|
||||||
|
/// rein technische <c>row-id</c> ordnet zurück, nie ein Schülername - nur Zeilen, die WebUntis
|
||||||
|
/// selbst eindeutig über die externe Schülernummer (ENr) einem Kursmitglied zuordnet, werden
|
||||||
|
/// überhaupt gelistet (unklare, nur namensbasiert auflösbare Fälle fließen bewusst NICHT hier
|
||||||
|
/// hinein, siehe <see cref="GetUntisAbsenceRows"/>).
|
||||||
|
/// - <see cref="GetNamedUntisAbsencePattern"/> (bewusste, eng begrenzte Ausnahme von der sonst in
|
||||||
|
/// <see cref="McpToolScope"/> geltenden Regel, dass personenbezogene Verhaltens-/Anwesenheitsdaten
|
||||||
|
/// nie mit einem Namen verknüpft nach außen gehen): exponiert Name UND Fehlzeiten gemeinsam, aber
|
||||||
|
/// nur für explizit angegebene Schüler-IDs und nur nach JEDES MAL gesonderter, prominenter
|
||||||
|
/// Bestätigung ohne Sitzungsfreigabe (<c>allowSessionTrust: false</c>).
|
||||||
|
///
|
||||||
|
/// <see cref="GetUntisHubStatus"/> ergänzt beide Wege um einen Überblick, welche Abgleiche laut
|
||||||
|
/// <see cref="UntisHubService"/> überhaupt fällig sind, ohne selbst WebUntis anzufragen.
|
||||||
|
/// </summary>
|
||||||
|
public class UntisComparisonTools(
|
||||||
|
IGroupRepository groups, IStudentRepository students, IParticipationSessionRepository sessions,
|
||||||
|
IParticipationRepository participation, WebUntisIntegrationService untis, UntisHubService hub,
|
||||||
|
IMcpConfirmationService confirmation)
|
||||||
|
{
|
||||||
|
// Statuswerte, die ApplyUntisAbsenceStatus akzeptiert - dieselbe Einschränkung wie
|
||||||
|
// WebUntisLessonAbsenceRow.SelectableStatuses im interaktiven Dialog (nicht z.B. "Geschwänzt"
|
||||||
|
// oder "Suspendiert", die WebUntis hier nie meldet). Eigenständig gehalten statt der Row-Klasse
|
||||||
|
// referenziert, da Tool-Klassen unter Services/Mcp nicht von ViewModel-Klassen abhängen sollen
|
||||||
|
// (siehe GradeTools).
|
||||||
|
private static readonly AttendanceStatus[] SelectableStatuses =
|
||||||
|
[
|
||||||
|
AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused,
|
||||||
|
AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present,
|
||||||
|
];
|
||||||
|
|
||||||
|
// In-Memory, pro Prozesslaufzeit - eine row-id aus GetUntisAbsenceRows ist nur bis zum nächsten
|
||||||
|
// Neustart von LehrerApp gültig; danach muss der KI-Client die Liste erneut abrufen. Bewusst
|
||||||
|
// keine Ablauf-/Größenbegrenzung (siehe Nutzerdiskussion: geringe Nutzungsfrequenz, winzige
|
||||||
|
// Einträge) - ein v1-Kompromiss, kein Deployment-Risiko wie bei den ai-backend-Endpunkten.
|
||||||
|
private readonly ConcurrentDictionary<string, PendingAbsenceRow> _pendingRows = new();
|
||||||
|
|
||||||
|
private sealed record PendingAbsenceRow(Guid StudentId, Guid SessionId, Guid GroupId, DateOnly Date);
|
||||||
|
|
||||||
|
[Description("Listet die Fälligkeit der Untis-Hub-Abgleiche (Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-/Hausaufgabenabgleich) - reine Lesefunktion aus der lokalen Fälligkeits-Historie, kein eigener WebUntis-Zugriff.")]
|
||||||
|
public List<UntisHubStatusRowDto> GetUntisHubStatus() =>
|
||||||
|
hub.GetRows().Select(r => new UntisHubStatusRowDto(
|
||||||
|
r.Kind.ToString(), r.GroupId, r.GroupName, r.DueState.ToString(), r.DueLabel,
|
||||||
|
r.LastRunAt, r.LastResultSummary)).ToList();
|
||||||
|
|
||||||
|
[Description("""
|
||||||
|
Listet Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis in einem Zeitraum, ANONYMISIERT:
|
||||||
|
enthält keinen Schülernamen, nur eine technische row-id je Zeile (für apply_untis_absence_status).
|
||||||
|
Enthält nur Zeilen, die WebUntis über die externe Schülernummer eindeutig einem Kursmitglied zuordnen
|
||||||
|
konnte - Zeilen, die nur über den Namen auflösbar wären, fehlen hier bewusst; für die braucht es
|
||||||
|
get_named_untis_absence_pattern (Namen exponierend, gesondert bestätigungspflichtig).
|
||||||
|
""")]
|
||||||
|
public async Task<List<UntisAbsenceRowDto>> GetUntisAbsenceRows(
|
||||||
|
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||||
|
[Description("Startdatum, Format YYYY-MM-DD.")] DateOnly startDate,
|
||||||
|
[Description("Enddatum, Format YYYY-MM-DD.")] DateOnly endDate,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var group = groups.GetById(groupId);
|
||||||
|
if (group?.WebUntisLessonId is not { } lessonId) return [];
|
||||||
|
|
||||||
|
var courseStudents = students.GetByGroup(groupId);
|
||||||
|
var byExternKey = courseStudents
|
||||||
|
.Select(s => (Student: s, Key: UntisLessonAbsenceHelper.StudentExternKey(s)))
|
||||||
|
.Where(x => x.Key is not null)
|
||||||
|
.ToDictionary(x => x.Key!.Value, x => x.Student);
|
||||||
|
var localSessions = sessions.GetByGroup(groupId)
|
||||||
|
.Where(s => s.Date >= startDate && s.Date <= endDate)
|
||||||
|
.GroupBy(s => s.Date).ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|
||||||
|
var absences = await untis.GetLessonAbsencesAsync(lessonId, startDate, endDate, ct);
|
||||||
|
var rows = new List<UntisAbsenceRowDto>();
|
||||||
|
foreach (var absence in absences)
|
||||||
|
{
|
||||||
|
if (absence.ExternKey is not { } key || !byExternKey.TryGetValue(key, out var student)) continue;
|
||||||
|
if (!TryParseDate(absence.Date, out var date) || !localSessions.TryGetValue(date, out var session)) continue;
|
||||||
|
|
||||||
|
var rowId = Guid.NewGuid().ToString("N");
|
||||||
|
_pendingRows[rowId] = new PendingAbsenceRow(student.Id, session.Id, groupId, date);
|
||||||
|
|
||||||
|
var entry = participation.GetBySessionAndStudent(session.Id, student.Id);
|
||||||
|
var guess = UntisLessonAbsenceHelper.MapStatus(absence);
|
||||||
|
rows.Add(new UntisAbsenceRowDto(
|
||||||
|
rowId, date, absence.Reason ?? "", absence.AbsentMinutes,
|
||||||
|
!string.IsNullOrWhiteSpace(absence.HandledOn),
|
||||||
|
absence.ExternKey is null ? null : absence.ExternKeyInParentheses,
|
||||||
|
LocalStatusLabel(entry?.Attendance), guess.ToString()));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id in den lokalen Anwesenheitsstatus. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen; die Bestätigungsmeldung nennt bewusst KEINEN Schülernamen (nur Datum, Lerngruppe, Zielstatus).")]
|
||||||
|
public async Task<WriteResultDto> ApplyUntisAbsenceStatus(
|
||||||
|
[Description("row-id aus get_untis_absence_rows.")] string rowId,
|
||||||
|
[Description("Zielstatus: Present, Late, LeftDuringClass, ExcusePending, Excused oder Unexcused.")] string status,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<AttendanceStatus>(status, out var target) || !SelectableStatuses.Contains(target))
|
||||||
|
return new WriteResultDto(false, null, "Unbekannter oder nicht zulässiger Status.");
|
||||||
|
if (!_pendingRows.TryGetValue(rowId, out var row))
|
||||||
|
return new WriteResultDto(false, null, "Unbekannte oder abgelaufene row-id - zuerst get_untis_absence_rows erneut aufrufen.");
|
||||||
|
|
||||||
|
var groupName = groups.GetById(row.GroupId)?.Name ?? "?";
|
||||||
|
var message = $"Fehlzeile vom {row.Date:dd.MM.yyyy} in Lerngruppe „{groupName}“: " +
|
||||||
|
$"Anwesenheitsstatus auf „{LocalStatusLabel(target)}“ setzen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Fehlzeiten-Status übernehmen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var entry = participation.GetBySessionAndStudent(row.SessionId, row.StudentId)
|
||||||
|
?? new ParticipationEntry { SessionId = row.SessionId, StudentId = row.StudentId };
|
||||||
|
entry.Attendance = target;
|
||||||
|
entry.UpdatedAt = DateTime.UtcNow;
|
||||||
|
participation.Save(entry);
|
||||||
|
_pendingRows.TryRemove(rowId, out _);
|
||||||
|
return new WriteResultDto(true, entry.Id, "Status übernommen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("""
|
||||||
|
Liefert Fehlzeiten für EXPLIZIT angegebene Schüler-IDs MIT Namen (z.B. für einen Bericht oder einen
|
||||||
|
Fehlmuster-Vergleich zwischen zwei Schülern) - bewusste, eng begrenzte Ausnahme von der sonst
|
||||||
|
geltenden Anonymisierung (siehe get_untis_absence_rows). So wenige studentIds wie für die Anfrage
|
||||||
|
nötig angeben, nicht den ganzen Kurs. Erfordert JEDES MAL eine gesonderte, prominente
|
||||||
|
Nutzerbestätigung ohne Sitzungsfreigabe - liefert bei Ablehnung granted:false und keine Zeilen.
|
||||||
|
""")]
|
||||||
|
public async Task<NamedUntisAbsenceResultDto> GetNamedUntisAbsencePattern(
|
||||||
|
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||||
|
[Description("Ids der Schüler, für die Name UND Fehlzeiten gemeinsam offengelegt werden sollen.")] List<Guid> studentIds,
|
||||||
|
[Description("Startdatum, Format YYYY-MM-DD.")] DateOnly startDate,
|
||||||
|
[Description("Enddatum, Format YYYY-MM-DD.")] DateOnly endDate,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var group = groups.GetById(groupId);
|
||||||
|
if (group?.WebUntisLessonId is not { } lessonId)
|
||||||
|
return new NamedUntisAbsenceResultDto(false, "Für diese Lerngruppe ist keine WebUntis-Unterrichtsnummer hinterlegt.", null);
|
||||||
|
|
||||||
|
var resolvedStudents = studentIds.Distinct()
|
||||||
|
.Select(students.GetById).Where(s => s is not null).Cast<Student>().ToList();
|
||||||
|
if (resolvedStudents.Count == 0)
|
||||||
|
return new NamedUntisAbsenceResultDto(false, "Keine der angegebenen Schüler-IDs ist bekannt.", null);
|
||||||
|
|
||||||
|
var names = string.Join(", ", resolvedStudents.Select(s => s.FullName));
|
||||||
|
var message = $"Name UND Fehlzeiten gemeinsam an den KI-Assistenten weitergeben für:\n{names}\n\n" +
|
||||||
|
$"Zeitraum: {startDate:dd.MM.yyyy}–{endDate:dd.MM.yyyy}, Lerngruppe „{group.Name}“.";
|
||||||
|
if (!await confirmation.ConfirmAsync("Namentliche Fehlzeitenauskunft freigeben?", message, ct, allowSessionTrust: false))
|
||||||
|
return new NamedUntisAbsenceResultDto(false, "Vom Nutzer abgelehnt oder nicht bestätigt.", null);
|
||||||
|
|
||||||
|
var byExternKey = resolvedStudents
|
||||||
|
.Select(s => (Student: s, Key: UntisLessonAbsenceHelper.StudentExternKey(s)))
|
||||||
|
.Where(x => x.Key is not null)
|
||||||
|
.ToDictionary(x => x.Key!.Value, x => x.Student);
|
||||||
|
var byName = resolvedStudents
|
||||||
|
.SelectMany(s => new[]
|
||||||
|
{
|
||||||
|
NameKey($"{s.LastName} {s.FirstName}"), NameKey($"{s.FirstName} {s.LastName}"),
|
||||||
|
}.Select(key => (Key: key, Student: s)))
|
||||||
|
.GroupBy(x => x.Key).Where(g => g.Select(x => x.Student).Distinct().Count() == 1)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Student);
|
||||||
|
var localSessions = sessions.GetByGroup(groupId)
|
||||||
|
.Where(s => s.Date >= startDate && s.Date <= endDate)
|
||||||
|
.GroupBy(s => s.Date).ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|
||||||
|
var absences = await untis.GetLessonAbsencesAsync(lessonId, startDate, endDate, ct);
|
||||||
|
var rows = new List<NamedUntisAbsenceRowDto>();
|
||||||
|
foreach (var absence in absences)
|
||||||
|
{
|
||||||
|
var match = absence.ExternKey is { } key && byExternKey.TryGetValue(key, out var byKeyStudent)
|
||||||
|
? byKeyStudent
|
||||||
|
: byName.GetValueOrDefault(NameKey(absence.StudentName));
|
||||||
|
if (match is null) continue; // nur die explizit freigegebenen Schüler, nie "geraten"
|
||||||
|
if (!TryParseDate(absence.Date, out var date)) continue;
|
||||||
|
|
||||||
|
var entry = localSessions.TryGetValue(date, out var session)
|
||||||
|
? participation.GetBySessionAndStudent(session.Id, match.Id) : null;
|
||||||
|
rows.Add(new NamedUntisAbsenceRowDto(
|
||||||
|
match.Id, match.FullName, date, absence.Reason ?? "",
|
||||||
|
LocalStatusLabel(entry?.Attendance), UntisLessonAbsenceHelper.MapStatus(absence).ToString()));
|
||||||
|
}
|
||||||
|
return new NamedUntisAbsenceResultDto(true, $"{rows.Count} Fehlzeile(n) für {resolvedStudents.Count} Schüler.", rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
||||||
|
private static bool TryParseDate(int value, out DateOnly date) =>
|
||||||
|
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||||
|
|
||||||
|
// Eigene, schlanke Beschriftung statt AttendanceDisplay (Views-/ViewModel-Bezug) - Tool-Klassen
|
||||||
|
// unter Services/Mcp sollen nicht von ViewModel-Klassen abhängen (siehe GradeTools).
|
||||||
|
private static string LocalStatusLabel(AttendanceStatus? s) => s switch
|
||||||
|
{
|
||||||
|
null => "kein Status erfasst",
|
||||||
|
AttendanceStatus.Present => "Anwesend",
|
||||||
|
AttendanceStatus.ExcusePending => "Krank (Entschuldigung offen)",
|
||||||
|
AttendanceStatus.Excused => "Krank, entschuldigt",
|
||||||
|
AttendanceStatus.Unexcused => "Krank, unentschuldigt",
|
||||||
|
AttendanceStatus.Late => "Verspätet",
|
||||||
|
AttendanceStatus.LeftDuringClass => "Während des Unterrichts abgängig",
|
||||||
|
_ => s.ToString()!,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
internal class McpSettingsConfig
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opt-in-Schalter für den lokalen MCP-Server (siehe Planungsdokument, Phase 1). Anders als
|
||||||
|
/// <see cref="AiSettingsService"/> braucht Phase 1 kein Login/Token — die Named Pipe selbst ist die
|
||||||
|
/// Vertrauensgrenze (lokaler Prozess, gleiche Windows-Session bzw. Unix-Dateirechte), siehe
|
||||||
|
/// Begründung im Planungsdokument.
|
||||||
|
/// </summary>
|
||||||
|
public class McpSettingsService
|
||||||
|
{
|
||||||
|
private readonly string _configPath;
|
||||||
|
private McpSettingsConfig _config;
|
||||||
|
|
||||||
|
public bool Enabled => _config.Enabled;
|
||||||
|
|
||||||
|
public McpSettingsService(string appDataPath)
|
||||||
|
{
|
||||||
|
_configPath = Path.Combine(appDataPath, "mcp-settings.json");
|
||||||
|
_config = Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
_config.Enabled = enabled;
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
|
||||||
|
|
||||||
|
private McpSettingsConfig Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_configPath))
|
||||||
|
return JsonSerializer.Deserialize<McpSettingsConfig>(File.ReadAllText(_configPath))
|
||||||
|
?? new McpSettingsConfig();
|
||||||
|
}
|
||||||
|
catch { /* beschädigte Konfiguration -> Standardwert */ }
|
||||||
|
return new McpSettingsConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
public enum AttendanceCalendarSize { Small, Medium, Large }
|
||||||
|
|
||||||
|
public sealed record AttendanceCalendarOptions(DateOnly StartMonth, int MonthCount,
|
||||||
|
AttendanceCalendarSize Size = AttendanceCalendarSize.Medium)
|
||||||
|
{
|
||||||
|
public DateOnly NormalizedStartMonth => new(StartMonth.Year, StartMonth.Month, 1);
|
||||||
|
public int NormalizedMonthCount => Math.Clamp(MonthCount, 1, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Erzeugt den portablen Advanced-Content-Platzhalter für Elternbriefe aus derselben
|
||||||
|
/// priorisierten Monatsansicht, die im Klassenlehrer-Sidebar-Widget verwendet wird.</summary>
|
||||||
|
public static class StudentAttendanceCalendarDrawingBuilder
|
||||||
|
{
|
||||||
|
public const string PlaceholderName = "Student.AttendanceCalendar";
|
||||||
|
|
||||||
|
public static DrawingValue Build(string studentName, DateOnly month,
|
||||||
|
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||||
|
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries) =>
|
||||||
|
Build(studentName, new AttendanceCalendarOptions(month, 1), absences, registerEntries);
|
||||||
|
|
||||||
|
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
|
||||||
|
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||||
|
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries)
|
||||||
|
{
|
||||||
|
const float width = 170;
|
||||||
|
var scale = options.Size switch
|
||||||
|
{
|
||||||
|
AttendanceCalendarSize.Small => .7f,
|
||||||
|
AttendanceCalendarSize.Large => 1f,
|
||||||
|
_ => .85f,
|
||||||
|
};
|
||||||
|
// Die Breite richtet sich nach der DRAWBOX-Deklaration im Layout-Skript (fest, unabhängig
|
||||||
|
// von der Größenwahl) - nur Schrift/Zellenhöhe skalieren mit "Größe". Würde die Breite mit
|
||||||
|
// skalieren, würde "Groß" (scale=1) exakt die Skript-Box ausfüllen, "Klein"/"Normal" aber
|
||||||
|
// nur einen Teil davon - und eine größere Skalierung als 1 liefe über die Box hinaus und
|
||||||
|
// würde am rechten Rand abgeschnitten (SVG overflow="hidden").
|
||||||
|
var contentWidth = width;
|
||||||
|
var cellHeight = 10 * scale;
|
||||||
|
var monthGapX = 10f;
|
||||||
|
var first = options.NormalizedStartMonth;
|
||||||
|
var monthCount = options.NormalizedMonthCount;
|
||||||
|
var monthWidth = (contentWidth - monthGapX * (monthCount - 1)) / monthCount;
|
||||||
|
var cellWidth = monthWidth / 7;
|
||||||
|
var commands = new List<DrawingCommand>();
|
||||||
|
var y = 0f;
|
||||||
|
commands.Add(new DrawStringEx(0, y, 14 * scale, contentWidth, "Anwesenheit",
|
||||||
|
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
|
||||||
|
y += 14 * scale;
|
||||||
|
commands.Add(new DrawStringEx(0, y, 10 * scale, contentWidth, studentName,
|
||||||
|
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
|
||||||
|
y += 10 * scale + 2 * scale;
|
||||||
|
var weekdays = new[] { "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So" };
|
||||||
|
var gridStartY = y;
|
||||||
|
var maxWeeks = 0;
|
||||||
|
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
|
||||||
|
{
|
||||||
|
var current = first.AddMonths(monthIndex);
|
||||||
|
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(current, absences, registerEntries,
|
||||||
|
DateOnly.FromDateTime(DateTime.Today), studentName);
|
||||||
|
var offset = ((int)current.DayOfWeek + 6) % 7;
|
||||||
|
var weeks = (int)Math.Ceiling((offset + days.Count) / 7d);
|
||||||
|
maxWeeks = Math.Max(maxWeeks, weeks);
|
||||||
|
var xOffset = monthIndex * (monthWidth + monthGapX);
|
||||||
|
var monthY = gridStartY;
|
||||||
|
commands.Add(new DrawStringEx(xOffset, monthY, 11 * scale, monthWidth,
|
||||||
|
current.ToString("MMMM yyyy", CultureInfo.GetCultureInfo("de-DE")),
|
||||||
|
DrawingTextAlignment.AlignLeft, 9 * scale, Color: "#374151", Bold: true));
|
||||||
|
monthY += 11 * scale;
|
||||||
|
for (var column = 0; column < 7; column++)
|
||||||
|
commands.Add(new DrawStringEx(xOffset + column * cellWidth, monthY, 9 * scale, cellWidth,
|
||||||
|
weekdays[column], DrawingTextAlignment.AlignCenter, 7 * scale, Color: "#6B7280", Bold: true));
|
||||||
|
monthY += 9 * scale;
|
||||||
|
|
||||||
|
foreach (var day in days)
|
||||||
|
{
|
||||||
|
var index = offset + day.Date.Day - 1;
|
||||||
|
var column = index % 7;
|
||||||
|
var row = index / 7;
|
||||||
|
var x = xOffset + column * cellWidth;
|
||||||
|
var cellY = monthY + row * cellHeight;
|
||||||
|
commands.Add(new DrawRectangle(x + scale, cellY, cellWidth - 2 * scale, cellHeight - scale,
|
||||||
|
"#D1D5DB", .35f, day.HasSignal ? day.SignalColorHex : "#FFFFFF"));
|
||||||
|
commands.Add(new DrawStringEx(x, cellY + scale, cellHeight - 2 * scale, cellWidth,
|
||||||
|
day.HasSignal ? day.SignalCode : day.DayNumber, DrawingTextAlignment.AlignCenter, 7 * scale,
|
||||||
|
Color: day.HasSignal ? "#FFFFFF" : "#374151", Bold: day.HasSignal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
y = gridStartY + 11 * scale + 9 * scale + maxWeeks * cellHeight + monthGapX;
|
||||||
|
|
||||||
|
commands.Add(new DrawStringEx(0, y, 8 * scale, contentWidth,
|
||||||
|
"U unentschuldigt · A abwesend · V verspätet · E entschuldigt · ! Klassenbuch",
|
||||||
|
DrawingTextAlignment.AlignLeft, 6.5f * scale, Color: "#6B7280"));
|
||||||
|
return new DrawingValue(commands, y + 9 * scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Chronologische, portable Fehlzeitenliste für Elternbriefvorlagen.</summary>
|
||||||
|
public static class StudentAbsenceDayListDrawingBuilder
|
||||||
|
{
|
||||||
|
public const string PlaceholderName = "Student.AbsenceDays";
|
||||||
|
|
||||||
|
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
|
||||||
|
IReadOnlyList<ClassAbsenceDaySummaryRow> absences)
|
||||||
|
{
|
||||||
|
const float width = 170;
|
||||||
|
var scale = options.Size switch
|
||||||
|
{
|
||||||
|
AttendanceCalendarSize.Small => .75f,
|
||||||
|
AttendanceCalendarSize.Large => 1f,
|
||||||
|
_ => .88f,
|
||||||
|
};
|
||||||
|
// Breite bleibt an die DRAWBOX-Deklaration im Layout-Skript gebunden (siehe
|
||||||
|
// StudentAttendanceCalendarDrawingBuilder) - nur Zeilenhöhe/Schrift skalieren mit "Größe".
|
||||||
|
var contentWidth = width;
|
||||||
|
var rowHeight = 12 * scale;
|
||||||
|
var start = options.NormalizedStartMonth;
|
||||||
|
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||||
|
var rows = absences
|
||||||
|
.Where(a => a.Date >= start && a.Date <= end &&
|
||||||
|
UntisNameMatching.NamesMatch(a.StudentName, studentName))
|
||||||
|
.OrderBy(a => a.Date)
|
||||||
|
.ToList();
|
||||||
|
var commands = new List<DrawingCommand>();
|
||||||
|
var y = 0f;
|
||||||
|
commands.Add(new DrawStringEx(0, y, 14 * scale, contentWidth, "Fehltage",
|
||||||
|
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
|
||||||
|
y += 14 * scale;
|
||||||
|
commands.Add(new DrawStringEx(0, y, 10 * scale, contentWidth, studentName,
|
||||||
|
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
|
||||||
|
y += 10 * scale + 2 * scale;
|
||||||
|
// Spaltenbreiten so gewählt, dass auch die Datenzeilen (nicht nur die kurzen Kopfzeilen-
|
||||||
|
// Labels) hineinpassen - "Datum" als Kopfzeile ist kürzer als "dd.MM.yyyy" und wurde bei
|
||||||
|
// 31 zu schmal bemessen, wodurch das Datum am rechten Rand abgeschnitten wurde.
|
||||||
|
const float dateColumnX = 2, dateColumnWidth = 38;
|
||||||
|
const float extentColumnX = 44, extentColumnWidth = 66;
|
||||||
|
const float statusColumnX = 114, statusColumnWidth = 54;
|
||||||
|
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#CBD5E1", .4f, "#F3F4F6"));
|
||||||
|
commands.Add(new DrawStringEx(dateColumnX, y + scale, rowHeight - scale, dateColumnWidth, "Datum",
|
||||||
|
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
|
||||||
|
commands.Add(new DrawStringEx(extentColumnX, y + scale, rowHeight - scale, extentColumnWidth, "Umfang",
|
||||||
|
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
|
||||||
|
commands.Add(new DrawStringEx(statusColumnX, y + scale, rowHeight - scale, statusColumnWidth, "Status",
|
||||||
|
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
|
||||||
|
y += rowHeight;
|
||||||
|
|
||||||
|
if (rows.Count == 0)
|
||||||
|
{
|
||||||
|
commands.Add(new DrawStringEx(2, y + 2 * scale, rowHeight, contentWidth - 4,
|
||||||
|
"Keine Fehltage im gewählten Zeitraum", DrawingTextAlignment.AlignLeft, 8 * scale,
|
||||||
|
Color: "#6B7280", Italic: true));
|
||||||
|
y += rowHeight + 3 * scale;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
for (var index = 0; index < rows.Count; index++)
|
||||||
|
{
|
||||||
|
var row = rows[index];
|
||||||
|
var fill = index % 2 == 0 ? "#FFFFFF" : "#F9FAFB";
|
||||||
|
var extent = row.CountsAsFullDay
|
||||||
|
? "Ganzer Fehltag"
|
||||||
|
: row.TotalAbsentPeriods > 0
|
||||||
|
? $"Fehlzeit · {row.TotalAbsentPeriods} Std."
|
||||||
|
: $"Fehlzeit · {row.TotalAbsentMinutes} Min.";
|
||||||
|
var status = row.IsUnexcused
|
||||||
|
? "Unentschuldigt"
|
||||||
|
: row.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "Entschuldigt"
|
||||||
|
: row.FriendlyStatusLabel;
|
||||||
|
var statusColor = row.IsUnexcused ? "#C62828" : "#2E7D32";
|
||||||
|
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#E5E7EB", .3f, fill));
|
||||||
|
commands.Add(new DrawStringEx(dateColumnX, y + scale, rowHeight - scale, dateColumnWidth,
|
||||||
|
row.Date.ToString("dd.MM.yyyy"), DrawingTextAlignment.AlignLeft, 7.5f * scale,
|
||||||
|
Color: "#374151"));
|
||||||
|
commands.Add(new DrawStringEx(extentColumnX, y + scale, rowHeight - scale, extentColumnWidth,
|
||||||
|
extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151"));
|
||||||
|
commands.Add(new DrawStringEx(statusColumnX, y + scale, rowHeight - scale, statusColumnWidth,
|
||||||
|
status, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: statusColor,
|
||||||
|
Bold: row.IsUnexcused));
|
||||||
|
y += rowHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new DrawingValue(commands, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Liest ausschließlich den lokalen WebUntis-Cache. Das Öffnen oder Rendern eines
|
||||||
|
/// Elternbriefs löst dadurch keinen unerwarteten Netzwerkabruf aus.</summary>
|
||||||
|
public sealed class StudentAttendanceCalendarService(
|
||||||
|
WebUntisSettingsService settings,
|
||||||
|
IUntisAbsenceCacheRepository absenceCache,
|
||||||
|
IUntisClassRegisterCacheRepository registerCache,
|
||||||
|
IUntisStudentRosterCacheRepository rosterCache)
|
||||||
|
{
|
||||||
|
public DrawingValue Build(Student student, DateOnly month) =>
|
||||||
|
Build(student, new AttendanceCalendarOptions(month, 1));
|
||||||
|
|
||||||
|
public DrawingValue Build(Student student, AttendanceCalendarOptions options)
|
||||||
|
{
|
||||||
|
var className = settings.HomeroomClassName;
|
||||||
|
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
|
||||||
|
|
||||||
|
var first = options.NormalizedStartMonth;
|
||||||
|
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||||
|
var matchName = $"{student.FirstName} {student.LastName}";
|
||||||
|
var rosterName = rosterCache.GetByClass(className)
|
||||||
|
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
|
||||||
|
?? matchName;
|
||||||
|
var start = first.Year * 10000 + first.Month * 100 + first.Day;
|
||||||
|
var end = last.Year * 10000 + last.Month * 100 + last.Day;
|
||||||
|
var absences = absenceCache.GetByClassAndRange(className, start, end)
|
||||||
|
.Select(e => new UntisClassAbsenceEntryDto(e.StudentName, e.ExternKey, e.ClassName, e.Date,
|
||||||
|
e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, e.Subject, e.AbsenceReason, e.Note,
|
||||||
|
e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, e.Status, e.CountsAsFullDay))
|
||||||
|
.ToList();
|
||||||
|
var register = registerCache.GetByClassAndRange(className, start, end)
|
||||||
|
.Select(e => new UntisForeignClassRegisterEventDto(e.ClassName, e.Date, e.Subject, e.StudentName,
|
||||||
|
e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text))
|
||||||
|
.ToList();
|
||||||
|
return StudentAttendanceCalendarDrawingBuilder.Build(rosterName, options,
|
||||||
|
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), register);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DrawingValue BuildAbsenceDayList(Student student, AttendanceCalendarOptions options)
|
||||||
|
{
|
||||||
|
var className = settings.HomeroomClassName;
|
||||||
|
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
|
||||||
|
|
||||||
|
var first = options.NormalizedStartMonth;
|
||||||
|
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||||
|
var matchName = $"{student.FirstName} {student.LastName}";
|
||||||
|
var rosterName = rosterCache.GetByClass(className)
|
||||||
|
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
|
||||||
|
?? matchName;
|
||||||
|
var start = first.Year * 10000 + first.Month * 100 + first.Day;
|
||||||
|
var end = last.Year * 10000 + last.Month * 100 + last.Day;
|
||||||
|
var absences = absenceCache.GetByClassAndRange(className, start, end)
|
||||||
|
.Select(e => new UntisClassAbsenceEntryDto(e.StudentName, e.ExternKey, e.ClassName, e.Date,
|
||||||
|
e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, e.Subject, e.AbsenceReason, e.Note,
|
||||||
|
e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, e.Status, e.CountsAsFullDay));
|
||||||
|
return StudentAbsenceDayListDrawingBuilder.Build(rosterName, options,
|
||||||
|
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using LehrerApp.Desktop.Views;
|
||||||
|
using LehrerApp.Desktop.Views.Groups;
|
||||||
|
using LehrerApp.Desktop.Views.Students;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
/// <summary>Führt einen der vier bestehenden WebUntis-Abgleiche (unverändert, über ihre bestehenden
|
||||||
|
/// Dialoge) aus und vermerkt das Ergebnis im <see cref="UntisHubService"/> - genutzt sowohl vom
|
||||||
|
/// Untis-Hub-Dialog ("Jetzt prüfen" je Zeile) als auch von den WebUntis-Menüpunkten in
|
||||||
|
/// <c>MainWindow</c>, damit beide Einstiegspunkte denselben Fälligkeitsstand pflegen.</summary>
|
||||||
|
public static class UntisHubActions
|
||||||
|
{
|
||||||
|
public static async Task RunFehlzeitenAsync(Window owner, LearningGroup group, UntisHubJobKind kind,
|
||||||
|
DateOnly start, DateOnly end, UntisHubService hub)
|
||||||
|
{
|
||||||
|
var vm = new WebUntisLessonAbsenceComparisonViewModel(group,
|
||||||
|
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||||
|
App.Services.GetRequiredService<IStudentRepository>(),
|
||||||
|
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||||
|
App.Services.GetRequiredService<IParticipationRepository>(),
|
||||||
|
App.Services.GetRequiredService<AiPlanningService>(),
|
||||||
|
App.Services.GetRequiredService<AiSettingsService>())
|
||||||
|
{ StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) };
|
||||||
|
var loaded = TrackLoad(vm, v => v.Busy);
|
||||||
|
await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner);
|
||||||
|
if (loaded()) hub.RecordRun(kind, group.Id, vm.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task RunKlassenbuchAsync(Window owner, UntisHubService hub)
|
||||||
|
{
|
||||||
|
var vm = new WebUntisDocumentationComparisonViewModel(
|
||||||
|
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||||
|
App.Services.GetRequiredService<IStudentRepository>(),
|
||||||
|
App.Services.GetRequiredService<IGroupRepository>(),
|
||||||
|
App.Services.GetRequiredService<IDocumentationRepository>(),
|
||||||
|
App.Services.GetRequiredService<SchoolYearService>());
|
||||||
|
var loaded = TrackLoad(vm, v => v.Busy);
|
||||||
|
await new WebUntisDocumentationComparisonDialog { DataContext = vm }.ShowDialog(owner);
|
||||||
|
if (loaded()) hub.RecordRun(UntisHubJobKind.Klassenbuchabgleich, null, vm.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task RunHausaufgabenAsync(Window owner, UntisHubService hub)
|
||||||
|
{
|
||||||
|
var vm = new WebUntisHomeworkComparisonViewModel(
|
||||||
|
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||||
|
App.Services.GetRequiredService<IStudentRepository>(),
|
||||||
|
App.Services.GetRequiredService<IGroupRepository>(),
|
||||||
|
App.Services.GetRequiredService<ISubjectRepository>(),
|
||||||
|
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||||
|
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||||
|
App.Services.GetRequiredService<IParticipationRepository>(),
|
||||||
|
App.Services.GetRequiredService<SchoolYearService>());
|
||||||
|
var loaded = TrackLoad(vm, v => v.Busy);
|
||||||
|
await new WebUntisHomeworkComparisonDialog { DataContext = vm }.ShowDialog(owner);
|
||||||
|
if (loaded()) hub.RecordRun(UntisHubJobKind.Hausaufgabenabgleich, null, vm.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task RunOffenePeriodsAsync(Window owner, UntisHubService hub)
|
||||||
|
{
|
||||||
|
var dialog = new OpenUntisPeriodsDialog();
|
||||||
|
await dialog.ShowDialog(owner);
|
||||||
|
hub.RecordRun(UntisHubJobKind.OffenePeriods, null, dialog.LastStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Erkennt nicht-invasiv (ohne die Vergleichs-ViewModels zu ändern), ob im Dialog
|
||||||
|
/// tatsächlich ein Ladeversuch stattfand: <c>Busy</c> wechselt in <c>Load()</c> immer erst auf
|
||||||
|
/// true und im <c>finally</c>-Block zurück auf false, egal ob erfolgreich oder mit Fehler
|
||||||
|
/// abgebrochen - genau dieser Übergang wird hier beobachtet.</summary>
|
||||||
|
private static Func<bool> TrackLoad<T>(T vm, Func<T, bool> isBusy) where T : ObservableObject
|
||||||
|
{
|
||||||
|
var loaded = false;
|
||||||
|
var wasBusy = false;
|
||||||
|
vm.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName != "Busy") return;
|
||||||
|
var busy = isBusy(vm);
|
||||||
|
if (wasBusy && !busy) loaded = true;
|
||||||
|
wasBusy = busy;
|
||||||
|
};
|
||||||
|
return () => loaded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
public enum UntisHubDueState { Ok, Due, Overdue }
|
||||||
|
|
||||||
|
/// <summary>Eine Zeile im Untis-Hub: eine Job-Instanz (Fehlzeiten-Kadenz je Lerngruppe, oder einer
|
||||||
|
/// der drei dashboard-weiten Jobs) mit ihrer aktuellen Fälligkeit.</summary>
|
||||||
|
public sealed record UntisHubJobRow(
|
||||||
|
UntisHubJobKind Kind, Guid? GroupId, string GroupName,
|
||||||
|
DateTime? LastRunAt, string? LastResultSummary, UntisHubDueState DueState, string DueLabel);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zeigt, welche der bestehenden, rein manuell ausgelösten WebUntis-Abgleiche (Fehlzeiten pro
|
||||||
|
/// Lerngruppe, offene Stunden, Klassenbuch-/Hausaufgabenabgleich) fällig sind - ohne selbst
|
||||||
|
/// WebUntis anzufragen (Nutzer-Feedback: "nicht bei WebUntis auffallen", siehe
|
||||||
|
/// <see cref="UntisReportCacheService"/>). Die eigentlichen Abgleiche laufen weiterhin über die
|
||||||
|
/// bestehenden Vergleichsdialoge (siehe <see cref="UntisHubActions"/>); dieser Service verwaltet
|
||||||
|
/// nur die Fälligkeits-Zeitstempel dazu.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UntisHubService(
|
||||||
|
IGroupRepository groups, IUntisHubJobStateRepository jobStates, SchoolYearService schoolYears)
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan FehlzeitenKurzDue = TimeSpan.FromDays(14);
|
||||||
|
private static readonly TimeSpan FehlzeitenKurzOverdue = TimeSpan.FromDays(21);
|
||||||
|
private static readonly TimeSpan FehlzeitenLangDue = TimeSpan.FromDays(60);
|
||||||
|
private static readonly TimeSpan FehlzeitenLangOverdue = TimeSpan.FromDays(90);
|
||||||
|
private static readonly TimeSpan GlobalDue = TimeSpan.FromDays(14);
|
||||||
|
private static readonly TimeSpan GlobalOverdue = TimeSpan.FromDays(21);
|
||||||
|
|
||||||
|
private static readonly (UntisHubJobKind Kind, string Label)[] GlobalJobs =
|
||||||
|
[
|
||||||
|
(UntisHubJobKind.OffenePeriods, "Offene Stunden"),
|
||||||
|
(UntisHubJobKind.Klassenbuchabgleich, "Klassenbuchabgleich"),
|
||||||
|
(UntisHubJobKind.Hausaufgabenabgleich, "Hausaufgabenabgleich"),
|
||||||
|
];
|
||||||
|
|
||||||
|
public List<UntisHubJobRow> GetRows()
|
||||||
|
{
|
||||||
|
var eligibleGroups = groups.GetBySchoolYear(schoolYears.CurrentSchoolYear())
|
||||||
|
.Where(g => g.WebUntisLessonId is not null && !g.ExcludedFromUntisHub)
|
||||||
|
.OrderBy(g => g.Name)
|
||||||
|
.ToList();
|
||||||
|
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Der Langzeit-Fehlzeitenabgleich deckt seit Schuljahresbeginn ein Zeitfenster ab, das
|
||||||
|
/// das kurzfristige vollständig einschließt - ohne das hier mitzuziehen bliebe die kurzfristige
|
||||||
|
/// Kadenz trotz erledigtem Langzeit-Abgleich als fällig stehen (Nutzer-Feedback). Umgekehrt deckt
|
||||||
|
/// ein Kurz-Lauf das lange Fenster nicht ab, bleibt also einseitig.
|
||||||
|
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
Save(kind, groupId, now, summary);
|
||||||
|
if (kind == UntisHubJobKind.FehlzeitenLang)
|
||||||
|
Save(UntisHubJobKind.FehlzeitenKurz, groupId, now, "durch Abgleich seit Schuljahresbeginn mit erledigt");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save(UntisHubJobKind kind, Guid? groupId, DateTime at, string? summary) =>
|
||||||
|
jobStates.Save(new UntisHubJobState
|
||||||
|
{
|
||||||
|
Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(),
|
||||||
|
Kind = kind, GroupId = groupId, LastRunAt = at, LastResultSummary = summary,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Reine Entscheidungslogik ohne Repository-Zugriff (gleiches Muster wie
|
||||||
|
/// <see cref="UntisReportCacheService.Plan"/>): aus den fälligkeitsrelevanten Lerngruppen und den
|
||||||
|
/// zuletzt gespeicherten Job-Zuständen wird die vollständige Hub-Zeilenliste gebaut - zwei
|
||||||
|
/// Fehlzeiten-Zeilen je Gruppe plus die drei dashboard-weiten Zeilen.
|
||||||
|
public static List<UntisHubJobRow> BuildRows(
|
||||||
|
IReadOnlyList<LearningGroup> eligibleGroups, IReadOnlyList<UntisHubJobState> states, DateTime utcNow)
|
||||||
|
{
|
||||||
|
// Nach Sync-Aktivierung von UntisHubJobState (siehe TODO.md) können zwei Geräte, die
|
||||||
|
// denselben, noch nie gelaufenen Job unabhängig voneinander zum ersten Mal ausführen,
|
||||||
|
// bevor sie sich gegenseitig gesehen haben, kurzzeitig zwei Datensätze für dasselbe
|
||||||
|
// (Kind, GroupId) anlegen - hier den zuletzt gelaufenen wählen statt einen beliebigen.
|
||||||
|
UntisHubJobState? State(UntisHubJobKind kind, Guid? groupId) =>
|
||||||
|
states.Where(s => s.Kind == kind && s.GroupId == groupId)
|
||||||
|
.OrderByDescending(s => s.LastRunAt)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
var rows = new List<UntisHubJobRow>();
|
||||||
|
foreach (var group in eligibleGroups)
|
||||||
|
{
|
||||||
|
rows.Add(Row(UntisHubJobKind.FehlzeitenKurz, group.Id, group.Name,
|
||||||
|
State(UntisHubJobKind.FehlzeitenKurz, group.Id), FehlzeitenKurzDue, FehlzeitenKurzOverdue, utcNow));
|
||||||
|
rows.Add(Row(UntisHubJobKind.FehlzeitenLang, group.Id, group.Name,
|
||||||
|
State(UntisHubJobKind.FehlzeitenLang, group.Id), FehlzeitenLangDue, FehlzeitenLangOverdue, utcNow));
|
||||||
|
}
|
||||||
|
foreach (var (kind, label) in GlobalJobs)
|
||||||
|
rows.Add(Row(kind, null, label, State(kind, null), GlobalDue, GlobalOverdue, utcNow));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UntisHubJobRow Row(UntisHubJobKind kind, Guid? groupId, string groupName,
|
||||||
|
UntisHubJobState? state, TimeSpan due, TimeSpan overdue, DateTime utcNow)
|
||||||
|
{
|
||||||
|
var (dueState, dueLabel) = DueStatus(state?.LastRunAt, due, overdue, utcNow);
|
||||||
|
return new UntisHubJobRow(kind, groupId, groupName, state?.LastRunAt, state?.LastResultSummary,
|
||||||
|
dueState, dueLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (UntisHubDueState, string) DueStatus(
|
||||||
|
DateTime? lastRunAt, TimeSpan due, TimeSpan overdue, DateTime utcNow)
|
||||||
|
{
|
||||||
|
if (lastRunAt is null) return (UntisHubDueState.Overdue, "noch nie geprüft");
|
||||||
|
var age = utcNow - lastRunAt.Value;
|
||||||
|
var days = (int)age.TotalDays;
|
||||||
|
if (age >= overdue) return (UntisHubDueState.Overdue, $"fällig seit {days} Tagen");
|
||||||
|
if (age >= due) return (UntisHubDueState.Due, $"fällig seit {days} Tagen");
|
||||||
|
return (UntisHubDueState.Ok, days == 0 ? "gerade eben geprüft" : $"vor {days} Tag(en) geprüft");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using LehrerApp.Core.Importing;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aus <c>WebUntisLessonAbsenceComparisonViewModel</c> herausgelöst (Nachtrag: die MCP-Tools in
|
||||||
|
/// <c>UntisComparisonTools</c> brauchen exakt dieselbe Regel für dieselbe Fehlzeile, siehe TODO.md) -
|
||||||
|
/// framework-frei (kein ObservableObject/Avalonia-Bezug), damit beide Aufrufer ohne Duplikat
|
||||||
|
/// garantiert denselben Statusvorschlag berechnen. Eine Abweichung zwischen Dialog und MCP-Tool für
|
||||||
|
/// dieselbe WebUntis-Zeile wäre verwirrender als die eine zusätzliche Indirektion hier.
|
||||||
|
/// </summary>
|
||||||
|
public static class UntisLessonAbsenceHelper
|
||||||
|
{
|
||||||
|
private const int FullLessonMinutes = 45;
|
||||||
|
|
||||||
|
// Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die
|
||||||
|
// (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in
|
||||||
|
// Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist
|
||||||
|
// wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der
|
||||||
|
// Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung
|
||||||
|
// oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist
|
||||||
|
// laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt.
|
||||||
|
public static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence)
|
||||||
|
{
|
||||||
|
if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass;
|
||||||
|
if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late;
|
||||||
|
if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending;
|
||||||
|
if (absence.ExternKey is null) return AttendanceStatus.ExcusePending;
|
||||||
|
return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) =>
|
||||||
|
absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
|
||||||
|
/// Erste Wahl zum Zuordnen einer WebUntis-Fehlzeile: die externe Schülernummer (ENr), sofern der
|
||||||
|
/// Schüler eine hat (z.B. nicht bei manuell statt per WebUntis-Import angelegten Schülern).
|
||||||
|
public static int? StudentExternKey(Student student)
|
||||||
|
{
|
||||||
|
student.ExternalIds ??= [];
|
||||||
|
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
|
||||||
|
&& int.TryParse(value, out var key) ? key : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,9 +65,9 @@ public class UntisSyncService : IDisposable
|
|||||||
TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes));
|
TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task PollAsync()
|
public async Task PollAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!await _gate.WaitAsync(0)) return;
|
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = _settings.GetIcalUrl();
|
var url = _settings.GetIcalUrl();
|
||||||
@@ -76,7 +76,7 @@ public class UntisSyncService : IDisposable
|
|||||||
string icsText;
|
string icsText;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
icsText = await _http.GetStringAsync(url);
|
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -88,7 +88,7 @@ public class UntisSyncService : IDisposable
|
|||||||
UntisPollResult result;
|
UntisPollResult result;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
result = ProcessIcsText(icsText);
|
result = await Task.Run(() => ProcessIcsText(icsText)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -118,11 +118,20 @@ public class UntisSyncService : IDisposable
|
|||||||
var diffResult = _diffService.Diff(events, previousSnapshot, confirmedMappings, today,
|
var diffResult = _diffService.Diff(events, previousSnapshot, confirmedMappings, today,
|
||||||
existingSupervisionDuties: _supervisionDuties.GetAll(), freeDates: BuildFreeDates(today));
|
existingSupervisionDuties: _supervisionDuties.GetAll(), freeDates: BuildFreeDates(today));
|
||||||
|
|
||||||
|
var savedSubstitutionCount = 0;
|
||||||
foreach (var candidate in diffResult.SubstitutionsToSave)
|
foreach (var candidate in diffResult.SubstitutionsToSave)
|
||||||
{
|
{
|
||||||
var existing = candidate.ExternalId is not null ? _substitutions.GetByExternalId(candidate.ExternalId) : null;
|
var existing = candidate.ExternalId is not null ? _substitutions.GetByExternalId(candidate.ExternalId) : null;
|
||||||
if (existing is not null) candidate.Id = existing.Id;
|
// Der Diff liefert auch weiterhin bestehende WebUntis-Abweichungen als Kandidaten.
|
||||||
|
// Ein unverändertes Upsert würde über Repository.OnChange bei jedem Poll erneut ein
|
||||||
|
// Sync-Ereignis erzeugen und bei vielen aktiven Abweichungen das Protokoll fluten.
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
candidate.Id = existing.Id;
|
||||||
|
if (SubstitutionContentEquals(existing, candidate)) continue;
|
||||||
|
}
|
||||||
_substitutions.Save(candidate);
|
_substitutions.Save(candidate);
|
||||||
|
savedSubstitutionCount++;
|
||||||
}
|
}
|
||||||
// Zuvor automatisch erzeugte Einträge, die jetzt nicht (mehr) gebraucht werden (siehe
|
// Zuvor automatisch erzeugte Einträge, die jetzt nicht (mehr) gebraucht werden (siehe
|
||||||
// UntisDiffResult.SubstitutionExternalIdsToDelete) - existiert keiner mit dieser
|
// UntisDiffResult.SubstitutionExternalIdsToDelete) - existiert keiner mit dieser
|
||||||
@@ -135,9 +144,17 @@ public class UntisSyncService : IDisposable
|
|||||||
foreach (var snapshot in diffResult.SnapshotToSave) _snapshots.Save(snapshot);
|
foreach (var snapshot in diffResult.SnapshotToSave) _snapshots.Save(snapshot);
|
||||||
foreach (var id in diffResult.SnapshotIdsToDelete) _snapshots.Delete(id);
|
foreach (var id in diffResult.SnapshotIdsToDelete) _snapshots.Delete(id);
|
||||||
|
|
||||||
return new UntisPollResult(events.Count, diffResult.SubstitutionsToSave.Count);
|
return new UntisPollResult(events.Count, savedSubstitutionCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool SubstitutionContentEquals(SubstitutionEntry left, SubstitutionEntry right) =>
|
||||||
|
left.Date == right.Date && left.Kind == right.Kind &&
|
||||||
|
left.PeriodNumber == right.PeriodNumber && left.AfterPeriod == right.AfterPeriod &&
|
||||||
|
left.FromPeriod == right.FromPeriod && left.ToPeriod == right.ToPeriod &&
|
||||||
|
left.IsAllDay == right.IsAllDay && left.GroupId == right.GroupId &&
|
||||||
|
left.GroupLabel == right.GroupLabel && left.Description == right.Description &&
|
||||||
|
left.Notes == right.Notes && left.ExternalId == right.ExternalId;
|
||||||
|
|
||||||
// Ferien-/Feiertagstage im relevanten Zeitfenster (deutlich über das Lookahead-Fenster
|
// Ferien-/Feiertagstage im relevanten Zeitfenster (deutlich über das Lookahead-Fenster
|
||||||
// hinaus, kostet bei kleinen Ferienlisten nichts) - verhindert, dass die aktive
|
// hinaus, kostet bei kleinen Ferienlisten nichts) - verhindert, dass die aktive
|
||||||
// "fehlt komplett im Feed"-Prüfung in UntisDiffService Ferientage fälschlich als Ausfall
|
// "fehlt komplett im Feed"-Prüfung in UntisDiffService Ferientage fälschlich als Ausfall
|
||||||
|
|||||||
@@ -205,6 +205,13 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<UntisOpenPeriodsMeta> GetOpenPeriodsMetaAsync(int schoolYearId, CancellationToken token = default) =>
|
||||||
|
ExecuteAsync(client => client.GetOpenPeriodsMetaAsync(schoolYearId, token), token);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<UntisOpenPeriod>> GetOpenPeriodsAsync(int schoolYearId, int? teacherId,
|
||||||
|
int? classId, DateOnly start, DateOnly end, CancellationToken token = default) =>
|
||||||
|
ExecuteAsync(client => client.GetOpenPeriodsAsync(schoolYearId, teacherId, classId, start, end, token), token);
|
||||||
|
|
||||||
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
|
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
|
||||||
{
|
{
|
||||||
try { return await operation(await GetClientAsync(token)); }
|
try { return await operation(await GetClientAsync(token)); }
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using System.Windows.Input;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>Die sieben Dashboard-Kacheln, die tatsächlich "wo muss ich reagieren" beantworten
|
||||||
|
/// (siehe <see cref="DashboardViewModel.AttentionCount"/>), zusammengefasst zu einer Karte mit
|
||||||
|
/// Filter-Chips statt sieben eigenen Sichtbarkeits-Schaltern. Reihenfolge hier ist die Reihenfolge
|
||||||
|
/// der Gruppen in der zusammengefassten Ansicht (siehe DashboardViewModel.RebuildAttention) — sie
|
||||||
|
/// übernimmt bewusst die frühere Sortierung aus DashboardSettingsService.DefaultCardOrder, damit
|
||||||
|
/// sich nichts, was Nutzer bereits kennen, unvorhersehbar umsortiert.
|
||||||
|
/// "Klausurwochen" (examload) bleibt bewusst außen vor: die Einträge dort sind Wochen-Aggregate,
|
||||||
|
/// keine Einzelvorgänge, die sich abhaken oder anklicken lassen — sie passen nicht ins Item-Modell.</summary>
|
||||||
|
public enum AttentionKind { MissingTeachingTime, Excuse, Correction, Unplanned, Alert, Attendance, SupportPlan }
|
||||||
|
|
||||||
|
/// <summary>Eine Inline-Aktion auf einem Handlungsbedarf-Eintrag (z.B. "Entschuldigt"/"Unentschuldigt",
|
||||||
|
/// "Erfassen"). Verwendet ICommand statt Action, damit die bereits vorhandenen generierten
|
||||||
|
/// RelayCommands (OpenExcuseItem.MarkExcusedCommand, DashboardViewModel.AddMissingTeachingTimeCommand)
|
||||||
|
/// direkt weiterverwendet werden können, statt sie in einen zweiten Delegate-Typ zu verpacken.</summary>
|
||||||
|
public sealed record AttentionAction(string Label, ICommand Command, object? Parameter = null);
|
||||||
|
|
||||||
|
/// <summary>Gemeinsame Projektion für die sieben Handlungsbedarf-Arten. Trägt nur Anzeigedaten —
|
||||||
|
/// die eigentliche Beschaffung bleibt unverändert in den jeweiligen DashboardViewModel.LoadXxx-
|
||||||
|
/// Methoden; sie erzeugen am Ende ein AttentionItem statt (wie vorher) ihre eigene Item-Klasse in
|
||||||
|
/// ihre eigene ObservableCollection einzutragen.</summary>
|
||||||
|
public sealed class AttentionItem
|
||||||
|
{
|
||||||
|
public AttentionKind Kind { get; }
|
||||||
|
public string Title { get; }
|
||||||
|
public string Subtitle { get; }
|
||||||
|
/// <summary>Kurztext rechts neben dem Titel — Fehlzeitenquote, Alert-Kind-Label. Meist leer.</summary>
|
||||||
|
public string TrailingText { get; }
|
||||||
|
public string DateDisplay { get; }
|
||||||
|
public bool IsOverdue { get; }
|
||||||
|
public AlertSeverity? Severity { get; }
|
||||||
|
/// <summary>Nur bei Korrekturen gesetzt (0–100); steuert die ProgressBar. Eigenes HasProgress statt
|
||||||
|
/// Nullability, damit die ProgressBar-Bindung ein einfaches int (kompatibel mit Value:double) bleibt.</summary>
|
||||||
|
public int ProgressPercent { get; }
|
||||||
|
public bool HasProgress { get; }
|
||||||
|
/// <summary>Text unter der ProgressBar, z.B. "3 von 5 Arbeiten bewertet". Nur bei Korrekturen gesetzt.</summary>
|
||||||
|
public string ProgressLabel { get; }
|
||||||
|
/// <summary>Färbt TrailingText wie die frühere Fehlzeiten-Warnung (fest "Foreground=Red") ein —
|
||||||
|
/// nur bei Attendance gesetzt, weil ein AttentionItem dieser Art per Konstruktion nur entsteht,
|
||||||
|
/// wenn die Fehlzeitenquote den Schwellenwert bereits überschreitet.</summary>
|
||||||
|
public bool IsWarningTrailing { get; }
|
||||||
|
public IReadOnlyList<AttentionAction> Actions { get; }
|
||||||
|
public Action? Navigate { get; }
|
||||||
|
|
||||||
|
public AttentionItem(AttentionKind kind, string title, string subtitle = "", string trailingText = "",
|
||||||
|
string dateDisplay = "", bool isOverdue = false, AlertSeverity? severity = null,
|
||||||
|
int progressPercent = 0, string progressLabel = "", bool hasProgress = false,
|
||||||
|
bool isWarningTrailing = false, IReadOnlyList<AttentionAction>? actions = null, Action? navigate = null)
|
||||||
|
{
|
||||||
|
Kind = kind;
|
||||||
|
Title = title;
|
||||||
|
Subtitle = subtitle;
|
||||||
|
TrailingText = trailingText;
|
||||||
|
DateDisplay = dateDisplay;
|
||||||
|
IsOverdue = isOverdue;
|
||||||
|
Severity = severity;
|
||||||
|
ProgressPercent = progressPercent;
|
||||||
|
ProgressLabel = progressLabel;
|
||||||
|
HasProgress = hasProgress;
|
||||||
|
IsWarningTrailing = isWarningTrailing;
|
||||||
|
Actions = actions ?? [];
|
||||||
|
Navigate = navigate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle);
|
||||||
|
public bool HasTrailingText => !string.IsNullOrWhiteSpace(TrailingText);
|
||||||
|
public bool HasDate => !string.IsNullOrWhiteSpace(DateDisplay);
|
||||||
|
public bool HasActions => Actions.Count > 0;
|
||||||
|
public bool HasSeverity => Severity is not null;
|
||||||
|
public bool IsHighSeverity => Severity == AlertSeverity.High;
|
||||||
|
public bool IsMediumSeverity => Severity == AlertSeverity.Medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Eine Kind-Gruppe innerhalb der zusammengefassten Handlungsbedarf-Liste. Items behalten
|
||||||
|
/// die Sortierung, die ihre jeweilige LoadXxx-Methode schon immer produziert hat (z.B. Fehlzeiten
|
||||||
|
/// nach Quote absteigend, Korrekturen nach Datum) — ein zweites, generisches "Importance"-Sortieren
|
||||||
|
/// quer über alle Arten würde diese bewusst gewählten Reihenfolgen wieder zerstören.</summary>
|
||||||
|
public sealed class AttentionGroup(AttentionKind kind, string header, IReadOnlyList<AttentionItem> items)
|
||||||
|
{
|
||||||
|
public AttentionKind Kind { get; } = kind;
|
||||||
|
public string Header { get; } = header;
|
||||||
|
public IReadOnlyList<AttentionItem> Items { get; } = items;
|
||||||
|
public int Count => Items.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein Filter-Chip für eine Handlungsbedarf-Art in der zusammengefassten Karte — ersetzt
|
||||||
|
/// die frühere Sichtbarkeit je Einzelkachel (siehe DashboardViewModel.LoadAttentionFilters).</summary>
|
||||||
|
public partial class AttentionFilterOption : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty] private bool _isActive = true;
|
||||||
|
public AttentionKind Kind { get; }
|
||||||
|
public string Label { get; }
|
||||||
|
public Action? OnChanged { get; set; }
|
||||||
|
|
||||||
|
public AttentionFilterOption(AttentionKind kind, string label)
|
||||||
|
{
|
||||||
|
Kind = kind;
|
||||||
|
Label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnIsActiveChanged(bool value) => OnChanged?.Invoke();
|
||||||
|
}
|
||||||
@@ -97,7 +97,8 @@ public partial class ClassTeacherCasesViewModel : ObservableObject
|
|||||||
var names = vorgang.StudentIds.Select(id => nameById.GetValueOrDefault(id, ""))
|
var names = vorgang.StudentIds.Select(id => nameById.GetValueOrDefault(id, ""))
|
||||||
.Where(n => n != "");
|
.Where(n => n != "");
|
||||||
var item = new VorgangItem(vorgang, string.Join(", ", names), LinkDocumentation,
|
var item = new VorgangItem(vorgang, string.Join(", ", names), LinkDocumentation,
|
||||||
UnlinkDocumentation, RemoveClassRegisterEntry) { IsRevealed = revealedIds.Contains(vorgang.Id) };
|
UnlinkDocumentation, RemoveClassRegisterEntry, EditDocumentation)
|
||||||
|
{ IsRevealed = revealedIds.Contains(vorgang.Id) };
|
||||||
|
|
||||||
var linkedIds = vorgang.DocumentationIds.ToHashSet();
|
var linkedIds = vorgang.DocumentationIds.ToHashSet();
|
||||||
foreach (var doc in allDocs.Where(d => linkedIds.Contains(d.Id)))
|
foreach (var doc in allDocs.Where(d => linkedIds.Contains(d.Id)))
|
||||||
@@ -169,6 +170,19 @@ public partial class ClassTeacherCasesViewModel : ObservableObject
|
|||||||
await LoadInternal();
|
await LoadInternal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Öffnet eine verknüpfte oder noch verknüpfbare Dokumentation im vollen Bearbeiten-Dialog, ohne
|
||||||
|
/// den Vorgänge-Tab zu verlassen (Nutzer-Feedback: bisher musste man dafür zum Nachlesen in den
|
||||||
|
/// Schülerdatensatz wechseln). Reine Bearbeitung der Dokumentation selbst — welche Vorgänge sie
|
||||||
|
/// verlinken, ändert sich dadurch nicht.
|
||||||
|
private async Task EditDocumentation(DocumentationItem doc)
|
||||||
|
{
|
||||||
|
if (OnEditDocumentation is null) return;
|
||||||
|
var result = await OnEditDocumentation(doc.Model.StudentId, _rosterStudentOptions, doc.Model);
|
||||||
|
if (result is null) return;
|
||||||
|
_documentation.Save(result);
|
||||||
|
await LoadInternal();
|
||||||
|
}
|
||||||
|
|
||||||
/// Dokumentation der Vorgangs-Schüler*innen, die noch nicht mit diesem Vorgang verknüpft ist —
|
/// Dokumentation der Vorgangs-Schüler*innen, die noch nicht mit diesem Vorgang verknüpft ist —
|
||||||
/// reine, ohne Repository-Zugriff testbare Filterlogik (gleiches Muster wie
|
/// reine, ohne Repository-Zugriff testbare Filterlogik (gleiches Muster wie
|
||||||
/// <see cref="ClassTeacherRosterRow.Build"/>).
|
/// <see cref="ClassTeacherRosterRow.Build"/>).
|
||||||
|
|||||||
@@ -119,6 +119,24 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
|
|||||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum ClassTeacherCalendarEventKind { Late, Absent, Unexcused, Excused, ClassRegister, Homework }
|
||||||
|
|
||||||
|
/// <summary>Kompakter, farblich codierter Marker in der Klassenlehrer-Monatsansicht.</summary>
|
||||||
|
public sealed record ClassTeacherCalendarEvent(string StudentName, string Code, string Label,
|
||||||
|
string ColorHex, ClassTeacherCalendarEventKind Kind)
|
||||||
|
{
|
||||||
|
public string Tooltip => $"{StudentName}: {Label}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein Werktag im 5-Spalten-Monatsraster; Tage außerhalb des Monats sind Platzhalter.</summary>
|
||||||
|
public sealed record ClassTeacherCalendarDay(DateOnly? Date, IReadOnlyList<ClassTeacherCalendarEvent> Events)
|
||||||
|
{
|
||||||
|
public bool IsPlaceholder => Date is null;
|
||||||
|
public string DayLabel => Date?.ToString("dd.") ?? "";
|
||||||
|
public string WeekdayLabel => Date?.ToString("ddd", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) ?? "";
|
||||||
|
public bool HasEvents => Events.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu
|
/// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu
|
||||||
/// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der
|
/// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der
|
||||||
@@ -149,6 +167,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
|
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
|
||||||
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
|
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
|
||||||
public ObservableCollection<DocumentationItem> OwnDocumentationEntries { get; } = [];
|
public ObservableCollection<DocumentationItem> OwnDocumentationEntries { get; } = [];
|
||||||
|
public ObservableCollection<ClassTeacherCalendarDay> CalendarDays { get; } = [];
|
||||||
|
|
||||||
/// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden).
|
/// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden).
|
||||||
[ObservableProperty] private ClassTeacherClassRegisterRow? _selectedEntry;
|
[ObservableProperty] private ClassTeacherClassRegisterRow? _selectedEntry;
|
||||||
@@ -160,6 +179,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
|
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
|
||||||
[ObservableProperty] private string _studentFilter = "";
|
[ObservableProperty] private string _studentFilter = "";
|
||||||
[ObservableProperty] private int _quickRangeIndex = 1;
|
[ObservableProperty] private int _quickRangeIndex = 1;
|
||||||
|
[ObservableProperty] private bool _showMonthlyCalendar;
|
||||||
|
[ObservableProperty] private bool _includeHomeworkInCalendar;
|
||||||
|
[ObservableProperty] private DateOnly _calendarMonth = new(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||||||
/// Umschalter Klassenbuch (WebUntis, andere Lehrkräfte) ↔ eigene Dokumentation.
|
/// Umschalter Klassenbuch (WebUntis, andere Lehrkräfte) ↔ eigene Dokumentation.
|
||||||
[ObservableProperty] private bool _showOwnDocumentation;
|
[ObservableProperty] private bool _showOwnDocumentation;
|
||||||
[ObservableProperty] private int _ownDocumentationCount;
|
[ObservableProperty] private int _ownDocumentationCount;
|
||||||
@@ -174,14 +196,18 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
|
|
||||||
public bool HasEntries => Entries.Count > 0;
|
public bool HasEntries => Entries.Count > 0;
|
||||||
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
|
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
|
||||||
|
public bool ShowAbsenceListEmpty => !ShowMonthlyCalendar && !HasAbsenceEntries;
|
||||||
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
|
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
|
||||||
public bool HasOwnDocumentationEntries => OwnDocumentationEntries.Count > 0;
|
public bool HasOwnDocumentationEntries => OwnDocumentationEntries.Count > 0;
|
||||||
|
public bool CanAddOwnDocumentation => !Busy && _rosterMatches.Count > 0;
|
||||||
/// Die Kategorien-Chipreihe fasst nur WebUntis-Kategorien zusammen (<see cref="CategoryAggregates"/>)
|
/// Die Kategorien-Chipreihe fasst nur WebUntis-Kategorien zusammen (<see cref="CategoryAggregates"/>)
|
||||||
/// — im Modus "Eigene Dokumentation" ausgeblendet, dort gibt es kein Äquivalent zu CategoryGroup.
|
/// — im Modus "Eigene Dokumentation" ausgeblendet, dort gibt es kein Äquivalent zu CategoryGroup.
|
||||||
public bool ShowCategoryAggregates => HasCategoryAggregates && !ShowOwnDocumentation;
|
public bool ShowCategoryAggregates => HasCategoryAggregates && !ShowOwnDocumentation;
|
||||||
public int UntisCriticalCount => Entries.Count(e => e.IsDangerStatus);
|
public int UntisCriticalCount => Entries.Count(e => e.IsDangerStatus);
|
||||||
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
|
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
|
||||||
? "Alle Schüler*innen" : StudentFilter;
|
? "Alle Schüler*innen" : StudentFilter;
|
||||||
|
public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy",
|
||||||
|
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||||
|
|
||||||
public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation,
|
public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation,
|
||||||
IStudentRepository students, ClassTeacherCasesViewModel cases)
|
IStudentRepository students, ClassTeacherCasesViewModel cases)
|
||||||
@@ -198,6 +224,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
StudentFilter = "";
|
StudentFilter = "";
|
||||||
Entries.Clear();
|
Entries.Clear();
|
||||||
AbsenceEntries.Clear();
|
AbsenceEntries.Clear();
|
||||||
|
CalendarDays.Clear();
|
||||||
CategoryAggregates.Clear();
|
CategoryAggregates.Clear();
|
||||||
OwnDocumentationEntries.Clear();
|
OwnDocumentationEntries.Clear();
|
||||||
_rosterMatches = [];
|
_rosterMatches = [];
|
||||||
@@ -208,6 +235,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
|
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
|
||||||
|
|
||||||
partial void OnShowOwnDocumentationChanged(bool value) => OnPropertyChanged(nameof(ShowCategoryAggregates));
|
partial void OnShowOwnDocumentationChanged(bool value) => OnPropertyChanged(nameof(ShowCategoryAggregates));
|
||||||
|
partial void OnShowMonthlyCalendarChanged(bool value) => OnPropertyChanged(nameof(ShowAbsenceListEmpty));
|
||||||
|
partial void OnIncludeHomeworkInCalendarChanged(bool value) => BuildCalendar();
|
||||||
|
partial void OnCalendarMonthChanged(DateOnly value) => OnPropertyChanged(nameof(CalendarMonthLabel));
|
||||||
|
|
||||||
partial void OnQuickRangeIndexChanged(int value)
|
partial void OnQuickRangeIndexChanged(int value)
|
||||||
{
|
{
|
||||||
@@ -230,6 +260,37 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private Task ApplyFilter() => LoadInternal(forceRefresh: false);
|
private Task ApplyFilter() => LoadInternal(forceRefresh: false);
|
||||||
|
|
||||||
|
[RelayCommand] private void ShowAbsenceList() => ShowMonthlyCalendar = false;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ShowCalendar()
|
||||||
|
{
|
||||||
|
ShowMonthlyCalendar = true;
|
||||||
|
await LoadCalendarMonth(forceRefresh: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task PreviousCalendarMonth()
|
||||||
|
{
|
||||||
|
CalendarMonth = CalendarMonth.AddMonths(-1);
|
||||||
|
await LoadCalendarMonth(forceRefresh: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task NextCalendarMonth()
|
||||||
|
{
|
||||||
|
CalendarMonth = CalendarMonth.AddMonths(1);
|
||||||
|
await LoadCalendarMonth(forceRefresh: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task LoadCalendarMonth(bool forceRefresh)
|
||||||
|
{
|
||||||
|
StartDate = new DateTimeOffset(CalendarMonth.ToDateTime(TimeOnly.MinValue));
|
||||||
|
var end = CalendarMonth.AddMonths(1).AddDays(-1);
|
||||||
|
EndDate = new DateTimeOffset(end.ToDateTime(TimeOnly.MinValue));
|
||||||
|
return LoadInternal(forceRefresh);
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ClearStudentFilter()
|
private async Task ClearStudentFilter()
|
||||||
{
|
{
|
||||||
@@ -270,6 +331,8 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
|
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
|
||||||
AbsenceEntries.Add(row);
|
AbsenceEntries.Add(row);
|
||||||
|
|
||||||
|
BuildCalendar();
|
||||||
|
|
||||||
var localStudents = _students.GetAll();
|
var localStudents = _students.GetAll();
|
||||||
_rosterMatches = rosterTask.Result
|
_rosterMatches = rosterTask.Result
|
||||||
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
|
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
|
||||||
@@ -289,6 +352,74 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
finally { Busy = false; NotifyListState(); }
|
finally { Busy = false; NotifyListState(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void BuildCalendar()
|
||||||
|
{
|
||||||
|
CalendarDays.Clear();
|
||||||
|
foreach (var day in BuildCalendarDays(CalendarMonth, AbsenceEntries, Entries, IncludeHomeworkInCalendar))
|
||||||
|
CalendarDays.Add(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Baut ein Montag-bis-Freitag-Raster aus den ohnehin geladenen Berichten. Negative
|
||||||
|
/// Klassenbucheinträge erscheinen als !; Hausaufgaben sind eine bewusst optionale Sonderlage.</summary>
|
||||||
|
public static IReadOnlyList<ClassTeacherCalendarDay> BuildCalendarDays(DateOnly month,
|
||||||
|
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||||
|
IReadOnlyList<ClassTeacherClassRegisterRow> registerEntries, bool includeHomework)
|
||||||
|
{
|
||||||
|
var first = new DateOnly(month.Year, month.Month, 1);
|
||||||
|
var last = first.AddMonths(1).AddDays(-1);
|
||||||
|
var gridStart = first.AddDays(-(((int)first.DayOfWeek + 6) % 7));
|
||||||
|
var gridEnd = last.AddDays((7 - (int)last.DayOfWeek) % 7);
|
||||||
|
var result = new List<ClassTeacherCalendarDay>();
|
||||||
|
|
||||||
|
for (var date = gridStart; date <= gridEnd; date = date.AddDays(1))
|
||||||
|
{
|
||||||
|
if (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) continue;
|
||||||
|
if (date.Month != first.Month)
|
||||||
|
{
|
||||||
|
result.Add(new ClassTeacherCalendarDay(null, []));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(new ClassTeacherCalendarDay(date,
|
||||||
|
BuildDayEvents(date, absences, registerEntries, includeHomework)));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<ClassTeacherCalendarEvent> BuildDayEvents(DateOnly date,
|
||||||
|
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||||
|
IReadOnlyList<ClassTeacherClassRegisterRow> registerEntries, bool includeHomework)
|
||||||
|
{
|
||||||
|
var events = new List<ClassTeacherCalendarEvent>();
|
||||||
|
foreach (var absence in absences.Where(a => a.Date == date).OrderBy(a => a.StudentDisplayName))
|
||||||
|
{
|
||||||
|
var (code, label, color, kind) = absence.IsUnexcused
|
||||||
|
? ("U", "Unentschuldigt gefehlt", "#C62828", ClassTeacherCalendarEventKind.Unexcused)
|
||||||
|
: absence.IsLate
|
||||||
|
? ("V", "Verspätet", "#EF6C00", ClassTeacherCalendarEventKind.Late)
|
||||||
|
: absence.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? ("E", "Entschuldigt gefehlt", "#2E7D32", ClassTeacherCalendarEventKind.Excused)
|
||||||
|
: ("A", "Abwesend", "#1565C0", ClassTeacherCalendarEventKind.Absent);
|
||||||
|
events.Add(new ClassTeacherCalendarEvent(absence.StudentDisplayName, code, label, color, kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var entry in registerEntries.Where(e => e.Date == date).OrderBy(e => e.StudentDisplayName))
|
||||||
|
{
|
||||||
|
var isHomework = ContainsHomework(entry);
|
||||||
|
if (isHomework && !includeHomework) continue;
|
||||||
|
if (!entry.IsDangerStatus && !isHomework) continue;
|
||||||
|
events.Add(new ClassTeacherCalendarEvent(entry.StudentDisplayName,
|
||||||
|
isHomework ? "H" : "!", isHomework ? "Hausaufgaben" : "Negativer Klassenbucheintrag",
|
||||||
|
isHomework ? "#6A1B9A" : "#8E24AA",
|
||||||
|
isHomework ? ClassTeacherCalendarEventKind.Homework : ClassTeacherCalendarEventKind.ClassRegister));
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsHomework(ClassTeacherClassRegisterRow entry) =>
|
||||||
|
entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true ||
|
||||||
|
entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
|
||||||
/// Baut <see cref="OwnDocumentationEntries"/> aus <see cref="_rosterMatches"/> und dem zuletzt
|
/// Baut <see cref="OwnDocumentationEntries"/> aus <see cref="_rosterMatches"/> und dem zuletzt
|
||||||
/// geladenen Zeitraum neu auf — separat von <see cref="LoadInternal"/>, damit Anlegen/Bearbeiten/
|
/// geladenen Zeitraum neu auf — separat von <see cref="LoadInternal"/>, damit Anlegen/Bearbeiten/
|
||||||
/// Löschen eines eigenen Eintrags nicht auch die WebUntis-Berichte neu abruft. Die eigentliche
|
/// Löschen eines eigenen Eintrags nicht auch die WebUntis-Berichte neu abruft. Die eigentliche
|
||||||
@@ -301,7 +432,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
_loadedStart, _loadedEnd, StudentFilter);
|
_loadedStart, _loadedEnd, StudentFilter);
|
||||||
foreach (var d in entries)
|
foreach (var d in entries)
|
||||||
{
|
{
|
||||||
var name = _rosterMatches.First(m => m.StudentId == d.StudentId).DisplayName;
|
var name = DocumentationStudentDisplay(d, _rosterMatches);
|
||||||
OwnDocumentationEntries.Add(new DocumentationItem(d, name));
|
OwnDocumentationEntries.Add(new DocumentationItem(d, name));
|
||||||
}
|
}
|
||||||
OwnDocumentationCount = OwnDocumentationEntries.Count;
|
OwnDocumentationCount = OwnDocumentationEntries.Count;
|
||||||
@@ -322,7 +453,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
var matchedIds = rosterMatches.Select(m => m.StudentId).ToHashSet();
|
var matchedIds = rosterMatches.Select(m => m.StudentId).ToHashSet();
|
||||||
return all
|
return all
|
||||||
.Where(d => !d.IsDeleted && matchedIds.Contains(d.StudentId) && d.Date >= start && d.Date <= end)
|
.Where(d => !d.IsDeleted &&
|
||||||
|
(matchedIds.Contains(d.StudentId) || IsUnassignedClassDocumentation(d)) &&
|
||||||
|
d.Date >= start && d.Date <= end)
|
||||||
.Where(d => MatchesOwnDocStudentFilter(d, rosterMatches, studentFilter))
|
.Where(d => MatchesOwnDocStudentFilter(d, rosterMatches, studentFilter))
|
||||||
.OrderByDescending(d => d.IsDraft).ThenByDescending(d => d.Date)
|
.OrderByDescending(d => d.IsDraft).ThenByDescending(d => d.Date)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -332,14 +465,37 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches, string studentFilter)
|
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches, string studentFilter)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(studentFilter)) return true;
|
if (string.IsNullOrWhiteSpace(studentFilter)) return true;
|
||||||
|
if (IsUnassignedClassDocumentation(d))
|
||||||
|
return d.Participants.Any(p => NameContainsSearchTerm(p, studentFilter));
|
||||||
var name = rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName;
|
var name = rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName;
|
||||||
return name is not null && UntisNameMatching.NamesMatch(name, studentFilter);
|
return name is not null && NameContainsSearchTerm(name, studentFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsUnassignedClassDocumentation(Documentation d) =>
|
||||||
|
d.StudentId == Guid.Empty && d.GroupId is null;
|
||||||
|
|
||||||
|
private static bool NameContainsSearchTerm(string name, string search)
|
||||||
|
{
|
||||||
|
if (UntisNameMatching.NamesMatch(name, search)) return true;
|
||||||
|
var searchKey = UntisNameMatching.NameKey(search);
|
||||||
|
return searchKey.Length > 0 && UntisNameMatching.NameKey(name).Split(' ').Contains(searchKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DocumentationStudentDisplay(Documentation d,
|
||||||
|
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches)
|
||||||
|
{
|
||||||
|
if (d.StudentId != Guid.Empty)
|
||||||
|
return rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName
|
||||||
|
?? "Unbekannter Schülerbezug";
|
||||||
|
return d.Participants.Count > 0
|
||||||
|
? $"Ohne festen Bezug · {string.Join(", ", d.Participants)}"
|
||||||
|
: "Ohne Schülerbezug";
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task AddOwnDocumentation()
|
private async Task AddOwnDocumentation()
|
||||||
{
|
{
|
||||||
if (OnEditOwnDocumentation is null) return;
|
if (OnEditOwnDocumentation is null || !CanAddOwnDocumentation) return;
|
||||||
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
|
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
|
||||||
var result = await OnEditOwnDocumentation(options, null);
|
var result = await OnEditOwnDocumentation(options, null);
|
||||||
if (result is null) return;
|
if (result is null) return;
|
||||||
@@ -419,9 +575,11 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(HasEntries));
|
OnPropertyChanged(nameof(HasEntries));
|
||||||
OnPropertyChanged(nameof(HasAbsenceEntries));
|
OnPropertyChanged(nameof(HasAbsenceEntries));
|
||||||
|
OnPropertyChanged(nameof(ShowAbsenceListEmpty));
|
||||||
OnPropertyChanged(nameof(HasCategoryAggregates));
|
OnPropertyChanged(nameof(HasCategoryAggregates));
|
||||||
OnPropertyChanged(nameof(ShowCategoryAggregates));
|
OnPropertyChanged(nameof(ShowCategoryAggregates));
|
||||||
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
|
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
|
||||||
|
OnPropertyChanged(nameof(CanAddOwnDocumentation));
|
||||||
OnPropertyChanged(nameof(UntisCriticalCount));
|
OnPropertyChanged(nameof(UntisCriticalCount));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
|||||||
/// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary>
|
/// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary>
|
||||||
public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
|
public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
|
||||||
|
|
||||||
|
/// <summary>Die drei Auffälligkeits-Achsen, die ein geschlossener <see cref="Vorgang"/> per
|
||||||
|
/// Schlagwort gezielt dämpfen kann, siehe <see cref="ClassTeacherRosterRow.MatchDomains"/>.</summary>
|
||||||
|
public enum VorgangScoreDomain { Attendance, Lateness, Classbook }
|
||||||
|
|
||||||
|
/// <summary>Auswahlbereich des kompakten Monatskalenders. Ein leerer Schülername steht für die
|
||||||
|
/// bisherige Klassen-Gesamtansicht.</summary>
|
||||||
|
public sealed record ClassTeacherCalendarScope(string? StudentName, string DisplayName);
|
||||||
|
|
||||||
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
|
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
|
||||||
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
|
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
|
||||||
{
|
{
|
||||||
@@ -130,22 +138,6 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
(HasSuspensionEntry ? " · Suspendierung vermerkt" : "") +
|
(HasSuspensionEntry ? " · Suspendierung vermerkt" : "") +
|
||||||
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : "");
|
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : "");
|
||||||
|
|
||||||
// Feste Gewichte statt einstellbarer Werte (Nutzer-Entscheidung): unentschuldigt fällt am
|
|
||||||
// stärksten ins Gewicht ("geschwänzt"), entschuldigte Abwesenheit mittel, reine Verspätung
|
|
||||||
// ("verpennt") am wenigsten — genau die vom Nutzer gewünschte Trennung, nur als Zahl statt als
|
|
||||||
// Statustext. Bei Bedarf später anpassbar, siehe TODO.md.
|
|
||||||
private const double UnexcusedDayWeight = 3.0;
|
|
||||||
private const double ExcusedDayWeight = 1.0;
|
|
||||||
private const double LateDayWeight = 0.5;
|
|
||||||
// Klassenbuch-Einträge anderer Lehrkräfte (Nutzer-Feedback): ein einzelner "Negativ"-Eintrag
|
|
||||||
// wiegt ungefähr wie eine entschuldigte Fehlzeit — erst die Häufung ("durch die Decke gehen")
|
|
||||||
// treibt den Score merklich. Die beiden genannten Muster (fehlende Hausaufgaben/schlechte
|
|
||||||
// Mitarbeit) zählen zusätzlich etwas stärker, weil sie explizit als wiederkehrend relevant
|
|
||||||
// genannt wurden. "Suspendiert" ist bewusst ein Ausreißer-Gewicht, das den Score sofort nach
|
|
||||||
// oben reißt, unabhängig vom sonstigen Verlauf.
|
|
||||||
private const double NegativeClassRegisterEntryWeight = 1.0;
|
|
||||||
private const double ConcerningKeywordBonus = 1.5;
|
|
||||||
private const double SuspensionKeywordWeight = 15.0;
|
|
||||||
private static readonly string[] ConcerningKeywords = ["hausaufgabe", "mitarbeit"];
|
private static readonly string[] ConcerningKeywords = ["hausaufgabe", "mitarbeit"];
|
||||||
private static readonly string[] SuspensionKeywords = ["suspendier"];
|
private static readonly string[] SuspensionKeywords = ["suspendier"];
|
||||||
|
|
||||||
@@ -157,12 +149,19 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
/// zusätzliche Komplexität einer Verfallskurve. Bezieht neben Fehlzeiten auch Klassenbucheinträge
|
/// zusätzliche Komplexität einer Verfallskurve. Bezieht neben Fehlzeiten auch Klassenbucheinträge
|
||||||
/// anderer Lehrkräfte mit ein (<see cref="ClassRegisterScoreComponent"/>) — beides sind
|
/// anderer Lehrkräfte mit ein (<see cref="ClassRegisterScoreComponent"/>) — beides sind
|
||||||
/// unabhängige Auffälligkeits-Achsen (Anwesenheit vs. Verhalten/Leistung), die sich addieren
|
/// unabhängige Auffälligkeits-Achsen (Anwesenheit vs. Verhalten/Leistung), die sich addieren
|
||||||
/// statt sich gegenseitig zu verdrängen.
|
/// statt sich gegenseitig zu verdrängen. Gewichte sind einstellbar (<see cref="PatternScoreWeights"/>,
|
||||||
public double PatternScore =>
|
/// Nutzer-Entscheidung nach anfänglich festen Konstanten) und die Bereiche eines geschlossenen
|
||||||
YearUnexcusedDayCount * UnexcusedDayWeight +
|
/// <see cref="Vorgang"/> sind ab dessen Schließ-Datum aus dem Score ausgeklammert (<see cref="Build"/>),
|
||||||
YearExcusedDayCount * ExcusedDayWeight +
|
/// deshalb jetzt ein zur Build-Zeit berechneter Wert statt einer reinen Formel über die
|
||||||
YearLateDayCount * LateDayWeight +
|
/// (unveränderten, für die Quote weiter vollständigen) Year*-Zähler.
|
||||||
ClassRegisterScoreComponent;
|
public double PatternScore { get; init; }
|
||||||
|
|
||||||
|
/// Score-Anteil, der NACH dem Schließen des zuletzt geschlossenen Vorgangs dieser/dieses
|
||||||
|
/// Schülers*in neu hinzugekommen ist, beschränkt auf dessen eigene Themenbereiche
|
||||||
|
/// (<see cref="MatchDomains"/>) — Grundlage für die "erneut auffällig"-Warnung. 0 ohne
|
||||||
|
/// geschlossenen Vorgang oder ohne neue Ereignisse seitdem.
|
||||||
|
public double RegressionScore { get; init; }
|
||||||
|
public string? RegressionVorgangTitle { get; init; }
|
||||||
|
|
||||||
private static bool IsLateReason(string reason) =>
|
private static bool IsLateReason(string reason) =>
|
||||||
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
|
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -175,15 +174,80 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
e.CategoryName?.Contains(k, StringComparison.OrdinalIgnoreCase) == true ||
|
e.CategoryName?.Contains(k, StringComparison.OrdinalIgnoreCase) == true ||
|
||||||
e.Text?.Contains(k, StringComparison.OrdinalIgnoreCase) == true);
|
e.Text?.Contains(k, StringComparison.OrdinalIgnoreCase) == true);
|
||||||
|
|
||||||
private static double ClassRegisterEntryWeight(UntisForeignClassRegisterEventDto e)
|
private static double ClassRegisterEntryWeight(UntisForeignClassRegisterEventDto e, PatternScoreWeights weights)
|
||||||
{
|
{
|
||||||
var weight = 0.0;
|
var weight = 0.0;
|
||||||
if (IsNegativeClassRegisterEntry(e)) weight += NegativeClassRegisterEntryWeight;
|
if (IsNegativeClassRegisterEntry(e)) weight += weights.NegativeClassRegisterEntryWeight;
|
||||||
if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += ConcerningKeywordBonus;
|
if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += weights.ConcerningKeywordBonus;
|
||||||
if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += SuspensionKeywordWeight;
|
if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += weights.SuspensionKeywordWeight;
|
||||||
return weight;
|
return weight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dämpfung nach geschlossenem Vorgang (Nutzer-Entscheidung: bereichsbezogen statt komplett) ──
|
||||||
|
|
||||||
|
private static readonly string[] AttendanceDomainKeywords = ["absent", "fehlzeit", "schwänz", "unentschuldigt"];
|
||||||
|
private static readonly string[] LatenessDomainKeywords = ["versp"];
|
||||||
|
private static readonly string[] ClassbookDomainKeywords =
|
||||||
|
["konflikt", "eskalat", "mitarbeit", "hausaufgabe", "klassenbuch"];
|
||||||
|
|
||||||
|
/// Ordnet die Freitext-Schlagwörter eines Vorgangs den Score-Bereichen zu, die er dämpfen soll
|
||||||
|
/// (gleiche Contains-Konvention wie die "Negativ"/"verspät"-Erkennung andernorts). Kein Treffer
|
||||||
|
/// auf irgendein Schlagwort -> alle drei Bereiche, damit ein geschlossener Vorgang nie
|
||||||
|
/// folgenlos bleibt (z.B. bei einem reinen "Elternkontakt"-Schlagwort ohne klare Zuordnung).
|
||||||
|
public static IReadOnlySet<VorgangScoreDomain> MatchDomains(IReadOnlyList<string> tags)
|
||||||
|
{
|
||||||
|
var domains = new HashSet<VorgangScoreDomain>();
|
||||||
|
if (tags.Any(t => AttendanceDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
|
||||||
|
domains.Add(VorgangScoreDomain.Attendance);
|
||||||
|
if (tags.Any(t => LatenessDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
|
||||||
|
domains.Add(VorgangScoreDomain.Lateness);
|
||||||
|
if (tags.Any(t => ClassbookDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
|
||||||
|
domains.Add(VorgangScoreDomain.Classbook);
|
||||||
|
if (domains.Count == 0)
|
||||||
|
domains = [VorgangScoreDomain.Attendance, VorgangScoreDomain.Lateness, VorgangScoreDomain.Classbook];
|
||||||
|
return domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pro Bereich das jüngste Schließ-Datum unter den passenden geschlossenen Vorgängen — Ereignisse
|
||||||
|
/// vor diesem Datum zählen für diesen Bereich nicht mehr in <see cref="PatternScore"/>, die volle
|
||||||
|
/// Historie (Year*-Zähler, Quote) bleibt davon unberührt.
|
||||||
|
public static IReadOnlyDictionary<VorgangScoreDomain, DateOnly> ComputeDampeningCutoffs(
|
||||||
|
IReadOnlyList<Vorgang> closedVorgaenge)
|
||||||
|
{
|
||||||
|
var cutoffs = new Dictionary<VorgangScoreDomain, DateOnly>();
|
||||||
|
foreach (var v in closedVorgaenge)
|
||||||
|
{
|
||||||
|
if (v.ClosedAt is not { } closedAt) continue;
|
||||||
|
var closedDate = DateOnly.FromDateTime(closedAt);
|
||||||
|
foreach (var domain in MatchDomains(v.Tags))
|
||||||
|
if (!cutoffs.TryGetValue(domain, out var existing) || closedDate > existing)
|
||||||
|
cutoffs[domain] = closedDate;
|
||||||
|
}
|
||||||
|
return cutoffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gemeinsame Score-Formel für <see cref="PatternScore"/> und <see cref="RegressionScore"/>:
|
||||||
|
/// <paramref name="cutoffFor"/> liefert je Bereich das früheste zählende Datum (Standard
|
||||||
|
/// <see cref="DateOnly.MinValue"/> = alles zählt, <see cref="DateOnly.MaxValue"/> = Bereich
|
||||||
|
/// bewusst ausgeklammert — so lässt sich derselbe Code für "gedämpfter Gesamt-Score" und
|
||||||
|
/// "nur die Bereiche eines einzelnen Vorgangs, nur danach" wiederverwenden).
|
||||||
|
private static double ComputeScore(IReadOnlyList<ClassAbsenceDaySummaryRow> yearEntries,
|
||||||
|
IReadOnlyList<UntisForeignClassRegisterEventDto> yearRegisterEntries,
|
||||||
|
Func<VorgangScoreDomain, DateOnly> cutoffFor, PatternScoreWeights weights)
|
||||||
|
{
|
||||||
|
var attendanceCutoff = cutoffFor(VorgangScoreDomain.Attendance);
|
||||||
|
var latenessCutoff = cutoffFor(VorgangScoreDomain.Lateness);
|
||||||
|
var classbookCutoff = cutoffFor(VorgangScoreDomain.Classbook);
|
||||||
|
var unexcused = yearEntries.Count(r => r.IsUnexcused && r.Date >= attendanceCutoff);
|
||||||
|
var excused = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate && r.Date >= attendanceCutoff);
|
||||||
|
var late = yearEntries.Count(r => !r.IsUnexcused && r.IsLate && r.Date >= latenessCutoff);
|
||||||
|
var classRegister = yearRegisterEntries
|
||||||
|
.Where(e => TryDate(e.Date, out var d) && d >= classbookCutoff)
|
||||||
|
.Sum(e => ClassRegisterEntryWeight(e, weights));
|
||||||
|
return unexcused * weights.UnexcusedDayWeight + excused * weights.ExcusedDayWeight +
|
||||||
|
late * weights.LateDayWeight + classRegister;
|
||||||
|
}
|
||||||
|
|
||||||
public static IReadOnlyList<ClassTeacherRosterRow> Build(
|
public static IReadOnlyList<ClassTeacherRosterRow> Build(
|
||||||
IReadOnlyList<UntisStudentRosterCacheEntry> students,
|
IReadOnlyList<UntisStudentRosterCacheEntry> students,
|
||||||
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
|
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
|
||||||
@@ -193,8 +257,11 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
int schoolDaysElapsed = 0,
|
int schoolDaysElapsed = 0,
|
||||||
int holidayWeekdaysExcluded = 0,
|
int holidayWeekdaysExcluded = 0,
|
||||||
DateOnly termStart = default,
|
DateOnly termStart = default,
|
||||||
IReadOnlyList<UntisForeignClassRegisterEventDto>? yearClassRegisterEntries = null)
|
IReadOnlyList<UntisForeignClassRegisterEventDto>? yearClassRegisterEntries = null,
|
||||||
|
PatternScoreWeights? weights = null,
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyList<Vorgang>>? closedVorgaengeByNameKey = null)
|
||||||
{
|
{
|
||||||
|
var effectiveWeights = weights ?? new PatternScoreWeights();
|
||||||
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
|
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
|
||||||
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
|
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
|
||||||
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First());
|
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First());
|
||||||
@@ -223,6 +290,24 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
|
var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
|
||||||
?? yearByName.GetValueOrDefault(nameKey) ?? [];
|
?? yearByName.GetValueOrDefault(nameKey) ?? [];
|
||||||
var yearRegisterEntries = yearRegisterByName.GetValueOrDefault(nameKey) ?? [];
|
var yearRegisterEntries = yearRegisterByName.GetValueOrDefault(nameKey) ?? [];
|
||||||
|
|
||||||
|
// Dämpfung/Rückfall (Nutzer-Entscheidung: bereichsbezogen statt kompletter Reset).
|
||||||
|
var closedVorgaenge = closedVorgaengeByNameKey?.GetValueOrDefault(nameKey) ?? [];
|
||||||
|
var dampeningCutoffs = ComputeDampeningCutoffs(closedVorgaenge);
|
||||||
|
var patternScore = ComputeScore(yearEntries, yearRegisterEntries,
|
||||||
|
d => dampeningCutoffs.GetValueOrDefault(d, DateOnly.MinValue), effectiveWeights);
|
||||||
|
|
||||||
|
var mostRecentClosed = closedVorgaenge.Where(v => v.ClosedAt.HasValue)
|
||||||
|
.OrderByDescending(v => v.ClosedAt).FirstOrDefault();
|
||||||
|
var regressionScore = 0.0;
|
||||||
|
if (mostRecentClosed is not null)
|
||||||
|
{
|
||||||
|
var relevantDomains = MatchDomains(mostRecentClosed.Tags);
|
||||||
|
var closedDate = DateOnly.FromDateTime(mostRecentClosed.ClosedAt!.Value);
|
||||||
|
regressionScore = ComputeScore(yearEntries, yearRegisterEntries,
|
||||||
|
d => relevantDomains.Contains(d) ? closedDate : DateOnly.MaxValue, effectiveWeights);
|
||||||
|
}
|
||||||
|
|
||||||
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
|
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
|
||||||
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
|
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
|
||||||
registerEntries.Count > 0)
|
registerEntries.Count > 0)
|
||||||
@@ -238,7 +323,10 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
YearExcusedDayCount = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate),
|
YearExcusedDayCount = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate),
|
||||||
YearNegativeClassRegisterCount = yearRegisterEntries.Count(IsNegativeClassRegisterEntry),
|
YearNegativeClassRegisterCount = yearRegisterEntries.Count(IsNegativeClassRegisterEntry),
|
||||||
HasSuspensionEntry = yearRegisterEntries.Any(e => MatchesAnyKeyword(e, SuspensionKeywords)),
|
HasSuspensionEntry = yearRegisterEntries.Any(e => MatchesAnyKeyword(e, SuspensionKeywords)),
|
||||||
ClassRegisterScoreComponent = yearRegisterEntries.Sum(ClassRegisterEntryWeight),
|
ClassRegisterScoreComponent = yearRegisterEntries.Sum(e => ClassRegisterEntryWeight(e, effectiveWeights)),
|
||||||
|
PatternScore = patternScore,
|
||||||
|
RegressionScore = regressionScore,
|
||||||
|
RegressionVorgangTitle = mostRecentClosed?.Title,
|
||||||
SchoolDaysElapsed = schoolDaysElapsed,
|
SchoolDaysElapsed = schoolDaysElapsed,
|
||||||
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
|
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
|
||||||
TermStart = termStart,
|
TermStart = termStart,
|
||||||
@@ -308,6 +396,14 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Eine einzelne, auf ein Minimum verdichtete Monatszelle im Übersichts-Widget.</summary>
|
||||||
|
public sealed record ClassTeacherCompactCalendarDay(DateOnly Date, string SignalCode,
|
||||||
|
string SignalColorHex, string Tooltip, bool IsToday)
|
||||||
|
{
|
||||||
|
public string DayNumber => Date.Day.ToString();
|
||||||
|
public bool HasSignal => !string.IsNullOrEmpty(SignalCode);
|
||||||
|
}
|
||||||
|
|
||||||
public partial class ClassTeacherOverviewViewModel : ObservableObject
|
public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly WebUntisSettingsService _settings;
|
private readonly WebUntisSettingsService _settings;
|
||||||
@@ -317,6 +413,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
private readonly IWorkTaskRepository _workTasks;
|
private readonly IWorkTaskRepository _workTasks;
|
||||||
private readonly IStudentRepository _students;
|
private readonly IStudentRepository _students;
|
||||||
private readonly IDocumentationRepository _documentation;
|
private readonly IDocumentationRepository _documentation;
|
||||||
|
private readonly IVorgangRepository _vorgaenge;
|
||||||
|
private readonly PatternScoreSettingsService _patternScoreSettings;
|
||||||
private readonly IParticipationRepository _participation;
|
private readonly IParticipationRepository _participation;
|
||||||
private readonly IParticipationSessionRepository _participationSessions;
|
private readonly IParticipationSessionRepository _participationSessions;
|
||||||
private readonly AppLogger? _logger;
|
private readonly AppLogger? _logger;
|
||||||
@@ -329,6 +427,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
public ObservableCollection<ClassTeacherTrendDay> TrendDays { get; } = [];
|
public ObservableCollection<ClassTeacherTrendDay> TrendDays { get; } = [];
|
||||||
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
|
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
|
||||||
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
|
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
|
||||||
|
public ObservableCollection<ClassTeacherCompactCalendarDay> CompactMonthDays { get; } = [];
|
||||||
|
public ObservableCollection<ClassTeacherCalendarScope> CalendarScopes { get; } = [];
|
||||||
|
|
||||||
[ObservableProperty] private string? _homeroomClassName;
|
[ObservableProperty] private string? _homeroomClassName;
|
||||||
[ObservableProperty] private int _activeTabIndex;
|
[ObservableProperty] private int _activeTabIndex;
|
||||||
@@ -358,6 +458,14 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
/// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte.
|
/// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte.
|
||||||
[ObservableProperty] private int _ownDocumentationFollowUpCount;
|
[ObservableProperty] private int _ownDocumentationFollowUpCount;
|
||||||
[ObservableProperty] private int _ownDocumentationCriticalCount;
|
[ObservableProperty] private int _ownDocumentationCriticalCount;
|
||||||
|
[ObservableProperty] private ClassTeacherCalendarScope? _selectedCalendarScope;
|
||||||
|
|
||||||
|
// Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein
|
||||||
|
// eingehendes Sync-Ereignis die Ansicht neu aufbauen kann, ohne selbst WebUntis anzufragen.
|
||||||
|
private IReadOnlyList<UntisStudentRosterCacheEntry>? _lastStudents;
|
||||||
|
private IReadOnlyList<UntisClassAbsenceEntryDto>? _lastAbsences;
|
||||||
|
private IReadOnlyList<UntisForeignClassRegisterEventDto>? _lastClassRegisterEvents;
|
||||||
|
private IReadOnlyList<CachedUntisHoliday>? _lastHolidays;
|
||||||
|
|
||||||
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
|
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
|
||||||
public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
|
public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
|
||||||
@@ -399,13 +507,25 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
|
public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
|
||||||
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
|
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
|
||||||
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
|
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
|
||||||
|
public string CompactMonthLabel
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var month = DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy",
|
||||||
|
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||||
|
return SelectedCalendarScope?.StudentName is { Length: > 0 }
|
||||||
|
? $"{month} · {SelectedCalendarScope.DisplayName}"
|
||||||
|
: $"{month} · gesamte Klasse";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Func<Task>? OnNavigateToSettings { get; set; }
|
public Func<Task>? OnNavigateToSettings { get; set; }
|
||||||
public Func<Task>? OnNavigateToWorkload { get; set; }
|
public Func<Task>? OnNavigateToWorkload { get; set; }
|
||||||
|
|
||||||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
|
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
|
||||||
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
||||||
IStudentRepository students, IDocumentationRepository documentation, IParticipationRepository participation,
|
IStudentRepository students, IDocumentationRepository documentation, IVorgangRepository vorgaenge,
|
||||||
|
PatternScoreSettingsService patternScoreSettings, IParticipationRepository participation,
|
||||||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
|
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
|
||||||
ClassTeacherCasesViewModel casesTab, AppLogger? logger = null)
|
ClassTeacherCasesViewModel casesTab, AppLogger? logger = null)
|
||||||
{
|
{
|
||||||
@@ -416,6 +536,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
_workTasks = workTasks;
|
_workTasks = workTasks;
|
||||||
_students = students;
|
_students = students;
|
||||||
_documentation = documentation;
|
_documentation = documentation;
|
||||||
|
_vorgaenge = vorgaenge;
|
||||||
|
_patternScoreSettings = patternScoreSettings;
|
||||||
_participation = participation;
|
_participation = participation;
|
||||||
_participationSessions = participationSessions;
|
_participationSessions = participationSessions;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -427,6 +549,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
|
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
|
||||||
partial void OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
|
partial void OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
|
||||||
partial void OnOwnDocumentationCriticalCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
|
partial void OnOwnDocumentationCriticalCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
|
||||||
|
partial void OnSelectedCalendarScopeChanged(ClassTeacherCalendarScope? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(CompactMonthLabel));
|
||||||
|
if (CalendarScopes.Count > 0) RebuildCompactMonthFromCachedData();
|
||||||
|
}
|
||||||
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
|
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
|
||||||
partial void OnSelectedRosterFilterChanged(int value)
|
partial void OnSelectedRosterFilterChanged(int value)
|
||||||
{
|
{
|
||||||
@@ -437,13 +564,42 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
ApplyRosterFilter();
|
ApplyRosterFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ResetRosterState()
|
||||||
|
{
|
||||||
|
ActiveTabIndex = 0;
|
||||||
|
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
|
||||||
|
OpenExcuses.Clear(); CompactMonthDays.Clear(); CalendarScopes.Clear(); SelectedCalendarScope = null;
|
||||||
|
OpenExcuseOverflowCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wird beim Navigieren auf diese Seite aufgerufen statt LoadCommand: baut nur den lokalen
|
||||||
|
// Grundzustand auf (keine WebUntis-Anfrage), damit Öffnen der Ansicht nicht auf einen
|
||||||
|
// WebUntis-Login+Report-Abruf wartet. Die eigentlichen Fehlzeiten/Klassenbuch-Daten holt sich
|
||||||
|
// die Nutzerin bewusst über den "Aktualisieren"-Button (LoadCommand).
|
||||||
|
public void PrepareForDisplay()
|
||||||
|
{
|
||||||
|
if (Busy) return;
|
||||||
|
HomeroomClassName = _settings.HomeroomClassName;
|
||||||
|
ResetRosterState();
|
||||||
|
if (!HomeroomClassConfigured)
|
||||||
|
{
|
||||||
|
Status = "Noch keine Klasse ausgewählt.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DetailsTab.Initialize(HomeroomClassName!);
|
||||||
|
CasesTab.Initialize(HomeroomClassName!);
|
||||||
|
if (StudentCount == 0)
|
||||||
|
Status = "Noch nicht geladen – auf „Aktualisieren“ klicken, um aktuelle WebUntis-Daten zu holen.";
|
||||||
|
}
|
||||||
|
NotifyRosterState();
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task Load()
|
private async Task Load()
|
||||||
{
|
{
|
||||||
HomeroomClassName = _settings.HomeroomClassName;
|
HomeroomClassName = _settings.HomeroomClassName;
|
||||||
ActiveTabIndex = 0;
|
ResetRosterState();
|
||||||
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
|
|
||||||
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
|
|
||||||
if (!HomeroomClassConfigured)
|
if (!HomeroomClassConfigured)
|
||||||
{
|
{
|
||||||
Status = "Noch keine Klasse ausgewählt.";
|
Status = "Noch keine Klasse ausgewählt.";
|
||||||
@@ -458,14 +614,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
NotifyRosterState();
|
NotifyRosterState();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
|
||||||
var sevenDayStart = today.AddDays(-6);
|
|
||||||
var trendDays = LastSchoolDays(today, 7);
|
|
||||||
// Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die
|
// Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die
|
||||||
// Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf.
|
// Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf.
|
||||||
// UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) —
|
// UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) —
|
||||||
// teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht.
|
// teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht.
|
||||||
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
|
var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow();
|
||||||
var studentsTask = _cache.GetStudentRosterAsync(className);
|
var studentsTask = _cache.GetStudentRosterAsync(className);
|
||||||
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
|
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
|
||||||
// Seit Schuljahresbeginn statt nur die letzte Woche (Nutzer-Feedback: Klassenbucheinträge
|
// Seit Schuljahresbeginn statt nur die letzte Woche (Nutzer-Feedback: Klassenbucheinträge
|
||||||
@@ -479,41 +632,100 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
var holidaysTask = GetHolidaysAsync();
|
var holidaysTask = GetHolidaysAsync();
|
||||||
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask);
|
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask);
|
||||||
|
|
||||||
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
|
_lastStudents = studentsTask.Result;
|
||||||
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
|
_lastAbsences = absencesTask.Result;
|
||||||
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
|
_lastClassRegisterEvents = classRegisterTask.Result;
|
||||||
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
|
_lastHolidays = holidaysTask.Result;
|
||||||
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result);
|
BuildRosterFromCachedData(studentsTask.Result, absencesTask.Result, classRegisterTask.Result,
|
||||||
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
holidaysTask.Result, today, yearStart, sevenDayStart, trendDays);
|
||||||
var recentRegisterEntries = classRegisterTask.Result
|
|
||||||
.Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList();
|
|
||||||
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
|
||||||
recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
|
|
||||||
holidayWeekdaysExcluded, termStart, classRegisterTask.Result)) Roster.Add(row);
|
|
||||||
|
|
||||||
StudentCount = Roster.Count;
|
|
||||||
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
|
||||||
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
|
|
||||||
LateCount = Roster.Count(r => r.IsLate);
|
|
||||||
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
|
|
||||||
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
|
|
||||||
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
|
|
||||||
RecentClassRegisterCount = recentRegisterEntries.Count;
|
|
||||||
BuildTrend(absenceDaysYear, trendDays);
|
|
||||||
BuildPatternNotices(absenceDaysYear, sevenDayStart);
|
|
||||||
BuildWeekdayPatternNotices(absenceDaysYear);
|
|
||||||
BuildAttendanceParticipationNotices();
|
|
||||||
BuildOwnDocumentationCounts();
|
|
||||||
BuildOpenExcuses(absenceDaysYear, today);
|
|
||||||
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
||||||
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
|
||||||
ApplyRosterFilter();
|
|
||||||
NotifySummary();
|
|
||||||
}
|
}
|
||||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
|
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
|
||||||
finally { Busy = false; NotifyRosterState(); }
|
finally { Busy = false; NotifyRosterState(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Baut die Ansicht ausschließlich aus den zuletzt per Load() geholten WebUntis-Daten plus
|
||||||
|
// aktuellen lokalen Daten (Vorgänge/Dokumentation) neu auf - löst NIE einen WebUntis-Request
|
||||||
|
// aus, auch nicht über UntisReportCacheService' stündliches Hot-Window-Refresh. Wird bei
|
||||||
|
// eingehenden Sync-Ereignissen aufgerufen (siehe MainWindowViewModel.RefreshCurrentPage);
|
||||||
|
// echte WebUntis-Daten holt bewusst ausschließlich der "Aktualisieren"-Button (LoadCommand),
|
||||||
|
// wenn die Nutzerin sich dafür entscheidet.
|
||||||
|
public void RefreshFromLocalDataOnly()
|
||||||
|
{
|
||||||
|
if (Busy || _lastStudents is null || _lastAbsences is null ||
|
||||||
|
_lastClassRegisterEvents is null || _lastHolidays is null) return;
|
||||||
|
var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow();
|
||||||
|
ResetRosterState();
|
||||||
|
BuildRosterFromCachedData(_lastStudents, _lastAbsences, _lastClassRegisterEvents, _lastHolidays,
|
||||||
|
today, yearStart, sevenDayStart, trendDays);
|
||||||
|
NotifyRosterState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private (DateOnly Today, DateOnly YearStart, DateOnly SevenDayStart, IReadOnlyList<DateOnly> TrendDays) RosterDateWindow()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
|
||||||
|
return (today, yearStart, today.AddDays(-6), LastSchoolDays(today, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildRosterFromCachedData(
|
||||||
|
IReadOnlyList<UntisStudentRosterCacheEntry> students,
|
||||||
|
IReadOnlyList<UntisClassAbsenceEntryDto> absences,
|
||||||
|
IReadOnlyList<UntisForeignClassRegisterEventDto> classRegisterEvents,
|
||||||
|
IReadOnlyList<CachedUntisHoliday> holidays,
|
||||||
|
DateOnly today, DateOnly yearStart, DateOnly sevenDayStart, IReadOnlyList<DateOnly> trendDays)
|
||||||
|
{
|
||||||
|
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences);
|
||||||
|
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
|
||||||
|
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
|
||||||
|
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
|
||||||
|
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidays);
|
||||||
|
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
||||||
|
var recentRegisterEntries = classRegisterEvents
|
||||||
|
.Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList();
|
||||||
|
// Geschlossene Vorgänge je Klassen-Roster-Namen (für die bereichsbezogene Score-Dämpfung/
|
||||||
|
// Rückfall-Erkennung in Build) — gleicher Namensabgleich wie bei den übrigen Build*-Methoden
|
||||||
|
// dieser Klasse (z.B. BuildAttendanceParticipationNotices).
|
||||||
|
var localStudents = _students.GetAll();
|
||||||
|
var closedVorgaengeByNameKey = students
|
||||||
|
.Select(r => (Roster: r, Student: MatchStudent(r.DisplayName, localStudents)))
|
||||||
|
.Where(x => x.Student is not null)
|
||||||
|
.ToDictionary(
|
||||||
|
x => UntisNameMatching.NameKey(x.Roster.DisplayName),
|
||||||
|
x => (IReadOnlyList<Vorgang>)_vorgaenge.GetByStudent(x.Student!.Id)
|
||||||
|
.Where(v => v.Status == VorgangStatus.Closed).ToList());
|
||||||
|
foreach (var row in ClassTeacherRosterRow.Build(students, todayAbsences,
|
||||||
|
recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
|
||||||
|
holidayWeekdaysExcluded, termStart, classRegisterEvents,
|
||||||
|
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row);
|
||||||
|
|
||||||
|
CalendarScopes.Add(new ClassTeacherCalendarScope(null, "Gesamte Klasse"));
|
||||||
|
foreach (var student in students.OrderBy(s => s.DisplayName))
|
||||||
|
CalendarScopes.Add(new ClassTeacherCalendarScope(student.DisplayName,
|
||||||
|
ClassTeacherOverviewViewModel.DisplayStudentName(student.DisplayName)));
|
||||||
|
SelectedCalendarScope = CalendarScopes[0];
|
||||||
|
|
||||||
|
StudentCount = Roster.Count;
|
||||||
|
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
||||||
|
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
|
||||||
|
LateCount = Roster.Count(r => r.IsLate);
|
||||||
|
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
|
||||||
|
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
|
||||||
|
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
|
||||||
|
RecentClassRegisterCount = recentRegisterEntries.Count;
|
||||||
|
BuildTrend(absenceDaysYear, trendDays);
|
||||||
|
BuildCompactMonth(absenceDaysYear, classRegisterEvents, today);
|
||||||
|
BuildPatternNotices(absenceDaysYear, sevenDayStart);
|
||||||
|
BuildWeekdayPatternNotices(absenceDaysYear);
|
||||||
|
BuildAttendanceParticipationNotices();
|
||||||
|
BuildVorgangRegressionNotices();
|
||||||
|
BuildOwnDocumentationCounts();
|
||||||
|
BuildOpenExcuses(absenceDaysYear, today);
|
||||||
|
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
||||||
|
ApplyRosterFilter();
|
||||||
|
NotifySummary();
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
|
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
|
||||||
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
|
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
|
||||||
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
|
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
|
||||||
@@ -535,6 +747,84 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
DetailsTab.LoadCommand.Execute(null);
|
DetailsTab.LoadCommand.Execute(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void OpenMonthlyCalendar()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
DetailsTab.StudentFilter = SelectedCalendarScope?.StudentName ?? "";
|
||||||
|
DetailsTab.CalendarMonth = new DateOnly(today.Year, today.Month, 1);
|
||||||
|
ActiveTabIndex = 2;
|
||||||
|
DetailsTab.ShowCalendarCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildCompactMonth(IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||||
|
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today)
|
||||||
|
{
|
||||||
|
CompactMonthDays.Clear();
|
||||||
|
foreach (var day in BuildCompactMonthDays(new DateOnly(today.Year, today.Month, 1),
|
||||||
|
absences, registerEntries, today, SelectedCalendarScope?.StudentName))
|
||||||
|
CompactMonthDays.Add(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildCompactMonthFromCachedData()
|
||||||
|
{
|
||||||
|
if (_lastAbsences is null || _lastClassRegisterEvents is null) return;
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
BuildCompactMonth(ClassAbsenceDaySummaryRow.GroupByStudentAndDay(_lastAbsences),
|
||||||
|
_lastClassRegisterEvents, today);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<ClassTeacherCompactCalendarDay> BuildCompactMonthDays(DateOnly month,
|
||||||
|
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||||
|
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today,
|
||||||
|
string? studentName = null)
|
||||||
|
{
|
||||||
|
var first = new DateOnly(month.Year, month.Month, 1);
|
||||||
|
if (!string.IsNullOrWhiteSpace(studentName))
|
||||||
|
{
|
||||||
|
absences = absences.Where(a => UntisNameMatching.NamesMatch(a.StudentName, studentName)).ToList();
|
||||||
|
registerEntries = registerEntries.Where(e => UntisNameMatching.NamesMatch(e.StudentName, studentName)).ToList();
|
||||||
|
}
|
||||||
|
var registerRows = registerEntries
|
||||||
|
.Select(e => (Entry: e, Valid: TryDate(e.Date, out var date), Date: date))
|
||||||
|
.Where(x => x.Valid && x.Date.Year == first.Year && x.Date.Month == first.Month)
|
||||||
|
.Select(x => new ClassTeacherClassRegisterRow(x.Date, x.Entry.Subject, x.Entry.StudentName,
|
||||||
|
x.Entry.TeacherUsername, x.Entry.CategoryName, x.Entry.CategoryGroup, x.Entry.Text))
|
||||||
|
.ToList();
|
||||||
|
var result = new List<ClassTeacherCompactCalendarDay>();
|
||||||
|
var days = DateTime.DaysInMonth(first.Year, first.Month);
|
||||||
|
|
||||||
|
for (var number = 1; number <= days; number++)
|
||||||
|
{
|
||||||
|
var date = new DateOnly(first.Year, first.Month, number);
|
||||||
|
var events = ClassTeacherDetailsViewModel.BuildDayEvents(date, absences, registerRows,
|
||||||
|
includeHomework: false);
|
||||||
|
var strongest = events.OrderBy(e => SignalPriority(e.Kind)).FirstOrDefault();
|
||||||
|
var tooltip = events.Count == 0
|
||||||
|
? $"{date:dd.MM.yyyy}: unauffällig"
|
||||||
|
: $"{date:dd.MM.yyyy}\n" + string.Join("\n", events.Select(e => e.Tooltip));
|
||||||
|
result.Add(new ClassTeacherCompactCalendarDay(date, strongest?.Code ?? "",
|
||||||
|
strongest?.ColorHex ?? "#00000000", tooltip, date == today));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string DisplayStudentName(string value)
|
||||||
|
{
|
||||||
|
var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
return parts.Length < 2 ? value : string.Join(" ", parts.Skip(1).Append(parts[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int SignalPriority(ClassTeacherCalendarEventKind kind) => kind switch
|
||||||
|
{
|
||||||
|
ClassTeacherCalendarEventKind.Unexcused => 0,
|
||||||
|
ClassTeacherCalendarEventKind.ClassRegister => 1,
|
||||||
|
ClassTeacherCalendarEventKind.Absent => 2,
|
||||||
|
ClassTeacherCalendarEventKind.Late => 3,
|
||||||
|
ClassTeacherCalendarEventKind.Excused => 4,
|
||||||
|
_ => 5,
|
||||||
|
};
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ShowDetailsForStudent(ClassTeacherRosterRow? row)
|
private void ShowDetailsForStudent(ClassTeacherRosterRow? row)
|
||||||
{
|
{
|
||||||
@@ -835,6 +1125,25 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(HasPatternNotices));
|
OnPropertyChanged(nameof(HasPatternNotices));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Schwelle für die "erneut auffällig"-Warnung nach einem geschlossenen Vorgang (Nutzer-
|
||||||
|
/// Entscheidung): erst ab einem spürbaren Muster, nicht schon bei der ersten Kleinigkeit nach
|
||||||
|
/// Abschluss — ≈ 1 unentschuldigter Tag oder vergleichbar (<see cref="PatternScoreWeights.UnexcusedDayWeight"/>
|
||||||
|
/// Default 3,0).
|
||||||
|
private const double RegressionThreshold = 3.0;
|
||||||
|
|
||||||
|
private void BuildVorgangRegressionNotices()
|
||||||
|
{
|
||||||
|
foreach (var row in Roster.Where(r => r.RegressionScore >= RegressionThreshold &&
|
||||||
|
r.RegressionVorgangTitle is not null))
|
||||||
|
{
|
||||||
|
if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue;
|
||||||
|
PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName,
|
||||||
|
$"Nach Bearbeitung von \"{row.RegressionVorgangTitle}\" erneut auffällig",
|
||||||
|
ClassTeacherStatusKind.Danger, CanCreateReminder: true));
|
||||||
|
}
|
||||||
|
OnPropertyChanged(nameof(HasPatternNotices));
|
||||||
|
}
|
||||||
|
|
||||||
/// Nacharbeiten-/Kritisch-Zählung für die eigene Dokumentation der Klasse — bewusst nicht auf
|
/// Nacharbeiten-/Kritisch-Zählung für die eigene Dokumentation der Klasse — bewusst nicht auf
|
||||||
/// die letzten 7 Tage begrenzt wie <see cref="RecentClassRegisterCount"/>: ein seit Wochen
|
/// die letzten 7 Tage begrenzt wie <see cref="RecentClassRegisterCount"/>: ein seit Wochen
|
||||||
/// offener Entwurf oder ein als "Kritisch" markierter Eintrag soll nicht aus der Kennzahl
|
/// offener Entwurf oder ein als "Kritisch" markierter Eintrag soll nicht aus der Kennzahl
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
|
|
||||||
|
/// <summary>Dialog zum Anpassen der <see cref="PatternScoreWeights"/> für den "Gesamtbild"-Reiter
|
||||||
|
/// der Klassenlehreransicht — ursprünglich feste Konstanten, auf Nutzerwunsch jetzt einstellbar.</summary>
|
||||||
|
public partial class PatternScoreWeightsDialogViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly PatternScoreSettingsService _settings;
|
||||||
|
|
||||||
|
[ObservableProperty] private double _unexcusedDayWeight;
|
||||||
|
[ObservableProperty] private double _excusedDayWeight;
|
||||||
|
[ObservableProperty] private double _lateDayWeight;
|
||||||
|
[ObservableProperty] private double _negativeClassRegisterEntryWeight;
|
||||||
|
[ObservableProperty] private double _concerningKeywordBonus;
|
||||||
|
[ObservableProperty] private double _suspensionKeywordWeight;
|
||||||
|
|
||||||
|
public PatternScoreWeightsDialogViewModel(PatternScoreSettingsService settings)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
var weights = settings.Load();
|
||||||
|
UnexcusedDayWeight = weights.UnexcusedDayWeight;
|
||||||
|
ExcusedDayWeight = weights.ExcusedDayWeight;
|
||||||
|
LateDayWeight = weights.LateDayWeight;
|
||||||
|
NegativeClassRegisterEntryWeight = weights.NegativeClassRegisterEntryWeight;
|
||||||
|
ConcerningKeywordBonus = weights.ConcerningKeywordBonus;
|
||||||
|
SuspensionKeywordWeight = weights.SuspensionKeywordWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Save() => _settings.Save(new PatternScoreWeights
|
||||||
|
{
|
||||||
|
UnexcusedDayWeight = UnexcusedDayWeight,
|
||||||
|
ExcusedDayWeight = ExcusedDayWeight,
|
||||||
|
LateDayWeight = LateDayWeight,
|
||||||
|
NegativeClassRegisterEntryWeight = NegativeClassRegisterEntryWeight,
|
||||||
|
ConcerningKeywordBonus = ConcerningKeywordBonus,
|
||||||
|
SuspensionKeywordWeight = SuspensionKeywordWeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ public partial class VorgangItem : ObservableObject
|
|||||||
private readonly Func<VorgangItem, DocumentationItem, Task> _onLink;
|
private readonly Func<VorgangItem, DocumentationItem, Task> _onLink;
|
||||||
private readonly Func<VorgangItem, DocumentationItem, Task> _onUnlink;
|
private readonly Func<VorgangItem, DocumentationItem, Task> _onUnlink;
|
||||||
private readonly Func<VorgangItem, VorgangClassRegisterEntryRow, Task> _onRemoveClassRegisterEntry;
|
private readonly Func<VorgangItem, VorgangClassRegisterEntryRow, Task> _onRemoveClassRegisterEntry;
|
||||||
|
private readonly Func<DocumentationItem, Task> _onEditDocumentation;
|
||||||
|
|
||||||
public Vorgang Model { get; }
|
public Vorgang Model { get; }
|
||||||
public string StudentNames { get; }
|
public string StudentNames { get; }
|
||||||
@@ -79,7 +80,8 @@ public partial class VorgangItem : ObservableObject
|
|||||||
public VorgangItem(Vorgang model, string studentNames,
|
public VorgangItem(Vorgang model, string studentNames,
|
||||||
Func<VorgangItem, DocumentationItem, Task> onLink,
|
Func<VorgangItem, DocumentationItem, Task> onLink,
|
||||||
Func<VorgangItem, DocumentationItem, Task> onUnlink,
|
Func<VorgangItem, DocumentationItem, Task> onUnlink,
|
||||||
Func<VorgangItem, VorgangClassRegisterEntryRow, Task> onRemoveClassRegisterEntry)
|
Func<VorgangItem, VorgangClassRegisterEntryRow, Task> onRemoveClassRegisterEntry,
|
||||||
|
Func<DocumentationItem, Task> onEditDocumentation)
|
||||||
{
|
{
|
||||||
Model = model;
|
Model = model;
|
||||||
StudentNames = studentNames;
|
StudentNames = studentNames;
|
||||||
@@ -87,10 +89,19 @@ public partial class VorgangItem : ObservableObject
|
|||||||
_onLink = onLink;
|
_onLink = onLink;
|
||||||
_onUnlink = onUnlink;
|
_onUnlink = onUnlink;
|
||||||
_onRemoveClassRegisterEntry = onRemoveClassRegisterEntry;
|
_onRemoveClassRegisterEntry = onRemoveClassRegisterEntry;
|
||||||
|
_onEditDocumentation = onEditDocumentation;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand] private void Reveal() => IsRevealed = !IsRevealed;
|
[RelayCommand] private void Reveal() => IsRevealed = !IsRevealed;
|
||||||
|
|
||||||
|
/// Öffnet den vollen Dokumentations-Dialog zum Nachlesen/Bearbeiten, ohne den Vorgänge-Tab zu
|
||||||
|
/// verlassen (Nutzer-Feedback: bisher musste man dafür in den Schülerdatensatz wechseln).
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task EditDocumentation(DocumentationItem? doc)
|
||||||
|
{
|
||||||
|
if (doc is not null) await _onEditDocumentation(doc);
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task Link(DocumentationItem? doc)
|
private async Task Link(DocumentationItem? doc)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
private readonly ITimeEntryRepository _timeEntries;
|
private readonly ITimeEntryRepository _timeEntries;
|
||||||
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
|
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
|
||||||
private readonly SchoolWeatherService? _schoolWeather;
|
private readonly SchoolWeatherService? _schoolWeather;
|
||||||
|
private readonly UntisHubService _untisHub;
|
||||||
|
private readonly WebUntisIntegrationService _webUntis;
|
||||||
|
private readonly Func<DateTime> _now;
|
||||||
|
|
||||||
private const int OpenExcuseMaxAgeDays = 21;
|
private const int OpenExcuseMaxAgeDays = 21;
|
||||||
private const int SupportPlanDueWithinDays = 14;
|
private const int SupportPlanDueWithinDays = 14;
|
||||||
@@ -87,20 +90,43 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
public ObservableCollection<TaskItem> OpenTasks { get; } = [];
|
public ObservableCollection<TaskItem> OpenTasks { get; } = [];
|
||||||
public ObservableCollection<GroupChip> CurrentGroups { get; } = [];
|
public ObservableCollection<GroupChip> CurrentGroups { get; } = [];
|
||||||
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
|
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
|
||||||
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
|
|
||||||
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
|
|
||||||
public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = [];
|
public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = [];
|
||||||
public ObservableCollection<MissingTeachingTimeItem> MissingTeachingTimeEntries { get; } = [];
|
|
||||||
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
|
|
||||||
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
|
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
|
||||||
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
|
|
||||||
public ObservableCollection<UnplannedLessonItem> UnplannedLessons { get; } = [];
|
|
||||||
public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
|
|
||||||
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
|
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
|
||||||
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
|
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
|
||||||
public ObservableCollection<DashboardWeatherWarningItem> WeatherWarnings { get; } = [];
|
public ObservableCollection<DashboardWeatherWarningItem> WeatherWarnings { get; } = [];
|
||||||
|
// Zusammengefasste "Handlungsbedarf"-Karte (vormals sieben eigene Kacheln/Collections:
|
||||||
|
// Entschuldigungen, Fehlzeiten, Förderplan, Korrekturen, ungeplante Stunden, Auffälligkeiten,
|
||||||
|
// Unterrichtszeit-Nacherfassung — siehe AttentionItem.cs). Attention traegt nur, was nach
|
||||||
|
// Filterung (AttentionFilters) tatsaechlich angezeigt wird; die Rohdaten je Art liegen in den
|
||||||
|
// privaten _xyzItems-Feldern und werden von RebuildAttention() neu zusammengesetzt, sobald sich
|
||||||
|
// Daten oder Filter aendern — ohne die Repos erneut abzufragen.
|
||||||
|
public ObservableCollection<AttentionGroup> Attention { get; } = [];
|
||||||
|
public ObservableCollection<AttentionFilterOption> AttentionFilters { get; } = [];
|
||||||
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||||
|
|
||||||
|
private readonly List<OpenExcuseItem> _excuses = [];
|
||||||
|
private readonly List<AttentionItem> _attendanceItems = [];
|
||||||
|
private readonly List<AttentionItem> _supportItems = [];
|
||||||
|
private readonly List<AttentionItem> _correctionItems = [];
|
||||||
|
private readonly List<AttentionItem> _unplannedItems = [];
|
||||||
|
private readonly List<AttentionItem> _alertItems = [];
|
||||||
|
private readonly List<AttentionItem> _missingTimeItems = [];
|
||||||
|
|
||||||
|
/// Reihenfolge und Überschriften der Gruppen in der zusammengefassten Handlungsbedarf-Karte —
|
||||||
|
/// übernimmt die frühere Kachel-Reihenfolge aus DashboardSettingsService.DefaultCardOrder,
|
||||||
|
/// damit sich für Nutzer nichts unvorhersehbar umsortiert.
|
||||||
|
private static readonly (AttentionKind Kind, string Header)[] AttentionGroupOrder =
|
||||||
|
[
|
||||||
|
(AttentionKind.MissingTeachingTime, "Unterrichtszeit nacherfassen"),
|
||||||
|
(AttentionKind.Excuse, "Offene Entschuldigungen"),
|
||||||
|
(AttentionKind.Correction, "Offene Korrekturen"),
|
||||||
|
(AttentionKind.Unplanned, "Ungeplante Stunden"),
|
||||||
|
(AttentionKind.Alert, "Auffälligkeiten"),
|
||||||
|
(AttentionKind.Attendance, "Fehlzeiten-Warnung"),
|
||||||
|
(AttentionKind.SupportPlan, "Förderplan-Wiedervorlage"),
|
||||||
|
];
|
||||||
|
|
||||||
// Navigation-Callback – wird von App.axaml.cs verdrahtet
|
// Navigation-Callback – wird von App.axaml.cs verdrahtet
|
||||||
public Action<Guid>? OnNavigateToGroup { get; set; }
|
public Action<Guid>? OnNavigateToGroup { get; set; }
|
||||||
public Action<Guid>? OnNavigateToStudent { get; set; }
|
public Action<Guid>? OnNavigateToStudent { get; set; }
|
||||||
@@ -123,26 +149,24 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
public DashboardCardOption TodayCard => Card("today");
|
public DashboardCardOption TodayCard => Card("today");
|
||||||
public DashboardCardOption TasksCard => Card("tasks");
|
public DashboardCardOption TasksCard => Card("tasks");
|
||||||
public DashboardCardOption CalendarCard => Card("calendar");
|
public DashboardCardOption CalendarCard => Card("calendar");
|
||||||
public DashboardCardOption ExcusesCard => Card("excuses");
|
|
||||||
public DashboardCardOption UpcomingCard => Card("upcoming");
|
public DashboardCardOption UpcomingCard => Card("upcoming");
|
||||||
public DashboardCardOption CorrectionsCard => Card("corrections");
|
|
||||||
public DashboardCardOption UnplannedCard => Card("unplanned");
|
|
||||||
public DashboardCardOption AlertsCard => Card("alerts");
|
|
||||||
public DashboardCardOption AttendanceCard => Card("attendance");
|
|
||||||
public DashboardCardOption ExamLoadCard => Card("examload");
|
public DashboardCardOption ExamLoadCard => Card("examload");
|
||||||
public DashboardCardOption MissingTeachingTimeCard => Card("missingteachingtime");
|
|
||||||
public DashboardCardOption SupportCard => Card("support");
|
|
||||||
public DashboardCardOption GroupsCard => Card("groups");
|
public DashboardCardOption GroupsCard => Card("groups");
|
||||||
|
public DashboardCardOption AttentionCard => Card("attention");
|
||||||
public int TodayLessonCount => TodaysLessons.Count;
|
public int TodayLessonCount => TodaysLessons.Count;
|
||||||
public int OpenTaskCount => OpenTasks.Count;
|
public int OpenTaskCount => OpenTasks.Count;
|
||||||
public int UpcomingCount => UpcomingDates.Count;
|
public int UpcomingCount => UpcomingDates.Count;
|
||||||
public int AttentionCount => OpenExcuses.Count + AttendanceWarnings.Count + SupportPlanReviews.Count
|
public int AttentionCount => _excuses.Count + _attendanceItems.Count + _supportItems.Count
|
||||||
+ OpenCorrections.Count + UnplannedLessons.Count + Alerts.Count;
|
+ _correctionItems.Count + _unplannedItems.Count + _alertItems.Count + _missingTimeItems.Count;
|
||||||
public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden";
|
public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden";
|
||||||
public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben";
|
public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben";
|
||||||
public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte";
|
public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte";
|
||||||
public string UpcomingSummary => UpcomingCount == 1 ? "1 Termin" : $"{UpcomingCount} Termine";
|
public string UpcomingSummary => UpcomingCount == 1 ? "1 Termin" : $"{UpcomingCount} Termine";
|
||||||
|
|
||||||
|
[ObservableProperty] private string _webUntisHealthLabel = "";
|
||||||
|
[ObservableProperty] private bool _isWebUntisHealthWarning;
|
||||||
|
[ObservableProperty] private bool _isWebUntisHealthVisible;
|
||||||
|
|
||||||
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
|
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
|
||||||
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
|
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
|
||||||
IReportGradeRepository reportGrades, IGroupMembershipRepository memberships,
|
IReportGradeRepository reportGrades, IGroupMembershipRepository memberships,
|
||||||
@@ -153,8 +177,10 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
|
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
|
||||||
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
|
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
|
||||||
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
|
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
|
||||||
|
UntisHubService untisHub, WebUntisIntegrationService webUntis,
|
||||||
IAnnualPlanEventRepository? annualPlanEvents = null,
|
IAnnualPlanEventRepository? annualPlanEvents = null,
|
||||||
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
|
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null,
|
||||||
|
Func<DateTime>? now = null)
|
||||||
{
|
{
|
||||||
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
|
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
|
||||||
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
|
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
|
||||||
@@ -167,13 +193,34 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
_substitutions = substitutions;
|
_substitutions = substitutions;
|
||||||
_annualPlanEvents = annualPlanEvents;
|
_annualPlanEvents = annualPlanEvents;
|
||||||
_schoolWeather = schoolWeather;
|
_schoolWeather = schoolWeather;
|
||||||
|
_untisHub = untisHub;
|
||||||
|
_webUntis = webUntis;
|
||||||
|
// Testbare Uhr statt direkter DateTime.Now-Aufrufe (siehe Load()/LoadMissingTeachingTime):
|
||||||
|
// TimeOnly.AddHours()/AddMinutes() wickelt bei Mitternacht um, ohne injizierbares "jetzt"
|
||||||
|
// waeren Tests fuer "kurz vor/nach Ablauf einer Wartezeit" je nach Ausfuehrungsuhrzeit
|
||||||
|
// zufaellig rot oder gruen (siehe TODO.md, Abschnitt 9, Nachtrag Uhrzeit-Wraparound).
|
||||||
|
_now = now ?? (() => DateTime.Now);
|
||||||
if (annualPlanSync is not null)
|
if (annualPlanSync is not null)
|
||||||
{
|
{
|
||||||
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
|
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
|
||||||
_ = annualPlanSync.PollAsync();
|
|
||||||
}
|
}
|
||||||
LoadDashboardCards();
|
LoadDashboardCards();
|
||||||
|
LoadAttentionFilters();
|
||||||
Load();
|
Load();
|
||||||
|
RefreshWebUntisHealth();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Liest nur den gespeicherten Fälligkeitsstand der Untis-Hub-Jobs (kein
|
||||||
|
/// WebUntis-Zugriff, siehe <see cref="UntisHubService.GetRows"/>) - aufgerufen bei jedem
|
||||||
|
/// Dashboard-Refresh und erneut, nachdem der Nutzer den Hub geöffnet/einen Abgleich gemacht hat.</summary>
|
||||||
|
public void RefreshWebUntisHealth()
|
||||||
|
{
|
||||||
|
IsWebUntisHealthVisible = _webUntis.IsAvailable;
|
||||||
|
if (!IsWebUntisHealthVisible) return;
|
||||||
|
var rows = _untisHub.GetRows();
|
||||||
|
var due = rows.Count(r => r.DueState != UntisHubDueState.Ok);
|
||||||
|
IsWebUntisHealthWarning = due > 0;
|
||||||
|
WebUntisHealthLabel = due > 0 ? $"WebUntis ⚠ {due} fällig" : "WebUntis ✓";
|
||||||
}
|
}
|
||||||
|
|
||||||
private DashboardCardOption Card(string key) => DashboardCards.First(c => c.Key == key);
|
private DashboardCardOption Card(string key) => DashboardCards.First(c => c.Key == key);
|
||||||
@@ -182,7 +229,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
|
|
||||||
private void Load()
|
private void Load()
|
||||||
{
|
{
|
||||||
var now = DateTime.Now;
|
var now = _now();
|
||||||
var today = DateOnly.FromDateTime(now);
|
var today = DateOnly.FromDateTime(now);
|
||||||
CurrentDate = now.ToString("dddd, d. MMMM yyyy", De);
|
CurrentDate = now.ToString("dddd, d. MMMM yyyy", De);
|
||||||
CurrentSchoolYear = _sy.CurrentSchoolYear();
|
CurrentSchoolYear = _sy.CurrentSchoolYear();
|
||||||
@@ -243,6 +290,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
LoadOpenCorrections(groups, today);
|
LoadOpenCorrections(groups, today);
|
||||||
LoadUnplannedLessons(groups, today);
|
LoadUnplannedLessons(groups, today);
|
||||||
LoadAlerts(groups, today);
|
LoadAlerts(groups, today);
|
||||||
|
RebuildAttention();
|
||||||
UpdateDashboardSummary();
|
UpdateDashboardSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,16 +299,14 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
TodayCard.IsEmpty = TodaysLessons.Count == 0;
|
TodayCard.IsEmpty = TodaysLessons.Count == 0;
|
||||||
TasksCard.IsEmpty = OpenTasks.Count == 0;
|
TasksCard.IsEmpty = OpenTasks.Count == 0;
|
||||||
CalendarCard.IsEmpty = false;
|
CalendarCard.IsEmpty = false;
|
||||||
ExcusesCard.IsEmpty = OpenExcuses.Count == 0;
|
|
||||||
UpcomingCard.IsEmpty = UpcomingDates.Count == 0;
|
UpcomingCard.IsEmpty = UpcomingDates.Count == 0;
|
||||||
CorrectionsCard.IsEmpty = OpenCorrections.Count == 0;
|
|
||||||
UnplannedCard.IsEmpty = UnplannedLessons.Count == 0;
|
|
||||||
AlertsCard.IsEmpty = Alerts.Count == 0;
|
|
||||||
AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0;
|
|
||||||
ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0;
|
ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0;
|
||||||
MissingTeachingTimeCard.IsEmpty = MissingTeachingTimeEntries.Count == 0;
|
|
||||||
SupportCard.IsEmpty = SupportPlanReviews.Count == 0;
|
|
||||||
GroupsCard.IsEmpty = CurrentGroups.Count == 0;
|
GroupsCard.IsEmpty = CurrentGroups.Count == 0;
|
||||||
|
AttentionCard.IsEmpty = AttentionCount == 0;
|
||||||
|
|
||||||
|
// Erst nachdem alle IsEmpty-Werte stehen: welche Kachel tatsaechlich gerendert wird, haengt
|
||||||
|
// ueber EffectiveIsVisible daran, und davon wiederum die Zeilen-/Spaltenzuordnung.
|
||||||
|
ApplyCardLayout();
|
||||||
|
|
||||||
OnPropertyChanged(nameof(TodayLessonCount));
|
OnPropertyChanged(nameof(TodayLessonCount));
|
||||||
OnPropertyChanged(nameof(OpenTaskCount));
|
OnPropertyChanged(nameof(OpenTaskCount));
|
||||||
@@ -327,7 +373,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
|
|
||||||
private void LoadAttendanceWarnings(DateOnly today)
|
private void LoadAttendanceWarnings(DateOnly today)
|
||||||
{
|
{
|
||||||
AttendanceWarnings.Clear();
|
_attendanceItems.Clear();
|
||||||
var schoolYear = _sy.CurrentSchoolYear();
|
var schoolYear = _sy.CurrentSchoolYear();
|
||||||
var from = _sy.SchoolYearStart(schoolYear);
|
var from = _sy.SchoolYearStart(schoolYear);
|
||||||
var to = _sy.SchoolYearEnd(schoolYear);
|
var to = _sy.SchoolYearEnd(schoolYear);
|
||||||
@@ -350,9 +396,13 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
|
items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
|
||||||
}
|
}
|
||||||
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
|
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
|
||||||
AttendanceWarnings.Add(item);
|
_attendanceItems.Add(ToAttentionItem(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AttentionItem ToAttentionItem(AttendanceWarningItem w) => new(
|
||||||
|
AttentionKind.Attendance, w.StudentName, trailingText: $"{w.AbsenceRatePercent:0.#} %",
|
||||||
|
isWarningTrailing: true, navigate: () => OnNavigateToStudent?.Invoke(w.StudentId));
|
||||||
|
|
||||||
// ── Klausurwochen (Nutzer-Feedback) ───────────────────────────────────────
|
// ── Klausurwochen (Nutzer-Feedback) ───────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Persönliche Klausurlast über alle Kurse hinweg — anders als die klassenbezogene
|
// Persönliche Klausurlast über alle Kurse hinweg — anders als die klassenbezogene
|
||||||
@@ -384,13 +434,13 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
|
|
||||||
private void LoadMissingTeachingTime(DateOnly today)
|
private void LoadMissingTeachingTime(DateOnly today)
|
||||||
{
|
{
|
||||||
MissingTeachingTimeEntries.Clear();
|
_missingTimeItems.Clear();
|
||||||
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
|
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
|
||||||
var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
|
var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
|
||||||
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
||||||
.Select(h => h.Date).ToHashSet();
|
.Select(h => h.Date).ToHashSet();
|
||||||
var schoolHolidays = _schoolHolidays.GetAll();
|
var schoolHolidays = _schoolHolidays.GetAll();
|
||||||
var nowTime = TimeOnly.FromDateTime(DateTime.Now);
|
var nowTime = TimeOnly.FromDateTime(_now());
|
||||||
|
|
||||||
var items = new List<MissingTeachingTimeItem>();
|
var items = new List<MissingTeachingTimeItem>();
|
||||||
for (var date = firstDay; date <= today; date = date.AddDays(1))
|
for (var date = firstDay; date <= today; date = date.AddDays(1))
|
||||||
@@ -419,14 +469,15 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd));
|
items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd));
|
||||||
}
|
}
|
||||||
foreach (var item in items.OrderBy(i => i.Date))
|
foreach (var item in items.OrderBy(i => i.Date))
|
||||||
MissingTeachingTimeEntries.Add(item);
|
_missingTimeItems.Add(new AttentionItem(AttentionKind.MissingTeachingTime, item.DateDisplay,
|
||||||
|
actions: [new AttentionAction("Erfassen", AddMissingTeachingTimeCommand, item)]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
|
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
|
||||||
|
|
||||||
private void LoadSupportPlanReviews(DateOnly today)
|
private void LoadSupportPlanReviews(DateOnly today)
|
||||||
{
|
{
|
||||||
SupportPlanReviews.Clear();
|
_supportItems.Clear();
|
||||||
var dueBy = today.AddDays(SupportPlanDueWithinDays);
|
var dueBy = today.AddDays(SupportPlanDueWithinDays);
|
||||||
|
|
||||||
var due = _documentation.GetAll()
|
var due = _documentation.GetAll()
|
||||||
@@ -439,8 +490,11 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
var student = _students.GetById(d.StudentId);
|
var student = _students.GetById(d.StudentId);
|
||||||
if (student is null) continue;
|
if (student is null) continue;
|
||||||
SupportPlanReviews.Add(new SupportPlanDueItem(
|
var reviewDate = d.SupportData!.ReviewDate!.Value;
|
||||||
d.StudentId, student.FullName, d.Title, d.SupportData!.ReviewDate!.Value, today));
|
var studentId = d.StudentId;
|
||||||
|
_supportItems.Add(new AttentionItem(AttentionKind.SupportPlan, student.FullName, subtitle: d.Title,
|
||||||
|
dateDisplay: reviewDate.ToString("dd.MM.yyyy"), isOverdue: reviewDate < today,
|
||||||
|
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,7 +539,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
|
|
||||||
private void LoadOpenCorrections(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
private void LoadOpenCorrections(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
||||||
{
|
{
|
||||||
OpenCorrections.Clear();
|
_correctionItems.Clear();
|
||||||
foreach (var group in groups.Values)
|
foreach (var group in groups.Values)
|
||||||
foreach (var exam in _exams.GetByGroup(group.Id)
|
foreach (var exam in _exams.GetByGroup(group.Id)
|
||||||
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
|
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
|
||||||
@@ -493,8 +547,13 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
var (expected, evaluated) = ExamCorrectionCounter.Count(exam,
|
var (expected, evaluated) = ExamCorrectionCounter.Count(exam,
|
||||||
_memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id));
|
_memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id));
|
||||||
OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title,
|
var percent = expected == 0 ? 0 : (int)Math.Round(evaluated * 100.0 / expected);
|
||||||
group.Name, exam.Date, evaluated, expected, today));
|
var groupId = group.Id;
|
||||||
|
_correctionItems.Add(new AttentionItem(AttentionKind.Correction, exam.Title, subtitle: group.Name,
|
||||||
|
dateDisplay: exam.Date.ToString("dd.MM.yyyy"),
|
||||||
|
isOverdue: exam.Date < today.AddDays(-7) && evaluated < expected,
|
||||||
|
progressPercent: percent, progressLabel: $"{evaluated} von {expected} Arbeiten bewertet", hasProgress: true,
|
||||||
|
navigate: () => OnNavigateToExam?.Invoke(groupId)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,14 +569,14 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
|
|
||||||
private void LoadUnplannedLessons(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
private void LoadUnplannedLessons(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
||||||
{
|
{
|
||||||
UnplannedLessons.Clear();
|
_unplannedItems.Clear();
|
||||||
var lastDay = today.AddDays(UnplannedLessonsLookaheadDays);
|
var lastDay = today.AddDays(UnplannedLessonsLookaheadDays);
|
||||||
var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1)
|
var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1)
|
||||||
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
||||||
.Select(h => h.Date).ToHashSet();
|
.Select(h => h.Date).ToHashSet();
|
||||||
var schoolHolidays = _schoolHolidays.GetAll();
|
var schoolHolidays = _schoolHolidays.GetAll();
|
||||||
|
|
||||||
var items = new List<UnplannedLessonItem>();
|
var items = new List<(DateOnly Date, int PeriodNumber, LearningGroup Group)>();
|
||||||
foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning))
|
foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning))
|
||||||
{
|
{
|
||||||
var slots = _timetableSlots.GetByGroup(group.Id);
|
var slots = _timetableSlots.GetByGroup(group.Id);
|
||||||
@@ -546,12 +605,19 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
items.Add(new UnplannedLessonItem(group.Id, group.Name, date, slot.PeriodNumber, today));
|
items.Add((date, slot.PeriodNumber, group));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach (var item in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber).ThenBy(i => i.GroupName))
|
foreach (var (date, periodNumber, group) in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber)
|
||||||
UnplannedLessons.Add(item);
|
.ThenBy(i => i.Group.Name))
|
||||||
|
{
|
||||||
|
var dateDisplay = date == today ? "Heute" : date == today.AddDays(1) ? "Morgen" : date.ToString("dd.MM.");
|
||||||
|
var groupId = group.Id;
|
||||||
|
_unplannedItems.Add(new AttentionItem(AttentionKind.Unplanned,
|
||||||
|
$"{group.Name} · {periodNumber}. Stunde", dateDisplay: dateDisplay,
|
||||||
|
navigate: () => OnNavigateToUnplannedLesson?.Invoke(groupId)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Erkennt, ob eine Stunde ohne eigene Lesson bereits Teil einer Doppelstunde ist, die
|
/// <summary>Erkennt, ob eine Stunde ohne eigene Lesson bereits Teil einer Doppelstunde ist, die
|
||||||
@@ -583,11 +649,11 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
|
|
||||||
private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
||||||
{
|
{
|
||||||
Alerts.Clear();
|
_alertItems.Clear();
|
||||||
foreach (var warning in AttendanceWarnings)
|
// Fehlzeiten-Auffälligkeiten erscheinen in der zusammengefassten Handlungsbedarf-Karte
|
||||||
Alerts.Add(new DashboardAlertItem(warning.StudentId, null, warning.StudentName,
|
// bereits als eigene Gruppe "Fehlzeiten-Warnung" (LoadAttendanceWarnings) — eine weitere
|
||||||
"Fehlzeiten", $"Fehlzeitenquote {warning.AbsenceRatePercent:0.#} %", AlertSeverity.High));
|
// Kopie hier wäre jetzt eine sichtbare Dopplung derselben Schüler/Zahl, die vor dem Merge
|
||||||
|
// durch zwei getrennte Kacheln (Auffälligkeiten vs. Fehlzeiten-Warnung) nicht auffiel.
|
||||||
foreach (var group in groups.Values)
|
foreach (var group in groups.Values)
|
||||||
{
|
{
|
||||||
var groupReportGrades = _reportGrades.GetByGroup(group.Id);
|
var groupReportGrades = _reportGrades.GetByGroup(group.Id);
|
||||||
@@ -596,6 +662,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
var student = _students.GetById(membership.StudentId);
|
var student = _students.GetById(membership.StudentId);
|
||||||
if (student is null) continue;
|
if (student is null) continue;
|
||||||
|
var studentId = student.Id;
|
||||||
var values = _grades.GetByStudentAndGroup(student.Id, group.Id)
|
var values = _grades.GetByStudentAndGroup(student.Id, group.Id)
|
||||||
.OrderBy(g => g.Date)
|
.OrderBy(g => g.Date)
|
||||||
.Select(g => int.TryParse(g.Value, out var value) ? (int?)value : null)
|
.Select(g => int.TryParse(g.Value, out var value) ? (int?)value : null)
|
||||||
@@ -609,9 +676,10 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
? recent - previous >= 1.0
|
? recent - previous >= 1.0
|
||||||
: previous - recent >= 3.0;
|
: previous - recent >= 3.0;
|
||||||
if (declined)
|
if (declined)
|
||||||
Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName,
|
_alertItems.Add(new AttentionItem(AttentionKind.Alert, student.FullName,
|
||||||
"Notenabfall", $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}",
|
subtitle: $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}",
|
||||||
AlertSeverity.Medium));
|
trailingText: "Notenabfall", severity: AlertSeverity.Medium,
|
||||||
|
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
var latestReport = groupReportGrades
|
var latestReport = groupReportGrades
|
||||||
@@ -620,9 +688,10 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
var effective = latestReport?.OverrideValue ?? latestReport?.CalculatedValue;
|
var effective = latestReport?.OverrideValue ?? latestReport?.CalculatedValue;
|
||||||
if (int.TryParse(effective, out var reportValue)
|
if (int.TryParse(effective, out var reportValue)
|
||||||
&& (group.GradingSystem == GradingSystem.Grades1To6 ? reportValue >= 5 : reportValue <= 4))
|
&& (group.GradingSystem == GradingSystem.Grades1To6 ? reportValue >= 5 : reportValue <= 4))
|
||||||
Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName,
|
_alertItems.Add(new AttentionItem(AttentionKind.Alert, student.FullName,
|
||||||
"Versetzungsgefährdung", $"{group.Name}: aktueller Stand {reportValue}",
|
subtitle: $"{group.Name}: aktueller Stand {reportValue}",
|
||||||
AlertSeverity.High));
|
trailingText: "Versetzungsgefährdung", severity: AlertSeverity.High,
|
||||||
|
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -644,19 +713,74 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
private static string CardTitle(string key) => key switch
|
private static string CardTitle(string key) => key switch
|
||||||
{
|
{
|
||||||
"today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender",
|
"today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender",
|
||||||
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
|
"upcoming" => "Anstehende Termine", "groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
|
||||||
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten",
|
"attention" => "Handlungsbedarf", _ => key,
|
||||||
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
|
|
||||||
"groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
|
|
||||||
"missingteachingtime" => "Unterrichtszeit nacherfassen", _ => key,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Handlungsbedarf: Filter-Chips ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Ersetzt die frühere Sichtbarkeit je Einzelkachel (sieben Schalter im "Bereiche anpassen"-
|
||||||
|
// Panel) durch Filter-Chips innerhalb der zusammengefassten Karte — dichter, und der
|
||||||
|
// naheliegende Ort, weil alle sieben jetzt eine Karte sind. Bewusst nur für die Dauer der
|
||||||
|
// Sitzung (keine Persistenz über DashboardSettingsService): das JSON-Format dort ist eine
|
||||||
|
// flache Liste von Kachel-Einstellungen, eine zweite Objektform nur für diese sieben Filter
|
||||||
|
// hätte das Dateiformat aufgespalten, ohne dass "welche Handlungsbedarf-Art blende ich
|
||||||
|
// dauerhaft aus" bisher als Bedürfnis geäußert wurde.
|
||||||
|
|
||||||
|
private void LoadAttentionFilters()
|
||||||
|
{
|
||||||
|
AttentionFilters.Clear();
|
||||||
|
foreach (var (kind, header) in AttentionGroupOrder)
|
||||||
|
{
|
||||||
|
var option = new AttentionFilterOption(kind, header) { OnChanged = RebuildAttention };
|
||||||
|
AttentionFilters.Add(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsAttentionFilterActive(AttentionKind kind) =>
|
||||||
|
AttentionFilters.FirstOrDefault(f => f.Kind == kind)?.IsActive ?? true;
|
||||||
|
|
||||||
|
/// <summary>Setzt Attention aus den bereits geladenen _xyzItems-Feldern neu zusammen — reine
|
||||||
|
/// Umsortierung/Filterung im Speicher, kein Repo-Zugriff. Wird nach jedem Load() sowie nach
|
||||||
|
/// jeder punktuellen Änderung (Entschuldigung aufgelöst, Zeit nacherfasst, Filter-Chip
|
||||||
|
/// umgeschaltet) aufgerufen.</summary>
|
||||||
|
private void RebuildAttention()
|
||||||
|
{
|
||||||
|
var byKind = new Dictionary<AttentionKind, IReadOnlyList<AttentionItem>>
|
||||||
|
{
|
||||||
|
[AttentionKind.MissingTeachingTime] = _missingTimeItems,
|
||||||
|
[AttentionKind.Excuse] = _excuses.Select(ToAttentionItem).ToList(),
|
||||||
|
[AttentionKind.Correction] = _correctionItems,
|
||||||
|
[AttentionKind.Unplanned] = _unplannedItems,
|
||||||
|
[AttentionKind.Alert] = _alertItems,
|
||||||
|
[AttentionKind.Attendance] = _attendanceItems,
|
||||||
|
[AttentionKind.SupportPlan] = _supportItems,
|
||||||
|
};
|
||||||
|
|
||||||
|
Attention.Clear();
|
||||||
|
foreach (var (kind, header) in AttentionGroupOrder)
|
||||||
|
{
|
||||||
|
if (!IsAttentionFilterActive(kind)) continue;
|
||||||
|
var items = byKind[kind];
|
||||||
|
if (items.Count == 0) continue;
|
||||||
|
Attention.Add(new AttentionGroup(kind, header, items));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AttentionItem ToAttentionItem(OpenExcuseItem e) => new(
|
||||||
|
AttentionKind.Excuse, e.StudentName, subtitle: $"{e.GroupName} · {e.DateDisplay}",
|
||||||
|
actions: [new AttentionAction("Entschuldigt", e.MarkExcusedCommand),
|
||||||
|
new AttentionAction("Unentschuldigt", e.MarkUnexcusedCommand)]);
|
||||||
|
|
||||||
|
// Zaehlt bewusst EffectiveIsVisible, nicht IsVisible: eine eingeschaltete, aber gerade leere
|
||||||
|
// HideWhenEmpty-Kachel wird nicht gerendert und darf deshalb auch keinen Rasterplatz belegen,
|
||||||
|
// sonst bleibt an ihrer Stelle eine Luecke im zweispaltigen Grid.
|
||||||
private void ApplyCardLayout()
|
private void ApplyCardLayout()
|
||||||
{
|
{
|
||||||
var visibleIndex = 0;
|
var visibleIndex = 0;
|
||||||
foreach (var card in DashboardCards)
|
foreach (var card in DashboardCards)
|
||||||
{
|
{
|
||||||
var index = card.IsVisible ? visibleIndex++ : 0;
|
var index = card.EffectiveIsVisible ? visibleIndex++ : 0;
|
||||||
card.Row = index / 2;
|
card.Row = index / 2;
|
||||||
card.Column = index % 2;
|
card.Column = index % 2;
|
||||||
}
|
}
|
||||||
@@ -692,11 +816,10 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
SaveAndApplyCardLayout();
|
SaveAndApplyCardLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand] private void OpenStudentAttendance(AttendanceWarningItem? item)
|
/// <summary>Ersetzt die früheren fünf eigenen Navigations-Commands (OpenStudentAttendance,
|
||||||
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
|
/// OpenStudentSupportPlan, OpenCorrection, OpenUnplannedLesson, OpenAlert) — jedes AttentionItem
|
||||||
|
/// trägt sein Sprungziel bereits als Closure in Navigate.</summary>
|
||||||
[RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item)
|
[RelayCommand] private void OpenAttentionItem(AttentionItem? item) => item?.Navigate?.Invoke();
|
||||||
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
|
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item)
|
private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item)
|
||||||
@@ -704,12 +827,13 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
if (item is null || OnAddMissingTeachingTime is null) return;
|
if (item is null || OnAddMissingTeachingTime is null) return;
|
||||||
await OnAddMissingTeachingTime(item);
|
await OnAddMissingTeachingTime(item);
|
||||||
LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today));
|
LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today));
|
||||||
|
RebuildAttention();
|
||||||
UpdateDashboardSummary();
|
UpdateDashboardSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
|
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
|
||||||
{
|
{
|
||||||
OpenExcuses.Clear();
|
_excuses.Clear();
|
||||||
var cutoff = today.AddDays(-OpenExcuseMaxAgeDays);
|
var cutoff = today.AddDays(-OpenExcuseMaxAgeDays);
|
||||||
|
|
||||||
var items = new List<OpenExcuseItem>();
|
var items = new List<OpenExcuseItem>();
|
||||||
@@ -728,8 +852,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach (var item in items.OrderBy(i => i.Date))
|
_excuses.AddRange(items.OrderBy(i => i.Date));
|
||||||
OpenExcuses.Add(item);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status)
|
private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status)
|
||||||
@@ -738,7 +861,8 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
if (entry is null) return;
|
if (entry is null) return;
|
||||||
entry.Attendance = status;
|
entry.Attendance = status;
|
||||||
_participationEntries.Save(entry);
|
_participationEntries.Save(entry);
|
||||||
OpenExcuses.Remove(item);
|
_excuses.Remove(item);
|
||||||
|
RebuildAttention();
|
||||||
UpdateDashboardSummary();
|
UpdateDashboardSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -939,12 +1063,6 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
else OnNavigateToGroup?.Invoke(groupId);
|
else OnNavigateToGroup?.Invoke(groupId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
[RelayCommand] private void OpenCorrection(CorrectionProgressItem? item)
|
|
||||||
{ if (item is not null) OnNavigateToExam?.Invoke(item.GroupId); }
|
|
||||||
[RelayCommand] private void OpenUnplannedLesson(UnplannedLessonItem? item)
|
|
||||||
{ if (item is not null) OnNavigateToUnplannedLesson?.Invoke(item.GroupId); }
|
|
||||||
[RelayCommand] private void OpenAlert(DashboardAlertItem? item)
|
|
||||||
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
|
|
||||||
[RelayCommand] private void Refresh() => Load();
|
[RelayCommand] private void Refresh() => Load();
|
||||||
|
|
||||||
[RelayCommand] private Task AddTask() => AddTaskInternal(startAsReminder: false);
|
[RelayCommand] private Task AddTask() => AddTaskInternal(startAsReminder: false);
|
||||||
@@ -1093,26 +1211,6 @@ public class MissingTeachingTimeItem(DateOnly date, TimeOnly windowStart, TimeOn
|
|||||||
public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De);
|
public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────────
|
|
||||||
|
|
||||||
public class SupportPlanDueItem
|
|
||||||
{
|
|
||||||
public Guid StudentId { get; }
|
|
||||||
public string StudentName { get; }
|
|
||||||
public string Title { get; }
|
|
||||||
public string ReviewDateDisplay { get; }
|
|
||||||
public bool IsOverdue { get; }
|
|
||||||
|
|
||||||
public SupportPlanDueItem(Guid studentId, string studentName, string title, DateOnly reviewDate, DateOnly today)
|
|
||||||
{
|
|
||||||
StudentId = studentId;
|
|
||||||
StudentName = studentName;
|
|
||||||
Title = title;
|
|
||||||
ReviewDateDisplay = reviewDate.ToString("dd.MM.yyyy");
|
|
||||||
IsOverdue = reviewDate < today;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public partial class CalendarDayCell : ObservableObject
|
public partial class CalendarDayCell : ObservableObject
|
||||||
{
|
{
|
||||||
[ObservableProperty] private bool _isSelected;
|
[ObservableProperty] private bool _isSelected;
|
||||||
@@ -1188,46 +1286,8 @@ public sealed class UpcomingDateItem(UpcomingDateKind kind, DateOnly date, strin
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class CorrectionProgressItem(Guid examId, Guid groupId, string title, string groupName,
|
|
||||||
DateOnly date, int completed, int total, DateOnly today)
|
|
||||||
{
|
|
||||||
public Guid ExamId { get; } = examId;
|
|
||||||
public Guid GroupId { get; } = groupId;
|
|
||||||
public string Title { get; } = title;
|
|
||||||
public string GroupName { get; } = groupName;
|
|
||||||
public DateOnly Date { get; } = date;
|
|
||||||
public int Completed { get; } = completed;
|
|
||||||
public int Total { get; } = total;
|
|
||||||
public int Percent => Total == 0 ? 0 : (int)Math.Round(Completed * 100.0 / Total);
|
|
||||||
public string ProgressDisplay => $"{Completed} von {Total} Arbeiten bewertet";
|
|
||||||
public string DateDisplay => Date.ToString("dd.MM.yyyy");
|
|
||||||
public bool IsOverdue => Date < today.AddDays(-7) && Completed < Total;
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class UnplannedLessonItem(Guid groupId, string groupName, DateOnly date, int periodNumber, DateOnly today)
|
|
||||||
{
|
|
||||||
public Guid GroupId { get; } = groupId;
|
|
||||||
public string GroupName { get; } = groupName;
|
|
||||||
public DateOnly Date { get; } = date;
|
|
||||||
public int PeriodNumber { get; } = periodNumber;
|
|
||||||
public string DateDisplay => Date == today ? "Heute" : Date == today.AddDays(1) ? "Morgen" : Date.ToString("dd.MM.");
|
|
||||||
public string Display => $"{GroupName} · {PeriodNumber}. Stunde";
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum AlertSeverity { Medium, High }
|
public enum AlertSeverity { Medium, High }
|
||||||
|
|
||||||
public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName,
|
|
||||||
string kindLabel, string detail, AlertSeverity severity)
|
|
||||||
{
|
|
||||||
public Guid StudentId { get; } = studentId;
|
|
||||||
public Guid? GroupId { get; } = groupId;
|
|
||||||
public string StudentName { get; } = studentName;
|
|
||||||
public string KindLabel { get; } = kindLabel;
|
|
||||||
public string Detail { get; } = detail;
|
|
||||||
public AlertSeverity Severity { get; } = severity;
|
|
||||||
public string SeverityColor => Severity == AlertSeverity.High ? "#D32F2F" : "#F59E0B";
|
|
||||||
}
|
|
||||||
|
|
||||||
public partial class DashboardCardOption : ObservableObject
|
public partial class DashboardCardOption : ObservableObject
|
||||||
{
|
{
|
||||||
[ObservableProperty] private bool _isVisible;
|
[ObservableProperty] private bool _isVisible;
|
||||||
@@ -1240,12 +1300,19 @@ public partial class DashboardCardOption : ObservableObject
|
|||||||
public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty);
|
public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty);
|
||||||
public Action? OnVisibilityChanged { get; set; }
|
public Action? OnVisibilityChanged { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Rinnstein zur jeweils anderen Rasterspalte. Muss aus der berechneten
|
||||||
|
/// <see cref="Column"/> kommen und darf nicht im XAML fest an der Kachel haengen: welche Kachel
|
||||||
|
/// links und welche rechts landet, entscheidet sich erst zur Laufzeit aus Reihenfolge und
|
||||||
|
/// Sichtbarkeit, ein fester Margin sitzt dann bei jeder Umschaltung auf der falschen Seite.</summary>
|
||||||
|
public Avalonia.Thickness Margin => Column == 0
|
||||||
|
? new Avalonia.Thickness(0, 0, 8, 8)
|
||||||
|
: new Avalonia.Thickness(8, 0, 0, 8);
|
||||||
|
|
||||||
public DashboardCardOption(string key, string title, bool isVisible)
|
public DashboardCardOption(string key, string title, bool isVisible)
|
||||||
{
|
{
|
||||||
Key = key;
|
Key = key;
|
||||||
Title = title;
|
Title = title;
|
||||||
HideWhenEmpty = key is "excuses" or "upcoming" or "corrections" or "unplanned"
|
HideWhenEmpty = key is "upcoming" or "attention";
|
||||||
or "alerts" or "attendance" or "support";
|
|
||||||
_isVisible = isVisible;
|
_isVisible = isVisible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1256,4 +1323,6 @@ public partial class DashboardCardOption : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
partial void OnIsEmptyChanged(bool value) => OnPropertyChanged(nameof(EffectiveIsVisible));
|
partial void OnIsEmptyChanged(bool value) => OnPropertyChanged(nameof(EffectiveIsVisible));
|
||||||
|
|
||||||
|
partial void OnColumnChanged(int value) => OnPropertyChanged(nameof(Margin));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
private readonly GradingService _grading;
|
private readonly GradingService _grading;
|
||||||
private readonly SchoolYearService _schoolYear;
|
private readonly SchoolYearService _schoolYear;
|
||||||
|
|
||||||
|
public ObservableCollection<Lesson> TodayLessons { get; } = [];
|
||||||
|
[ObservableProperty] private Lesson? _selectedTeachingLesson;
|
||||||
|
public bool HasTodayLessons => TodayLessons.Count > 0;
|
||||||
|
public Action<Lesson>? OnOpenTeachingMode { get; set; }
|
||||||
|
[RelayCommand] private void StartTeachingMode()
|
||||||
|
{
|
||||||
|
if (SelectedTeachingLesson is { } lesson) OnOpenTeachingMode?.Invoke(lesson);
|
||||||
|
}
|
||||||
|
|
||||||
private Guid _groupId;
|
private Guid _groupId;
|
||||||
private string _groupName = "";
|
private string _groupName = "";
|
||||||
|
|
||||||
@@ -130,6 +139,16 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
public void Refresh()
|
public void Refresh()
|
||||||
{
|
{
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
TodayLessons.Clear();
|
||||||
|
if (_groups.GetById(_groupId)?.IsActive == true)
|
||||||
|
foreach (var lesson in _lessons.GetByGroupAndRange(_groupId, today, today)
|
||||||
|
.Where(l => l.Status != LessonStatus.Cancelled)
|
||||||
|
.OrderBy(l => l.StartTime).ThenBy(l => l.LessonNumber))
|
||||||
|
TodayLessons.Add(lesson);
|
||||||
|
var now = TimeOnly.FromDateTime(DateTime.Now);
|
||||||
|
SelectedTeachingLesson = TodayLessons.LastOrDefault(l => l.StartTime <= now)
|
||||||
|
?? TodayLessons.FirstOrDefault();
|
||||||
|
OnPropertyChanged(nameof(HasTodayLessons));
|
||||||
LoadNextLesson(today);
|
LoadNextLesson(today);
|
||||||
LoadNextExam(today);
|
LoadNextExam(today);
|
||||||
LoadYearComparison();
|
LoadYearComparison();
|
||||||
@@ -145,7 +164,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 +239,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();
|
||||||
|
|
||||||
|
|||||||
@@ -826,6 +826,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _isDifferentiated;
|
[ObservableProperty] private bool _isDifferentiated;
|
||||||
[ObservableProperty] private bool _requiresLessonPlanning = true;
|
[ObservableProperty] private bool _requiresLessonPlanning = true;
|
||||||
[ObservableProperty] private int? _webUntisLessonId;
|
[ObservableProperty] private int? _webUntisLessonId;
|
||||||
|
[ObservableProperty] private bool _excludedFromUntisHub;
|
||||||
[ObservableProperty] private string _nameError = "";
|
[ObservableProperty] private string _nameError = "";
|
||||||
[ObservableProperty] private string _gradeLevelError = "";
|
[ObservableProperty] private string _gradeLevelError = "";
|
||||||
|
|
||||||
@@ -866,6 +867,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
IsDifferentiated = group.IsDifferentiated;
|
IsDifferentiated = group.IsDifferentiated;
|
||||||
RequiresLessonPlanning = group.RequiresLessonPlanning;
|
RequiresLessonPlanning = group.RequiresLessonPlanning;
|
||||||
WebUntisLessonId = group.WebUntisLessonId;
|
WebUntisLessonId = group.WebUntisLessonId;
|
||||||
|
ExcludedFromUntisHub = group.ExcludedFromUntisHub;
|
||||||
OnPropertyChanged(nameof(DialogTitle));
|
OnPropertyChanged(nameof(DialogTitle));
|
||||||
OnPropertyChanged(nameof(SaveButtonText));
|
OnPropertyChanged(nameof(SaveButtonText));
|
||||||
}
|
}
|
||||||
@@ -913,6 +915,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
Result.IsDifferentiated = IsDifferentiated;
|
Result.IsDifferentiated = IsDifferentiated;
|
||||||
Result.RequiresLessonPlanning = RequiresLessonPlanning;
|
Result.RequiresLessonPlanning = RequiresLessonPlanning;
|
||||||
Result.WebUntisLessonId = WebUntisLessonId;
|
Result.WebUntisLessonId = WebUntisLessonId;
|
||||||
|
Result.ExcludedFromUntisHub = ExcludedFromUntisHub;
|
||||||
_groups.Save(Result);
|
_groups.Save(Result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
|
||||||
|
/// <summary>Erzeugt aus einer in TemplateDesigner gebauten Arbeitsblatt-.lavorlage-Vorlage ein
|
||||||
|
/// personalisiertes PDF je aktivem Mitglied der aktuell geöffneten Lerngruppe. Nutzt bewusst
|
||||||
|
/// dieselbe Platzhalter-/Rendering-Infrastruktur wie der Elternbrief-Dialog
|
||||||
|
/// (<see cref="LetterPlaceholderBuilder"/>, <see cref="ITemplateRenderer"/>), aber eine getrennte
|
||||||
|
/// <see cref="WorksheetTemplateStore"/>-Vorlagenbibliothek.</summary>
|
||||||
|
public partial class PersonalizeWorksheetDialogViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly LearningGroup _group;
|
||||||
|
private readonly WorksheetTemplateStore _templates;
|
||||||
|
private readonly ITemplateRenderer _renderer;
|
||||||
|
|
||||||
|
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
|
||||||
|
[ObservableProperty] private string _statusMessage = "";
|
||||||
|
|
||||||
|
public string GroupName => $"{_group.Name} · {_group.SchoolYear}";
|
||||||
|
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
|
||||||
|
public ObservableCollection<WorksheetStudentChoice> Students { get; } = [];
|
||||||
|
public ObservableCollection<WorksheetGenerationResult> Results { get; } = [];
|
||||||
|
public bool HasNoTemplates => Templates.Count == 0;
|
||||||
|
public bool HasResults => Results.Count > 0;
|
||||||
|
|
||||||
|
public PersonalizeWorksheetDialogViewModel(LearningGroup group, IReadOnlyList<Guid> studentIds,
|
||||||
|
IStudentRepository students, WorksheetTemplateStore templates, ITemplateRenderer renderer)
|
||||||
|
{
|
||||||
|
_group = group; _templates = templates; _renderer = renderer;
|
||||||
|
foreach (var template in templates.Store.GetTemplates()) Templates.Add(new(template));
|
||||||
|
SelectedTemplate = Templates.FirstOrDefault();
|
||||||
|
foreach (var id in studentIds)
|
||||||
|
if (students.GetById(id) is { } student) Students.Add(new(student));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Generate(string outputFolder)
|
||||||
|
{
|
||||||
|
Results.Clear();
|
||||||
|
if (SelectedTemplate is null) return;
|
||||||
|
LoadedTemplate loaded;
|
||||||
|
try { loaded = _templates.Store.Load(SelectedTemplate.Model); }
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||||
|
{ StatusMessage = $"Vorlage ist ungültig: {ex.Message}"; return; }
|
||||||
|
|
||||||
|
Directory.CreateDirectory(outputFolder);
|
||||||
|
foreach (var choice in Students.Where(x => x.IsIncluded))
|
||||||
|
{
|
||||||
|
var student = choice.Model;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact: null, _group,
|
||||||
|
DateOnly.FromDateTime(DateTime.Now), "", "");
|
||||||
|
var pdf = _renderer.RenderToPdf(loaded, new LetterDataProvider(values));
|
||||||
|
var fileName = SanitizeFileName($"{SelectedTemplate.Name}_{student.LastName}_{student.FirstName}.pdf");
|
||||||
|
File.WriteAllBytes(Path.Combine(outputFolder, fileName), pdf);
|
||||||
|
Results.Add(new(student.FullName, true, ""));
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
|
||||||
|
{ Results.Add(new(student.FullName, false, ex.Message)); }
|
||||||
|
}
|
||||||
|
OnPropertyChanged(nameof(HasResults));
|
||||||
|
StatusMessage = $"{Results.Count(x => x.Success)} von {Results.Count} Arbeitsblättern erzeugt.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeFileName(string value)
|
||||||
|
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class WorksheetStudentChoice(Student model) : ObservableObject
|
||||||
|
{
|
||||||
|
public Student Model { get; } = model;
|
||||||
|
public string FullName => Model.FullName;
|
||||||
|
[ObservableProperty] private bool _isIncluded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record WorksheetGenerationResult(string StudentName, bool Success, string ErrorMessage)
|
||||||
|
{
|
||||||
|
public string Icon => Success ? "✓" : "⚠";
|
||||||
|
public string Color => Success ? "SeaGreen" : "#D97706";
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ using LehrerApp.Core.Interfaces;
|
|||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
using LehrerApp.Desktop.ViewModels.Students;
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
@@ -75,6 +76,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
public Func<Lesson, List<string>, List<string>, Task<bool>>? OnEditLesson { get; set; }
|
public Func<Lesson, List<string>, List<string>, Task<bool>>? OnEditLesson { get; set; }
|
||||||
public Func<LessonSummary, Task<bool>>? OnConfirmDeleteLesson { get; set; }
|
public Func<LessonSummary, Task<bool>>? OnConfirmDeleteLesson { get; set; }
|
||||||
public Func<Lesson, Task<MoveLessonTarget?>>? OnPickMoveTarget { get; set; }
|
public Func<Lesson, Task<MoveLessonTarget?>>? OnPickMoveTarget { get; set; }
|
||||||
|
public Func<Lesson, Task<Unit?>>? OnPickUnitChange { get; set; }
|
||||||
public Func<Lesson, Task>? OnShowLesson { get; set; }
|
public Func<Lesson, Task>? OnShowLesson { get; set; }
|
||||||
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
|
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
|
||||||
public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
|
public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
|
||||||
@@ -100,10 +102,10 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
GradeLevel = group?.GradeLevel ?? 0;
|
GradeLevel = group?.GradeLevel ?? 0;
|
||||||
SubjectName = SubjectId is Guid sid ? _subjects.GetById(sid)?.Name ?? "" : "";
|
SubjectName = SubjectId is Guid sid ? _subjects.GetById(sid)?.Name ?? "" : "";
|
||||||
GroupLabel = group?.Name ?? "";
|
GroupLabel = group?.Name ?? "";
|
||||||
LoadUnits();
|
LoadUnits(preferActive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadUnits()
|
private void LoadUnits(bool preferActive = false)
|
||||||
{
|
{
|
||||||
var selectedId = SelectedUnit?.Id;
|
var selectedId = SelectedUnit?.Id;
|
||||||
Units.Clear();
|
Units.Clear();
|
||||||
@@ -126,7 +128,11 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
KnownMaterials = materials.OrderBy(m => m, StringComparer.CurrentCultureIgnoreCase).ToList();
|
KnownMaterials = materials.OrderBy(m => m, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||||
KnownShorthands = shorthands.OrderBy(s => s, StringComparer.CurrentCultureIgnoreCase).ToList();
|
KnownShorthands = shorthands.OrderBy(s => s, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||||
|
|
||||||
SelectedUnit = Units.FirstOrDefault(u => u.Id == selectedId) ?? Units.FirstOrDefault();
|
// Beim ersten Öffnen bzw. beim Wechsel aus dem Stundenplan steht die laufende Einheit im
|
||||||
|
// Fokus. Eine bewusste Auswahl innerhalb derselben Gruppe bleibt bei Refreshes erhalten.
|
||||||
|
SelectedUnit = !preferActive && selectedId is not null
|
||||||
|
? Units.FirstOrDefault(u => u.Id == selectedId) ?? Units.FirstOrDefault(u => u.Status == UnitStatus.Active) ?? Units.FirstOrDefault()
|
||||||
|
: Units.FirstOrDefault(u => u.Status == UnitStatus.Active) ?? Units.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedUnitChanged(UnitSummary? value)
|
partial void OnSelectedUnitChanged(UnitSummary? value)
|
||||||
@@ -160,6 +166,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
EditLessonCommand.NotifyCanExecuteChanged();
|
EditLessonCommand.NotifyCanExecuteChanged();
|
||||||
DeleteLessonCommand.NotifyCanExecuteChanged();
|
DeleteLessonCommand.NotifyCanExecuteChanged();
|
||||||
MoveLessonCommand.NotifyCanExecuteChanged();
|
MoveLessonCommand.NotifyCanExecuteChanged();
|
||||||
|
ChangeLessonUnitCommand.NotifyCanExecuteChanged();
|
||||||
AdvanceLessonStatusCommand.NotifyCanExecuteChanged();
|
AdvanceLessonStatusCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,10 +356,27 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
new LessonSchedulingService(_lessons).Move(moved, newDate, newPeriod, shiftFollowing);
|
new LessonSchedulingService(_lessons).Move(moved, newDate, newPeriod, shiftFollowing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ordnet eine bereits angelegte Stunde einer anderen Einheit derselben Gruppe zu (Korrektur
|
||||||
|
/// einer im Erstellen-Dialog falsch gewählten Einheit, Nutzer-Feedback) — bisher war das nur
|
||||||
|
/// über Löschen + Neuanlegen möglich. Datum/Stundennummer bleiben unverändert, nur UnitId ändert
|
||||||
|
/// sich. Nach dem Speichern springt die Ansicht direkt zur Ziel-Einheit mit der Stunde
|
||||||
|
/// vorausgewählt, damit sichtbar bleibt, wohin sie verschoben wurde.
|
||||||
|
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||||
|
private async Task ChangeLessonUnit()
|
||||||
|
{
|
||||||
|
if (OnPickUnitChange is null || SelectedLesson is null) return;
|
||||||
|
var lesson = SelectedLesson.Model;
|
||||||
|
var targetUnit = await OnPickUnitChange(lesson);
|
||||||
|
if (targetUnit is null) return;
|
||||||
|
lesson.UnitId = targetUnit.Id;
|
||||||
|
_lessons.Save(lesson);
|
||||||
|
RefreshPlanning(selectUnitId: targetUnit.Id, selectLessonId: lesson.Id);
|
||||||
|
}
|
||||||
|
|
||||||
[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
|
||||||
@@ -443,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";
|
||||||
}
|
}
|
||||||
@@ -483,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";
|
||||||
@@ -519,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",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -534,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -554,6 +584,7 @@ public partial class UnitDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _startDateText = "";
|
[ObservableProperty] private string _startDateText = "";
|
||||||
[ObservableProperty] private string _endDateText = "";
|
[ObservableProperty] private string _endDateText = "";
|
||||||
[ObservableProperty] private string _statusName = UnitStatusDisplay.Options[0];
|
[ObservableProperty] private string _statusName = UnitStatusDisplay.Options[0];
|
||||||
|
[ObservableProperty] private string _statusNotice = "";
|
||||||
[ObservableProperty] private string _notes = "";
|
[ObservableProperty] private string _notes = "";
|
||||||
[ObservableProperty] private bool _isCompetencyPanelOpen;
|
[ObservableProperty] private bool _isCompetencyPanelOpen;
|
||||||
[ObservableProperty] private string _titleError = "";
|
[ObservableProperty] private string _titleError = "";
|
||||||
@@ -574,6 +605,20 @@ public partial class UnitDialogViewModel : ObservableObject
|
|||||||
public string DialogTitle => _editingUnit is null ? "Neue Einheit anlegen" : "Einheit bearbeiten";
|
public string DialogTitle => _editingUnit is null ? "Neue Einheit anlegen" : "Einheit bearbeiten";
|
||||||
public string SaveButtonText => _editingUnit is null ? "Anlegen" : "Speichern";
|
public string SaveButtonText => _editingUnit is null ? "Anlegen" : "Speichern";
|
||||||
|
|
||||||
|
partial void OnStatusNameChanged(string value)
|
||||||
|
{
|
||||||
|
if (UnitStatusDisplay.FromName(value) != UnitStatus.Active)
|
||||||
|
{
|
||||||
|
StatusNotice = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previous = _units.GetByGroup(_groupId)
|
||||||
|
.FirstOrDefault(u => u.Status == UnitStatus.Active && u.Id != _editingUnit?.Id);
|
||||||
|
StatusNotice = previous is null ? ""
|
||||||
|
: $"„{previous.Title}“ wird beim Speichern automatisch abgeschlossen.";
|
||||||
|
}
|
||||||
|
|
||||||
public UnitDialogViewModel(IUnitRepository units, ICompetencyDomainRepository competencyDomains,
|
public UnitDialogViewModel(IUnitRepository units, ICompetencyDomainRepository competencyDomains,
|
||||||
Guid groupId, Guid? subjectId, int gradeLevel, string groupName, string subjectName, Unit? editingUnit)
|
Guid groupId, Guid? subjectId, int gradeLevel, string groupName, string subjectName, Unit? editingUnit)
|
||||||
{
|
{
|
||||||
@@ -659,6 +704,17 @@ public partial class UnitDialogViewModel : ObservableObject
|
|||||||
Result.Status = UnitStatusDisplay.FromName(StatusName);
|
Result.Status = UnitStatusDisplay.FromName(StatusName);
|
||||||
Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim();
|
Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim();
|
||||||
Result.Competencies = _competencyCodes;
|
Result.Competencies = _competencyCodes;
|
||||||
|
if (Result.Status == UnitStatus.Active)
|
||||||
|
{
|
||||||
|
// Pro Lerngruppe gibt es genau einen aktuellen Arbeitskontext. Frühere laufende
|
||||||
|
// Einheiten werden nicht verworfen, sondern fachlich sauber abgeschlossen.
|
||||||
|
foreach (var previous in _units.GetByGroup(_groupId)
|
||||||
|
.Where(u => u.Status == UnitStatus.Active && u.Id != Result.Id))
|
||||||
|
{
|
||||||
|
previous.Status = UnitStatus.Completed;
|
||||||
|
_units.Save(previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
_units.Save(Result);
|
_units.Save(Result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -681,6 +737,7 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private int? _lessonNumber;
|
[ObservableProperty] private int? _lessonNumber;
|
||||||
[ObservableProperty] private string _topic = "";
|
[ObservableProperty] private string _topic = "";
|
||||||
[ObservableProperty] private string _startTimeText = "";
|
[ObservableProperty] private string _startTimeText = "";
|
||||||
|
[ObservableProperty] private string _planningIdeas = "";
|
||||||
[ObservableProperty] private string _homework = "";
|
[ObservableProperty] private string _homework = "";
|
||||||
[ObservableProperty] private bool _homeworkChecked;
|
[ObservableProperty] private bool _homeworkChecked;
|
||||||
[ObservableProperty] private bool _homeworkCheckDismissed;
|
[ObservableProperty] private bool _homeworkCheckDismissed;
|
||||||
@@ -725,8 +782,10 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
// gespeicherte Stunden sinnvoll, eine gerade erst angelegte, noch ungespeicherte Stunde hat
|
// gespeicherte Stunden sinnvoll, eine gerade erst angelegte, noch ungespeicherte Stunde hat
|
||||||
// keine echte Id, auf die sich die KI beziehen könnte.
|
// keine echte Id, auf die sich die KI beziehen könnte.
|
||||||
public Guid UnitId => _unitId;
|
public Guid UnitId => _unitId;
|
||||||
|
public Guid GroupId => _groupId;
|
||||||
public Lesson? EditingLesson => _editingLesson;
|
public Lesson? EditingLesson => _editingLesson;
|
||||||
public bool CanAiAssist => _editingLesson is not null;
|
public bool CanAiAssist => _editingLesson is not null;
|
||||||
|
public bool IsNewLesson => _editingLesson is null;
|
||||||
|
|
||||||
/// Vom Code-Behind nach einer über die KI angewendeten Änderung aufgerufen: der Dialog schließt
|
/// Vom Code-Behind nach einer über die KI angewendeten Änderung aufgerufen: der Dialog schließt
|
||||||
/// sich danach mit Result != null, damit die aufrufende Liste neu lädt — die eigenen, jetzt
|
/// sich danach mit Result != null, damit die aufrufende Liste neu lädt — die eigenen, jetzt
|
||||||
@@ -765,6 +824,7 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
LessonNumber = editingLesson.LessonNumber;
|
LessonNumber = editingLesson.LessonNumber;
|
||||||
Topic = editingLesson.Topic;
|
Topic = editingLesson.Topic;
|
||||||
StartTimeText = editingLesson.StartTime?.ToString("HH:mm") ?? "";
|
StartTimeText = editingLesson.StartTime?.ToString("HH:mm") ?? "";
|
||||||
|
PlanningIdeas = editingLesson.PlanningIdeas ?? "";
|
||||||
Homework = editingLesson.Homework ?? "";
|
Homework = editingLesson.Homework ?? "";
|
||||||
HomeworkChecked = editingLesson.HomeworkChecked;
|
HomeworkChecked = editingLesson.HomeworkChecked;
|
||||||
HomeworkCheckDismissed = editingLesson.HomeworkCheckDismissed;
|
HomeworkCheckDismissed = editingLesson.HomeworkCheckDismissed;
|
||||||
@@ -830,6 +890,7 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
Activity = source?.Activity ?? "",
|
Activity = source?.Activity ?? "",
|
||||||
Material = source?.Material ?? "",
|
Material = source?.Material ?? "",
|
||||||
Shorthand = source?.Shorthand ?? "",
|
Shorthand = source?.Shorthand ?? "",
|
||||||
|
MaterialPrompt = source?.MaterialPrompt,
|
||||||
};
|
};
|
||||||
item.OnChanged = RecomputeTimes;
|
item.OnChanged = RecomputeTimes;
|
||||||
item.OnRemove = RemovePhase;
|
item.OnRemove = RemovePhase;
|
||||||
@@ -1017,6 +1078,7 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
Result.LessonNumber = LessonNumber;
|
Result.LessonNumber = LessonNumber;
|
||||||
Result.Topic = Topic.Trim();
|
Result.Topic = Topic.Trim();
|
||||||
Result.StartTime = startTime;
|
Result.StartTime = startTime;
|
||||||
|
Result.PlanningIdeas = string.IsNullOrWhiteSpace(PlanningIdeas) ? null : PlanningIdeas.Trim();
|
||||||
Result.Phases = Phases.Select(p => p.ToModel()).ToList();
|
Result.Phases = Phases.Select(p => p.ToModel()).ToList();
|
||||||
Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim();
|
Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim();
|
||||||
Result.HomeworkChecked = HomeworkChecked;
|
Result.HomeworkChecked = HomeworkChecked;
|
||||||
@@ -1045,6 +1107,13 @@ public partial class PhaseStepEditItem : ObservableObject
|
|||||||
[ObservableProperty] private string _material = "";
|
[ObservableProperty] private string _material = "";
|
||||||
[ObservableProperty] private string _shorthand = "";
|
[ObservableProperty] private string _shorthand = "";
|
||||||
[ObservableProperty] private string _computedTimeDisplay = "";
|
[ObservableProperty] private string _computedTimeDisplay = "";
|
||||||
|
/// Gespeicherter Materialerstellungs-Prompt (4.5.20/4.5.36) — nur gesetzt, wenn die KI beim
|
||||||
|
/// letzten "Übernehmen" einen Medienvorschlag für diese Phase gemacht hatte. Steuert die
|
||||||
|
/// Sichtbarkeit des Kopieren-Buttons im Verlaufsplan-Editor.
|
||||||
|
[ObservableProperty] private string? _materialPrompt;
|
||||||
|
|
||||||
|
public bool HasMaterialPrompt => !string.IsNullOrWhiteSpace(MaterialPrompt);
|
||||||
|
partial void OnMaterialPromptChanged(string? value) => OnPropertyChanged(nameof(HasMaterialPrompt));
|
||||||
|
|
||||||
/// Checkbox-Zustand im Editor: unchecked→checked öffnet den Zuweisen-Dialog
|
/// Checkbox-Zustand im Editor: unchecked→checked öffnet den Zuweisen-Dialog
|
||||||
/// (<see cref="OnAssignAlternativePath"/>); checked→unchecked entfernt die Zuordnung.
|
/// (<see cref="OnAssignAlternativePath"/>); checked→unchecked entfernt die Zuordnung.
|
||||||
@@ -1104,6 +1173,7 @@ public partial class PhaseStepEditItem : ObservableObject
|
|||||||
Activity = Activity.Trim(),
|
Activity = Activity.Trim(),
|
||||||
Material = Material.Trim(),
|
Material = Material.Trim(),
|
||||||
Shorthand = Shorthand.Trim(),
|
Shorthand = Shorthand.Trim(),
|
||||||
|
MaterialPrompt = MaterialPrompt,
|
||||||
AlternativePathId = AlternativePathId,
|
AlternativePathId = AlternativePathId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1206,6 +1276,88 @@ public partial class MoveLessonDialogViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dialog: Stunde einer anderen Einheit zuweisen (Korrektur der Einheit) ────
|
||||||
|
|
||||||
|
public partial class ChangeLessonUnitDialogViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty] private TimetableUnitOption? _selectedUnit;
|
||||||
|
[ObservableProperty] private string _error = "";
|
||||||
|
|
||||||
|
public string CurrentUnitLabel { get; }
|
||||||
|
public ObservableCollection<TimetableUnitOption> Units { get; } = [];
|
||||||
|
public bool HasUnits => Units.Count > 0;
|
||||||
|
public Unit? Result { get; private set; }
|
||||||
|
|
||||||
|
public ChangeLessonUnitDialogViewModel(IUnitRepository units, Guid groupId, Guid currentUnitId,
|
||||||
|
string currentUnitTitle)
|
||||||
|
{
|
||||||
|
CurrentUnitLabel = currentUnitTitle;
|
||||||
|
foreach (var unit in units.GetByGroup(groupId).Where(u => u.Id != currentUnitId)
|
||||||
|
.OrderBy(u => u.Status == UnitStatus.Active ? 0 : u.Status == UnitStatus.Planned ? 1 : 2)
|
||||||
|
.ThenByDescending(u => u.StartDate)
|
||||||
|
.ThenBy(u => u.Title, StringComparer.CurrentCultureIgnoreCase))
|
||||||
|
Units.Add(new TimetableUnitOption(unit));
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
Error = "";
|
||||||
|
if (SelectedUnit is null) { Error = "Bitte eine Ziel-Einheit auswählen."; return; }
|
||||||
|
Result = SelectedUnit.Model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dialog: Vorhandene Stunde zum Verknüpfen auswählen (Stundenplan-FixIt) ────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sucht Stunden, die zu einem Stundenplan-Termin gehören könnten, aber (z.B. durch JSON-Import
|
||||||
|
/// oder KI-Übernahme ohne Stundenplan-Bezug) keine passende <see cref="Lesson.LessonNumber"/>
|
||||||
|
/// haben und deshalb von <c>TimetableViewModel.FindLessonForSlot</c> nicht gefunden werden.
|
||||||
|
/// Als eigene, von Avalonia unabhängige Methode extrahiert, damit die Zuordnungslogik ohne Fenster
|
||||||
|
/// testbar ist — Aufrufer ist <c>LessonDialog.axaml.cs</c> (Button "Vorhandene Stunde verknüpfen").
|
||||||
|
/// </summary>
|
||||||
|
public static class LessonFixItSearch
|
||||||
|
{
|
||||||
|
public static List<Lesson> FindCandidates(ILessonRepository lessons, Guid groupId, DateOnly date) =>
|
||||||
|
[.. lessons.GetByGroupAndRange(groupId, date.AddDays(-14), date.AddDays(14))
|
||||||
|
.Where(l => l.LessonNumber is null || l.Date == date)
|
||||||
|
.OrderBy(l => l.Date == date ? 0 : 1)
|
||||||
|
.ThenBy(l => Math.Abs(l.Date.DayNumber - date.DayNumber))];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LessonLinkOption(Lesson lesson, string unitTitle)
|
||||||
|
{
|
||||||
|
public Lesson Model { get; } = lesson;
|
||||||
|
public string Label { get; } = string.IsNullOrWhiteSpace(lesson.Topic) ? "(ohne Thema)" : lesson.Topic;
|
||||||
|
public string Detail { get; } =
|
||||||
|
$"{lesson.Date:dd.MM.yyyy} · {(lesson.LessonNumber is int n ? $"{n}. Stunde" : "keine Stundennummer")} · Einheit „{unitTitle}“";
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class LinkExistingLessonDialogViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty] private LessonLinkOption? _selectedOption;
|
||||||
|
[ObservableProperty] private string _error = "";
|
||||||
|
|
||||||
|
public ObservableCollection<LessonLinkOption> Options { get; } = [];
|
||||||
|
public Lesson? Result { get; private set; }
|
||||||
|
|
||||||
|
public LinkExistingLessonDialogViewModel(List<Lesson> candidates, IUnitRepository units)
|
||||||
|
{
|
||||||
|
foreach (var lesson in candidates)
|
||||||
|
Options.Add(new LessonLinkOption(lesson, units.GetById(lesson.UnitId)?.Title ?? "?"));
|
||||||
|
SelectedOption = Options.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
Error = "";
|
||||||
|
if (SelectedOption is null) { Error = "Bitte eine Stunde auswählen."; return; }
|
||||||
|
Result = SelectedOption.Model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dialog: Stunden serienweise aus dem Stundenplan erzeugen (4.2.5) ────────
|
// ── Dialog: Stunden serienweise aus dem Stundenplan erzeugen (4.2.5) ────────
|
||||||
|
|
||||||
public partial class GenerateLessonSeriesDialogViewModel : ObservableObject
|
public partial class GenerateLessonSeriesDialogViewModel : ObservableObject
|
||||||
@@ -1387,10 +1539,16 @@ public partial class AiAssistDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _errorMessage = "";
|
[ObservableProperty] private string _errorMessage = "";
|
||||||
[ObservableProperty] private bool _hasResults;
|
[ObservableProperty] private bool _hasResults;
|
||||||
[ObservableProperty] private string? _summary;
|
[ObservableProperty] private string? _summary;
|
||||||
|
[ObservableProperty] private string? _rawResponse;
|
||||||
|
|
||||||
public string UnitSummary { get; }
|
public string UnitSummary { get; }
|
||||||
public ObservableCollection<AiLessonReviewItem> ReviewItems { get; } = [];
|
public ObservableCollection<AiLessonReviewItem> ReviewItems { get; } = [];
|
||||||
public bool Result { get; private set; }
|
public bool Result { get; private set; }
|
||||||
|
public Unit Unit => _unit;
|
||||||
|
public Guid? FocusLessonId => _focusLesson?.Id;
|
||||||
|
public bool CanRescueResponse => !string.IsNullOrWhiteSpace(RawResponse);
|
||||||
|
|
||||||
|
partial void OnRawResponseChanged(string? value) => OnPropertyChanged(nameof(CanRescueResponse));
|
||||||
|
|
||||||
/// Aus dem Editor einer einzelnen Stunde heraus gestartet (statt aus der Einheiten-Übersicht,
|
/// Aus dem Editor einer einzelnen Stunde heraus gestartet (statt aus der Einheiten-Übersicht,
|
||||||
/// Nutzer-Feedback nach den ersten Live-Tests) — die KI darf dann ausschließlich diese eine
|
/// Nutzer-Feedback nach den ersten Live-Tests) — die KI darf dann ausschließlich diese eine
|
||||||
@@ -1429,7 +1587,7 @@ public partial class AiAssistDialogViewModel : ObservableObject
|
|||||||
? ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList()
|
? ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
ErrorMessage = ""; IsBusy = true;
|
ErrorMessage = ""; RawResponse = null; IsBusy = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting,
|
var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting,
|
||||||
@@ -1461,7 +1619,11 @@ public partial class AiAssistDialogViewModel : ObservableObject
|
|||||||
Summary = response.Summary;
|
Summary = response.Summary;
|
||||||
HasResults = true;
|
HasResults = true;
|
||||||
}
|
}
|
||||||
catch (AiBackendException ex) { ErrorMessage = ex.Message; }
|
catch (AiBackendException ex)
|
||||||
|
{
|
||||||
|
ErrorMessage = ex.Message;
|
||||||
|
RawResponse = ex.RawResponse;
|
||||||
|
}
|
||||||
finally { IsBusy = false; }
|
finally { IsBusy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1475,6 +1637,132 @@ public partial class AiAssistDialogViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand] private void Cancel() => Result = false;
|
[RelayCommand] private void Cancel() => Result = false;
|
||||||
|
|
||||||
|
public void MarkRescueImported() => Result = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Aus einer manuell geprüften KI-Antwort auswählbare Stunde.</summary>
|
||||||
|
public record AiRescueLessonOption(AiLesson Lesson, string Label);
|
||||||
|
|
||||||
|
/// <summary>Ziel für den manuellen Import in eine bereits vorhandene Stunde.</summary>
|
||||||
|
public record AiRescueTargetOption(Lesson Lesson, string Label);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rettungsdialog für syntaktisch fehlerhafte oder mit Freitext vermischte Modellantworten. Der
|
||||||
|
/// Nutzer entscheidet selbst, welcher Textabschnitt geparst und wohin die Stunde importiert wird.
|
||||||
|
/// </summary>
|
||||||
|
public partial class AiResponseRescueDialogViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly AiPlanningService _aiPlanning;
|
||||||
|
private readonly ILessonRepository _lessons;
|
||||||
|
private readonly Unit _unit;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _responseText;
|
||||||
|
[ObservableProperty] private string _parseMessage =
|
||||||
|
"Markiere den gültigen JSON-Abschnitt oder bearbeite den Text und klicke auf „Markierung prüfen“.";
|
||||||
|
[ObservableProperty] private AiRescueLessonOption? _selectedParsedLesson;
|
||||||
|
[ObservableProperty] private AiRescueTargetOption? _selectedTarget;
|
||||||
|
|
||||||
|
public ObservableCollection<AiRescueLessonOption> ParsedLessons { get; } = [];
|
||||||
|
public ObservableCollection<AiRescueTargetOption> ExistingLessons { get; } = [];
|
||||||
|
public bool HasParsedLesson => SelectedParsedLesson is not null;
|
||||||
|
public bool CanImportIntoExisting => SelectedParsedLesson is not null && SelectedTarget is not null;
|
||||||
|
public bool Result { get; private set; }
|
||||||
|
|
||||||
|
partial void OnSelectedParsedLessonChanged(AiRescueLessonOption? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasParsedLesson));
|
||||||
|
OnPropertyChanged(nameof(CanImportIntoExisting));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedTargetChanged(AiRescueTargetOption? value) =>
|
||||||
|
OnPropertyChanged(nameof(CanImportIntoExisting));
|
||||||
|
|
||||||
|
public AiResponseRescueDialogViewModel(AiPlanningService aiPlanning, ILessonRepository lessons,
|
||||||
|
Unit unit, string rawResponse, Guid? preferredTargetId = null)
|
||||||
|
{
|
||||||
|
_aiPlanning = aiPlanning;
|
||||||
|
_lessons = lessons;
|
||||||
|
_unit = unit;
|
||||||
|
_responseText = rawResponse;
|
||||||
|
|
||||||
|
foreach (var lesson in lessons.GetByUnit(unit.Id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber))
|
||||||
|
{
|
||||||
|
var date = lesson.Date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
|
||||||
|
var number = lesson.LessonNumber is { } n ? $", Stunde {n}" : "";
|
||||||
|
ExistingLessons.Add(new AiRescueTargetOption(lesson, $"{date}{number}: {lesson.Topic}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectedTarget = ExistingLessons.FirstOrDefault(x => x.Lesson.Id == preferredTargetId)
|
||||||
|
?? ExistingLessons.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ParseSelection(string selectedText)
|
||||||
|
{
|
||||||
|
ParsedLessons.Clear();
|
||||||
|
SelectedParsedLesson = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsed = AiPlanningService.ParsePlanningLessons(selectedText);
|
||||||
|
foreach (var lesson in parsed)
|
||||||
|
{
|
||||||
|
var date = lesson.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "ohne Datum";
|
||||||
|
ParsedLessons.Add(new AiRescueLessonOption(lesson, $"{date}: {lesson.Topic}"));
|
||||||
|
}
|
||||||
|
SelectedParsedLesson = ParsedLessons[0];
|
||||||
|
ParseMessage = parsed.Count == 1
|
||||||
|
? "Eine gültige Stunde erkannt."
|
||||||
|
: $"{parsed.Count} gültige Stunden erkannt. Bitte die gewünschte Stunde auswählen.";
|
||||||
|
}
|
||||||
|
catch (AiBackendException ex)
|
||||||
|
{
|
||||||
|
ParseMessage = ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ImportIntoExisting()
|
||||||
|
{
|
||||||
|
if (SelectedParsedLesson is null || SelectedTarget is null) return;
|
||||||
|
var lesson = CloneForImport(SelectedParsedLesson.Lesson, SelectedTarget.Lesson.Id);
|
||||||
|
Save([lesson], focusLessonId: SelectedTarget.Lesson.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ImportAsNew()
|
||||||
|
{
|
||||||
|
if (SelectedParsedLesson is null) return;
|
||||||
|
Save([CloneForImport(SelectedParsedLesson.Lesson, null)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save(List<AiLesson> source, Guid? focusLessonId = null)
|
||||||
|
{
|
||||||
|
foreach (var lesson in _aiPlanning.ApplyResponse(_unit, source,
|
||||||
|
allowModifyingExistingLessons: true, focusLessonId))
|
||||||
|
_lessons.Save(lesson);
|
||||||
|
Result = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AiLesson CloneForImport(AiLesson source, Guid? id) => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Date = source.Date,
|
||||||
|
LessonNumber = source.LessonNumber,
|
||||||
|
Topic = source.Topic,
|
||||||
|
StartTime = source.StartTime,
|
||||||
|
Homework = source.Homework,
|
||||||
|
Reflection = source.Reflection,
|
||||||
|
Phases = source.Phases.Select(p => new AiPhaseStep
|
||||||
|
{
|
||||||
|
Name = p.Name,
|
||||||
|
DurationMinutes = p.DurationMinutes,
|
||||||
|
Activity = p.Activity,
|
||||||
|
Material = p.Material,
|
||||||
|
Shorthand = p.Shorthand,
|
||||||
|
AlternativePathName = p.AlternativePathName,
|
||||||
|
MaterialSuggestion = p.MaterialSuggestion,
|
||||||
|
}).ToList(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dialog: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ────────────
|
// ── Dialog: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ────────────
|
||||||
|
|||||||
@@ -16,6 +16,45 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
private readonly IParticipationRepository _participation;
|
private readonly IParticipationRepository _participation;
|
||||||
private readonly IParticipationAspectRepository _aspects;
|
private readonly IParticipationAspectRepository _aspects;
|
||||||
private readonly IDocumentationRepository? _documentation;
|
private readonly IDocumentationRepository? _documentation;
|
||||||
|
[ObservableProperty] private bool _isTeachingMode;
|
||||||
|
[ObservableProperty] private string _quickMode = "";
|
||||||
|
public bool IsQuickMode => QuickMode.Length > 0;
|
||||||
|
public string QuickModeDisplay => QuickMode switch
|
||||||
|
{
|
||||||
|
"Attendance" => "Anwesenheit kontrollieren · Fehlend = Entschuldigung offen",
|
||||||
|
"Homework" => "Hausaufgaben kontrollieren",
|
||||||
|
_ => "Klicken: bewerten"
|
||||||
|
};
|
||||||
|
[RelayCommand] private void CheckAttendance() => QuickMode = "Attendance";
|
||||||
|
[RelayCommand] private void CheckHomework() => QuickMode = "Homework";
|
||||||
|
[RelayCommand] private void EndQuickCheck() => QuickMode = "";
|
||||||
|
partial void OnQuickModeChanged(string value)
|
||||||
|
{
|
||||||
|
if (value.Length > 0) IsEditMode = false;
|
||||||
|
foreach (var seat in Seats) seat.QuickMode = value;
|
||||||
|
OnPropertyChanged(nameof(IsQuickMode));
|
||||||
|
OnPropertyChanged(nameof(QuickModeDisplay));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveQuickStatus(SeatCellViewModel seat, bool positive)
|
||||||
|
{
|
||||||
|
if (!IsEditable || !IsQuickMode || seat.SelectedOption.StudentId is not Guid studentId) return;
|
||||||
|
var session = EnsureTodaySession();
|
||||||
|
if (session is null) return;
|
||||||
|
// Always merge with the latest entry, so quick checks preserve ratings and counters.
|
||||||
|
var entry = _participation.GetBySessionAndStudent(session.Id, studentId)
|
||||||
|
?? new ParticipationEntry { SessionId = session.Id, StudentId = studentId };
|
||||||
|
if (QuickMode == "Attendance") entry.Attendance = positive ? AttendanceStatus.Present : AttendanceStatus.ExcusePending;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
entry.Homework = positive ? HomeworkStatus.Completed : HomeworkStatus.MissingOpen;
|
||||||
|
entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(entry.Homework);
|
||||||
|
}
|
||||||
|
_participation.Save(entry);
|
||||||
|
RefreshSeatLessonData();
|
||||||
|
OnAssessmentChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
private Guid _groupId;
|
private Guid _groupId;
|
||||||
private SeatingPlan? _currentPlan;
|
private SeatingPlan? _currentPlan;
|
||||||
private bool _isReadOnly;
|
private bool _isReadOnly;
|
||||||
@@ -235,7 +274,12 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
?? StudentSeatOption.Empty;
|
?? StudentSeatOption.Empty;
|
||||||
var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column);
|
var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column);
|
||||||
Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged,
|
Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged,
|
||||||
CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation));
|
CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation)
|
||||||
|
{
|
||||||
|
QuickMode = QuickMode,
|
||||||
|
OnQuickStatus = SaveQuickStatus,
|
||||||
|
OnQuickSpecial = AssessStudent
|
||||||
|
});
|
||||||
}
|
}
|
||||||
UpdateAssignmentSummary();
|
UpdateAssignmentSummary();
|
||||||
RefreshSeatLessonData();
|
RefreshSeatLessonData();
|
||||||
@@ -488,6 +532,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
|
|
||||||
partial void OnIsEditModeChanged(bool value)
|
partial void OnIsEditModeChanged(bool value)
|
||||||
{
|
{
|
||||||
|
if (value) QuickMode = "";
|
||||||
OnPropertyChanged(nameof(CanEditLayout));
|
OnPropertyChanged(nameof(CanEditLayout));
|
||||||
foreach (var seat in Seats)
|
foreach (var seat in Seats)
|
||||||
{
|
{
|
||||||
@@ -540,6 +585,32 @@ public sealed class ParticipationSessionOption(ParticipationSession session)
|
|||||||
|
|
||||||
public partial class SeatCellViewModel : ObservableObject
|
public partial class SeatCellViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
|
[ObservableProperty] private string _quickMode = "";
|
||||||
|
public bool ShowQuickCheck => QuickMode.Length > 0 && IsOccupied && CanRecordLesson;
|
||||||
|
public bool ShowNormalActions => ShowLessonOverview && QuickMode.Length == 0;
|
||||||
|
public bool ShowSituationActions => ShowNormalActions && CanRecordLesson;
|
||||||
|
public string QuickPositiveLabel => QuickMode == "Attendance" ? "Anwesend" : "Gemacht";
|
||||||
|
public string QuickNegativeLabel => QuickMode == "Attendance" ? "Fehlend" : "Fehlt";
|
||||||
|
public Action<SeatCellViewModel, bool>? OnQuickStatus { get; init; }
|
||||||
|
public Func<SeatCellViewModel, Task>? OnQuickSpecial { get; init; }
|
||||||
|
[RelayCommand] private void QuickPositive() => OnQuickStatus?.Invoke(this, true);
|
||||||
|
[RelayCommand] private void QuickNegative() => OnQuickStatus?.Invoke(this, false);
|
||||||
|
[RelayCommand] private Task QuickSpecial() => OnQuickSpecial?.Invoke(this) ?? Task.CompletedTask;
|
||||||
|
partial void OnQuickModeChanged(string value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowQuickCheck));
|
||||||
|
OnPropertyChanged(nameof(ShowNormalActions));
|
||||||
|
OnPropertyChanged(nameof(ShowSituationActions));
|
||||||
|
OnPropertyChanged(nameof(QuickPositiveLabel));
|
||||||
|
OnPropertyChanged(nameof(QuickNegativeLabel));
|
||||||
|
OnPropertyChanged(nameof(DisplayOpacity));
|
||||||
|
}
|
||||||
|
partial void OnCanRecordLessonChanged(bool value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowQuickCheck));
|
||||||
|
OnPropertyChanged(nameof(ShowSituationActions));
|
||||||
|
}
|
||||||
|
|
||||||
private readonly Action<SeatCellViewModel> _onChanged;
|
private readonly Action<SeatCellViewModel> _onChanged;
|
||||||
private bool _suppressChange;
|
private bool _suppressChange;
|
||||||
private readonly Action<SeatCellViewModel, string> _toggleSituationTag;
|
private readonly Action<SeatCellViewModel, string> _toggleSituationTag;
|
||||||
@@ -581,7 +652,7 @@ public partial class SeatCellViewModel : ObservableObject
|
|||||||
/// Opacity ist im DataTemplate bereits lokal an LessonOpacity gebunden gewesen; ein lokal
|
/// Opacity ist im DataTemplate bereits lokal an LessonOpacity gebunden gewesen; ein lokal
|
||||||
/// gebundener Wert überschreibt aber jeden Style-Setter für dieselbe Eigenschaft, daher muss
|
/// gebundener Wert überschreibt aber jeden Style-Setter für dieselbe Eigenschaft, daher muss
|
||||||
/// die Abblendung für ausgeblendete Plätze hier statt per CSS-Klasse erfolgen.</summary>
|
/// die Abblendung für ausgeblendete Plätze hier statt per CSS-Klasse erfolgen.</summary>
|
||||||
public double DisplayOpacity => IsHidden ? 0.4 : LessonOpacity;
|
public double DisplayOpacity => IsHidden ? 0.4 : ShowQuickCheck ? 1 : LessonOpacity;
|
||||||
|
|
||||||
private readonly Action<SeatCellViewModel, bool> _tally;
|
private readonly Action<SeatCellViewModel, bool> _tally;
|
||||||
|
|
||||||
@@ -615,6 +686,9 @@ public partial class SeatCellViewModel : ObservableObject
|
|||||||
|
|
||||||
partial void OnSelectedOptionChanged(StudentSeatOption value)
|
partial void OnSelectedOptionChanged(StudentSeatOption value)
|
||||||
{
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowQuickCheck));
|
||||||
|
OnPropertyChanged(nameof(ShowNormalActions));
|
||||||
|
OnPropertyChanged(nameof(ShowSituationActions));
|
||||||
OnPropertyChanged(nameof(IsOccupied));
|
OnPropertyChanged(nameof(IsOccupied));
|
||||||
OnPropertyChanged(nameof(StudentName));
|
OnPropertyChanged(nameof(StudentName));
|
||||||
OnPropertyChanged(nameof(ShowLessonOverview));
|
OnPropertyChanged(nameof(ShowLessonOverview));
|
||||||
@@ -624,6 +698,8 @@ public partial class SeatCellViewModel : ObservableObject
|
|||||||
|
|
||||||
partial void OnCanEditChanged(bool value)
|
partial void OnCanEditChanged(bool value)
|
||||||
{
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowNormalActions));
|
||||||
|
OnPropertyChanged(nameof(ShowSituationActions));
|
||||||
OnPropertyChanged(nameof(ShowLessonOverview));
|
OnPropertyChanged(nameof(ShowLessonOverview));
|
||||||
OnPropertyChanged(nameof(ShowSeat));
|
OnPropertyChanged(nameof(ShowSeat));
|
||||||
OnPropertyChanged(nameof(CanToggleHidden));
|
OnPropertyChanged(nameof(CanToggleHidden));
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class TeachingModeViewModel
|
public class TeachingModeViewModel
|
||||||
{
|
{
|
||||||
|
public TeachingTimelineViewModel Timeline { get; }
|
||||||
public string GroupName { get; }
|
public string GroupName { get; }
|
||||||
public LessonViewerViewModel LessonInfo { get; }
|
public LessonViewerViewModel LessonInfo { get; }
|
||||||
public SeatingPlanTabViewModel SeatingPlan { get; }
|
public SeatingPlanTabViewModel SeatingPlan { get; }
|
||||||
@@ -38,9 +39,11 @@ public class TeachingModeViewModel
|
|||||||
SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation)
|
SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation)
|
||||||
{
|
{
|
||||||
GroupName = group.Name;
|
GroupName = group.Name;
|
||||||
|
Timeline = new TeachingTimelineViewModel(lesson, lessons, !group.IsActive);
|
||||||
LessonInfo = new LessonViewerViewModel(lesson, alternativePaths);
|
LessonInfo = new LessonViewerViewModel(lesson, alternativePaths);
|
||||||
|
|
||||||
SeatingPlan = seatingPlan;
|
SeatingPlan = seatingPlan;
|
||||||
|
SeatingPlan.IsTeachingMode = true;
|
||||||
SeatingPlan.Initialize(group.Id, !group.IsActive);
|
SeatingPlan.Initialize(group.Id, !group.IsActive);
|
||||||
SeatingPlan.SelectOrCreateSessionForLesson(lesson);
|
SeatingPlan.SelectOrCreateSessionForLesson(lesson);
|
||||||
|
|
||||||
@@ -102,14 +105,19 @@ public partial class TeachingModeHomeworkViewModel : ObservableObject
|
|||||||
if (_previousLesson is null) return;
|
if (_previousLesson is null) return;
|
||||||
_previousLesson.HomeworkChecked = value;
|
_previousLesson.HomeworkChecked = value;
|
||||||
if (value) _previousLesson.HomeworkCheckDismissed = false;
|
if (value) _previousLesson.HomeworkCheckDismissed = false;
|
||||||
_lessons.Save(_previousLesson);
|
var latest = _lessons.GetById(_previousLesson.Id) ?? _previousLesson;
|
||||||
|
latest.HomeworkChecked = value;
|
||||||
|
if (value) latest.HomeworkCheckDismissed = false;
|
||||||
|
_lessons.Save(latest);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void SaveCurrentHomework()
|
private void SaveCurrentHomework()
|
||||||
{
|
{
|
||||||
_lesson.Homework = CurrentHomework;
|
_lesson.Homework = CurrentHomework;
|
||||||
_lessons.Save(_lesson);
|
var latest = _lessons.GetById(_lesson.Id) ?? _lesson;
|
||||||
|
latest.Homework = CurrentHomework;
|
||||||
|
_lessons.Save(latest);
|
||||||
SaveStatus = "Gespeichert.";
|
SaveStatus = "Gespeichert.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
|
||||||
|
public partial class TeachingTimelineViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly Lesson _lesson;
|
||||||
|
private readonly ILessonRepository _lessons;
|
||||||
|
private readonly Func<DateTime> _utcNow;
|
||||||
|
private TeachingTimelineState? _state;
|
||||||
|
public bool IsEditable { get; }
|
||||||
|
public ObservableCollection<TeachingPhaseViewModel> Phases { get; } = [];
|
||||||
|
public ObservableCollection<Lesson> TransferTargets { get; } = [];
|
||||||
|
[ObservableProperty] private Lesson? _transferTarget;
|
||||||
|
[ObservableProperty] private string _status = "";
|
||||||
|
[ObservableProperty] private bool _needsStart;
|
||||||
|
public bool HasPhases => Phases.Count > 0;
|
||||||
|
public bool HasTransferTargets => TransferTargets.Count > 0;
|
||||||
|
public bool HasRemainder => Phases.Any(p => p.IsOverflow && !p.IsCompleted && !p.IsTransferred);
|
||||||
|
|
||||||
|
public TeachingTimelineViewModel(Lesson lesson, ILessonRepository lessons, bool readOnly = false,
|
||||||
|
Func<DateTime>? utcNow = null)
|
||||||
|
{
|
||||||
|
_lesson = lesson;
|
||||||
|
_lessons = lessons;
|
||||||
|
_utcNow = utcNow ?? (() => DateTime.UtcNow);
|
||||||
|
IsEditable = !readOnly;
|
||||||
|
_state = lesson.TeachingTimeline;
|
||||||
|
if (_state is not null)
|
||||||
|
{
|
||||||
|
// LiteDB returns local DateTimes by default; arithmetic below uses UTC.
|
||||||
|
_state.StartUtc = _state.StartUtc.ToUniversalTime();
|
||||||
|
_state.EndUtc = _state.EndUtc.ToUniversalTime();
|
||||||
|
_state.HeldSinceUtc = _state.HeldSinceUtc?.ToUniversalTime();
|
||||||
|
}
|
||||||
|
foreach (var phase in lesson.Phases.Where(p => p.AlternativePathId is null))
|
||||||
|
Phases.Add(new TeachingPhaseViewModel(phase, this));
|
||||||
|
if (_state is null && lesson.StartTime is { } start)
|
||||||
|
CreateState(lesson.Date.ToDateTime(start).ToUniversalTime());
|
||||||
|
foreach (var target in lessons.GetByGroupAndRange(lesson.GroupId, lesson.Date, lesson.Date.AddDays(120))
|
||||||
|
.Where(l => l.Id != lesson.Id && l.Status is not (LessonStatus.Cancelled or LessonStatus.Conducted)
|
||||||
|
&& (l.Date > lesson.Date || l.StartTime > lesson.StartTime || l.LessonNumber > lesson.LessonNumber))
|
||||||
|
.OrderBy(l => l.Date).ThenBy(l => l.StartTime).ThenBy(l => l.LessonNumber))
|
||||||
|
TransferTargets.Add(target);
|
||||||
|
TransferTarget = TransferTargets.FirstOrDefault();
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateState(DateTime start)
|
||||||
|
{
|
||||||
|
_state = new TeachingTimelineState
|
||||||
|
{
|
||||||
|
StartUtc = start,
|
||||||
|
EndUtc = start.AddMinutes(Phases.Sum(p => Math.Max(0, p.Source.DurationMinutes))),
|
||||||
|
Phases = Phases.Select(p => new TeachingPhaseTiming
|
||||||
|
{ PhaseId = p.Source.Id, Minutes = Math.Max(0, p.Source.DurationMinutes) }).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand] private void StartNow()
|
||||||
|
{
|
||||||
|
if (!IsEditable || _state is not null) return;
|
||||||
|
CreateState(_utcNow());
|
||||||
|
Save();
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Refresh()
|
||||||
|
{
|
||||||
|
NeedsStart = _state is null && HasPhases;
|
||||||
|
if (_state is null) return;
|
||||||
|
var now = _utcNow();
|
||||||
|
var cursor = _state.StartUtc;
|
||||||
|
_state.Phases.RemoveAll(t => !Phases.Any(p => p.Source.Id == t.PhaseId));
|
||||||
|
var ordered = _state.Phases.Select(t => Phases.FirstOrDefault(p => p.Source.Id == t.PhaseId))
|
||||||
|
.OfType<TeachingPhaseViewModel>().ToList();
|
||||||
|
// Keep surviving IDs in their live order when a plan was edited between openings.
|
||||||
|
foreach (var phase in Phases.Where(p => !ordered.Contains(p)).ToList())
|
||||||
|
{
|
||||||
|
_state.Phases.Add(new TeachingPhaseTiming { PhaseId = phase.Source.Id, Minutes = Math.Max(0, phase.Source.DurationMinutes) });
|
||||||
|
ordered.Add(phase);
|
||||||
|
}
|
||||||
|
for (var i = 0; i < ordered.Count; i++)
|
||||||
|
if (Phases.IndexOf(ordered[i]) != i) Phases.Move(Phases.IndexOf(ordered[i]), i);
|
||||||
|
foreach (var phase in Phases)
|
||||||
|
{
|
||||||
|
var timing = _state.Phases.First(t => t.PhaseId == phase.Source.Id);
|
||||||
|
var held = _state.HeldPhaseId == phase.Source.Id && _state.HeldSinceUtc.HasValue;
|
||||||
|
var extension = held ? Math.Max(0, (now - _state.HeldSinceUtc!.Value).TotalMinutes) : 0;
|
||||||
|
var end = cursor.AddMinutes(timing.Minutes + extension);
|
||||||
|
// Phases pushed entirely out of the lesson remain pending, even after closing
|
||||||
|
// the window overnight. They run only if explicitly brought forward.
|
||||||
|
var canRun = cursor < _state.EndUtc || timing.ExplicitlyStarted || held;
|
||||||
|
phase.StartUtc = cursor;
|
||||||
|
phase.EndUtc = end;
|
||||||
|
phase.IsActive = canRun && cursor <= now && (now < end || held)
|
||||||
|
&& (now < _state.EndUtc || timing.ExplicitlyStarted || held);
|
||||||
|
phase.IsCompleted = canRun && !held && end <= now
|
||||||
|
&& (end <= _state.EndUtc || timing.ExplicitlyStarted);
|
||||||
|
phase.IsOverflow = end > _state.EndUtc && !phase.IsCompleted;
|
||||||
|
phase.IsTransferred = _state.TransferredPhaseIds.Contains(phase.Source.Id);
|
||||||
|
phase.IsHeld = held;
|
||||||
|
var effectiveNow = timing.ExplicitlyStarted || held || now < _state.EndUtc ? now : _state.EndUtc;
|
||||||
|
var elapsed = Math.Clamp((effectiveNow - cursor).TotalMinutes, 0, timing.Minutes + extension);
|
||||||
|
phase.RemainingMinutes = phase.IsCompleted ? 0 : timing.Minutes + extension - elapsed;
|
||||||
|
phase.Progress = !canRun ? 0 : phase.IsCompleted ? 100 : elapsed / Math.Max(0.01, timing.Minutes + extension) * 100;
|
||||||
|
phase.TimeDisplay = $"{cursor.ToLocalTime():HH:mm}–{end.ToLocalTime():HH:mm}";
|
||||||
|
phase.CanStartNow = IsEditable && !phase.IsCompleted && !phase.IsActive && !phase.IsTransferred;
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
OnPropertyChanged(nameof(HasRemainder));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Extend(TeachingPhaseViewModel phase, int minutes)
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
if (!IsEditable || !phase.IsActive || _state is null) return;
|
||||||
|
var timing = _state.Phases.First(p => p.PhaseId == phase.Source.Id);
|
||||||
|
timing.Minutes += minutes;
|
||||||
|
timing.ExplicitlyStarted = true;
|
||||||
|
Save(); Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Hold(TeachingPhaseViewModel phase)
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
if (!IsEditable || !phase.IsActive || phase.IsHeld || _state is null) return;
|
||||||
|
_state.HeldPhaseId = phase.Source.Id;
|
||||||
|
_state.HeldSinceUtc = _utcNow();
|
||||||
|
_state.Phases.First(p => p.PhaseId == phase.Source.Id).ExplicitlyStarted = true;
|
||||||
|
Save(); Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Finish(TeachingPhaseViewModel phase, bool advanceNext = true)
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
if (!IsEditable || !phase.IsActive || _state is null) return;
|
||||||
|
_state.Phases.First(p => p.PhaseId == phase.Source.Id).Minutes = Math.Max(0, (_utcNow() - phase.StartUtc).TotalMinutes);
|
||||||
|
_state.HeldPhaseId = null;
|
||||||
|
_state.HeldSinceUtc = null;
|
||||||
|
if (advanceNext)
|
||||||
|
{
|
||||||
|
var nextIndex = _state.Phases.FindIndex(p => p.PhaseId == phase.Source.Id) + 1;
|
||||||
|
if (nextIndex < _state.Phases.Count) _state.Phases[nextIndex].ExplicitlyStarted = true;
|
||||||
|
}
|
||||||
|
Save(); Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BringForward(TeachingPhaseViewModel phase)
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
if (!phase.CanStartNow || _state is null) return;
|
||||||
|
var active = Phases.FirstOrDefault(p => p.IsActive);
|
||||||
|
if (active is not null) Finish(active, advanceNext: false);
|
||||||
|
var timing = _state.Phases.First(p => p.PhaseId == phase.Source.Id);
|
||||||
|
timing.Minutes = phase.RemainingMinutes;
|
||||||
|
_state.Phases.Remove(timing);
|
||||||
|
var completed = Phases.TakeWhile(p => p.IsCompleted).Count();
|
||||||
|
_state.Phases.Insert(Math.Min(completed, _state.Phases.Count), timing);
|
||||||
|
timing.ExplicitlyStarted = true;
|
||||||
|
var now = _utcNow();
|
||||||
|
// A pending phase may be selected long after the scheduled end. Anchor it to
|
||||||
|
// now instead of letting yesterday's timestamps immediately complete it.
|
||||||
|
var prefix = _state.Phases.Take(completed).Sum(p => p.Minutes);
|
||||||
|
var delay = (now - _state.StartUtc.AddMinutes(prefix)).TotalMinutes;
|
||||||
|
if (completed == 0) _state.StartUtc = now;
|
||||||
|
else if (delay > 0) _state.Phases[completed - 1].Minutes += delay;
|
||||||
|
Save(); Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand] private void TransferRemainder()
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
if (!IsEditable || _state is null || TransferTarget is null) return;
|
||||||
|
var target = _lessons.GetById(TransferTarget.Id);
|
||||||
|
if (target is null || target.Status is LessonStatus.Cancelled or LessonStatus.Conducted) return;
|
||||||
|
var remainder = Phases.Where(p => p.IsOverflow && !p.IsCompleted && !p.IsTransferred).ToList();
|
||||||
|
if (remainder.Count == 0) return;
|
||||||
|
foreach (var phase in remainder)
|
||||||
|
{
|
||||||
|
var source = phase.Source;
|
||||||
|
target.Phases.Add(new LessonPhaseStep { Name = source.Name, DurationMinutes = (int)Math.Ceiling(phase.RemainingMinutes),
|
||||||
|
Activity = source.Activity, Material = source.Material, Shorthand = source.Shorthand });
|
||||||
|
}
|
||||||
|
_lessons.Save(target);
|
||||||
|
_state.TransferredPhaseIds.AddRange(remainder.Select(p => p.Source.Id));
|
||||||
|
Save(); Refresh();
|
||||||
|
Status = $"{remainder.Count} Phase(n) nach {target.Date:dd.MM.yyyy} · {target.Topic} kopiert.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
var latest = _lessons.GetById(_lesson.Id) ?? _lesson;
|
||||||
|
latest.TeachingTimeline = _state;
|
||||||
|
_lesson.TeachingTimeline = _state;
|
||||||
|
_lessons.Save(latest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class TeachingPhaseViewModel(LessonPhaseStep source, TeachingTimelineViewModel owner) : ObservableObject
|
||||||
|
{
|
||||||
|
public LessonPhaseStep Source { get; } = source;
|
||||||
|
public DateTime StartUtc { get; set; }
|
||||||
|
public DateTime EndUtc { get; set; }
|
||||||
|
public double RemainingMinutes { get; set; }
|
||||||
|
[ObservableProperty] private bool _isActive;
|
||||||
|
[ObservableProperty] private bool _isCompleted;
|
||||||
|
[ObservableProperty] private bool _isOverflow;
|
||||||
|
[ObservableProperty] private bool _isHeld;
|
||||||
|
[ObservableProperty] private bool _isTransferred;
|
||||||
|
[ObservableProperty] private bool _canStartNow;
|
||||||
|
[ObservableProperty] private double _progress;
|
||||||
|
[ObservableProperty] private string _timeDisplay = "";
|
||||||
|
public bool IsEditable => owner.IsEditable;
|
||||||
|
[RelayCommand] private void ExtendFive() => owner.Extend(this, 5);
|
||||||
|
[RelayCommand] private void ExtendTen() => owner.Extend(this, 10);
|
||||||
|
[RelayCommand] private void Hold() => owner.Hold(this);
|
||||||
|
[RelayCommand] private void Finish() => owner.Finish(this);
|
||||||
|
[RelayCommand] private void BringForward() => owner.BringForward(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
|
||||||
|
/// <summary>Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar - <see cref="AssignedStudent"/>
|
||||||
|
/// kann manuell per Auswahlliste gesetzt werden, gleiches Muster wie
|
||||||
|
/// <see cref="WebUntisLessonAbsenceComparisonViewModel"/> und
|
||||||
|
/// <see cref="LehrerApp.Desktop.ViewModels.Students.WebUntisDocumentationComparisonViewModel"/>.</summary>
|
||||||
|
public partial class WebUntisHomeworkRow : ObservableObject
|
||||||
|
{
|
||||||
|
public required string ClassName { get; init; }
|
||||||
|
public required DateOnly Date { get; init; }
|
||||||
|
public required string UntisStudentName { get; init; }
|
||||||
|
public string? SubjectLabel { get; init; }
|
||||||
|
public string? Text { get; init; }
|
||||||
|
public required IReadOnlyList<Student> Candidates { get; init; }
|
||||||
|
internal Guid? MatchedSubjectId { get; init; }
|
||||||
|
internal Action<WebUntisHomeworkRow>? OnAssignmentChanged { get; init; }
|
||||||
|
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||||||
|
public bool CanApply => SessionId is not null;
|
||||||
|
|
||||||
|
[ObservableProperty] private Student? _assignedStudent;
|
||||||
|
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
|
||||||
|
[ObservableProperty] private Guid? _sessionId;
|
||||||
|
[ObservableProperty] private bool _selected;
|
||||||
|
|
||||||
|
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Abgleich negativer Klassenbucheinträge zum Stichwort "Hausaufgabe" (eigene, siehe
|
||||||
|
/// <see cref="WebUntisIntegrationService.GetOwnClassRegisterEventsAsync"/>) gegen
|
||||||
|
/// <see cref="ParticipationEntry.Homework"/> - Dashboard-weit statt pro Lerngruppe, aus demselben
|
||||||
|
/// Grund wie beim Dokumentations-Abgleich (der WebUntis-"-alle-"-Bericht ist klassenübergreifend,
|
||||||
|
/// siehe TODO.md). Nutzer-Feedback: WebUntis liefert für Klassenbucheinträge weder eine externe
|
||||||
|
/// Schülerkennung noch eine feste Fach-/Lerngruppenzuordnung, aber der Lehrkraft-eigene Text enthält
|
||||||
|
/// praktisch immer das Untis-Fachkürzel - darüber wird die passende Lerngruppe (SubjectId + aktive
|
||||||
|
/// Mitgliedschaft am Eintragsdatum) aufgelöst, mehrdeutige Treffer bleiben unaufgelöst statt zu raten.
|
||||||
|
/// Ein Eintrag setzt lokal ausschließlich <see cref="HomeworkStatus.MissingOpen"/> vor, und auch nur,
|
||||||
|
/// wenn dort noch gar kein Status hinterlegt ist - bereits vorhandene, feinere Erfassungen ("Teilweise
|
||||||
|
/// angefertigt", "nachgereicht" usw.) werden nie automatisch überschrieben, sondern nur zum Vergleich
|
||||||
|
/// danebengestellt (Nutzerwunsch).</summary>
|
||||||
|
public partial class WebUntisHomeworkComparisonViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly WebUntisIntegrationService _untis;
|
||||||
|
private readonly IStudentRepository _students;
|
||||||
|
private readonly IGroupRepository _groups;
|
||||||
|
private readonly ISubjectRepository _subjects;
|
||||||
|
private readonly IGroupMembershipRepository _memberships;
|
||||||
|
private readonly IParticipationSessionRepository _sessions;
|
||||||
|
private readonly IParticipationRepository _participation;
|
||||||
|
private readonly SchoolYearService _schoolYears;
|
||||||
|
|
||||||
|
public ObservableCollection<WebUntisHomeworkRow> Rows { get; } = [];
|
||||||
|
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-7);
|
||||||
|
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||||||
|
[ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden.";
|
||||||
|
[ObservableProperty] private bool _busy;
|
||||||
|
|
||||||
|
public WebUntisHomeworkComparisonViewModel(WebUntisIntegrationService untis, IStudentRepository students,
|
||||||
|
IGroupRepository groups, ISubjectRepository subjects, IGroupMembershipRepository memberships,
|
||||||
|
IParticipationSessionRepository sessions, IParticipationRepository participation,
|
||||||
|
SchoolYearService schoolYears)
|
||||||
|
{
|
||||||
|
_untis = untis; _students = students; _groups = groups; _subjects = subjects;
|
||||||
|
_memberships = memberships; _sessions = sessions; _participation = participation;
|
||||||
|
_schoolYears = schoolYears;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task Load()
|
||||||
|
{
|
||||||
|
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||||
|
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||||
|
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||||
|
Busy = true; Rows.Clear();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ownStudents = _students.GetAll();
|
||||||
|
var globalIndex = BuildNameIndex(ownStudents);
|
||||||
|
// Wie beim Dokumentations-Abgleich zusätzlich pro Klasse indiziert, um gleiche Namen in
|
||||||
|
// verschiedenen Klassen unterscheiden zu können.
|
||||||
|
var classIndexes = _groups.GetAll()
|
||||||
|
.Where(g => g.Type == GroupType.Class)
|
||||||
|
.GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
var shortNameIndex = _subjects.GetAll()
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s.ShortName))
|
||||||
|
.GroupBy(s => s.ShortName.Trim().ToUpperInvariant())
|
||||||
|
.Where(g => g.Count() == 1) // mehrdeutiges Kürzel lieber nicht zuordnen als raten
|
||||||
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|
||||||
|
var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end);
|
||||||
|
var ordered = entries
|
||||||
|
.Where(IsMissingHomeworkEntry)
|
||||||
|
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
|
||||||
|
.Where(x => x.Date is not null)
|
||||||
|
.OrderBy(x => x.Date).ThenBy(x => x.Entry.StudentName);
|
||||||
|
|
||||||
|
void ResolveLocalMatch(WebUntisHomeworkRow row)
|
||||||
|
{
|
||||||
|
if (row.AssignedStudent is not { } student)
|
||||||
|
{
|
||||||
|
row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var group = ResolveGroup(student, row.MatchedSubjectId, row.Date);
|
||||||
|
var session = group is null ? null
|
||||||
|
: _sessions.GetByGroup(group.Id).FirstOrDefault(s => s.Date == row.Date);
|
||||||
|
var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id);
|
||||||
|
var current = entry is null ? null : HomeworkDisplay.Effective(entry);
|
||||||
|
row.SessionId = session?.Id;
|
||||||
|
row.LocalStatus = session is null
|
||||||
|
? row.MatchedSubjectId is null ? "Fachkürzel nicht erkannt"
|
||||||
|
: group is null ? "kein passender Kurs gefunden" : "keine lokale Stunde"
|
||||||
|
: HomeworkDisplay.Label(current);
|
||||||
|
// Nur vorbelegen, wenn lokal noch überhaupt nichts erfasst ist - jeder vorhandene
|
||||||
|
// Status (auch ein bereits gesetztes "fehlt") bleibt unangetastet, siehe Klassenkommentar.
|
||||||
|
row.Selected = session is not null && current is null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (entry, date) in ordered)
|
||||||
|
{
|
||||||
|
var nameKey = NameKey(entry.StudentName);
|
||||||
|
var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex)
|
||||||
|
? classIndex.GetValueOrDefault(nameKey)
|
||||||
|
: null)
|
||||||
|
?? globalIndex.GetValueOrDefault(nameKey);
|
||||||
|
var subject = MatchSubject(entry.Text, shortNameIndex) ?? MatchSubject(entry.CategoryName, shortNameIndex);
|
||||||
|
|
||||||
|
var row = new WebUntisHomeworkRow
|
||||||
|
{
|
||||||
|
ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName,
|
||||||
|
SubjectLabel = subject?.ShortName, Text = entry.Text, Candidates = ownStudents,
|
||||||
|
MatchedSubjectId = subject?.Id, OnAssignmentChanged = ResolveLocalMatch,
|
||||||
|
};
|
||||||
|
Rows.Add(row);
|
||||||
|
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
|
||||||
|
}
|
||||||
|
|
||||||
|
var unresolvedStudent = Rows.Count(x => x.AssignedStudent is null);
|
||||||
|
var unresolvedSubject = Rows.Count(x => x.MatchedSubjectId is null);
|
||||||
|
Status = $"{Rows.Count} Einträge \"fehlende Hausaufgabe\" erhalten" +
|
||||||
|
(unresolvedStudent > 0 ? $", {unresolvedStudent} bitte manuell zuordnen" : "") +
|
||||||
|
(unresolvedSubject > 0 ? $", bei {unresolvedSubject} kein Fachkürzel im Text erkannt" : "") +
|
||||||
|
$". {Rows.Count(x => x.CanApply)} einer lokalen Stunde zuordenbar.";
|
||||||
|
}
|
||||||
|
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||||
|
finally { Busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Apply()
|
||||||
|
{
|
||||||
|
var selected = Rows.Where(x => x.Selected && x.SessionId is not null && x.AssignedStudent is not null).ToList();
|
||||||
|
foreach (var row in selected)
|
||||||
|
{
|
||||||
|
var studentId = row.AssignedStudent!.Id;
|
||||||
|
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
|
||||||
|
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
|
||||||
|
entry.Homework = HomeworkStatus.MissingOpen;
|
||||||
|
entry.HomeworkMissing = true;
|
||||||
|
entry.UpdatedAt = DateTime.UtcNow;
|
||||||
|
_participation.Save(entry);
|
||||||
|
row.LocalStatus = HomeworkDisplay.Label(HomeworkStatus.MissingOpen);
|
||||||
|
row.Selected = false;
|
||||||
|
}
|
||||||
|
Status = $"{selected.Count} Hausaufgaben-Status übernommen.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nur Lerngruppen (nicht die Klasse selbst), bei denen der/die Schüler*in am Eintragsdatum aktiv
|
||||||
|
/// Mitglied ist und deren Fach zum erkannten Kürzel passt - mehr als ein Treffer bleibt bewusst
|
||||||
|
/// unaufgelöst statt irgendeinen davon zu wählen.
|
||||||
|
private LearningGroup? ResolveGroup(Student student, Guid? subjectId, DateOnly date)
|
||||||
|
{
|
||||||
|
if (subjectId is null) return null;
|
||||||
|
var schoolYear = _schoolYears.CurrentSchoolYear(date);
|
||||||
|
var candidates = _memberships.GetByStudent(student.Id)
|
||||||
|
.Where(m => GroupMembershipService.IsActiveOn(m, date))
|
||||||
|
.Select(m => _groups.GetById(m.GroupId))
|
||||||
|
.Where(g => g is not null && g.SubjectId == subjectId && g.SchoolYear == schoolYear)
|
||||||
|
.Cast<LearningGroup>()
|
||||||
|
.ToList();
|
||||||
|
return candidates.Count == 1 ? candidates[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gleiche Heuristik wie ClassTeacherDetailsViewModel.ContainsHomework, zusätzlich auf negative
|
||||||
|
// Einträge eingeschränkt (eine positive "Hausaufgabe"-Kategorie wäre kein Fehlen-Signal).
|
||||||
|
private static bool IsMissingHomeworkEntry(UntisClassRegisterEventDto entry) =>
|
||||||
|
string.Equals(entry.CategoryGroup, "Negativ", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
(entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true ||
|
||||||
|
entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true);
|
||||||
|
|
||||||
|
private static Subject? MatchSubject(string? text, IReadOnlyDictionary<string, Subject> shortNameIndex)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||||
|
foreach (var token in Regex.Split(text, @"[^\p{L}\p{Nd}]+"))
|
||||||
|
if (token.Length > 0 && shortNameIndex.TryGetValue(token.ToUpperInvariant(), out var subject))
|
||||||
|
return subject;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
// Wie bei den übrigen WebUntis-Abgleichen: beide Namensreihenfolgen registriert, aber nur falls
|
||||||
|
// innerhalb der Kandidaten eindeutig.
|
||||||
|
private static Dictionary<string, Student> BuildNameIndex(IReadOnlyList<Student> candidates) =>
|
||||||
|
candidates
|
||||||
|
.SelectMany(student => new[]
|
||||||
|
{
|
||||||
|
NameKey($"{student.LastName} {student.FirstName}"),
|
||||||
|
NameKey($"{student.FirstName} {student.LastName}"),
|
||||||
|
}.Select(key => (Key: key, Student: student)))
|
||||||
|
.GroupBy(x => x.Key)
|
||||||
|
.Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
|
||||||
|
.ToDictionary(group => group.Key, group => group.First().Student);
|
||||||
|
|
||||||
|
private static bool TryDate(int value, out DateOnly date) =>
|
||||||
|
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using LehrerApp.Core.Importing;
|
using LehrerApp.Core.AiPlanning;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
@@ -24,10 +24,38 @@ public partial class WebUntisLessonAbsenceRow : ObservableObject
|
|||||||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||||||
public bool CanApply => SessionId is not null;
|
public bool CanApply => SessionId is not null;
|
||||||
|
|
||||||
|
// Nur für den optionalen KI-Statusvorschlag mitgeführt (siehe
|
||||||
|
// WebUntisLessonAbsenceComparisonViewModel.SuggestStatusWithAi) - dieselben Rohsignale, aus
|
||||||
|
// denen MapStatus den TargetStatus berechnet, bewusst OHNE Name/Klasse/Datum, damit die Anfrage
|
||||||
|
// an das KI-Backend personenbezogen leer bleibt.
|
||||||
|
internal int AbsentMinutes { get; init; }
|
||||||
|
internal bool HandledOn { get; init; }
|
||||||
|
internal bool? ExternKeyInParentheses { get; init; }
|
||||||
|
|
||||||
|
// Statusübernahme ist per ComboBox anpassbar (Nutzer-Feedback: der aus WebUntis abgeleitete
|
||||||
|
// TargetStatus war über den reinen Anzeigetext oft nicht eindeutig nachvollziehbar; Ablehnen der
|
||||||
|
// ganzen Zeile und der Status manuell nachtragen war die einzige Korrekturmöglichkeit) - die
|
||||||
|
// ComboBox ist mit TargetStatus vorbelegt, aber vor "Übernehmen" frei änderbar. Bindet wie bei
|
||||||
|
// GradeCategoryDisplay über einen String-Wrapper statt direkt ans Enum (sonst ToString() auf
|
||||||
|
// Englisch). Nur die Stati, die MapStatus tatsächlich liefert bzw. die als Korrektur plausibel
|
||||||
|
// sind (nicht z.B. "Geschwänzt" oder "Suspendiert", die WebUntis hier nie meldet). Internal statt
|
||||||
|
// private, damit SuggestStatusWithAi eine von der KI zurückgegebene Statusangabe dagegen validieren
|
||||||
|
// kann, statt jeden von der KI genannten Enum-Namen blind zu übernehmen.
|
||||||
|
internal static readonly AttendanceStatus[] SelectableStatuses =
|
||||||
|
[
|
||||||
|
AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused,
|
||||||
|
AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present,
|
||||||
|
];
|
||||||
|
public static string[] StatusOptions { get; } = SelectableStatuses.Select(s => AttendanceDisplay.Label(s)).ToArray();
|
||||||
|
|
||||||
[ObservableProperty] private Student? _assignedStudent;
|
[ObservableProperty] private Student? _assignedStudent;
|
||||||
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
|
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
|
||||||
[ObservableProperty] private Guid? _sessionId;
|
[ObservableProperty] private Guid? _sessionId;
|
||||||
[ObservableProperty] private bool _selected;
|
[ObservableProperty] private bool _selected;
|
||||||
|
[ObservableProperty] private string _selectedStatusName = "";
|
||||||
|
|
||||||
|
public AttendanceStatus SelectedStatus =>
|
||||||
|
SelectableStatuses.FirstOrDefault(s => AttendanceDisplay.Label(s) == SelectedStatusName, TargetStatus);
|
||||||
|
|
||||||
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
|
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
|
||||||
}
|
}
|
||||||
@@ -42,6 +70,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
private readonly IStudentRepository _students;
|
private readonly IStudentRepository _students;
|
||||||
private readonly IParticipationSessionRepository _sessions;
|
private readonly IParticipationSessionRepository _sessions;
|
||||||
private readonly IParticipationRepository _participation;
|
private readonly IParticipationRepository _participation;
|
||||||
|
private readonly AiPlanningService _ai;
|
||||||
|
private readonly AiSettingsService _aiSettings;
|
||||||
|
|
||||||
private IReadOnlyList<Student> _loadedStudents = [];
|
private IReadOnlyList<Student> _loadedStudents = [];
|
||||||
private IReadOnlyDictionary<DateOnly, ParticipationSession> _loadedSessions =
|
private IReadOnlyDictionary<DateOnly, ParticipationSession> _loadedSessions =
|
||||||
@@ -52,14 +82,15 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||||||
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
|
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
|
||||||
[ObservableProperty] private bool _busy;
|
[ObservableProperty] private bool _busy;
|
||||||
|
[ObservableProperty] private bool _aiSuggestBusy;
|
||||||
[ObservableProperty] private bool _markUnknownAsPresent;
|
[ObservableProperty] private bool _markUnknownAsPresent;
|
||||||
|
|
||||||
public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
|
public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
|
||||||
IStudentRepository students, IParticipationSessionRepository sessions,
|
IStudentRepository students, IParticipationSessionRepository sessions,
|
||||||
IParticipationRepository participation)
|
IParticipationRepository participation, AiPlanningService ai, AiSettingsService aiSettings)
|
||||||
{
|
{
|
||||||
_group = group; _untis = untis; _students = students; _sessions = sessions;
|
_group = group; _untis = untis; _students = students; _sessions = sessions;
|
||||||
_participation = participation;
|
_participation = participation; _ai = ai; _aiSettings = aiSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -88,7 +119,7 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
// Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt
|
// Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt
|
||||||
// per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb
|
// per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb
|
||||||
// der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten.
|
// der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten.
|
||||||
var byKey = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
|
var byKey = courseStudents.Select(student => (Student: student, Key: UntisLessonAbsenceHelper.StudentExternKey(student)))
|
||||||
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
|
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
|
||||||
var byName = courseStudents
|
var byName = courseStudents
|
||||||
.SelectMany(student => new[]
|
.SelectMany(student => new[]
|
||||||
@@ -125,13 +156,18 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent)
|
var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent)
|
||||||
? byKeyStudent
|
? byKeyStudent
|
||||||
: byName.GetValueOrDefault(NameKey(absence.StudentName));
|
: byName.GetValueOrDefault(NameKey(absence.StudentName));
|
||||||
|
var targetStatus = MapStatus(absence);
|
||||||
var row = new WebUntisLessonAbsenceRow
|
var row = new WebUntisLessonAbsenceRow
|
||||||
{
|
{
|
||||||
UntisStudentName = absence.StudentName, Date = date!.Value,
|
UntisStudentName = absence.StudentName, Date = date!.Value,
|
||||||
TimeLabel = TimeLabel(absence.StartTime, absence.EndTime),
|
TimeLabel = TimeLabel(absence.StartTime, absence.EndTime),
|
||||||
UntisStatus = DisplayUntisStatus(absence),
|
UntisStatus = DisplayUntisStatus(absence),
|
||||||
TargetStatus = MapStatus(absence), Reason = absence.Reason,
|
TargetStatus = targetStatus, Reason = absence.Reason,
|
||||||
|
AbsentMinutes = absence.AbsentMinutes,
|
||||||
|
HandledOn = !string.IsNullOrWhiteSpace(absence.HandledOn),
|
||||||
|
ExternKeyInParentheses = absence.ExternKey is null ? null : absence.ExternKeyInParentheses,
|
||||||
Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch,
|
Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch,
|
||||||
|
SelectedStatusName = AttendanceDisplay.Label(targetStatus),
|
||||||
};
|
};
|
||||||
Rows.Add(row);
|
Rows.Add(row);
|
||||||
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
|
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
|
||||||
@@ -146,6 +182,59 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
finally { Busy = false; }
|
finally { Busy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fragt für alle geladenen, einer lokalen Kursstunde zuordenbaren Zeilen in einer gebündelten
|
||||||
|
/// Anfrage (Kosten/Latenz, siehe AiPlanningService.RequestUntisStatusSuggestionsAsync) einen
|
||||||
|
/// KI-Statusvorschlag ab und setzt ihn nur in der "Übernahme als"-ComboBox vor - Namen, Klasse
|
||||||
|
/// und Datum verlassen dafür nie die App (siehe AiUntisStatusRow), nur die je Zeile rein
|
||||||
|
/// technische Positions-Id sowie die bereits lokal bekannten Rohsignale. Ersetzt nie
|
||||||
|
/// eigenständig einen bestehenden Übernahme-Status ohne Zutun der Lehrkraft - "Markierte
|
||||||
|
/// übernehmen" bleibt der einzige schreibende Schritt.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SuggestStatusWithAi()
|
||||||
|
{
|
||||||
|
var token = _aiSettings.GetToken();
|
||||||
|
if (token is null)
|
||||||
|
{
|
||||||
|
Status = "Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var candidates = Rows.Where(x => x.CanApply).ToList();
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
Status = "Keine Zeilen mit lokaler Kursstunde geladen.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AiSuggestBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var requestRows = candidates.Select((row, i) => new AiUntisStatusRow
|
||||||
|
{
|
||||||
|
Id = i.ToString(), ReasonText = row.Reason ?? "", AbsentMinutes = row.AbsentMinutes,
|
||||||
|
HandledOn = row.HandledOn, ExternKeyInParentheses = row.ExternKeyInParentheses,
|
||||||
|
CurrentGuess = row.TargetStatus.ToString(),
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var suggestions = await _ai.RequestUntisStatusSuggestionsAsync(requestRows, token);
|
||||||
|
var applied = 0;
|
||||||
|
for (var i = 0; i < candidates.Count; i++)
|
||||||
|
{
|
||||||
|
if (!suggestions.TryGetValue(i.ToString(), out var statusName)) continue;
|
||||||
|
if (!Enum.TryParse<AttendanceStatus>(statusName, out var status)) continue;
|
||||||
|
if (!WebUntisLessonAbsenceRow.SelectableStatuses.Contains(status)) continue;
|
||||||
|
candidates[i].SelectedStatusName = AttendanceDisplay.Label(status);
|
||||||
|
applied++;
|
||||||
|
}
|
||||||
|
Status = suggestions.Count == 0
|
||||||
|
? "Die KI hat keinen verwertbaren Vorschlag geliefert, die bisherige Vorbelegung bleibt unverändert."
|
||||||
|
: $"KI-Vorschlag für {applied} von {candidates.Count} Zeilen in \"Übernahme als\" vorbelegt - bitte prüfen.";
|
||||||
|
}
|
||||||
|
catch (AiBackendException ex) { Status = ex.Message; }
|
||||||
|
finally { AiSuggestBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Apply()
|
private void Apply()
|
||||||
{
|
{
|
||||||
@@ -155,7 +244,7 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
var studentId = row.AssignedStudent!.Id;
|
var studentId = row.AssignedStudent!.Id;
|
||||||
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
|
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
|
||||||
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
|
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
|
||||||
entry.Attendance = row.TargetStatus;
|
entry.Attendance = row.SelectedStatus;
|
||||||
entry.UpdatedAt = DateTime.UtcNow;
|
entry.UpdatedAt = DateTime.UtcNow;
|
||||||
_participation.Save(entry);
|
_participation.Save(entry);
|
||||||
}
|
}
|
||||||
@@ -192,38 +281,16 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
return filled;
|
return filled;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int? StudentKey(Student student)
|
|
||||||
{
|
|
||||||
student.ExternalIds ??= [];
|
|
||||||
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
|
|
||||||
&& int.TryParse(value, out var key) ? key : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht
|
// Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht
|
||||||
// dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen
|
// dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen
|
||||||
// werden beim Aufbau von `byName` registriert; hier wird nur normalisiert.
|
// werden beim Aufbau von `byName` registriert; hier wird nur normalisiert.
|
||||||
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
private const int FullLessonMinutes = 45;
|
// MapStatus/StudentKey leben jetzt in UntisLessonAbsenceHelper (framework-frei), damit
|
||||||
|
// UntisComparisonTools (MCP) exakt dieselbe Regel verwendet statt eines eigenen Duplikats, das
|
||||||
// Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die
|
// aus dem Tritt geraten könnte.
|
||||||
// (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in
|
private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence) =>
|
||||||
// Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist
|
UntisLessonAbsenceHelper.MapStatus(absence);
|
||||||
// wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der
|
|
||||||
// Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung
|
|
||||||
// oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist
|
|
||||||
// laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt.
|
|
||||||
private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence)
|
|
||||||
{
|
|
||||||
if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass;
|
|
||||||
if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late;
|
|
||||||
if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending;
|
|
||||||
if (absence.ExternKey is null) return AttendanceStatus.ExcusePending;
|
|
||||||
return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) =>
|
|
||||||
absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true;
|
|
||||||
|
|
||||||
// Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung
|
// Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung
|
||||||
// (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden.
|
// (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using LehrerApp.Desktop.ViewModels.Students;
|
|||||||
using LehrerApp.Desktop.ViewModels.Workload;
|
using LehrerApp.Desktop.ViewModels.Workload;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.ViewModels;
|
namespace LehrerApp.Desktop.ViewModels;
|
||||||
|
|
||||||
@@ -38,6 +39,9 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher;
|
public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher;
|
||||||
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
|
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
|
||||||
|
|
||||||
|
public GroupDetailViewModel? CurrentGroupDetail => CurrentPage as GroupDetailViewModel;
|
||||||
|
public bool CanPersonalizeWorksheet => CurrentGroupDetail?.Group is not null;
|
||||||
|
|
||||||
public MainWindowViewModel(IServiceProvider services,
|
public MainWindowViewModel(IServiceProvider services,
|
||||||
DashboardViewModel dashboard, SchoolYearService sy,
|
DashboardViewModel dashboard, SchoolYearService sy,
|
||||||
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock,
|
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock,
|
||||||
@@ -90,7 +94,10 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
case TimetableViewModel vm: vm.Load(); break;
|
case TimetableViewModel vm: vm.Load(); break;
|
||||||
case WorkloadViewModel vm:
|
case WorkloadViewModel vm:
|
||||||
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break;
|
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break;
|
||||||
case ClassTeacherOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
|
// RefreshFromLocalDataOnly() statt LoadCommand: baut die Ansicht aus den zuletzt
|
||||||
|
// geholten WebUntis-Daten + aktuellen lokalen Daten neu auf, löst aber selbst nie einen
|
||||||
|
// WebUntis-Request aus - den holt bewusst ausschließlich der "Aktualisieren"-Button.
|
||||||
|
case ClassTeacherOverviewViewModel vm: vm.RefreshFromLocalDataOnly(); break;
|
||||||
case ExamsOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
|
case ExamsOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
|
||||||
case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
|
case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
|
||||||
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
|
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
|
||||||
@@ -100,6 +107,25 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GroupDetailViewModel.Group wird erst nach dem Wechsel von CurrentPage per LoadGroup gesetzt
|
||||||
|
// (siehe Kommentar in NavigateToGroupDetail) - ohne dieses Abonnement bliebe
|
||||||
|
// CanPersonalizeWorksheet bis zum nächsten Seitenwechsel auf dem alten Stand.
|
||||||
|
private GroupDetailViewModel? _observedGroupDetail;
|
||||||
|
|
||||||
|
partial void OnCurrentPageChanged(ObservableObject? value)
|
||||||
|
{
|
||||||
|
if (_observedGroupDetail is not null) _observedGroupDetail.PropertyChanged -= OnGroupDetailPropertyChanged;
|
||||||
|
_observedGroupDetail = value as GroupDetailViewModel;
|
||||||
|
if (_observedGroupDetail is not null) _observedGroupDetail.PropertyChanged += OnGroupDetailPropertyChanged;
|
||||||
|
OnPropertyChanged(nameof(CurrentGroupDetail));
|
||||||
|
OnPropertyChanged(nameof(CanPersonalizeWorksheet));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGroupDetailPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(GroupDetailViewModel.Group)) OnPropertyChanged(nameof(CanPersonalizeWorksheet));
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnActiveNavItemChanged(NavItem value)
|
partial void OnActiveNavItemChanged(NavItem value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsDashboardActive));
|
OnPropertyChanged(nameof(IsDashboardActive));
|
||||||
@@ -147,8 +173,11 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
|
|
||||||
private TimetableViewModel GetTimetable()
|
private TimetableViewModel GetTimetable()
|
||||||
{
|
{
|
||||||
|
// WeekOffset bewusst nicht zurücksetzen: TimetableViewModel ist ein DI-Singleton, die
|
||||||
|
// zuletzt angezeigte Woche bleibt also automatisch über Tab-Wechsel hinweg erhalten
|
||||||
|
// (Nutzer-Feedback: beim Planen der nächsten Woche nervt es, nach jedem kurzen Abstecher
|
||||||
|
// in einen anderen Bereich wieder manuell dorthin zurückblättern zu müssen).
|
||||||
var timetable = _services.GetRequiredService<TimetableViewModel>();
|
var timetable = _services.GetRequiredService<TimetableViewModel>();
|
||||||
timetable.WeekOffset = 0;
|
|
||||||
timetable.Load();
|
timetable.Load();
|
||||||
timetable.ActiveTabIndex = 0;
|
timetable.ActiveTabIndex = 0;
|
||||||
return timetable;
|
return timetable;
|
||||||
@@ -176,7 +205,11 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>();
|
var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>();
|
||||||
vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); return Task.CompletedTask; };
|
vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); return Task.CompletedTask; };
|
||||||
vm.OnNavigateToWorkload = () => { NavigateToWorkload(); return Task.CompletedTask; };
|
vm.OnNavigateToWorkload = () => { NavigateToWorkload(); return Task.CompletedTask; };
|
||||||
vm.LoadCommand.Execute(null);
|
// Kein automatischer WebUntis-Abruf beim Navigieren: PrepareForDisplay() baut nur den
|
||||||
|
// lokalen Grundzustand auf, die eigentlichen Report-Daten holt die Nutzerin bewusst über
|
||||||
|
// den "Aktualisieren"-Button (LoadCommand) - vermeidet WebUntis-Login+Report-Latenz beim
|
||||||
|
// bloßen Öffnen der Seite.
|
||||||
|
vm.PrepareForDisplay();
|
||||||
return vm;
|
return vm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||||
|
|
||||||
|
public partial class SettingsViewModel
|
||||||
|
{
|
||||||
|
// ── MCP-Server (lokal, Phase 1) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _mcpEnabled;
|
||||||
|
|
||||||
|
private void LoadMcpSettings() => McpEnabled = _mcpSettings.Enabled;
|
||||||
|
|
||||||
|
// Kein Live-Reload (siehe McpServerHostedService/Planungsdokument) - der Pipe-Listener wird
|
||||||
|
// nur beim App-Start eingerichtet, deshalb wirkt eine Änderung hier erst nach Neustart.
|
||||||
|
partial void OnMcpEnabledChanged(bool value) => _mcpSettings.SetEnabled(value);
|
||||||
|
|
||||||
|
// ── MCP: Registrierung bei Claude Desktop ─────────────────────────────────
|
||||||
|
|
||||||
|
[ObservableProperty] private string _mcpRegistrationStatus = "";
|
||||||
|
[ObservableProperty] private bool _mcpIsRegisteredWithClaude;
|
||||||
|
|
||||||
|
private void LoadMcpRegistrationStatus() => McpIsRegisteredWithClaude = _mcpRegistration.IsRegistered();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RegisterWithClaudeDesktop()
|
||||||
|
{
|
||||||
|
var result = _mcpRegistration.Register();
|
||||||
|
McpRegistrationStatus = result.Message;
|
||||||
|
McpIsRegisteredWithClaude = _mcpRegistration.IsRegistered();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void UnregisterFromClaudeDesktop()
|
||||||
|
{
|
||||||
|
var result = _mcpRegistration.Unregister();
|
||||||
|
McpRegistrationStatus = result.Message;
|
||||||
|
McpIsRegisteredWithClaude = _mcpRegistration.IsRegistered();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,6 +229,13 @@ public partial class SettingsViewModel
|
|||||||
_eventQueue.MarkReviewed(item.Id);
|
_eventQueue.MarkReviewed(item.Id);
|
||||||
SyncConflicts.Remove(item);
|
SyncConflicts.Remove(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ClearSyncConflicts()
|
||||||
|
{
|
||||||
|
_eventQueue.ClearConflicts();
|
||||||
|
SyncConflicts.Clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SyncConflictListItem(ConflictEntry c)
|
public class SyncConflictListItem(ConflictEntry c)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||||
|
|
||||||
|
public partial class SettingsViewModel
|
||||||
|
{
|
||||||
|
[ObservableProperty] private string _worksheetTemplateStatus = "";
|
||||||
|
public ObservableCollection<LetterTemplateListItem> WorksheetTemplateList { get; } = [];
|
||||||
|
|
||||||
|
private void LoadWorksheetTemplates()
|
||||||
|
{
|
||||||
|
WorksheetTemplateList.Clear();
|
||||||
|
foreach (var template in _worksheetTemplates.Store.GetTemplates()) WorksheetTemplateList.Add(CreateWorksheetItem(template));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ImportWorksheetTemplate(string path)
|
||||||
|
{
|
||||||
|
WorksheetTemplateStatus = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var template = _worksheetTemplates.Store.Import(path);
|
||||||
|
LoadWorksheetTemplates();
|
||||||
|
WorksheetTemplateStatus = $"„{template.Name}“ wurde geprüft und lokal importiert.";
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
|
||||||
|
{ WorksheetTemplateStatus = $"Import fehlgeschlagen: {ex.Message}"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ValidateWorksheetTemplate(LetterTemplateListItem? item)
|
||||||
|
{
|
||||||
|
if (item is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var refreshed = CreateWorksheetItem(item.Model);
|
||||||
|
var index = WorksheetTemplateList.IndexOf(item);
|
||||||
|
if (index >= 0) WorksheetTemplateList[index] = refreshed;
|
||||||
|
WorksheetTemplateStatus = $"„{item.Name}“ ist gültig (Schema {refreshed.SchemaVersion}).";
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||||
|
{ WorksheetTemplateStatus = $"„{item.Name}“ ist ungültig: {ex.Message}"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void DeleteWorksheetTemplate(LetterTemplateListItem? item)
|
||||||
|
{
|
||||||
|
if (item is null) return;
|
||||||
|
_worksheetTemplates.Store.Delete(item.Id); WorksheetTemplateList.Remove(item); WorksheetTemplateStatus = "Vorlage gelöscht.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private LetterTemplateListItem CreateWorksheetItem(InstalledTemplate template)
|
||||||
|
{
|
||||||
|
var loaded = _worksheetTemplates.Store.Load(template);
|
||||||
|
return new(template, loaded.Manifest.SchemaVersion, loaded.Manifest.Placeholders.Count(x => !x.IsConstant),
|
||||||
|
loaded.Manifest.Placeholders.Count(x => !x.IsConstant && x.Required));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ public enum SettingsTab
|
|||||||
WebUntis = 13,
|
WebUntis = 13,
|
||||||
Appearance = 14,
|
Appearance = 14,
|
||||||
Trash = 15,
|
Trash = 15,
|
||||||
|
Mcp = 16,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Haupt-ViewModel ───────────────────────────────────────────────────────────
|
// ── Haupt-ViewModel ───────────────────────────────────────────────────────────
|
||||||
@@ -61,6 +62,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
private readonly IStudentRepository _students;
|
private readonly IStudentRepository _students;
|
||||||
private readonly IShorthandCodeRepository _shorthandCodes;
|
private readonly IShorthandCodeRepository _shorthandCodes;
|
||||||
private readonly TemplateStore _letterTemplates;
|
private readonly TemplateStore _letterTemplates;
|
||||||
|
private readonly WorksheetTemplateStore _worksheetTemplates;
|
||||||
|
|
||||||
[ObservableProperty] private int _activeTabIndex;
|
[ObservableProperty] private int _activeTabIndex;
|
||||||
|
|
||||||
@@ -73,6 +75,8 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
private readonly ISupervisionDutyRepository _supervisionDuties;
|
private readonly ISupervisionDutyRepository _supervisionDuties;
|
||||||
private readonly AiSettingsService _aiSettings;
|
private readonly AiSettingsService _aiSettings;
|
||||||
private readonly AiPlanningService _aiPlanning;
|
private readonly AiPlanningService _aiPlanning;
|
||||||
|
private readonly McpSettingsService _mcpSettings;
|
||||||
|
private readonly Services.Mcp.McpClientRegistrationService _mcpRegistration;
|
||||||
private readonly WebUntisSettingsService _untisSettings;
|
private readonly WebUntisSettingsService _untisSettings;
|
||||||
private readonly WebUntisIntegrationService? _untisIntegration;
|
private readonly WebUntisIntegrationService? _untisIntegration;
|
||||||
private readonly UntisSyncService? _untisSync;
|
private readonly UntisSyncService? _untisSync;
|
||||||
@@ -99,7 +103,9 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
||||||
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||||||
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
|
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
|
||||||
AiSettingsService aiSettings, AiPlanningService aiPlanning,
|
WorksheetTemplateStore worksheetTemplates,
|
||||||
|
AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings,
|
||||||
|
Services.Mcp.McpClientRegistrationService mcpRegistration,
|
||||||
WebUntisSettingsService untisSettings,
|
WebUntisSettingsService untisSettings,
|
||||||
AnnualPlanSettingsService annualPlanSettings,
|
AnnualPlanSettingsService annualPlanSettings,
|
||||||
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
|
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
|
||||||
@@ -135,8 +141,11 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
_periodSchedule = periodSchedule;
|
_periodSchedule = periodSchedule;
|
||||||
_supervisionDuties = supervisionDuties;
|
_supervisionDuties = supervisionDuties;
|
||||||
_letterTemplates = letterTemplates;
|
_letterTemplates = letterTemplates;
|
||||||
|
_worksheetTemplates = worksheetTemplates;
|
||||||
_aiSettings = aiSettings;
|
_aiSettings = aiSettings;
|
||||||
_aiPlanning = aiPlanning;
|
_aiPlanning = aiPlanning;
|
||||||
|
_mcpSettings = mcpSettings;
|
||||||
|
_mcpRegistration = mcpRegistration;
|
||||||
_untisSettings = untisSettings;
|
_untisSettings = untisSettings;
|
||||||
_untisIntegration = untisIntegration;
|
_untisIntegration = untisIntegration;
|
||||||
_untisSync = untisSync;
|
_untisSync = untisSync;
|
||||||
@@ -163,7 +172,10 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
LoadPeriodTimes();
|
LoadPeriodTimes();
|
||||||
LoadSupervisionDuties();
|
LoadSupervisionDuties();
|
||||||
LoadLetterTemplates();
|
LoadLetterTemplates();
|
||||||
|
LoadWorksheetTemplates();
|
||||||
LoadAiSettings();
|
LoadAiSettings();
|
||||||
|
LoadMcpSettings();
|
||||||
|
LoadMcpRegistrationStatus();
|
||||||
LoadUntisSettings();
|
LoadUntisSettings();
|
||||||
LoadAnnualPlanSettings();
|
LoadAnnualPlanSettings();
|
||||||
LoadSyncSettings();
|
LoadSyncSettings();
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
|
||||||
|
public sealed record AttendanceCalendarSizeChoice(AttendanceCalendarSize Value, string DisplayName);
|
||||||
|
|
||||||
|
public partial class AttendanceCalendarConfigurationViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty] private DateTimeOffset? _startMonth;
|
||||||
|
[ObservableProperty] private int _monthCount;
|
||||||
|
[ObservableProperty] private AttendanceCalendarSizeChoice _selectedSize;
|
||||||
|
|
||||||
|
public IReadOnlyList<int> MonthCounts { get; } = [1, 2, 3];
|
||||||
|
public IReadOnlyList<AttendanceCalendarSizeChoice> Sizes { get; } =
|
||||||
|
[
|
||||||
|
new(AttendanceCalendarSize.Small, "Klein"),
|
||||||
|
new(AttendanceCalendarSize.Medium, "Standard"),
|
||||||
|
new(AttendanceCalendarSize.Large, "Groß"),
|
||||||
|
];
|
||||||
|
|
||||||
|
public AttendanceCalendarConfigurationViewModel(AttendanceCalendarOptions options)
|
||||||
|
{
|
||||||
|
StartMonth = new DateTimeOffset(options.NormalizedStartMonth.ToDateTime(TimeOnly.MinValue));
|
||||||
|
MonthCount = options.NormalizedMonthCount;
|
||||||
|
SelectedSize = Sizes.First(s => s.Value == options.Size);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AttendanceCalendarOptions BuildResult()
|
||||||
|
{
|
||||||
|
var date = DateOnly.FromDateTime((StartMonth ?? DateTimeOffset.Now).LocalDateTime);
|
||||||
|
return new AttendanceCalendarOptions(new DateOnly(date.Year, date.Month, 1), MonthCount,
|
||||||
|
SelectedSize.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Templating;
|
using LehrerApp.Templating;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
@@ -11,31 +12,54 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
|||||||
private readonly Student _student;
|
private readonly Student _student;
|
||||||
private readonly TemplateStore _templates;
|
private readonly TemplateStore _templates;
|
||||||
private readonly ITemplateRenderer _renderer;
|
private readonly ITemplateRenderer _renderer;
|
||||||
|
private readonly Func<AttendanceCalendarOptions, DrawingValue>? _attendanceCalendarFactory;
|
||||||
|
private readonly Func<AttendanceCalendarOptions, DrawingValue>? _absenceDayListFactory;
|
||||||
|
private readonly Func<AttendanceCalendarOptions, CancellationToken, Task>? _attendanceDataRefresher;
|
||||||
|
private AttendanceCalendarOptions _attendanceCalendarOptions = new(
|
||||||
|
new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1), 1);
|
||||||
|
|
||||||
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
|
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
|
||||||
[ObservableProperty] private LetterContactChoice? _selectedContact;
|
[ObservableProperty] private LetterContactChoice? _selectedContact;
|
||||||
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
|
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
|
||||||
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
|
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
|
||||||
|
[ObservableProperty] private string _anrede = "";
|
||||||
[ObservableProperty] private string _letterText = "";
|
[ObservableProperty] private string _letterText = "";
|
||||||
[ObservableProperty] private string _teacherName = "";
|
[ObservableProperty] private string _teacherName = "";
|
||||||
[ObservableProperty] private string _generationError = "";
|
[ObservableProperty] private string _generationError = "";
|
||||||
[ObservableProperty] private bool _canGenerate;
|
[ObservableProperty] private bool _canGenerate;
|
||||||
|
[ObservableProperty] private bool _usesAttendanceCalendar;
|
||||||
|
[ObservableProperty] private bool _usesAbsenceDayList;
|
||||||
|
[ObservableProperty] private bool _attendanceCalendarConfigured;
|
||||||
|
[ObservableProperty] private string _attendanceCalendarSummary = "1 Monat · Standardgröße";
|
||||||
|
[ObservableProperty] private bool _isRefreshingAttendanceData;
|
||||||
|
[ObservableProperty] private string _attendanceRefreshError = "";
|
||||||
|
|
||||||
public string StudentName => _student.FullName;
|
public string StudentName => _student.FullName;
|
||||||
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
|
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
|
||||||
public ObservableCollection<LetterContactChoice> Contacts { get; } = [];
|
public ObservableCollection<LetterContactChoice> Contacts { get; } = [];
|
||||||
public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
|
public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
|
||||||
public ObservableCollection<LetterGenerationIssue> Issues { get; } = [];
|
public ObservableCollection<LetterGenerationIssue> Issues { get; } = [];
|
||||||
|
public ObservableCollection<LetterPlaceholderInput> CustomPlaceholders { get; } = [];
|
||||||
public bool HasIssues => Issues.Count > 0;
|
public bool HasIssues => Issues.Count > 0;
|
||||||
|
public bool HasCustomPlaceholders => CustomPlaceholders.Count > 0;
|
||||||
public bool HasNoTemplates => Templates.Count == 0;
|
public bool HasNoTemplates => Templates.Count == 0;
|
||||||
public bool HasNoContacts => Contacts.Count == 0;
|
public bool HasNoContacts => Contacts.Count == 0;
|
||||||
|
public string AddressPreview => LetterPlaceholderBuilder.FormatAddress(SelectedContact?.Model);
|
||||||
|
public bool HasAddressPreview => !string.IsNullOrWhiteSpace(AddressPreview);
|
||||||
|
public bool UsesAttendanceAdvancedContent => UsesAttendanceCalendar || UsesAbsenceDayList;
|
||||||
public string SuggestedFileName => SanitizeFileName(
|
public string SuggestedFileName => SanitizeFileName(
|
||||||
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
|
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
|
||||||
|
|
||||||
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
|
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
|
||||||
IGroupMembershipRepository memberships, IGroupRepository groups)
|
IGroupMembershipRepository memberships, IGroupRepository groups,
|
||||||
|
Func<AttendanceCalendarOptions, DrawingValue>? attendanceCalendarFactory = null,
|
||||||
|
Func<AttendanceCalendarOptions, DrawingValue>? absenceDayListFactory = null,
|
||||||
|
Func<AttendanceCalendarOptions, CancellationToken, Task>? attendanceDataRefresher = null)
|
||||||
{
|
{
|
||||||
_student = student; _templates = templates; _renderer = renderer;
|
_student = student; _templates = templates; _renderer = renderer;
|
||||||
|
_attendanceCalendarFactory = attendanceCalendarFactory;
|
||||||
|
_absenceDayListFactory = absenceDayListFactory;
|
||||||
|
_attendanceDataRefresher = attendanceDataRefresher;
|
||||||
foreach (var template in templates.GetTemplates()) Templates.Add(new(template));
|
foreach (var template in templates.GetTemplates()) Templates.Add(new(template));
|
||||||
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact));
|
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact));
|
||||||
foreach (var membership in memberships.GetByStudent(student.Id))
|
foreach (var membership in memberships.GetByStudent(student.Id))
|
||||||
@@ -44,10 +68,33 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
|||||||
RefreshValidation();
|
RefreshValidation();
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { OnPropertyChanged(nameof(SuggestedFileName)); RefreshValidation(); }
|
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value)
|
||||||
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation();
|
{
|
||||||
|
OnPropertyChanged(nameof(SuggestedFileName));
|
||||||
|
UsesAttendanceCalendar = TemplateUsesPlaceholder(value,
|
||||||
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName);
|
||||||
|
UsesAbsenceDayList = TemplateUsesPlaceholder(value,
|
||||||
|
StudentAbsenceDayListDrawingBuilder.PlaceholderName);
|
||||||
|
OnPropertyChanged(nameof(UsesAttendanceAdvancedContent));
|
||||||
|
AttendanceCalendarConfigured = false;
|
||||||
|
ResetAttendanceCalendarOptions();
|
||||||
|
RebuildCustomPlaceholders(value);
|
||||||
|
RefreshValidation();
|
||||||
|
}
|
||||||
|
partial void OnSelectedContactChanged(LetterContactChoice? value)
|
||||||
|
{
|
||||||
|
Anrede = value?.Model.LetterSalutation ?? "";
|
||||||
|
OnPropertyChanged(nameof(AddressPreview));
|
||||||
|
OnPropertyChanged(nameof(HasAddressPreview));
|
||||||
|
RefreshValidation();
|
||||||
|
}
|
||||||
|
partial void OnAnredeChanged(string value) => RefreshValidation();
|
||||||
partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation();
|
partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation();
|
||||||
partial void OnLetterDateChanged(DateTimeOffset? value) => RefreshValidation();
|
partial void OnLetterDateChanged(DateTimeOffset? value)
|
||||||
|
{
|
||||||
|
if (!AttendanceCalendarConfigured) ResetAttendanceCalendarOptions();
|
||||||
|
RefreshValidation();
|
||||||
|
}
|
||||||
partial void OnLetterTextChanged(string value) => RefreshValidation();
|
partial void OnLetterTextChanged(string value) => RefreshValidation();
|
||||||
partial void OnTeacherNameChanged(string value) => RefreshValidation();
|
partial void OnTeacherNameChanged(string value) => RefreshValidation();
|
||||||
|
|
||||||
@@ -90,25 +137,111 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
|||||||
|
|
||||||
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
|
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
|
||||||
{
|
{
|
||||||
var contact = SelectedContact?.Model; var group = SelectedGroup?.Model;
|
var values = LetterPlaceholderBuilder.BuildStandardValues(
|
||||||
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
_student, SelectedContact?.Model, SelectedGroup?.Model,
|
||||||
var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName,
|
||||||
var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime);
|
UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions) : null,
|
||||||
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
|
UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions) : null);
|
||||||
{
|
values["Anrede"] = new TextValue(Anrede); values["Letter.Salutation"] = new TextValue(Anrede);
|
||||||
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
|
foreach (var custom in CustomPlaceholders) values[custom.Name] = custom.ToPlaceholderValue();
|
||||||
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
|
return values;
|
||||||
["Brieftext"] = new MultilineValue(LetterText), ["LehrerName"] = new TextValue(TeacherName),
|
|
||||||
["Student.FirstName"] = new TextValue(_student.FirstName), ["Student.LastName"] = new TextValue(_student.LastName),
|
|
||||||
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
|
|
||||||
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
|
|
||||||
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
|
|
||||||
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsEmpty(PlaceholderValue value) => value switch
|
private void RebuildCustomPlaceholders(LetterTemplateChoice? choice)
|
||||||
{ TextValue x => string.IsNullOrWhiteSpace(x.Value), MultilineValue x => string.IsNullOrWhiteSpace(x.Value), _ => false };
|
{
|
||||||
|
foreach (var existing in CustomPlaceholders) existing.PropertyChanged -= OnCustomPlaceholderChanged;
|
||||||
|
CustomPlaceholders.Clear();
|
||||||
|
if (choice is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loaded = _templates.Load(choice.Model);
|
||||||
|
foreach (var placeholder in loaded.Manifest.Placeholders.Where(p => !p.IsConstant
|
||||||
|
&& !StandardPlaceholderNames.Contains(p.Name) && p.Type is PlaceholderType.Text
|
||||||
|
or PlaceholderType.Multiline or PlaceholderType.Date or PlaceholderType.Number))
|
||||||
|
{
|
||||||
|
var input = new LetterPlaceholderInput(placeholder.Name, placeholder.Type);
|
||||||
|
input.PropertyChanged += OnCustomPlaceholderChanged;
|
||||||
|
CustomPlaceholders.Add(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { }
|
||||||
|
}
|
||||||
|
OnPropertyChanged(nameof(HasCustomPlaceholders));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCustomPlaceholderChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => RefreshValidation();
|
||||||
|
|
||||||
|
private static readonly HashSet<string> StandardPlaceholderNames = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"Datum", "CurrentDate", "Empfaenger", "Anrede", "Brieftext", "LehrerName",
|
||||||
|
"Student.FirstName", "Student.LastName", "Student.Name", "Contact.Name", "Contact.Address", "Contact.Street",
|
||||||
|
"Contact.PostalCode", "Contact.City", "Letter.Salutation", "Group.Name", "SchoolYear",
|
||||||
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, StudentAbsenceDayListDrawingBuilder.PlaceholderName,
|
||||||
|
};
|
||||||
|
|
||||||
|
public AttendanceCalendarOptions GetAttendanceCalendarOptions() => _attendanceCalendarOptions;
|
||||||
|
|
||||||
|
public async Task SetAttendanceCalendarOptionsAsync(AttendanceCalendarOptions options, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
_attendanceCalendarOptions = options with { StartMonth = options.NormalizedStartMonth,
|
||||||
|
MonthCount = options.NormalizedMonthCount };
|
||||||
|
AttendanceCalendarConfigured = true;
|
||||||
|
AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions);
|
||||||
|
AttendanceRefreshError = "";
|
||||||
|
if (_attendanceDataRefresher is not null)
|
||||||
|
{
|
||||||
|
IsRefreshingAttendanceData = true;
|
||||||
|
try { await _attendanceDataRefresher(_attendanceCalendarOptions, token); }
|
||||||
|
catch (WebUntisIntegrationException ex)
|
||||||
|
{ AttendanceRefreshError = $"WebUntis-Daten konnten nicht aktualisiert werden: {ex.Message}"; }
|
||||||
|
finally { IsRefreshingAttendanceData = false; }
|
||||||
|
}
|
||||||
|
RefreshValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetAttendanceCalendarOptions()
|
||||||
|
{
|
||||||
|
var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||||
|
_attendanceCalendarOptions = new AttendanceCalendarOptions(new(date.Year, date.Month, 1), 1);
|
||||||
|
AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TemplateUsesPlaceholder(LetterTemplateChoice? choice, string placeholderName)
|
||||||
|
{
|
||||||
|
if (choice is null) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loaded = _templates.Load(choice.Model);
|
||||||
|
return UsesPlaceholder(loaded.Layout, placeholderName) ||
|
||||||
|
(loaded.ContinuationLayout is not null && UsesPlaceholder(loaded.ContinuationLayout, placeholderName));
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool UsesPlaceholder(TemplateLayout layout, string placeholderName) =>
|
||||||
|
layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements))
|
||||||
|
.Concat(layout.ContentFlows.SelectMany(f => f.Elements))
|
||||||
|
.Any(e => e is DrawBoxElement draw && draw.Placeholder == placeholderName ||
|
||||||
|
e is FlowDrawBoxElement flow && flow.Placeholder == placeholderName);
|
||||||
|
|
||||||
|
private static string FormatAttendanceCalendarSummary(AttendanceCalendarOptions options)
|
||||||
|
{
|
||||||
|
var month = options.NormalizedStartMonth.ToString("MMMM yyyy",
|
||||||
|
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||||
|
var size = options.Size switch
|
||||||
|
{
|
||||||
|
AttendanceCalendarSize.Small => "Klein",
|
||||||
|
AttendanceCalendarSize.Large => "Groß",
|
||||||
|
_ => "Standard",
|
||||||
|
};
|
||||||
|
return $"Ab {month} · {options.NormalizedMonthCount} Monat{(options.NormalizedMonthCount == 1 ? "" : "e")} · {size}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEmpty(PlaceholderValue value) => LetterPlaceholderBuilder.IsEmpty(value);
|
||||||
private static string SanitizeFileName(string value)
|
private static string SanitizeFileName(string value)
|
||||||
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
|
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
|
||||||
}
|
}
|
||||||
@@ -120,3 +253,33 @@ public sealed class LetterContactChoice(Contact model) { public Contact Model {
|
|||||||
public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; }
|
public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; }
|
||||||
public sealed class LetterGenerationIssue(string message, bool isStrong)
|
public sealed class LetterGenerationIssue(string message, bool isStrong)
|
||||||
{ public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; }
|
{ public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; }
|
||||||
|
|
||||||
|
public sealed partial class LetterPlaceholderInput : ObservableObject
|
||||||
|
{
|
||||||
|
public string Name { get; }
|
||||||
|
public PlaceholderType Type { get; }
|
||||||
|
public string Label => Type switch
|
||||||
|
{
|
||||||
|
PlaceholderType.Date => $"{Name} (Datum)",
|
||||||
|
PlaceholderType.Number => $"{Name} (Zahl)",
|
||||||
|
_ => Name,
|
||||||
|
};
|
||||||
|
public bool IsTextType => Type == PlaceholderType.Text;
|
||||||
|
public bool IsMultilineType => Type == PlaceholderType.Multiline;
|
||||||
|
public bool IsDateType => Type == PlaceholderType.Date;
|
||||||
|
public bool IsNumberType => Type == PlaceholderType.Number;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _textValue = "";
|
||||||
|
[ObservableProperty] private DateTimeOffset? _dateValue;
|
||||||
|
[ObservableProperty] private decimal? _numberValue;
|
||||||
|
|
||||||
|
public LetterPlaceholderInput(string name, PlaceholderType type) { Name = name; Type = type; }
|
||||||
|
|
||||||
|
public PlaceholderValue ToPlaceholderValue() => Type switch
|
||||||
|
{
|
||||||
|
PlaceholderType.Multiline => new MultilineValue(TextValue),
|
||||||
|
PlaceholderType.Date => new DateValue(DateValue.HasValue ? DateOnly.FromDateTime(DateValue.Value.LocalDateTime) : default),
|
||||||
|
PlaceholderType.Number => new NumberValue(NumberValue ?? 0),
|
||||||
|
_ => new TextValue(TextValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -304,6 +304,11 @@ public partial class DocumentationDialogViewModel : ObservableObject
|
|||||||
var valid = true;
|
var valid = true;
|
||||||
|
|
||||||
if (CanPickStudent && SelectedStudent is null) { StudentError = "Bezug auswählen."; valid = false; }
|
if (CanPickStudent && SelectedStudent is null) { StudentError = "Bezug auswählen."; valid = false; }
|
||||||
|
if (!CanPickStudent && _studentId == Guid.Empty && _contextGroupId is null)
|
||||||
|
{
|
||||||
|
StudentError = "Die Schülerliste ist noch nicht geladen. Dialog schließen und erneut öffnen.";
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
|
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
|
||||||
|
public partial class SelectContactDialogViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty] private LetterContactChoice? _selectedContact;
|
||||||
|
|
||||||
|
public IReadOnlyList<LetterContactChoice> Contacts { get; }
|
||||||
|
public string AddressPreview => LetterPlaceholderBuilder.FormatAddress(SelectedContact?.Model);
|
||||||
|
public bool HasAddressPreview => !string.IsNullOrWhiteSpace(AddressPreview);
|
||||||
|
|
||||||
|
public SelectContactDialogViewModel(IReadOnlyList<LetterContactChoice> contacts, LetterContactChoice? current)
|
||||||
|
{
|
||||||
|
Contacts = contacts;
|
||||||
|
SelectedContact = current ?? contacts.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedContactChanged(LetterContactChoice? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(AddressPreview));
|
||||||
|
OnPropertyChanged(nameof(HasAddressPreview));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user