Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
15463bc292 | ||
|
|
987b49e761 | ||
|
|
fb7a015b42 | ||
|
|
09e97efb61 | ||
|
|
d297f9f109 | ||
|
|
de6d4236a2 | ||
|
|
edf7510765 | ||
|
|
2ccc3201d2 | ||
|
|
ab1feba0f0 | ||
|
|
b2d0ccd30d | ||
|
|
c6efdab9ef | ||
|
|
11ef73ab1f | ||
|
|
4b4a746e17 | ||
|
|
e2e2bfb854 | ||
|
|
3e5f197bdb | ||
|
|
95e3f677e4 | ||
|
|
cdbed69d8b | ||
|
|
527c186090 | ||
|
|
28469bf547 | ||
|
|
9dce6576e2 | ||
|
|
9f28081931 | ||
|
|
6468596bf3 | ||
|
|
23abe7af63 | ||
|
|
3f71f7f371 | ||
|
|
2f2761caf8 | ||
|
|
a088823b30 | ||
|
|
1215d4ac22 | ||
|
|
84c03962ef | ||
|
|
b7c7d46ee4 | ||
|
|
da4b88c93d | ||
|
|
538ad65a3a | ||
|
|
dade41ee8d | ||
|
|
2b299fc940 | ||
|
|
dbd8777e64 | ||
|
|
89a6376fb6 | ||
|
|
b7a105af73 |
@@ -0,0 +1,24 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore LehrerApp.sln
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build LehrerApp.sln --no-restore --configuration Release
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: dotnet test LehrerApp.sln --no-build --configuration Release --logger "console;verbosity=normal"
|
||||||
Vendored
+21
-6
@@ -5,7 +5,7 @@
|
|||||||
"name": "Desktop App starten",
|
"name": "Desktop App starten",
|
||||||
"type": "coreclr",
|
"type": "coreclr",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"preLaunchTask": "Desktop bauen",
|
"preLaunchTask": "TemplateDesigner bauen",
|
||||||
"program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll",
|
"program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll",
|
||||||
"args": [],
|
"args": [],
|
||||||
"cwd": "${workspaceFolder}/LehrerApp.Desktop",
|
"cwd": "${workspaceFolder}/LehrerApp.Desktop",
|
||||||
@@ -15,6 +15,20 @@
|
|||||||
"DOTNET_ENVIRONMENT": "Development"
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "TemplateDesigner App starten",
|
||||||
|
"type": "coreclr",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "Desktop bauen",
|
||||||
|
"program": "${workspaceFolder}/LehrerApp.TemplateDesigner/bin/Debug/net10.0/LehrerApp.TemplateDesigner.dll",
|
||||||
|
"args": [],
|
||||||
|
"cwd": "${workspaceFolder}/LehrerApp.TemplateDesigner",
|
||||||
|
"stopAtEntry": false,
|
||||||
|
"console": "internalConsole",
|
||||||
|
"env": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "API Server starten",
|
"name": "API Server starten",
|
||||||
"type": "coreclr",
|
"type": "coreclr",
|
||||||
@@ -33,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": [
|
||||||
|
|||||||
Vendored
+13
@@ -27,6 +27,19 @@
|
|||||||
"problemMatcher": "$msCompile",
|
"problemMatcher": "$msCompile",
|
||||||
"group": "build"
|
"group": "build"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "TemplateDesigner bauen",
|
||||||
|
"command": "dotnet",
|
||||||
|
"type": "process",
|
||||||
|
"args": [
|
||||||
|
"build",
|
||||||
|
"${workspaceFolder}/LehrerApp.TemplateDesigner/LehrerApp.TemplateDesigner.csproj",
|
||||||
|
"--configuration", "Debug",
|
||||||
|
"--verbosity", "minimal"
|
||||||
|
],
|
||||||
|
"problemMatcher": "$msCompile",
|
||||||
|
"group": "build"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "Solution bauen",
|
"label": "Solution bauen",
|
||||||
"command": "dotnet",
|
"command": "dotnet",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator -->
|
<!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator -->
|
||||||
<NoWarn>CS8618</NoWarn>
|
<NoWarn>CS8618</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -17,13 +17,17 @@
|
|||||||
<!-- Datenbank -->
|
<!-- Datenbank -->
|
||||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
||||||
|
|
||||||
<!-- PDF-Erzeugung (11.3) - nur LehrerApp.Desktop: zieht SkiaSharp-Native-Binaries mit,
|
<!-- PDF-Erzeugung: Desktop-Exporte und die unabhängige Templating-Library. -->
|
||||||
die im Server-Image (LehrerApp.Api) nichts verloren haben. -->
|
|
||||||
<PackageVersion Include="QuestPDF" Version="2025.7.0" />
|
<PackageVersion Include="QuestPDF" Version="2025.7.0" />
|
||||||
|
<PackageVersion Include="PDFtoImage" Version="5.4.0" />
|
||||||
|
<PackageVersion Include="PdfPig" Version="0.1.13" />
|
||||||
|
|
||||||
<!-- 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" />
|
||||||
|
|||||||
@@ -100,6 +100,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>
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -200,6 +209,15 @@ public interface IDocumentationRepository
|
|||||||
/// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2).
|
/// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2).
|
||||||
void HardDelete(Guid id);
|
void HardDelete(Guid id);
|
||||||
}
|
}
|
||||||
|
public interface IVorgangRepository
|
||||||
|
{
|
||||||
|
List<Vorgang> GetAll();
|
||||||
|
List<Vorgang> GetByStudent(Guid studentId);
|
||||||
|
Vorgang? GetById(Guid id);
|
||||||
|
void Save(Vorgang vorgang);
|
||||||
|
/// Markiert den Vorgang als gelöscht, statt ihn hart zu entfernen (gleiches Muster wie Documentation).
|
||||||
|
void Delete(Guid id);
|
||||||
|
}
|
||||||
public interface IWorkTaskRepository
|
public interface IWorkTaskRepository
|
||||||
{
|
{
|
||||||
List<WorkTask> GetByStatus(WorkTaskStatus status);
|
List<WorkTask> GetByStatus(WorkTaskStatus status);
|
||||||
@@ -225,6 +243,9 @@ public interface IParticipationSessionRepository
|
|||||||
{
|
{
|
||||||
List<ParticipationSession> GetByGroup(Guid groupId);
|
List<ParticipationSession> GetByGroup(Guid groupId);
|
||||||
ParticipationSession? GetById(Guid id);
|
ParticipationSession? GetById(Guid id);
|
||||||
|
// Für Aggregationen über alle Gruppen (z.B. Dashboard-Fehlzeiten-Warnung), die sonst pro
|
||||||
|
// Eintrag einzeln GetById aufrufen müssten — ein Bulk-Laden vermeidet dieses N+1-Muster.
|
||||||
|
List<ParticipationSession> GetAll();
|
||||||
void Save(ParticipationSession session);
|
void Save(ParticipationSession session);
|
||||||
void Delete(Guid id);
|
void Delete(Guid id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
@@ -26,6 +26,13 @@ public class LearningGroup
|
|||||||
/// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
|
/// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? WebUntisLessonId { get; set; }
|
public int? WebUntisLessonId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) —
|
||||||
|
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
|
||||||
|
/// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
|
||||||
|
/// hochgestufte Gruppen bleiben unverknüpft (kein rückwirkendes Verknüpfen vorgesehen).
|
||||||
|
/// </summary>
|
||||||
|
public Guid? PreviousGroupId { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,26 @@ public class ParticipationEntry
|
|||||||
// Bleibt für bereits gespeicherte Daten erhalten. Neue Schreibvorgänge setzen beide Felder.
|
// Bleibt für bereits gespeicherte Daten erhalten. Neue Schreibvorgänge setzen beide Felder.
|
||||||
public bool HomeworkMissing { get; set; }
|
public bool HomeworkMissing { get; set; }
|
||||||
public AttendanceStatus? Attendance { get; set; }
|
public AttendanceStatus? Attendance { get; set; }
|
||||||
|
// Strichliste im Sitzplan (Nutzer-Feedback): RaisedHandCount zählt jede beobachtete Meldung,
|
||||||
|
// CalledOnCount die Teilmenge davon, bei der der Schüler auch drangekommen ist —
|
||||||
|
// "drangekommen" ist immer auch eine Meldung und erhöht daher immer beide Zähler zugleich,
|
||||||
|
// nie CalledOnCount allein. Ergibt nebenbei eine Aufrufgerechtigkeits-Quote
|
||||||
|
// (CalledOnCount / RaisedHandCount) und schlägt die Quantitätsbewertung vor, siehe
|
||||||
|
// ParticipationCountSuggestion.
|
||||||
|
public int RaisedHandCount { get; set; }
|
||||||
|
public int CalledOnCount { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Tagesflagge (Nutzer-Feedback): markiert eine session-bezogene Bewertung als besonders
|
||||||
|
/// herausragend oder besonders schwach, unabhängig von der Richtung ("egal in welche
|
||||||
|
/// Richtung, herausragend festhalten"). Rein deskriptiv — fließt nirgends in eine Berechnung
|
||||||
|
/// (Mitarbeitsnote 3.2, Aufrufgerechtigkeit) ein, nur zur Erinnerung/Dokumentation.
|
||||||
|
/// </summary>
|
||||||
|
public DayHighlightKind? DayHighlight { get; set; }
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum DayHighlightKind { Standout, Sleepy, Rough }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hausaufgabenstatus einer Sitzung; null bedeutet, dass für diesen Termin keine
|
/// Hausaufgabenstatus einer Sitzung; null bedeutet, dass für diesen Termin keine
|
||||||
/// Hausaufgabe erfasst wurde. MissingOpen kann später in MissingOverdue oder SubmittedLate
|
/// Hausaufgabe erfasst wurde. MissingOpen kann später in MissingOverdue oder SubmittedLate
|
||||||
@@ -160,3 +177,27 @@ public static class ParticipationRatingScale
|
|||||||
_ => value,
|
_ => value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nutzer-Feedback: die Strichliste im Sitzplan (<see cref="ParticipationEntry.RaisedHandCount"/>)
|
||||||
|
/// schlägt direkt eine Quantitätsbewertung vor — wie oft sich jemand meldet, ist ja bereits die
|
||||||
|
/// Quantität. Nur ein Richtwert, kein fester Automatismus: die Lehrkraft übernimmt den Vorschlag
|
||||||
|
/// per Klick oder ignoriert ihn. Bewusst nur für <see cref="AspectValueType.Scale5"/> (der
|
||||||
|
/// Standardtyp des "quantity"-Aspekts) — für individuell umkonfigurierte Aspekttypen gäbe es keine
|
||||||
|
/// sinnvolle, unmissverständliche Abbildung.
|
||||||
|
/// </summary>
|
||||||
|
public static class ParticipationCountSuggestion
|
||||||
|
{
|
||||||
|
public static int? SuggestQuantity(AspectValueType type, int raisedHandCount)
|
||||||
|
{
|
||||||
|
if (type != AspectValueType.Scale5) return null;
|
||||||
|
return raisedHandCount switch
|
||||||
|
{
|
||||||
|
0 => -2,
|
||||||
|
1 => -1,
|
||||||
|
2 or 3 => 0,
|
||||||
|
4 or 5 => 1,
|
||||||
|
_ => 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ public class LessonPhaseStep
|
|||||||
}
|
}
|
||||||
|
|
||||||
public enum UnitStatus { Planned, Active, Completed }
|
public enum UnitStatus { Planned, Active, Completed }
|
||||||
public enum LessonStatus { Planned, Conducted }
|
// Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen:
|
||||||
|
// LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit
|
||||||
|
// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden.
|
||||||
|
public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3 }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
|
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
namespace LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fallmappe für ein konkretes, laufendes Problem mit einer/einem oder mehreren Schüler*innen
|
||||||
|
/// (z.B. Schuleschwänzen, wiederholte Hausaufgaben-Verweigerung, ein Konflikt) — bündelt Titel,
|
||||||
|
/// Problembeschreibung, Schlagwörter sowie die dazugehörigen <see cref="Documentation"/>-Einträge
|
||||||
|
/// und eingefrorene WebUntis-Klassenbucheinträge an einem Ort, statt sie über datumsgefilterte
|
||||||
|
/// Listen verstreut zu lassen. Bewusst nur Referenzen (<see cref="DocumentationIds"/>) statt
|
||||||
|
/// gecachtem Inhalt — die Datenmenge pro Lehrkraft ist klein genug, dass ein Cache nur
|
||||||
|
/// Invalidierungs-Komplexität einführen würde, ohne echten Nutzen (siehe TODO.md, Präzedenzfall
|
||||||
|
/// 5.3.3: eine Plan-ID-Gruppierung für Förderpläne wurde aus demselben Grund verworfen).
|
||||||
|
/// </summary>
|
||||||
|
public class Vorgang
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
public List<Guid> StudentIds { get; set; } = [];
|
||||||
|
/// Freie Labels, z.B. "Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte" —
|
||||||
|
/// siehe VorgangTagDisplay.Suggestions im Desktop-Projekt für die Vorschlagsliste.
|
||||||
|
public List<string> Tags { get; set; } = [];
|
||||||
|
public VorgangStatus Status { get; set; } = VorgangStatus.Open;
|
||||||
|
public List<Guid> DocumentationIds { get; set; } = [];
|
||||||
|
public List<VorgangClassRegisterEntry> ClassRegisterEntries { get; set; } = [];
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? ClosedAt { get; set; }
|
||||||
|
public bool IsDeleted { get; set; }
|
||||||
|
public DateTime? DeletedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum VorgangStatus { Open, Closed }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Eingefrorene Kopie einer WebUntis-Klassenbuchzeile zum Zeitpunkt des Anheftens an einen
|
||||||
|
/// <see cref="Vorgang"/> — keine Referenz per ID, da weder <c>UntisForeignClassRegisterEventDto</c>
|
||||||
|
/// noch <c>UntisClassRegisterCacheEntry</c> eine über einen Cache-Refresh hinweg stabile ID haben.
|
||||||
|
/// </summary>
|
||||||
|
public class VorgangClassRegisterEntry
|
||||||
|
{
|
||||||
|
public DateOnly Date { get; set; }
|
||||||
|
public string StudentName { get; set; } = "";
|
||||||
|
public string? Subject { get; set; }
|
||||||
|
public string? TeacherUsername { get; set; }
|
||||||
|
public string? CategoryName { get; set; }
|
||||||
|
public string? CategoryGroup { get; set; }
|
||||||
|
public string? Text { get; set; }
|
||||||
|
public DateTime PinnedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -93,7 +93,7 @@ public interface IHasAttachments
|
|||||||
List<DocumentAttachment> Attachments { get; }
|
List<DocumentAttachment> Attachments { get; }
|
||||||
}
|
}
|
||||||
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
|
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
|
||||||
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter }
|
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter, Planning }
|
||||||
public enum SupportStatus { Active, Completed, Paused }
|
public enum SupportStatus { Active, Completed, Paused }
|
||||||
|
|
||||||
public class WorkTask
|
public class WorkTask
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ public class BackupService
|
|||||||
|
|
||||||
public string BackupDirectory => _backupDirectory;
|
public string BackupDirectory => _backupDirectory;
|
||||||
|
|
||||||
|
/// Optionales zweites Sicherungsziel (z.B. USB-Stick, Netzlaufwerk) — Backups liegen sonst
|
||||||
|
/// ausschließlich neben der Live-Datenbank und sind bei einem Plattenausfall oder einem
|
||||||
|
/// versehentlich gelöschten Profilverzeichnis wertlos. Wird von außen gesetzt (Desktop-Settings,
|
||||||
|
/// siehe BackupSettingsService); bewusst best-effort — ist das Ziel beim Sichern nicht
|
||||||
|
/// erreichbar (Stick nicht eingesteckt, Freigabe offline), bleibt nur das Hauptbackup bestehen.
|
||||||
|
public string? SecondaryBackupDirectory { get; set; }
|
||||||
|
|
||||||
public BackupService(string appDataPath)
|
public BackupService(string appDataPath)
|
||||||
{
|
{
|
||||||
_backupDirectory = Path.Combine(appDataPath, "backups");
|
_backupDirectory = Path.Combine(appDataPath, "backups");
|
||||||
@@ -34,9 +41,36 @@ public class BackupService
|
|||||||
File.Copy(databasePath, target, overwrite: true);
|
File.Copy(databasePath, target, overwrite: true);
|
||||||
|
|
||||||
PruneOldBackups(keepCount);
|
PruneOldBackups(keepCount);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(SecondaryBackupDirectory))
|
||||||
|
TryMirrorToSecondary(target, fileName, keepCount);
|
||||||
|
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void TryMirrorToSecondary(string sourcePath, string fileName, int keepCount)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(SecondaryBackupDirectory!);
|
||||||
|
File.Copy(sourcePath, Path.Combine(SecondaryBackupDirectory!, fileName), overwrite: true);
|
||||||
|
|
||||||
|
foreach (var stale in Directory.GetFiles(SecondaryBackupDirectory!, "lehrerapp-*.db")
|
||||||
|
.Select(p => new FileInfo(p))
|
||||||
|
.OrderByDescending(f => f.LastWriteTime)
|
||||||
|
.Skip(keepCount))
|
||||||
|
{
|
||||||
|
try { stale.Delete(); }
|
||||||
|
catch { /* nächster Lauf versucht es erneut */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Sicherungsziel nicht erreichbar — das Hauptbackup (_backupDirectory) ist davon
|
||||||
|
// unberührt, ein manuelles/automatisches Backup soll daran nicht scheitern.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public List<BackupInfo> ListBackups() =>
|
public List<BackupInfo> ListBackups() =>
|
||||||
Directory.GetFiles(_backupDirectory, "lehrerapp-*.db")
|
Directory.GetFiles(_backupDirectory, "lehrerapp-*.db")
|
||||||
.Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length))
|
.Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length))
|
||||||
|
|||||||
@@ -12,10 +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",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly string _configPath;
|
private readonly string _configPath;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zählt eigene Klausuren pro ISO-Kalenderwoche über alle Lerngruppen hinweg. Nutzer-Feedback:
|
||||||
|
/// die klassenbezogene Kollisionsprüfung (mehrere Klausuren einer einzelnen Klasse in einer
|
||||||
|
/// Woche) übernimmt bereits der schulische Klausurplaner (externes Klassenbuch) — was dort
|
||||||
|
/// fehlt, ist die persönliche Belastung der Lehrkraft: mehr eigene Klausuren in derselben Woche
|
||||||
|
/// bedeuten unabhängig von der Klasse mehr Erstellungsaufwand vorher und mehr Korrekturaufwand
|
||||||
|
/// danach. Da eine LiteDB-Datei genau einem Nutzer gehört (siehe CLAUDE.md), sind "alle
|
||||||
|
/// Klausuren in der Datenbank" bereits gleichbedeutend mit "meine Klausuren".
|
||||||
|
/// </summary>
|
||||||
|
public static class ExamWeekLoadService
|
||||||
|
{
|
||||||
|
/// Ab dieser Anzahl eigener Klausuren in derselben Woche gilt die Woche als spürbar belastet.
|
||||||
|
/// Nutzer-Feedback: zwei Klausuren aus zwei unterrichteten Fächern in derselben Woche sind
|
||||||
|
/// normal, eine dritte ist die eigentliche Häufung.
|
||||||
|
public const int WarningThreshold = 3;
|
||||||
|
|
||||||
|
/// Anzahl Klausuren (ohne <paramref name="excludeExamId"/>, z.B. die gerade im Dialog
|
||||||
|
/// bearbeitete), die in dieselbe ISO-Kalenderwoche wie <paramref name="date"/> fallen.
|
||||||
|
public static int CountInSameWeek(IEnumerable<Exam> allExams, DateOnly date, Guid? excludeExamId = null)
|
||||||
|
{
|
||||||
|
var (year, week) = IsoWeek(date);
|
||||||
|
return allExams.Count(e => e.Id != excludeExamId && IsoWeek(e.Date) == (year, week));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gruppiert die übergebenen Klausuren nach ISO-Kalenderwoche und liefert nur die Wochen mit
|
||||||
|
/// mindestens <see cref="WarningThreshold"/> Klausuren, chronologisch sortiert — für einen
|
||||||
|
/// vorausschauenden Überblick (z.B. Dashboard), nicht für die Einzelprüfung beim Anlegen.
|
||||||
|
public static List<ExamWeekLoad> FindOverloadedWeeks(IEnumerable<Exam> exams) =>
|
||||||
|
exams
|
||||||
|
.GroupBy(e => IsoWeek(e.Date))
|
||||||
|
.Where(g => g.Count() >= WarningThreshold)
|
||||||
|
.Select(g => new ExamWeekLoad(g.Key.Year, g.Key.Week, WeekStart(g.Key.Year, g.Key.Week), g.Count()))
|
||||||
|
.OrderBy(w => w.WeekStart)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private static (int Year, int Week) IsoWeek(DateOnly date)
|
||||||
|
{
|
||||||
|
var dt = date.ToDateTime(TimeOnly.MinValue);
|
||||||
|
return (ISOWeek.GetYear(dt), ISOWeek.GetWeekOfYear(dt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly WeekStart(int isoYear, int isoWeek) =>
|
||||||
|
DateOnly.FromDateTime(ISOWeek.ToDateTime(isoYear, isoWeek, DayOfWeek.Monday));
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct ExamWeekLoad(int IsoYear, int IsoWeek, DateOnly WeekStart, int ExamCount)
|
||||||
|
{
|
||||||
|
public DateOnly WeekEnd => WeekStart.AddDays(6);
|
||||||
|
}
|
||||||
@@ -77,13 +77,29 @@ public class GradingService
|
|||||||
public double WeightedAverage(List<(string Grade, double Weight)> grades)
|
public double WeightedAverage(List<(string Grade, double Weight)> grades)
|
||||||
{
|
{
|
||||||
var numeric = grades
|
var numeric = grades
|
||||||
.Select(g => (Value: int.TryParse(g.Grade, out var n) ? (int?)n : null, g.Weight))
|
.Select(g => (Value: ParseGradeValue(g.Grade), g.Weight))
|
||||||
.Where(g => g.Value.HasValue).ToList();
|
.Where(g => g.Value.HasValue).ToList();
|
||||||
if (numeric.Count == 0) return 0;
|
if (numeric.Count == 0) return 0;
|
||||||
var total = numeric.Sum(g => g.Weight);
|
var total = numeric.Sum(g => g.Weight);
|
||||||
return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total;
|
return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wandelt eine Notenangabe mit optionaler Tendenz ("3+", "2-") in einen rechnerischen
|
||||||
|
/// Notenwert um. Tendenzen verschieben um ein Drittel in Richtung der besseren Note
|
||||||
|
/// (z.B. "3+" → 2,67, "2-" → 2,33) — der in deutschen Notensystemen übliche Drittelabstand.
|
||||||
|
private static double? ParseGradeValue(string grade)
|
||||||
|
{
|
||||||
|
var trimmed = grade.Trim();
|
||||||
|
if (trimmed.Length == 0) return null;
|
||||||
|
|
||||||
|
var tendency = 0.0;
|
||||||
|
var core = trimmed;
|
||||||
|
if (core.EndsWith('+')) { tendency = -1.0 / 3.0; core = core[..^1].TrimEnd(); }
|
||||||
|
else if (core.EndsWith('-')) { tendency = 1.0 / 3.0; core = core[..^1].TrimEnd(); }
|
||||||
|
|
||||||
|
return int.TryParse(core, out var n) ? n + tendency : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Rundet einen rechnerischen Notenwert auf eine ganze Note/Punktzahl.
|
/// Rundet einen rechnerischen Notenwert auf eine ganze Note/Punktzahl.
|
||||||
/// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard).
|
/// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard).
|
||||||
/// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note
|
/// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ public sealed class GroupRolloverService(
|
|||||||
IsActive = true,
|
IsActive = true,
|
||||||
IsOwnClass = source.IsOwnClass,
|
IsOwnClass = source.IsOwnClass,
|
||||||
IsDifferentiated = source.IsDifferentiated,
|
IsDifferentiated = source.IsDifferentiated,
|
||||||
|
PreviousGroupId = source.Id,
|
||||||
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
|
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
|
||||||
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
|
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>Gemeinsame Terminlogik für Verschieben und Trennen von Stunden.</summary>
|
||||||
|
public sealed class LessonSchedulingService(ILessonRepository lessons)
|
||||||
|
{
|
||||||
|
public void Move(Lesson lesson, DateOnly newDate, int? newPeriod, bool shiftFollowing)
|
||||||
|
{
|
||||||
|
EnsureTargetIsFree(lesson, newDate, newPeriod);
|
||||||
|
var oldDate = lesson.Date;
|
||||||
|
var delta = newDate.DayNumber - oldDate.DayNumber;
|
||||||
|
|
||||||
|
if (shiftFollowing && delta != 0)
|
||||||
|
{
|
||||||
|
foreach (var other in lessons.GetByUnit(lesson.UnitId))
|
||||||
|
{
|
||||||
|
if (other.Id == lesson.Id || other.Status == LessonStatus.Conducted || other.Date <= oldDate)
|
||||||
|
continue;
|
||||||
|
other.Date = other.Date.AddDays(delta);
|
||||||
|
lessons.Save(other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lesson.Date = newDate;
|
||||||
|
if (newPeriod.HasValue) lesson.LessonNumber = newPeriod;
|
||||||
|
lessons.Save(lesson);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Lesson SplitAndMoveSecondPart(Lesson source, int splitAfterMinutes, DateOnly newDate,
|
||||||
|
int newPeriod, TimeOnly? newStartTime)
|
||||||
|
{
|
||||||
|
if (splitAfterMinutes <= 0 || source.Phases.Sum(p => p.DurationMinutes) <= splitAfterMinutes)
|
||||||
|
throw new InvalidOperationException("Der Verlauf reicht nicht über die erste Stunde hinaus.");
|
||||||
|
EnsureTargetIsFree(source, newDate, newPeriod);
|
||||||
|
|
||||||
|
var first = new List<LessonPhaseStep>();
|
||||||
|
var second = new List<LessonPhaseStep>();
|
||||||
|
var elapsed = 0;
|
||||||
|
foreach (var phase in source.Phases)
|
||||||
|
{
|
||||||
|
var remainingInFirst = splitAfterMinutes - elapsed;
|
||||||
|
if (remainingInFirst <= 0)
|
||||||
|
second.Add(Clone(phase, phase.DurationMinutes));
|
||||||
|
else if (phase.DurationMinutes <= remainingInFirst)
|
||||||
|
first.Add(Clone(phase, phase.DurationMinutes));
|
||||||
|
else
|
||||||
|
{
|
||||||
|
first.Add(Clone(phase, remainingInFirst));
|
||||||
|
second.Add(Clone(phase, phase.DurationMinutes - remainingInFirst));
|
||||||
|
}
|
||||||
|
elapsed += phase.DurationMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
source.Phases = first;
|
||||||
|
var continuation = new Lesson
|
||||||
|
{
|
||||||
|
UnitId = source.UnitId,
|
||||||
|
GroupId = source.GroupId,
|
||||||
|
Date = newDate,
|
||||||
|
LessonNumber = newPeriod,
|
||||||
|
StartTime = newStartTime,
|
||||||
|
Topic = string.IsNullOrWhiteSpace(source.Topic) ? "Fortsetzung" : $"{source.Topic} – Fortsetzung",
|
||||||
|
Phases = second,
|
||||||
|
Homework = source.Homework,
|
||||||
|
HomeworkChecked = source.HomeworkChecked,
|
||||||
|
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
|
||||||
|
Reflection = source.Reflection,
|
||||||
|
Status = source.Status == LessonStatus.Conducted ? LessonStatus.Draft : source.Status,
|
||||||
|
};
|
||||||
|
source.Homework = null;
|
||||||
|
source.HomeworkChecked = false;
|
||||||
|
source.HomeworkCheckDismissed = false;
|
||||||
|
source.Reflection = null;
|
||||||
|
lessons.Save(source);
|
||||||
|
lessons.Save(continuation);
|
||||||
|
return continuation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureTargetIsFree(Lesson source, DateOnly date, int? period)
|
||||||
|
{
|
||||||
|
if (period is null) return;
|
||||||
|
var occupied = lessons.GetByGroupAndDate(source.GroupId, date)
|
||||||
|
.Any(l => l.Id != source.Id && l.LessonNumber == period);
|
||||||
|
if (occupied)
|
||||||
|
throw new InvalidOperationException($"Für die Lerngruppe existiert am {date:dd.MM.yyyy} in der {period}. Stunde bereits eine Planung.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LessonPhaseStep Clone(LessonPhaseStep source, int duration) => new()
|
||||||
|
{
|
||||||
|
Name = source.Name,
|
||||||
|
DurationMinutes = duration,
|
||||||
|
Activity = source.Activity,
|
||||||
|
Material = source.Material,
|
||||||
|
Shorthand = source.Shorthand,
|
||||||
|
AlternativePathId = source.AlternativePathId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
using System.IO.Compression;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Xml.Linq;
|
|
||||||
|
|
||||||
namespace LehrerApp.Core.Services;
|
|
||||||
|
|
||||||
public enum TemplateIssueSeverity
|
|
||||||
{
|
|
||||||
Warning,
|
|
||||||
StrongWarning,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record LetterPlaceholder(string Tag, string Description);
|
|
||||||
|
|
||||||
public sealed record TemplateValidationIssue(
|
|
||||||
TemplateIssueSeverity Severity,
|
|
||||||
string Message,
|
|
||||||
string? Tag = null,
|
|
||||||
string? SuggestedTag = null);
|
|
||||||
|
|
||||||
public sealed record TemplateValidationResult(
|
|
||||||
IReadOnlyList<string> Tags,
|
|
||||||
IReadOnlyList<TemplateValidationIssue> Issues)
|
|
||||||
{
|
|
||||||
public bool CanGenerate => Issues.All(i => i.Severity != TemplateIssueSeverity.Error);
|
|
||||||
public bool HasStrongWarnings => Issues.Any(i => i.Severity == TemplateIssueSeverity.StrongWarning);
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class LetterTemplateInfo
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
public string Name { get; set; } = "";
|
|
||||||
public string StoredFileName { get; set; } = "";
|
|
||||||
public string OriginalFileName { get; set; } = "";
|
|
||||||
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verwaltet DOCX-Briefvorlagen und befüllt Word-Inhaltssteuerelemente anhand ihres Tags.
|
|
||||||
/// Die Originalvorlage wird nie verändert.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class LetterTemplateService
|
|
||||||
{
|
|
||||||
private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
|
||||||
|
|
||||||
public static IReadOnlyList<LetterPlaceholder> SupportedPlaceholders { get; } =
|
|
||||||
[
|
|
||||||
new("Student.FirstName", "Vorname des Schülers"),
|
|
||||||
new("Student.LastName", "Nachname des Schülers"),
|
|
||||||
new("Contact.Name", "Name des ausgewählten Kontakts"),
|
|
||||||
new("Contact.Address", "Vollständige Anschrift (mehrzeilig)"),
|
|
||||||
new("Contact.Street", "Straße und Hausnummer"),
|
|
||||||
new("Contact.PostalCode", "Postleitzahl"),
|
|
||||||
new("Contact.City", "Ort"),
|
|
||||||
new("Letter.Salutation", "Gespeicherte Briefanrede des Kontakts"),
|
|
||||||
new("Group.Name", "Ausgewählte Lerngruppe"),
|
|
||||||
new("SchoolYear", "Schuljahr der ausgewählten Gruppe"),
|
|
||||||
new("CurrentDate", "Gewähltes Briefdatum"),
|
|
||||||
];
|
|
||||||
|
|
||||||
private readonly string _templateDirectory;
|
|
||||||
private readonly string _indexPath;
|
|
||||||
|
|
||||||
public LetterTemplateService(string appDataPath)
|
|
||||||
{
|
|
||||||
_templateDirectory = Path.Combine(appDataPath, "letter-templates");
|
|
||||||
_indexPath = Path.Combine(_templateDirectory, "templates.json");
|
|
||||||
Directory.CreateDirectory(_templateDirectory);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyList<LetterTemplateInfo> GetTemplates() => LoadIndex()
|
|
||||||
.OrderBy(t => t.Name, StringComparer.CurrentCultureIgnoreCase)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
public string GetTemplatePath(LetterTemplateInfo template) =>
|
|
||||||
Path.Combine(_templateDirectory, template.StoredFileName);
|
|
||||||
|
|
||||||
public LetterTemplateInfo Import(string sourcePath, string? displayName = null)
|
|
||||||
{
|
|
||||||
var validation = Validate(sourcePath);
|
|
||||||
if (!validation.CanGenerate)
|
|
||||||
throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message);
|
|
||||||
|
|
||||||
var templates = LoadIndex();
|
|
||||||
var info = new LetterTemplateInfo
|
|
||||||
{
|
|
||||||
Name = string.IsNullOrWhiteSpace(displayName)
|
|
||||||
? Path.GetFileNameWithoutExtension(sourcePath)
|
|
||||||
: displayName.Trim(),
|
|
||||||
OriginalFileName = Path.GetFileName(sourcePath),
|
|
||||||
};
|
|
||||||
info.StoredFileName = $"{info.Id:N}.docx";
|
|
||||||
File.Copy(sourcePath, GetTemplatePath(info), overwrite: false);
|
|
||||||
templates.Add(info);
|
|
||||||
SaveIndex(templates);
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Delete(Guid id)
|
|
||||||
{
|
|
||||||
var templates = LoadIndex();
|
|
||||||
var template = templates.FirstOrDefault(t => t.Id == id);
|
|
||||||
if (template is null) return;
|
|
||||||
var path = GetTemplatePath(template);
|
|
||||||
if (File.Exists(path)) File.Delete(path);
|
|
||||||
templates.Remove(template);
|
|
||||||
SaveIndex(templates);
|
|
||||||
}
|
|
||||||
|
|
||||||
public TemplateValidationResult Validate(LetterTemplateInfo template) =>
|
|
||||||
Validate(GetTemplatePath(template));
|
|
||||||
|
|
||||||
public TemplateValidationResult Validate(string path)
|
|
||||||
{
|
|
||||||
var tags = new List<string>();
|
|
||||||
var issues = new List<TemplateValidationIssue>();
|
|
||||||
|
|
||||||
if (!File.Exists(path))
|
|
||||||
return Error("Die Vorlagendatei wurde nicht gefunden.");
|
|
||||||
if (!string.Equals(Path.GetExtension(path), ".docx", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return Error("Die Vorlage muss eine DOCX-Datei sein.");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var archive = ZipFile.OpenRead(path);
|
|
||||||
var xmlEntries = WordXmlEntries(archive).ToList();
|
|
||||||
if (xmlEntries.All(e => !string.Equals(e.FullName, "word/document.xml", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
return Error("Die Datei ist kein gültiges Word-DOCX-Dokument.");
|
|
||||||
|
|
||||||
foreach (var entry in xmlEntries)
|
|
||||||
{
|
|
||||||
using var stream = entry.Open();
|
|
||||||
var document = XDocument.Load(stream, LoadOptions.PreserveWhitespace);
|
|
||||||
foreach (var control in document.Descendants(W + "sdt"))
|
|
||||||
{
|
|
||||||
var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(tag))
|
|
||||||
{
|
|
||||||
issues.Add(new(TemplateIssueSeverity.StrongWarning,
|
|
||||||
$"Ein Inhaltssteuerelement in {PartLabel(entry.FullName)} besitzt keinen Tag und kann nicht befüllt werden."));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
tags.Add(tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (InvalidDataException)
|
|
||||||
{
|
|
||||||
return Error("Die Datei ist beschädigt oder kein gültiges DOCX-Dokument.");
|
|
||||||
}
|
|
||||||
catch (System.Xml.XmlException)
|
|
||||||
{
|
|
||||||
return Error("Die Word-Vorlage enthält ungültiges XML und kann nicht gelesen werden.");
|
|
||||||
}
|
|
||||||
catch (IOException ex)
|
|
||||||
{
|
|
||||||
return Error($"Die Vorlage konnte nicht geöffnet werden: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tags.Count == 0 && issues.Count == 0)
|
|
||||||
issues.Add(new(TemplateIssueSeverity.Warning,
|
|
||||||
"Die Vorlage enthält keine Inhaltssteuerelemente. Es werden keine Felder befüllt."));
|
|
||||||
|
|
||||||
foreach (var tag in tags.Distinct(StringComparer.Ordinal))
|
|
||||||
{
|
|
||||||
if (SupportedPlaceholders.Any(p => p.Tag == tag)) continue;
|
|
||||||
var suggestion = FindSuggestion(tag);
|
|
||||||
issues.Add(suggestion is null
|
|
||||||
? new(TemplateIssueSeverity.Warning,
|
|
||||||
$"Der unbekannte Tag „{tag}“ wird nicht befüllt.", tag)
|
|
||||||
: new(TemplateIssueSeverity.StrongWarning,
|
|
||||||
$"Wahrscheinlicher Schreibfehler: „{tag}“. Meinten Sie „{suggestion}“?", tag, suggestion));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new(tags.Distinct(StringComparer.Ordinal).OrderBy(t => t).ToList(), issues);
|
|
||||||
|
|
||||||
TemplateValidationResult Error(string message) =>
|
|
||||||
new([], [new(TemplateIssueSeverity.Error, message)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Generate(string templatePath, string outputPath,
|
|
||||||
IReadOnlyDictionary<string, string?> values)
|
|
||||||
{
|
|
||||||
var validation = Validate(templatePath);
|
|
||||||
if (!validation.CanGenerate)
|
|
||||||
throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message);
|
|
||||||
|
|
||||||
var outputDirectory = Path.GetDirectoryName(outputPath);
|
|
||||||
if (!string.IsNullOrEmpty(outputDirectory)) Directory.CreateDirectory(outputDirectory);
|
|
||||||
|
|
||||||
using var source = ZipFile.OpenRead(templatePath);
|
|
||||||
using var target = ZipFile.Open(outputPath, ZipArchiveMode.Create);
|
|
||||||
foreach (var entry in source.Entries)
|
|
||||||
{
|
|
||||||
var targetEntry = target.CreateEntry(entry.FullName, CompressionLevel.Optimal);
|
|
||||||
targetEntry.LastWriteTime = entry.LastWriteTime;
|
|
||||||
using var input = entry.Open();
|
|
||||||
using var output = targetEntry.Open();
|
|
||||||
|
|
||||||
if (!IsWordXmlEntry(entry))
|
|
||||||
{
|
|
||||||
input.CopyTo(output);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var document = XDocument.Load(input, LoadOptions.PreserveWhitespace);
|
|
||||||
foreach (var control in document.Descendants(W + "sdt").ToList())
|
|
||||||
{
|
|
||||||
var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim();
|
|
||||||
if (tag is null || !values.TryGetValue(tag, out var value)) continue;
|
|
||||||
ReplaceContent(control.Element(W + "sdtContent"), value ?? "");
|
|
||||||
}
|
|
||||||
document.Save(output, SaveOptions.DisableFormatting);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ReplaceContent(XElement? content, string value)
|
|
||||||
{
|
|
||||||
if (content is null) return;
|
|
||||||
var paragraph = content.Elements(W + "p").FirstOrDefault();
|
|
||||||
XElement run;
|
|
||||||
|
|
||||||
if (paragraph is not null)
|
|
||||||
{
|
|
||||||
run = paragraph.Descendants(W + "r").FirstOrDefault() ?? new XElement(W + "r");
|
|
||||||
var paragraphProperties = paragraph.Element(W + "pPr");
|
|
||||||
var runProperties = run.Element(W + "rPr") is { } rp ? new XElement(rp) : null;
|
|
||||||
paragraph.RemoveNodes();
|
|
||||||
if (paragraphProperties is not null) paragraph.Add(paragraphProperties);
|
|
||||||
run = new XElement(W + "r");
|
|
||||||
if (runProperties is not null) run.Add(runProperties);
|
|
||||||
paragraph.Add(run);
|
|
||||||
foreach (var extra in content.Elements(W + "p").Skip(1).ToList()) extra.Remove();
|
|
||||||
foreach (var node in content.Nodes().Where(n => n is XElement e && e.Name != W + "p").ToList()) node.Remove();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var firstRun = content.Descendants(W + "r").FirstOrDefault();
|
|
||||||
var runProperties = firstRun?.Element(W + "rPr") is { } rp ? new XElement(rp) : null;
|
|
||||||
content.RemoveNodes();
|
|
||||||
run = new XElement(W + "r");
|
|
||||||
if (runProperties is not null) run.Add(runProperties);
|
|
||||||
content.Add(run);
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines = value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
|
|
||||||
for (var i = 0; i < lines.Length; i++)
|
|
||||||
{
|
|
||||||
if (i > 0) run.Add(new XElement(W + "br"));
|
|
||||||
run.Add(new XElement(W + "t", new XAttribute(XNamespace.Xml + "space", "preserve"), lines[i]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerable<ZipArchiveEntry> WordXmlEntries(ZipArchive archive) =>
|
|
||||||
archive.Entries.Where(IsWordXmlEntry);
|
|
||||||
|
|
||||||
private static bool IsWordXmlEntry(ZipArchiveEntry entry) =>
|
|
||||||
entry.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& entry.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
private static string PartLabel(string path) => path switch
|
|
||||||
{
|
|
||||||
"word/document.xml" => "Dokumenttext",
|
|
||||||
var p when p.StartsWith("word/header", StringComparison.OrdinalIgnoreCase) => "einer Kopfzeile",
|
|
||||||
var p when p.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase) => "einer Fußzeile",
|
|
||||||
_ => $"dem Dokumentteil „{Path.GetFileName(path)}“",
|
|
||||||
};
|
|
||||||
|
|
||||||
private static string? FindSuggestion(string unknown)
|
|
||||||
{
|
|
||||||
var candidate = SupportedPlaceholders
|
|
||||||
.Select(p => (p.Tag, Distance: Levenshtein(unknown.ToLowerInvariant(), p.Tag.ToLowerInvariant())))
|
|
||||||
.OrderBy(x => x.Distance)
|
|
||||||
.ThenBy(x => x.Tag)
|
|
||||||
.First();
|
|
||||||
var threshold = candidate.Tag.Length >= 12 ? 3 : 2;
|
|
||||||
return candidate.Distance <= threshold ? candidate.Tag : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int Levenshtein(string left, string right)
|
|
||||||
{
|
|
||||||
var previous = Enumerable.Range(0, right.Length + 1).ToArray();
|
|
||||||
for (var i = 1; i <= left.Length; i++)
|
|
||||||
{
|
|
||||||
var current = new int[right.Length + 1];
|
|
||||||
current[0] = i;
|
|
||||||
for (var j = 1; j <= right.Length; j++)
|
|
||||||
current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1),
|
|
||||||
previous[j - 1] + (left[i - 1] == right[j - 1] ? 0 : 1));
|
|
||||||
previous = current;
|
|
||||||
}
|
|
||||||
return previous[right.Length];
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<LetterTemplateInfo> LoadIndex()
|
|
||||||
{
|
|
||||||
if (!File.Exists(_indexPath)) return [];
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return JsonSerializer.Deserialize<List<LetterTemplateInfo>>(File.ReadAllText(_indexPath), JsonOptions) ?? [];
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SaveIndex(List<LetterTemplateInfo> templates)
|
|
||||||
{
|
|
||||||
var temporaryPath = _indexPath + ".tmp";
|
|
||||||
File.WriteAllText(temporaryPath, JsonSerializer.Serialize(templates, JsonOptions));
|
|
||||||
File.Move(temporaryPath, _indexPath, overwrite: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
/// Welcher der drei Leistungsbereiche (siehe GradingScheme) bei der Zielnoten-Frage der
|
||||||
|
/// gesuchte/unbekannte ist.
|
||||||
|
public enum GradeBucketKind { Exams, Participation, Other }
|
||||||
|
|
||||||
|
public readonly record struct TargetGradeResult(double RequiredAverage, bool IsAchievable);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Beantwortet die Zielnoten-Frage "Was brauche ich noch, um Zeugnisnote X zu erreichen?"
|
||||||
|
/// (Nutzer-Feedback) — kehrt <see cref="GradingService.CalculateReportGrade"/> um: löst nach
|
||||||
|
/// dem Durchschnitt eines gewählten Bereichs auf, der zusammen mit den bekannten Durchschnitten
|
||||||
|
/// der übrigen Bereiche (gewichtet nach <see cref="GradingScheme"/>) genau die Zielnote ergibt.
|
||||||
|
/// Bereiche ohne bekannten Durchschnitt (z.B. noch keine Mitarbeitsnote im Halbjahr) fließen
|
||||||
|
/// nicht in die bekannte Seite der Gleichung ein — exakt dieselbe Normierung wie
|
||||||
|
/// <see cref="GradingService.CalculateReportGrade"/> bei fehlenden Bereichen.
|
||||||
|
/// </summary>
|
||||||
|
public static class ReportGradeTargetCalculator
|
||||||
|
{
|
||||||
|
public static TargetGradeResult? SolveRequiredAverage(double target, GradeBucketKind solveFor,
|
||||||
|
double? examsAverage, double? participationAverage, double? otherAverage,
|
||||||
|
GradingScheme scheme, GradingSystem system)
|
||||||
|
{
|
||||||
|
var solvePercent = solveFor switch
|
||||||
|
{
|
||||||
|
GradeBucketKind.Exams => scheme.ExamsPercent,
|
||||||
|
GradeBucketKind.Participation => scheme.ParticipationPercent,
|
||||||
|
_ => scheme.OtherPercent,
|
||||||
|
};
|
||||||
|
// Dieser Bereich fließt laut Gewichtungsschema gar nicht in die Zeugnisnote ein —
|
||||||
|
// keine Zielnoten-Frage für ihn beantwortbar.
|
||||||
|
if (solvePercent <= 0) return null;
|
||||||
|
|
||||||
|
var known = new List<(double Avg, double Percent)>();
|
||||||
|
if (solveFor != GradeBucketKind.Exams && examsAverage is { } e) known.Add((e, scheme.ExamsPercent));
|
||||||
|
if (solveFor != GradeBucketKind.Participation && participationAverage is { } p) known.Add((p, scheme.ParticipationPercent));
|
||||||
|
if (solveFor != GradeBucketKind.Other && otherAverage is { } o) known.Add((o, scheme.OtherPercent));
|
||||||
|
|
||||||
|
var totalPercent = known.Sum(k => k.Percent) + solvePercent;
|
||||||
|
var knownContribution = known.Sum(k => k.Avg * k.Percent);
|
||||||
|
var required = (target * totalPercent - knownContribution) / solvePercent;
|
||||||
|
|
||||||
|
var (min, max) = system == GradingSystem.Grades1To6 ? (1.0, 6.0) : (0.0, 15.0);
|
||||||
|
return new TargetGradeResult(required, required >= min && required <= max);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -193,4 +193,21 @@ public sealed class ChangeHookCascadeTests
|
|||||||
var call = Assert.Single(calls);
|
var call = Assert.Single(calls);
|
||||||
Assert.Equal((nameof(Documentation), "Delete"), call);
|
Assert.Equal((nameof(Documentation), "Delete"), call);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VorgangRepository_Delete_LoestSaveMitIsDeletedAus()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new VorgangRepository(db);
|
||||||
|
var vorgang = new Vorgang { Title = "Konflikt" };
|
||||||
|
repo.Save(vorgang);
|
||||||
|
var calls = new List<(string EntityType, string Operation, object? Payload)>();
|
||||||
|
db.OnChange = (type, id, op, payload) => calls.Add((type, op, payload));
|
||||||
|
|
||||||
|
repo.Delete(vorgang.Id);
|
||||||
|
|
||||||
|
var call = Assert.Single(calls);
|
||||||
|
Assert.Equal((nameof(Vorgang), "Save"), (call.EntityType, call.Operation));
|
||||||
|
Assert.True(((Vorgang)call.Payload!).IsDeleted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,65 @@ public sealed class DatabaseEncryptionServiceTests
|
|||||||
Assert.Single(reopened.GetCollection<BsonDocument>("t").FindAll());
|
Assert.Single(reopened.GetCollection<BsonDocument>("t").FindAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanOpenAndRead_OhneVorhandeneDatei_GibtFalseZurueck()
|
||||||
|
{
|
||||||
|
using var temp = new TempDatabase();
|
||||||
|
var service = new DatabaseEncryptionService();
|
||||||
|
|
||||||
|
Assert.False(service.CanOpenAndRead(temp.Path, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanOpenAndRead_GueltigeUnverschluesselteDatenbank_GibtTrueZurueck()
|
||||||
|
{
|
||||||
|
using var temp = new TempDatabase();
|
||||||
|
using (var db = new LiteDatabase(temp.Path))
|
||||||
|
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||||
|
|
||||||
|
var service = new DatabaseEncryptionService();
|
||||||
|
|
||||||
|
Assert.True(service.CanOpenAndRead(temp.Path, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanOpenAndRead_GueltigeVerschluesselteDatenbankMitRichtigemPasswort_GibtTrueZurueck()
|
||||||
|
{
|
||||||
|
using var temp = new TempDatabase();
|
||||||
|
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
|
||||||
|
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||||
|
|
||||||
|
var service = new DatabaseEncryptionService();
|
||||||
|
|
||||||
|
Assert.True(service.CanOpenAndRead(temp.Path, "geheim123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanOpenAndRead_FalschesPasswort_GibtFalseZurueck()
|
||||||
|
{
|
||||||
|
using var temp = new TempDatabase();
|
||||||
|
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
|
||||||
|
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||||
|
|
||||||
|
var service = new DatabaseEncryptionService();
|
||||||
|
|
||||||
|
Assert.False(service.CanOpenAndRead(temp.Path, "falsch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanOpenAndRead_BeschaedigteDatei_GibtFalseZurueck()
|
||||||
|
{
|
||||||
|
using var temp = new TempDatabase();
|
||||||
|
// Eine kurze Textdatei initialisiert LiteDB stillschweigend als neue, leere Datenbank
|
||||||
|
// (kleiner als eine Seite) — erst genug Datenmüll jenseits der Seitengröße lässt das
|
||||||
|
// Datei-Layout tatsächlich fehlschlagen und simuliert eine echte Beschädigung
|
||||||
|
// (z.B. durch einen vollen Datenträger während der Backup-Kopie).
|
||||||
|
File.WriteAllBytes(temp.Path, Enumerable.Repeat((byte)0x42, 32_768).ToArray());
|
||||||
|
var service = new DatabaseEncryptionService();
|
||||||
|
|
||||||
|
Assert.False(service.CanOpenAndRead(temp.Path, null));
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class TempDatabase : IDisposable
|
private sealed class TempDatabase : IDisposable
|
||||||
{
|
{
|
||||||
private readonly string _directory = System.IO.Path.Combine(
|
private readonly string _directory = System.IO.Path.Combine(
|
||||||
|
|||||||
@@ -715,6 +715,67 @@ public sealed class RepositoryTests
|
|||||||
Assert.Null(storage.OpenRead(attachmentId));
|
Assert.Null(storage.OpenRead(attachmentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── VorgangRepository ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VorgangRepository_Save_SetztUpdatedAt()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new VorgangRepository(db);
|
||||||
|
var vorgang = new Vorgang { Title = "Schuleschwänzen Max" };
|
||||||
|
|
||||||
|
repo.Save(vorgang);
|
||||||
|
|
||||||
|
var raw = db.Vorgaenge.FindById(vorgang.Id);
|
||||||
|
Assert.NotNull(raw);
|
||||||
|
Assert.True((DateTime.UtcNow - raw!.UpdatedAt).TotalSeconds < 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VorgangRepository_GetByStudent_FindetVorgaengeMitMehrerenSchuelern()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new VorgangRepository(db);
|
||||||
|
var ada = Guid.NewGuid();
|
||||||
|
var ben = Guid.NewGuid();
|
||||||
|
var other = Guid.NewGuid();
|
||||||
|
var shared = new Vorgang { Title = "Konflikt", StudentIds = [ada, ben] };
|
||||||
|
var unrelated = new Vorgang { Title = "Anderes", StudentIds = [other] };
|
||||||
|
repo.Save(shared);
|
||||||
|
repo.Save(unrelated);
|
||||||
|
|
||||||
|
Assert.Equal([shared.Id], repo.GetByStudent(ada).Select(v => v.Id));
|
||||||
|
Assert.Equal([shared.Id], repo.GetByStudent(ben).Select(v => v.Id));
|
||||||
|
Assert.Empty(repo.GetByStudent(Guid.NewGuid()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VorgangRepository_Delete_MarkiertNurAlsGeloescht()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new VorgangRepository(db);
|
||||||
|
var vorgang = new Vorgang { Title = "Hausaufgaben fehlen" };
|
||||||
|
repo.Save(vorgang);
|
||||||
|
|
||||||
|
repo.Delete(vorgang.Id);
|
||||||
|
|
||||||
|
Assert.Empty(repo.GetAll());
|
||||||
|
Assert.Null(repo.GetById(vorgang.Id));
|
||||||
|
var raw = db.Vorgaenge.FindById(vorgang.Id);
|
||||||
|
Assert.NotNull(raw);
|
||||||
|
Assert.True(raw!.IsDeleted);
|
||||||
|
Assert.NotNull(raw.DeletedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VorgangRepository_GetById_LiefertNullFuerUnbekannteId()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var repo = new VorgangRepository(db);
|
||||||
|
|
||||||
|
Assert.Null(repo.GetById(Guid.NewGuid()));
|
||||||
|
}
|
||||||
|
|
||||||
// ── LessonRepository ──────────────────────────────────────────────────────
|
// ── LessonRepository ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -37,6 +37,29 @@ public class DatabaseEncryptionService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Öffnet die Datei probeweise und liest die Sammlungsliste, um eine strukturell gültige
|
||||||
|
/// (nicht nur vorhandene) LiteDB-Datei zu bestätigen — z.B. direkt nach dem Anlegen eines
|
||||||
|
/// Backups, das durch einen vollen Datenträger oder einen vorzeitig entfernten USB-Stick
|
||||||
|
/// beschädigt worden sein könnte. Bewusst breites Catch: jede Art von Fehlschlag beim Öffnen
|
||||||
|
/// oder Lesen bedeutet hier "Backup nicht verifizierbar", nicht nur ein falsches Passwort.
|
||||||
|
public bool CanOpenAndRead(string dbPath, string? password)
|
||||||
|
{
|
||||||
|
if (!File.Exists(dbPath)) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var connection = password is null
|
||||||
|
? new ConnectionString(dbPath)
|
||||||
|
: new ConnectionString(dbPath) { Password = password };
|
||||||
|
using var db = new LiteDatabase(connection);
|
||||||
|
_ = db.GetCollectionNames().ToList();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das
|
/// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das
|
||||||
/// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere
|
/// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere
|
||||||
/// Verbindung (z.B. der laufende <see cref="LiteDbContext"/>) die Datei offen hält.
|
/// Verbindung (z.B. der laufende <see cref="LiteDbContext"/>) die Datei offen hält.
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ public class LiteDbContext : IDisposable
|
|||||||
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
|
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
|
||||||
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
|
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
|
||||||
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
|
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
|
||||||
|
public ILiteCollection<Vorgang> Vorgaenge => _db.GetCollection<Vorgang>("vorgaenge");
|
||||||
public ILiteStorage<string> Attachments => _db.GetStorage<string>("attachments", "attachments_chunks");
|
public ILiteStorage<string> Attachments => _db.GetStorage<string>("attachments", "attachments_chunks");
|
||||||
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
|
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
|
||||||
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries");
|
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries");
|
||||||
@@ -71,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) =>
|
||||||
@@ -431,6 +446,37 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class VorgangRepository(LiteDbContext db) : IVorgangRepository
|
||||||
|
{
|
||||||
|
public List<Vorgang> GetAll() =>
|
||||||
|
db.Vorgaenge.Find(v => !v.IsDeleted).OrderByDescending(v => v.UpdatedAt).ToList();
|
||||||
|
public List<Vorgang> GetByStudent(Guid studentId) =>
|
||||||
|
GetAll().Where(v => v.StudentIds.Contains(studentId)).ToList();
|
||||||
|
public Vorgang? GetById(Guid id)
|
||||||
|
{
|
||||||
|
var vorgang = db.Vorgaenge.FindById(id);
|
||||||
|
return vorgang is null || vorgang.IsDeleted ? null : vorgang;
|
||||||
|
}
|
||||||
|
public void Save(Vorgang vorgang)
|
||||||
|
{
|
||||||
|
vorgang.UpdatedAt = DateTime.UtcNow;
|
||||||
|
db.Vorgaenge.Upsert(vorgang);
|
||||||
|
db.OnChange?.Invoke(nameof(Vorgang), vorgang.Id.ToString(), "Save", vorgang);
|
||||||
|
}
|
||||||
|
public void Delete(Guid id)
|
||||||
|
{
|
||||||
|
var vorgang = db.Vorgaenge.FindById(id);
|
||||||
|
if (vorgang is null) return;
|
||||||
|
vorgang.IsDeleted = true;
|
||||||
|
vorgang.DeletedAt = DateTime.UtcNow;
|
||||||
|
db.Vorgaenge.Update(vorgang);
|
||||||
|
// Weiches Löschen ist inhaltlich eine Änderung, kein Entfernen -> "Save", damit ein
|
||||||
|
// anwendendes Gerät den IsDeleted-Stand einfach übernimmt statt den Datensatz zu entfernen
|
||||||
|
// (gleiches Muster wie DocumentationRepository.Delete).
|
||||||
|
db.OnChange?.Invoke(nameof(Vorgang), id.ToString(), "Save", vorgang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
|
public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
|
||||||
{
|
{
|
||||||
public List<WorkTask> GetByStatus(WorkTaskStatus s) =>
|
public List<WorkTask> GetByStatus(WorkTaskStatus s) =>
|
||||||
@@ -492,6 +538,7 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
|
|||||||
public List<ParticipationSession> GetByGroup(Guid groupId) =>
|
public List<ParticipationSession> GetByGroup(Guid groupId) =>
|
||||||
db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList();
|
db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList();
|
||||||
public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id);
|
public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id);
|
||||||
|
public List<ParticipationSession> GetAll() => db.ParticipationSessions.FindAll().ToList();
|
||||||
public void Save(ParticipationSession s)
|
public void Save(ParticipationSession s)
|
||||||
{
|
{
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
||||||
@@ -853,6 +900,16 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
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) =>
|
||||||
|
|||||||
@@ -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,130 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,4 +1,5 @@
|
|||||||
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 Xunit;
|
using Xunit;
|
||||||
@@ -7,6 +8,69 @@ 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]
|
[Fact]
|
||||||
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
|
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
|
||||||
{
|
{
|
||||||
@@ -184,7 +248,8 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20));
|
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20,
|
||||||
|
termStart: new DateOnly(2026, 8, 1)));
|
||||||
|
|
||||||
Assert.True(row.HasYearSummary);
|
Assert.True(row.HasYearSummary);
|
||||||
Assert.Equal(2, row.YearAbsenceDayCount);
|
Assert.Equal(2, row.YearAbsenceDayCount);
|
||||||
@@ -192,9 +257,29 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
Assert.Equal(10, row.YearAbsenceRatePercent);
|
Assert.Equal(10, row.YearAbsenceRatePercent);
|
||||||
Assert.Contains("10 %", row.YearSummaryLabel);
|
Assert.Contains("10 %", row.YearSummaryLabel);
|
||||||
Assert.Contains("2 von 20", row.YearSummaryTooltip);
|
Assert.Contains("2 von 20", row.YearSummaryTooltip);
|
||||||
|
Assert.Contains("seit 01.08.", row.YearSummaryTooltip);
|
||||||
Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip);
|
Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RosterBuild_ZeigtAbgezogeneFerientageImTooltip()
|
||||||
|
{
|
||||||
|
// Nutzer-Nachfrage: die Herkunft des Nenners soll sich ohne Blick in die WebUntis-Ferienliste
|
||||||
|
// direkt in der App nachvollziehen lassen, nachdem er zeitweise Ferientage mitzählte.
|
||||||
|
var students = new[] { Student(1001, "Ada Müller") };
|
||||||
|
var yearAbsences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 2, 90,
|
||||||
|
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||||
|
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 14, holidayWeekdaysExcluded: 7,
|
||||||
|
termStart: new DateOnly(2026, 8, 1)));
|
||||||
|
|
||||||
|
Assert.Contains("7 Ferientage abgezogen", row.YearSummaryTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
|
public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
|
||||||
{
|
{
|
||||||
@@ -207,6 +292,198 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
Assert.Null(row.YearSummaryTooltip);
|
Assert.Null(row.YearSummaryTooltip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fehlquote behandelt Verspätung nicht mehr wie einen ganzen Fehltag (Nutzer-Feedback) ────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RosterBuild_ReineVerspaetungZaehltNichtInDieFehlquote()
|
||||||
|
{
|
||||||
|
// Nutzer-Feedback: "10× 5 Min. verspätet" wurde bisher exakt wie "10× unentschuldigt
|
||||||
|
// gefehlt" behandelt. Reine (nicht unentschuldigte) Verspätung darf die Quote nicht mehr
|
||||||
|
// treiben, taucht aber weiterhin als eigene Zahl auf.
|
||||||
|
var students = new[] { Student(1001, "Ada Müller") };
|
||||||
|
var yearAbsences = Enumerable.Range(0, 3)
|
||||||
|
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10 + i), "Müller Ada", 1001, 0, 5,
|
||||||
|
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||||
|
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
|
||||||
|
|
||||||
|
Assert.Equal(0, row.YearAbsenceDayCount);
|
||||||
|
Assert.Equal(3, row.YearLateDayCount);
|
||||||
|
Assert.Equal(0, row.YearAbsenceRatePercent);
|
||||||
|
Assert.True(row.HasYearSummary);
|
||||||
|
Assert.Contains("verspätet", row.YearSummaryLabel);
|
||||||
|
Assert.DoesNotContain("%", row.YearSummaryLabel);
|
||||||
|
Assert.Contains("3 Tage verspätet", row.YearSummaryTooltip);
|
||||||
|
Assert.Contains("zählt nicht zur Quote", row.YearSummaryTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RosterBuild_UnentschuldigtUndVerspaetetZaehltAlsUnentschuldigtNichtAlsVerspaetung()
|
||||||
|
{
|
||||||
|
// Wer unentschuldigt UND verspätet ist, bleibt der schwerwiegendere Fall (dieselbe
|
||||||
|
// Rangfolge wie AttentionRank/StatusKind/BuildTrend: Unentschuldigt vor Verspätet) — zählt
|
||||||
|
// deshalb weiter voll in die Quote statt in YearLateDayCount zu verschwinden.
|
||||||
|
var students = new[] { Student(1001, "Ada Müller") };
|
||||||
|
var yearAbsences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 0, 60,
|
||||||
|
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||||
|
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
|
||||||
|
|
||||||
|
Assert.Equal(1, row.YearAbsenceDayCount);
|
||||||
|
Assert.Equal(1, row.YearUnexcusedDayCount);
|
||||||
|
Assert.Equal(0, row.YearLateDayCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PatternScore_GewichtetUnentschuldigtDeutlichStaerkerAlsVerspaetung()
|
||||||
|
{
|
||||||
|
// Kernfall aus dem Nutzer-Feedback: 10× 5 Minuten verspätet darf nicht denselben Score
|
||||||
|
// ergeben wie 10× ganztägig unentschuldigt gefehlt.
|
||||||
|
var lateStudent = Student(1001, "Verpennt Timo");
|
||||||
|
var truantStudent = Student(1002, "Geschwaenzt Cem");
|
||||||
|
var yearAbsences = Enumerable.Range(0, 10)
|
||||||
|
.SelectMany(i => new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Verpennt Timo", 1001, 0, 5,
|
||||||
|
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Geschwaenzt Cem", 1002, 6, 270,
|
||||||
|
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var roster = ClassTeacherRosterRow.Build([lateStudent, truantStudent], [], [],
|
||||||
|
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1));
|
||||||
|
|
||||||
|
var timo = Assert.Single(roster, r => r.StudentName == "Verpennt Timo");
|
||||||
|
var cem = Assert.Single(roster, r => r.StudentName == "Geschwaenzt Cem");
|
||||||
|
Assert.Equal(5.0, timo.PatternScore);
|
||||||
|
Assert.Equal(30.0, cem.PatternScore);
|
||||||
|
Assert.True(cem.PatternScore > timo.PatternScore * 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PatternScore_ErfasstMusterAuchWennHeuteAnwesend()
|
||||||
|
{
|
||||||
|
// Nutzer-Feedback: ein/e Schüler*in, die/der heute da ist, aber zuvor mehrfach unentschuldigt
|
||||||
|
// fehlte, soll nicht spurlos aus der Musterauswertung verschwinden — anders als bei der
|
||||||
|
// reinen "heute auffällig"-Sortierung (HasAbsenceToday).
|
||||||
|
var student = Student(1001, "Ada Müller");
|
||||||
|
var yearAbsences = Enumerable.Range(0, 5)
|
||||||
|
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10 + i), "Müller Ada", 1001, 6, 270,
|
||||||
|
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// Kein todayAbsences-Eintrag: heute anwesend.
|
||||||
|
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||||
|
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
|
||||||
|
|
||||||
|
Assert.False(row.HasAbsenceToday);
|
||||||
|
Assert.True(row.PatternScore > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PatternScore_IstNullOhneJahresdaten()
|
||||||
|
{
|
||||||
|
var students = new[] { Student(1001, "Ada Müller") };
|
||||||
|
|
||||||
|
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
|
||||||
|
|
||||||
|
Assert.Equal(0.0, row.PatternScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Klassenbucheinträge anderer Lehrkräfte fließen ebenfalls in den Score ein ────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PatternScore_NegativeKlassenbucheintraegeErhoehenDenScoreAuchOhneFehlzeiten()
|
||||||
|
{
|
||||||
|
var student = Student(1001, "Ada Müller");
|
||||||
|
var yearRegister = Enumerable.Range(0, 4)
|
||||||
|
.Select(i => new UntisForeignClassRegisterEventDto("6a", 20260810 + i, "Deu", "Müller Ada", "test",
|
||||||
|
"Unterrichtsstörung", "Negativ", "Gestört"))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||||
|
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||||
|
yearClassRegisterEntries: yearRegister));
|
||||||
|
|
||||||
|
Assert.Equal(4, row.YearNegativeClassRegisterCount);
|
||||||
|
Assert.Equal(4.0, row.PatternScore);
|
||||||
|
Assert.True(row.HasYearSummary);
|
||||||
|
Assert.Contains("negative Klassenbucheinträge", row.YearSummaryLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PatternScore_HausaufgabenUndMitarbeitSchluesselwoerterZaehlenStaerkerAlsPlainNegativ()
|
||||||
|
{
|
||||||
|
var withKeyword = Student(1001, "Keyword Kim");
|
||||||
|
var plain = Student(1002, "Plain Priya");
|
||||||
|
var yearRegister = new[]
|
||||||
|
{
|
||||||
|
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Keyword Kim", "test",
|
||||||
|
"Hausaufgaben fehlen", "Negativ", "HA nicht dabei"),
|
||||||
|
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Plain Priya", "test",
|
||||||
|
"Sonstiges", "Negativ", "Gestört"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var roster = ClassTeacherRosterRow.Build([withKeyword, plain], [], [],
|
||||||
|
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||||
|
yearClassRegisterEntries: yearRegister);
|
||||||
|
|
||||||
|
var kim = Assert.Single(roster, r => r.StudentName == "Keyword Kim");
|
||||||
|
var priya = Assert.Single(roster, r => r.StudentName == "Plain Priya");
|
||||||
|
Assert.True(kim.PatternScore > priya.PatternScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PatternScore_SuspendiertSchiesstDenScoreWeitUeberVieleAndereEintraegeHinaus()
|
||||||
|
{
|
||||||
|
var suspended = Student(1001, "Suspendiert Sam");
|
||||||
|
var chronic = Student(1002, "Viele Vera");
|
||||||
|
var yearRegister = new List<UntisForeignClassRegisterEventDto>
|
||||||
|
{
|
||||||
|
new("6a", 20260810, "Deu", "Suspendiert Sam", "test", "Ordnungsmaßnahme", "Negativ",
|
||||||
|
"Vom Unterricht suspendiert"),
|
||||||
|
};
|
||||||
|
yearRegister.AddRange(Enumerable.Range(0, 6)
|
||||||
|
.Select(i => new UntisForeignClassRegisterEventDto("6a", 20260810 + i, "Deu", "Viele Vera", "test",
|
||||||
|
"Sonstiges", "Negativ", "Gestört")));
|
||||||
|
|
||||||
|
var roster = ClassTeacherRosterRow.Build([suspended, chronic], [], [],
|
||||||
|
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||||
|
yearClassRegisterEntries: yearRegister);
|
||||||
|
|
||||||
|
var sam = Assert.Single(roster, r => r.StudentName == "Suspendiert Sam");
|
||||||
|
var vera = Assert.Single(roster, r => r.StudentName == "Viele Vera");
|
||||||
|
Assert.True(sam.HasSuspensionEntry);
|
||||||
|
Assert.True(sam.PatternScore > vera.PatternScore);
|
||||||
|
Assert.Contains("Suspendierung vermerkt", sam.YearSummaryTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PatternScore_OhneEigeneJahresklassenbuchlisteFaelltAufDenUebergebenenParameterZurueck()
|
||||||
|
{
|
||||||
|
// Rückwärtskompatibilität: Aufrufer, die (wie die bestehenden Tests) nur den ursprünglichen
|
||||||
|
// recentClassRegisterEntries-Parameter befüllen, sollen trotzdem einen Score aus diesen
|
||||||
|
// Einträgen bekommen statt stillschweigend leer auszugehen.
|
||||||
|
var student = Student(1001, "Ada Müller");
|
||||||
|
var entries = new[]
|
||||||
|
{
|
||||||
|
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Müller Ada", "test",
|
||||||
|
"Sonstiges", "Negativ", "Gestört"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], entries,
|
||||||
|
new DateOnly(2026, 8, 26)));
|
||||||
|
|
||||||
|
Assert.True(row.PatternScore > 0);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
|
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
|
||||||
{
|
{
|
||||||
@@ -264,6 +541,79 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
Assert.Equal(row.StatusText, row.StatusTextWithGlyph);
|
Assert.Equal(row.StatusText, row.StatusTextWithGlyph);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Nenner der Jahresfehlquote: echter WebUntis-Ferienkalender statt grober Werktagszählung ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CountSchoolWeekdays_SchliesstFerientageAus()
|
||||||
|
{
|
||||||
|
// 1.-31.08.2026 hat 21 Werktage; die (fiktiven) Sommerferien bis 11.08. nehmen davon 7
|
||||||
|
// Werktage weg (Mo 03. - Fr 07., Mo 10. - Di 11.) - Nutzer-Feedback: Schüler, die seit
|
||||||
|
// Unterrichtsbeginn jeden Tag fehlten, kamen wegen dieser mitgezählten Ferienzeit nur auf
|
||||||
|
// ~57 % statt ~100 % Fehlquote.
|
||||||
|
var holidays = new[] { new CachedUntisHoliday("Sommerferien", new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 11)) };
|
||||||
|
|
||||||
|
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
|
||||||
|
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), holidays);
|
||||||
|
|
||||||
|
Assert.Equal(14, schoolDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CountSchoolWeekdays_OhneFerienVerhaeltSichWieReineWerktagszaehlung()
|
||||||
|
{
|
||||||
|
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
|
||||||
|
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), []);
|
||||||
|
|
||||||
|
Assert.Equal(21, schoolDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EstimateTermStart_NutztFruehestenFehlzeitEintragDerGanzenKlasse()
|
||||||
|
{
|
||||||
|
// Realer Befund (Nutzer-Feedback): WebUntis' getHolidays liefert für Bremen nie einen
|
||||||
|
// Sommerferien-Eintrag (über 11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-
|
||||||
|
// Zeitraum dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei
|
||||||
|
// Schuljahres-Datensätzen liegen (1.8./31.7.-Grenze), nicht "in" einem davon. Für den
|
||||||
|
// Schuljahresbeginn bleibt deshalb weiterhin der früheste Fehlzeiten-Eintrag der Klasse
|
||||||
|
// nötig statt CountSchoolWeekdays allein.
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 13), "Fehlt Cem", 1001, 1, 45,
|
||||||
|
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 19), "Andere Ada", 1002, 1, 45,
|
||||||
|
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart(absences, new DateOnly(2026, 8, 1));
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(2026, 8, 13), termStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EstimateTermStart_FaelltOhneFehlzeitenAufSchuljahresbeginnZurueck()
|
||||||
|
{
|
||||||
|
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart([], new DateOnly(2026, 8, 1));
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(2026, 8, 1), termStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CountSchoolWeekdays_AbEchtemTerminstartOhneSommerferienEintragStimmtMitBeobachtungUeberein()
|
||||||
|
{
|
||||||
|
// Regressionstest für den konkret gemeldeten Fall: 13 tatsächliche Schultage seit
|
||||||
|
// Unterrichtsbeginn (13.08.2026, ein Donnerstag) bis 31.08.2026, keine WebUntis-Ferien im
|
||||||
|
// Bereich (die nächste, "Herbstferien", liegt erst im Oktober) - Kombination aus
|
||||||
|
// EstimateTermStart (Sommerferien-Lücke) und CountSchoolWeekdays (übrige Ferien) muss auf
|
||||||
|
// die vom Nutzer nachgezählte Zahl kommen, nicht auf die volle Werktagszahl ab 1.8. (21).
|
||||||
|
var termStart = new DateOnly(2026, 8, 13);
|
||||||
|
var noHolidaysInRange = Array.Empty<CachedUntisHoliday>();
|
||||||
|
|
||||||
|
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
|
||||||
|
termStart, new DateOnly(2026, 8, 31), noHolidaysInRange);
|
||||||
|
|
||||||
|
Assert.Equal(13, schoolDays);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
|
// ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -290,6 +640,87 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
Assert.False(rows[1].IsOverdue);
|
Assert.False(rows[1].IsOverdue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenExcuseBuild_IgnoriertUnentschuldigteVerspaetungen()
|
||||||
|
{
|
||||||
|
// Verspätungen sind i.d.R. nicht entschuldigungsfähig - "unentschuldigt" darf hier keine
|
||||||
|
// Erinnerung an eine fehlende Entschuldigung auslösen (Nutzer-Feedback).
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 0, 15,
|
||||||
|
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1003, 1, 45,
|
||||||
|
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
|
||||||
|
|
||||||
|
Assert.Equal(["Cem Fehlt"], rows.Select(r => r.StudentName));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Verspätungsmuster: Elterngespräch-Eskalation (Nutzer-Feedback) ───────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectLatePatterns_EskaliertBeiHoherJahressummeMitWiedervorlageOption()
|
||||||
|
{
|
||||||
|
var absences = Enumerable.Range(0, 5)
|
||||||
|
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Spaet Timo", 1001, 0, 10,
|
||||||
|
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
|
||||||
|
.ToArray();
|
||||||
|
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
|
||||||
|
|
||||||
|
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
|
||||||
|
absences, names, sevenDayStart: new DateOnly(2026, 8, 31));
|
||||||
|
|
||||||
|
var notice = Assert.Single(notices);
|
||||||
|
Assert.Equal("Timo Spät", notice.StudentName);
|
||||||
|
Assert.Contains("5 Verspätungen seit Schuljahresbeginn", notice.Message);
|
||||||
|
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
|
||||||
|
Assert.True(notice.CanCreateReminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectLatePatterns_KurzfristigesClusterBleibtHinweisOhneAktion()
|
||||||
|
{
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Spaet Timo", 1001, 0, 10,
|
||||||
|
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Spaet Timo", 1001, 0, 10,
|
||||||
|
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||||
|
};
|
||||||
|
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
|
||||||
|
|
||||||
|
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
|
||||||
|
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
|
||||||
|
|
||||||
|
var notice = Assert.Single(notices);
|
||||||
|
Assert.Equal("2-mal verspätet in 7 Tagen", notice.Message);
|
||||||
|
Assert.Equal(ClassTeacherStatusKind.Warning, notice.Kind);
|
||||||
|
Assert.False(notice.CanCreateReminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectLatePatterns_UnentschuldigteFehltageGehenVorVerspaetungseskalation()
|
||||||
|
{
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1001, 1, 45,
|
||||||
|
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Fehlt Cem", 1001, 1, 45,
|
||||||
|
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
};
|
||||||
|
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Fehlt Cem")] = "Cem Fehlt" };
|
||||||
|
|
||||||
|
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
|
||||||
|
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
|
||||||
|
|
||||||
|
var notice = Assert.Single(notices);
|
||||||
|
Assert.Contains("unentschuldigte Fehltage", notice.Message);
|
||||||
|
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
|
||||||
|
Assert.False(notice.CanCreateReminder);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ──────────────────
|
// ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ──────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -409,4 +840,325 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
|
|
||||||
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
|
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
|
||||||
new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
|
new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
|
||||||
|
|
||||||
|
// ── Klassenbuch-Tab als Dokumentations-Hub: eigene Dokumentation neben WebUntis ─────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FilterOwnDocumentation_ZeigtNurEintraegeZugeordneterSchuelerImZeitraum()
|
||||||
|
{
|
||||||
|
var ada = Guid.NewGuid();
|
||||||
|
var fremd = Guid.NewGuid();
|
||||||
|
var rosterMatches = new List<(Guid StudentId, string DisplayName)> { (ada, "Ada Müller") };
|
||||||
|
var docs = new List<Documentation>
|
||||||
|
{
|
||||||
|
new() { StudentId = ada, Date = new DateOnly(2026, 8, 20), Title = "Im Zeitraum" },
|
||||||
|
new() { StudentId = ada, Date = new DateOnly(2026, 7, 1), Title = "Vor dem Zeitraum" },
|
||||||
|
new() { StudentId = fremd, Date = new DateOnly(2026, 8, 20), Title = "Anderer Schüler" },
|
||||||
|
new() { StudentId = ada, Date = new DateOnly(2026, 8, 21), Title = "Gelöscht", IsDeleted = true },
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches,
|
||||||
|
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "");
|
||||||
|
|
||||||
|
Assert.Equal(["Im Zeitraum"], result.Select(d => d.Title));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FilterOwnDocumentation_SortiertEntwuerfeVorNeuestenZuerst()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var rosterMatches = new List<(Guid StudentId, string DisplayName)> { (id, "Ada Müller") };
|
||||||
|
var docs = new List<Documentation>
|
||||||
|
{
|
||||||
|
new() { StudentId = id, Date = new DateOnly(2026, 8, 25), Title = "Neu, fertig" },
|
||||||
|
new() { StudentId = id, Date = new DateOnly(2026, 8, 10), Title = "Alt, Entwurf", IsDraft = true },
|
||||||
|
new() { StudentId = id, Date = new DateOnly(2026, 8, 20), Title = "Mittel, fertig" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches,
|
||||||
|
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "");
|
||||||
|
|
||||||
|
Assert.Equal(["Alt, Entwurf", "Neu, fertig", "Mittel, fertig"], result.Select(d => d.Title));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FilterOwnDocumentation_SchuelerfilterErkenntVertauschteReihenfolge()
|
||||||
|
{
|
||||||
|
// Derselbe Namensabgleich wie bei den WebUntis-Klassenbuchzeilen (siehe
|
||||||
|
// RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge) - der Filter kommt aus der
|
||||||
|
// Übersicht als "Nachname Vorname" oder "Vorname Nachname" je nach Bericht.
|
||||||
|
var ada = Guid.NewGuid();
|
||||||
|
var ben = Guid.NewGuid();
|
||||||
|
var rosterMatches = new List<(Guid StudentId, string DisplayName)>
|
||||||
|
{
|
||||||
|
(ada, "Ada Müller"), (ben, "Ben Schmidt"),
|
||||||
|
};
|
||||||
|
var docs = new List<Documentation>
|
||||||
|
{
|
||||||
|
new() { StudentId = ada, Date = new DateOnly(2026, 8, 20), Title = "Ada" },
|
||||||
|
new() { StudentId = ben, Date = new DateOnly(2026, 8, 20), Title = "Ben" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches,
|
||||||
|
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "Müller Ada");
|
||||||
|
|
||||||
|
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 ───────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildClassRegisterSnapshot_KopiertAlleFelderDerZeile()
|
||||||
|
{
|
||||||
|
var row = new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben",
|
||||||
|
"mueller", "Fehlende HA", "Negativ", "Buch vergessen");
|
||||||
|
|
||||||
|
var snapshot = ClassTeacherDetailsViewModel.BuildClassRegisterSnapshot(row);
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(2026, 8, 20), snapshot.Date);
|
||||||
|
Assert.Equal("Schmidt Ben", snapshot.StudentName);
|
||||||
|
Assert.Equal("Deu", snapshot.Subject);
|
||||||
|
Assert.Equal("mueller", snapshot.TeacherUsername);
|
||||||
|
Assert.Equal("Fehlende HA", snapshot.CategoryName);
|
||||||
|
Assert.Equal("Negativ", snapshot.CategoryGroup);
|
||||||
|
Assert.Equal("Buch vergessen", snapshot.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FilterAvailableDocumentation_ZeigtNurUnverknuepfteDokumentationDerVorgangsSchueler()
|
||||||
|
{
|
||||||
|
var ada = Guid.NewGuid();
|
||||||
|
var ben = Guid.NewGuid();
|
||||||
|
var fremd = Guid.NewGuid();
|
||||||
|
var linked = new Documentation { StudentId = ada, Title = "Schon verknüpft" };
|
||||||
|
var unlinked = new Documentation { StudentId = ada, Title = "Noch offen" };
|
||||||
|
var otherStudentInCase = new Documentation { StudentId = ben, Title = "Ben, offen" };
|
||||||
|
var unrelated = new Documentation { StudentId = fremd, Title = "Anderer Schüler" };
|
||||||
|
var vorgang = new Vorgang { StudentIds = [ada, ben], DocumentationIds = [linked.Id] };
|
||||||
|
var allDocs = new List<Documentation> { linked, unlinked, otherStudentInCase, unrelated };
|
||||||
|
|
||||||
|
var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation(allDocs, vorgang);
|
||||||
|
|
||||||
|
Assert.Equal(["Noch offen", "Ben, offen"], result.Select(d => d.Title));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FilterAvailableDocumentation_LeerOhneUnverknuepfteEintraege()
|
||||||
|
{
|
||||||
|
var ada = Guid.NewGuid();
|
||||||
|
var doc = new Documentation { StudentId = ada, Title = "Verknüpft" };
|
||||||
|
var vorgang = new Vorgang { StudentIds = [ada], DocumentationIds = [doc.Id] };
|
||||||
|
|
||||||
|
var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation([doc], vorgang);
|
||||||
|
|
||||||
|
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,7 +1,6 @@
|
|||||||
using System.IO.Compression;
|
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
|
||||||
using LehrerApp.Desktop.ViewModels.Students;
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using LehrerApp.Templating;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
@@ -9,16 +8,12 @@ namespace LehrerApp.Desktop.Tests;
|
|||||||
public sealed class CreateLetterDialogViewModelTests : IDisposable
|
public sealed class CreateLetterDialogViewModelTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}");
|
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory);
|
public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OhneVorlage_KannKeinenBriefErzeugen()
|
public void OhneVorlage_KannKeinenBriefErzeugen()
|
||||||
{
|
{
|
||||||
var student = StudentWithContact("Sehr geehrte Frau Muster,");
|
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), new TemplateStore(_directory));
|
||||||
|
|
||||||
var vm = Build(student, new LetterTemplateService(_directory));
|
|
||||||
|
|
||||||
Assert.False(vm.CanGenerate);
|
Assert.False(vm.CanGenerate);
|
||||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase));
|
Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
@@ -26,83 +21,63 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void VerwendeteBriefanredeFehlt_BlockiertErzeugung()
|
public void VerwendeteBriefanredeFehlt_BlockiertErzeugung()
|
||||||
{
|
{
|
||||||
var service = ServiceWithTemplate("Letter.Salutation");
|
var vm = Build(StudentWithContact(null), StoreWithTemplate(
|
||||||
var vm = Build(StudentWithContact(null), service);
|
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
|
||||||
|
|
||||||
Assert.False(vm.CanGenerate);
|
Assert.False(vm.CanGenerate);
|
||||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Briefanrede"));
|
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void VollstaendigeDaten_ErzeugenEditierbareDocxKopie()
|
public void VollstaendigeDaten_ErzeugenFlachesPdf()
|
||||||
{
|
{
|
||||||
var service = ServiceWithTemplate("Letter.Salutation", "Student.FirstName", "CurrentDate");
|
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), service);
|
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||||
var output = Path.Combine(_directory, "Brief.docx");
|
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
|
||||||
|
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||||
|
vm.LetterText = "Dies ist der Inhalt.";
|
||||||
|
var output = Path.Combine(_directory, "Brief.pdf");
|
||||||
|
|
||||||
var generated = vm.Generate(output);
|
Assert.True(vm.Generate(output));
|
||||||
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||||
Assert.True(generated);
|
|
||||||
Assert.True(File.Exists(output));
|
|
||||||
using var archive = ZipFile.OpenRead(output);
|
|
||||||
using var reader = new StreamReader(archive.GetEntry("word/document.xml")!.Open());
|
|
||||||
var xml = reader.ReadToEnd();
|
|
||||||
Assert.Contains("Sehr geehrte Frau Muster,", xml);
|
|
||||||
Assert.Contains("Lena", xml);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void KontaktBearbeiten_SpeichertExpliziteBriefanrede()
|
public void KonstanterPflichttext_BenoetigtKeineExterneEingabe()
|
||||||
{
|
{
|
||||||
var vm = new ContactEditDialogViewModel(new Contact { Name = "Frau Muster" });
|
var store = StoreWithTemplate(new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline,
|
||||||
vm.LetterSalutation = "Sehr geehrte Frau Muster,";
|
Required: true, IsConstant: true, ConstantValue: "Fest im Vorlagenpaket"));
|
||||||
|
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||||
|
var output = Path.Combine(_directory, "Konstant.pdf");
|
||||||
|
|
||||||
vm.SaveCommand.Execute(null);
|
Assert.True(vm.CanGenerate);
|
||||||
|
Assert.True(vm.Generate(output));
|
||||||
Assert.Equal("Sehr geehrte Frau Muster,", vm.Result!.LetterSalutation);
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
private CreateLetterDialogViewModel Build(Student student, LetterTemplateService service) =>
|
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
|
||||||
new(student, service, new FakeMemberships([]), new FakeGroups([]));
|
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
|
||||||
|
|
||||||
private LetterTemplateService ServiceWithTemplate(params string[] tags)
|
private TemplateStore StoreWithTemplate(params PlaceholderDefinition[] definitions)
|
||||||
{
|
{
|
||||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.docx");
|
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||||
using (var archive = ZipFile.Open(source, ZipArchiveMode.Create))
|
var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief", Placeholders = [.. definitions] };
|
||||||
|
var lines = new List<string> { "PAGE 210 297 mm" };
|
||||||
|
var y = 20;
|
||||||
|
foreach (var definition in definitions)
|
||||||
{
|
{
|
||||||
var entry = archive.CreateEntry("word/document.xml");
|
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
|
||||||
using var writer = new StreamWriter(entry.Open());
|
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
|
||||||
var controls = string.Join("", tags.Select(tag =>
|
y += 20;
|
||||||
$"<w:sdt><w:sdtPr><w:tag w:val=\"{tag}\"/></w:sdtPr><w:sdtContent>" +
|
|
||||||
"<w:p><w:r><w:t>Platzhalter</w:t></w:r></w:p></w:sdtContent></w:sdt>"));
|
|
||||||
writer.Write("<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">" +
|
|
||||||
$"<w:body>{controls}</w:body></w:document>");
|
|
||||||
}
|
}
|
||||||
var service = new LetterTemplateService(Path.Combine(_directory, $"service-{Guid.NewGuid():N}"));
|
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
|
||||||
service.Import(source, "Elternbrief");
|
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")); store.Import(source); return store;
|
||||||
return service;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Student StudentWithContact(string? salutation) => new()
|
private static Student StudentWithContact(string? salutation) => new()
|
||||||
{
|
{
|
||||||
FirstName = "Lena",
|
FirstName = "Lena", LastName = "Beispiel",
|
||||||
LastName = "Beispiel",
|
Contacts = [new Contact { Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
|
||||||
Contacts =
|
Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt" }],
|
||||||
[
|
|
||||||
new Contact
|
|
||||||
{
|
|
||||||
Name = "Frau Muster",
|
|
||||||
Relation = "Mutter",
|
|
||||||
LetterSalutation = salutation,
|
|
||||||
Street = "Hauptstraße 1",
|
|
||||||
PostalCode = "12345",
|
|
||||||
City = "Musterstadt",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
|
using System.Globalization;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
@@ -19,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(
|
||||||
@@ -59,12 +121,13 @@ public sealed class DashboardViewModelTests
|
|||||||
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
|
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
|
||||||
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)
|
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
|
||||||
|
FakeTimeEntries? timeEntries = null, Func<DateTime>? now = null)
|
||||||
{
|
{
|
||||||
lessons ??= new FakeLessons();
|
lessons ??= new FakeLessons();
|
||||||
lessons.Add(lesson);
|
lessons.Add(lesson);
|
||||||
return new DashboardViewModel(
|
return new DashboardViewModel(
|
||||||
new FakeGroups([group]), new FakeSubjects([]), lessons,
|
new FakeGroups(allGroups ?? [group]), new FakeSubjects([]), lessons,
|
||||||
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
|
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
|
||||||
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
|
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
|
||||||
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
|
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
|
||||||
@@ -72,7 +135,196 @@ 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(), 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
|
||||||
|
/// Klausurwochen-Vorausschau — unabhängig davon, welcher Wochentag "heute" gerade ist.
|
||||||
|
private static DateOnly NextIsoWeekMonday()
|
||||||
|
{
|
||||||
|
var reference = DateTime.Today.AddDays(7);
|
||||||
|
return DateOnly.FromDateTime(
|
||||||
|
ISOWeek.ToDateTime(ISOWeek.GetYear(reference), ISOWeek.GetWeekOfYear(reference), DayOfWeek.Monday));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExamWeekLoads_DreiGeplanteKlausurenInDerselbenWoche_ErzeugtEintrag()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var monday = NextIsoWeekMonday();
|
||||||
|
var exams = new FakeExams([
|
||||||
|
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned },
|
||||||
|
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned },
|
||||||
|
new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned },
|
||||||
|
]);
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
|
||||||
|
|
||||||
|
var entry = Assert.Single(vm.ExamWeekLoads);
|
||||||
|
Assert.Equal(3, entry.ExamCount);
|
||||||
|
Assert.True(vm.ExamLoadCard.EffectiveIsVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExamWeekLoads_WenigeKlausurenProWoche_BleibtLeer()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var monday = NextIsoWeekMonday();
|
||||||
|
var exams = new FakeExams([
|
||||||
|
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned },
|
||||||
|
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned },
|
||||||
|
]);
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
|
||||||
|
|
||||||
|
Assert.Empty(vm.ExamWeekLoads);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExamWeekLoads_BereitsDurchgefuehrteKlausurenZaehlenNicht()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var monday = NextIsoWeekMonday();
|
||||||
|
var exams = new FakeExams([
|
||||||
|
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Conducted },
|
||||||
|
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Graded },
|
||||||
|
new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned },
|
||||||
|
]);
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
|
||||||
|
|
||||||
|
Assert.Empty(vm.ExamWeekLoads);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingTeachingTime_VergangenerUnterrichtstagOhneErfassung_WirdGemeldet()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var pastDay = today.AddDays(-1);
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var periodSchedule = NewPeriodSchedule();
|
||||||
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
|
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
|
||||||
|
|
||||||
|
Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
|
||||||
|
Assert.True(vm.AttentionCard.EffectiveIsVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingTeachingTime_BereitsErfassterTag_WirdNichtGemeldet()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var pastDay = today.AddDays(-1);
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var periodSchedule = NewPeriodSchedule();
|
||||||
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
|
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||||
|
var timeEntries = new FakeTimeEntries();
|
||||||
|
timeEntries.Add(new TimeEntry { Date = pastDay, Category = "Unterricht", DurationMinutes = 45 });
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||||
|
slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingTeachingTime_KomplettAusgefallenerTag_WirdNichtGemeldet()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var pastDay = today.AddDays(-1);
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var periodSchedule = NewPeriodSchedule();
|
||||||
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
|
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||||
|
var substitutions = new FakeSubstitutionEntries();
|
||||||
|
substitutions.Add(new SubstitutionEntry { Date = pastDay, Kind = SubstitutionKind.Cancelled, PeriodNumber = 1 });
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||||
|
slots: slots, periodSchedule: periodSchedule, substitutions: substitutions);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingTeachingTime_SchulferienTagWirdNichtGemeldet()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var pastDay = today.AddDays(-1);
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var periodSchedule = NewPeriodSchedule();
|
||||||
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
|
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||||
|
var schoolHolidays = new FakeSchoolHolidays();
|
||||||
|
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = pastDay, EndDate = pastDay });
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||||
|
slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays);
|
||||||
|
|
||||||
|
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]
|
||||||
|
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(FixedNow);
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var periodSchedule = NewPeriodSchedule();
|
||||||
|
// Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) noch keine 30 Minuten zurück.
|
||||||
|
var futureEnd = TimeOnly.FromDateTime(FixedNow).AddHours(2);
|
||||||
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
|
{ PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]);
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots,
|
||||||
|
periodSchedule: periodSchedule, now: () => FixedNow);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(FixedNow);
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var periodSchedule = NewPeriodSchedule();
|
||||||
|
// Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) mehr als 30 Minuten zurück.
|
||||||
|
var pastEnd = TimeOnly.FromDateTime(FixedNow).AddHours(-2);
|
||||||
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
|
{ PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]);
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots,
|
||||||
|
periodSchedule: periodSchedule, now: () => FixedNow);
|
||||||
|
|
||||||
|
Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -199,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]
|
||||||
@@ -223,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]
|
||||||
@@ -242,6 +493,26 @@ public sealed class DashboardViewModelTests
|
|||||||
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test");
|
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Kalenderauswahl_SortiertKurseDesTagesVorDenAlphabetischenRest()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var selectedDate = today.AddDays(1);
|
||||||
|
var alphabeticallyFirst = new LearningGroup { Name = "10a" };
|
||||||
|
var selectedDayGroup = new LearningGroup { Name = "WAT 10c" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(new Lesson { GroupId = selectedDayGroup.Id, Date = selectedDate });
|
||||||
|
var vm = BuildVm(alphabeticallyFirst,
|
||||||
|
new Lesson { GroupId = alphabeticallyFirst.Id, Date = today }, lessons: lessons,
|
||||||
|
allGroups: [alphabeticallyFirst, selectedDayGroup]);
|
||||||
|
|
||||||
|
vm.SelectCalendarDayCommand.Execute(vm.CalendarDays.Single(d => d.Date == selectedDate));
|
||||||
|
|
||||||
|
Assert.Equal(selectedDayGroup.Id, vm.CurrentGroups[0].GroupId);
|
||||||
|
Assert.True(vm.CurrentGroups[0].IsOnSelectedDay);
|
||||||
|
Assert.Equal(alphabeticallyFirst.Id, vm.CurrentGroups[1].GroupId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp()
|
public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp()
|
||||||
{
|
{
|
||||||
@@ -362,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,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]
|
||||||
@@ -393,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]
|
||||||
@@ -409,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]
|
||||||
@@ -424,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]
|
||||||
@@ -439,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]
|
||||||
@@ -459,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]
|
||||||
@@ -482,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]
|
||||||
@@ -506,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()
|
||||||
{
|
{
|
||||||
@@ -247,6 +261,25 @@ public sealed class DocumentationDialogViewModelTests
|
|||||||
Assert.Equal(groupId, vm.Result.GroupId);
|
Assert.Equal(groupId, vm.Result.GroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GesamteLerngruppe_SaveSpeichertOhneSchuelerbezug()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var wholeGroup = new StudentOption(Guid.Empty, "Gesamte Lerngruppe");
|
||||||
|
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(),
|
||||||
|
[wholeGroup], groupId)
|
||||||
|
{
|
||||||
|
SelectedStudent = wholeGroup, Title = "Klausur planen", TypeName = "Planung / Erinnerung",
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.NotNull(vm.Result);
|
||||||
|
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
|
||||||
|
Assert.Equal(groupId, vm.Result.GroupId);
|
||||||
|
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt()
|
public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class ExamDialogViewModelTests
|
||||||
|
{
|
||||||
|
private static ExamDialogViewModel BuildViewModel(List<Exam> existingExams, string dateText,
|
||||||
|
Exam? editingExam = null)
|
||||||
|
{
|
||||||
|
var vm = new ExamDialogViewModel(new FakeExams(existingExams), new FakeCompetencyDomains(),
|
||||||
|
new FakeGradingKeyTemplates(), new GradingService(), Guid.NewGuid(), null, 9,
|
||||||
|
GradingSystem.Grades1To6, "Chemie", isDifferentiated: false, editingExam, null);
|
||||||
|
vm.DateText = dateText;
|
||||||
|
return vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Exam MakeExam(DateOnly date) => new() { GroupId = Guid.NewGuid(), Title = "x", Date = date };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WeekLoadHint_WenigeKlausurenInDerWoche_BleibtLeer()
|
||||||
|
{
|
||||||
|
var monday = new DateOnly(2026, 3, 16);
|
||||||
|
var existing = new List<Exam> { MakeExam(monday) };
|
||||||
|
|
||||||
|
var vm = BuildViewModel(existing, monday.AddDays(1).ToString("dd.MM.yyyy"));
|
||||||
|
|
||||||
|
Assert.Equal("", vm.WeekLoadHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WeekLoadHint_DritteKlausurInDerWoche_ZeigtHinweis()
|
||||||
|
{
|
||||||
|
var monday = new DateOnly(2026, 3, 16);
|
||||||
|
var existing = new List<Exam> { MakeExam(monday), MakeExam(monday.AddDays(1)) };
|
||||||
|
|
||||||
|
var vm = BuildViewModel(existing, monday.AddDays(2).ToString("dd.MM.yyyy"));
|
||||||
|
|
||||||
|
Assert.Contains("3 Klausuren", vm.WeekLoadHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WeekLoadHint_BearbeiteteKlausurZaehltNichtDoppelt()
|
||||||
|
{
|
||||||
|
var monday = new DateOnly(2026, 3, 16);
|
||||||
|
var editing = MakeExam(monday);
|
||||||
|
var existing = new List<Exam> { editing, MakeExam(monday.AddDays(1)) };
|
||||||
|
|
||||||
|
// Nur die eigene Klausur (editing) + eine weitere -> 2 insgesamt, kein Hinweis.
|
||||||
|
var vm = BuildViewModel(existing, monday.ToString("dd.MM.yyyy"), editing);
|
||||||
|
|
||||||
|
Assert.Equal("", vm.WeekLoadHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WeekLoadHint_UngueltigesDatum_BleibtLeer()
|
||||||
|
{
|
||||||
|
var vm = BuildViewModel([], "keindatum");
|
||||||
|
|
||||||
|
Assert.Equal("", vm.WeekLoadHint);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,20 @@ public sealed class ExamsOverviewViewModelTests
|
|||||||
Assert.Equal("Neue Klausur", vm.Rows[1].Title);
|
Assert.Equal("Neue Klausur", vm.Rows[1].Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LeereListe_BietetAbsprungZuLerngruppen()
|
||||||
|
{
|
||||||
|
var vm = BuildVm([], []);
|
||||||
|
var navigated = false;
|
||||||
|
vm.OnNavigateToGroups = () => navigated = true;
|
||||||
|
|
||||||
|
vm.LoadCommand.Execute(null);
|
||||||
|
vm.GoToGroupsCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.HasNoExams);
|
||||||
|
Assert.True(navigated);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_ParallelkurseWerdenAlsGeschwisterErkannt()
|
public void Load_ParallelkurseWerdenAlsGeschwisterErkannt()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
@@ -170,6 +190,7 @@ public class FakeSessions(List<ParticipationSession> all) : IParticipationSessio
|
|||||||
{
|
{
|
||||||
public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
|
public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
|
||||||
public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
||||||
|
public List<ParticipationSession> GetAll() => all.ToList();
|
||||||
public void Save(ParticipationSession session)
|
public void Save(ParticipationSession session)
|
||||||
{
|
{
|
||||||
all.RemoveAll(s => s.Id == session.Id);
|
all.RemoveAll(s => s.Id == session.Id);
|
||||||
@@ -337,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) =>
|
||||||
@@ -524,6 +546,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 = [];
|
||||||
@@ -564,3 +599,22 @@ 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 Task<bool> ConfirmAsync(string title, string message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
CallCount++;
|
||||||
|
LastTitle = title;
|
||||||
|
LastMessage = message;
|
||||||
|
return Task.FromResult(Response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ public sealed class GlobalSearchViewModelTests
|
|||||||
Assert.Collection(vm.Results,
|
Assert.Collection(vm.Results,
|
||||||
item => Assert.Equal(GlobalSearchAction.NewTask, item.Action),
|
item => Assert.Equal(GlobalSearchAction.NewTask, item.Action),
|
||||||
item => Assert.Equal(GlobalSearchAction.NewReminder, item.Action),
|
item => Assert.Equal(GlobalSearchAction.NewReminder, item.Action),
|
||||||
item => Assert.Equal(GlobalSearchAction.NewStudent, item.Action));
|
item => Assert.Equal(GlobalSearchAction.NewStudent, item.Action),
|
||||||
|
item => Assert.Equal(GlobalSearchAction.NewGroupDocumentation, item.Action));
|
||||||
Assert.Same(vm.Results[0], vm.SelectedResult);
|
Assert.Same(vm.Results[0], vm.SelectedResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +68,36 @@ public sealed class GlobalSearchViewModelTests
|
|||||||
Assert.True(reminder);
|
Assert.True(reminder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Suche_FindetAktiveGruppeUeberFachUndZeigtFachImUntertitel()
|
||||||
|
{
|
||||||
|
var subject = new Subject { Name = "Wirtschaft-Arbeit-Technik", ShortName = "WAT" };
|
||||||
|
var group = new LearningGroup { Name = "10c", SubjectId = subject.Id, SchoolYear = "2026/27", GradeLevel = 10 };
|
||||||
|
var vm = BuildVm(groups: [group], subjects: [subject]);
|
||||||
|
|
||||||
|
vm.Query = "wat";
|
||||||
|
|
||||||
|
var result = Assert.Single(vm.Results, x => x.Kind == GlobalSearchResultKind.Group);
|
||||||
|
Assert.Contains("WAT", result.Subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Suche_UeberspringtArchivierteSchuelerUndGruppenSamtDerenKlausuren()
|
||||||
|
{
|
||||||
|
var archivedGroup = new LearningGroup { Name = "Testkurs Vorjahr", IsActive = false };
|
||||||
|
var archivedStudent = new Student { FirstName = "Test", LastName = "Archiv", IsActive = false };
|
||||||
|
var exam = new Exam { GroupId = archivedGroup.Id, Title = "Testklausur" };
|
||||||
|
var vm = BuildVm([archivedStudent], [archivedGroup], [exam]);
|
||||||
|
|
||||||
|
vm.Query = "Test";
|
||||||
|
|
||||||
|
Assert.DoesNotContain(vm.Results, x => x.Kind is GlobalSearchResultKind.Student
|
||||||
|
or GlobalSearchResultKind.Group or GlobalSearchResultKind.Exam);
|
||||||
|
}
|
||||||
|
|
||||||
private static GlobalSearchViewModel BuildVm(List<Student>? students = null,
|
private static GlobalSearchViewModel BuildVm(List<Student>? students = null,
|
||||||
List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null) =>
|
List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null,
|
||||||
|
List<Subject>? subjects = null) =>
|
||||||
new(new FakeStudents(students ?? []), new FakeGroups(groups ?? []),
|
new(new FakeStudents(students ?? []), new FakeGroups(groups ?? []),
|
||||||
new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks());
|
new FakeSubjects(subjects ?? []), new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ public sealed class GroupDetailViewModelTests
|
|||||||
var grades = new FakeGrades();
|
var grades = new FakeGrades();
|
||||||
|
|
||||||
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
|
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
|
||||||
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeSessions([]), new FakeEntries(), students,
|
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeResults(), new FakeSessions([]), new FakeEntries(), students,
|
||||||
new FakeDocumentation(), tasks, new AttendanceBalanceService(), new SchoolYearService()),
|
new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()),
|
||||||
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
||||||
students, memberships, groups, new FakeCompetencyDomains()),
|
students, memberships, groups, new FakeCompetencyDomains()),
|
||||||
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
|
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class GroupDocumentationQuickViewModelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Save_ErstelltPlanungFuerGesamteLerngruppe()
|
||||||
|
{
|
||||||
|
var group = new GroupDocumentationOption(Guid.NewGuid(), "10c · WAT");
|
||||||
|
var vm = new GroupDocumentationQuickViewModel([group])
|
||||||
|
{
|
||||||
|
SelectedGroup = group, Title = "Klausur ankündigen", Content = "Termin abstimmen",
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.NotNull(vm.Result);
|
||||||
|
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
|
||||||
|
Assert.Equal(group.Id, vm.Result.GroupId);
|
||||||
|
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
|
||||||
|
Assert.True(vm.Result.ExcludeFromWebUntisSync);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,25 @@ public sealed class GroupDocumentationTabViewModelTests
|
|||||||
Assert.Equal("Alt", vm.Entries[1].Model.Title);
|
Assert.Equal("Alt", vm.Entries[1].Model.Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Initialize_ZeigtGruppenweitenEintragAuchOhneSchueler()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "10c" };
|
||||||
|
var docs = new FakeDocumentation();
|
||||||
|
docs.Add(new Documentation
|
||||||
|
{
|
||||||
|
StudentId = Guid.Empty, GroupId = group.Id, Type = DocumentationType.Planning,
|
||||||
|
Title = "Klausur planen", Date = new DateOnly(2026, 9, 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
var vm = BuildVm([], [group], docs);
|
||||||
|
vm.Initialize(group.Id);
|
||||||
|
|
||||||
|
var entry = Assert.Single(vm.Entries);
|
||||||
|
Assert.Equal("Gesamte Lerngruppe", entry.StudentName);
|
||||||
|
Assert.Equal("Planung / Erinnerung", entry.TypeLabel);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert()
|
public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ public sealed class GroupOverviewViewModelTests
|
|||||||
{
|
{
|
||||||
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,
|
||||||
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), sessions ?? new FakeSessions([]),
|
FakeResults? results = null, FakeGroups? groups = null) =>
|
||||||
|
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), results ?? new FakeResults(),
|
||||||
|
sessions ?? new FakeSessions([]),
|
||||||
entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
|
entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
|
||||||
tasks ?? new FakeWorkTasks(), new AttendanceBalanceService(), new SchoolYearService());
|
tasks ?? new FakeWorkTasks(), groups ?? new FakeGroups([]), new AttendanceBalanceService(),
|
||||||
|
new GradingService(), new SchoolYearService());
|
||||||
|
|
||||||
private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
|
private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
|
||||||
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
|
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
|
||||||
@@ -76,6 +79,86 @@ public sealed class GroupOverviewViewModelTests
|
|||||||
Assert.Contains("Klassenarbeit 2", vm.NextExamLabel);
|
Assert.Contains("Klassenarbeit 2", vm.NextExamLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vorjahresvergleich_OhneVerknuepfteVorgaengergruppe_BleibtVerborgen()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var groups = new FakeGroups([new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6 }]);
|
||||||
|
var vm = NewVm(groups: groups);
|
||||||
|
|
||||||
|
vm.Initialize(groupId, "Testkurs");
|
||||||
|
|
||||||
|
Assert.False(vm.HasYearComparison);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vorjahresvergleich_VorgaengerOhneKlausurergebnisse_BleibtVerborgen()
|
||||||
|
{
|
||||||
|
var previousId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var groups = new FakeGroups([
|
||||||
|
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||||
|
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||||
|
]);
|
||||||
|
var vm = NewVm(groups: groups);
|
||||||
|
|
||||||
|
vm.Initialize(groupId, "Testkurs");
|
||||||
|
|
||||||
|
Assert.False(vm.HasYearComparison);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vorjahresvergleich_ZeigtDurchschnitteBeiderJahreAn()
|
||||||
|
{
|
||||||
|
var previousId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var groups = new FakeGroups([
|
||||||
|
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||||
|
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
|
||||||
|
var currentExam = new Exam { Id = Guid.NewGuid(), GroupId = groupId };
|
||||||
|
var exams = new FakeExams([previousExam, currentExam]);
|
||||||
|
|
||||||
|
var results = new FakeResults();
|
||||||
|
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "2" });
|
||||||
|
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "4" });
|
||||||
|
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Grade = "1" });
|
||||||
|
// Abwesende und leere Ergebnisse fließen bewusst nicht in den Schnitt ein.
|
||||||
|
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Absent = true, Grade = "6" });
|
||||||
|
|
||||||
|
var vm = NewVm(exams: exams, results: results, groups: groups);
|
||||||
|
|
||||||
|
vm.Initialize(groupId, "Testkurs");
|
||||||
|
|
||||||
|
Assert.True(vm.HasYearComparison);
|
||||||
|
Assert.Contains("Dieses Jahr: Ø 1,0 (1 Klausuren)", vm.YearComparisonLabel);
|
||||||
|
Assert.Contains("Vorjahr: Ø 3,0 (2 Klausuren)", vm.YearComparisonLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vorjahresvergleich_OhneEigeneKlausurenZeigtNurVorjahr()
|
||||||
|
{
|
||||||
|
var previousId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var groups = new FakeGroups([
|
||||||
|
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||||
|
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||||
|
]);
|
||||||
|
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
|
||||||
|
var exams = new FakeExams([previousExam]);
|
||||||
|
var results = new FakeResults();
|
||||||
|
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "3" });
|
||||||
|
|
||||||
|
var vm = NewVm(exams: exams, results: results, groups: groups);
|
||||||
|
|
||||||
|
vm.Initialize(groupId, "Testkurs");
|
||||||
|
|
||||||
|
Assert.True(vm.HasYearComparison);
|
||||||
|
Assert.Contains("noch keine Klausur", vm.YearComparisonLabel);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung()
|
public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class LessonSchedulingServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void UnitPicker_SchlaegtLaufendeEinheitVor_UndSpeichertNeuanlageNochNicht()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var planned = new Unit { GroupId = groupId, Title = "Später", Status = UnitStatus.Planned };
|
||||||
|
var active = new Unit { GroupId = groupId, Title = "Aktuell", Status = UnitStatus.Active };
|
||||||
|
var repo = new FakeUnits();
|
||||||
|
repo.Add(planned); repo.Add(active);
|
||||||
|
var vm = new TimetableUnitPickerViewModel(repo, groupId, "10c", new(2026, 9, 2), 3);
|
||||||
|
|
||||||
|
Assert.Equal(active.Id, vm.SelectedUnit?.Model.Id);
|
||||||
|
vm.NewUnitTitle = "Neue Reihe";
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.ResultIsNew);
|
||||||
|
Assert.Equal("Neue Reihe", vm.Result?.Title);
|
||||||
|
Assert.Null(repo.GetById(vm.Result!.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Move_AendertDatumUndStunde_UndRuecktNurNichtDurchgefuehrteFolgestundenNach()
|
||||||
|
{
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var moved = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 2 };
|
||||||
|
var draftFollowing = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 3), Status = LessonStatus.Draft };
|
||||||
|
var conducted = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 4), Status = LessonStatus.Conducted };
|
||||||
|
var repo = new FakeLessons();
|
||||||
|
repo.Add(moved); repo.Add(draftFollowing); repo.Add(conducted);
|
||||||
|
|
||||||
|
new LessonSchedulingService(repo).Move(moved, new(2026, 9, 8), 5, shiftFollowing: true);
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 8), moved.Date);
|
||||||
|
Assert.Equal(5, moved.LessonNumber);
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 10), draftFollowing.Date);
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
|
||||||
|
{
|
||||||
|
var source = new Lesson
|
||||||
|
{
|
||||||
|
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new(2026, 9, 1),
|
||||||
|
LessonNumber = 3, Topic = "Fotosynthese", Homework = "Aufgabe 2",
|
||||||
|
Phases =
|
||||||
|
[
|
||||||
|
new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 30 },
|
||||||
|
new LessonPhaseStep { Name = "Experiment", DurationMinutes = 30 },
|
||||||
|
new LessonPhaseStep { Name = "Sicherung", DurationMinutes = 20 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var repo = new FakeLessons();
|
||||||
|
repo.Add(source);
|
||||||
|
|
||||||
|
var continuation = new LessonSchedulingService(repo).SplitAndMoveSecondPart(source, 45,
|
||||||
|
new(2026, 9, 3), 6, new TimeOnly(12, 15));
|
||||||
|
|
||||||
|
Assert.Equal([30, 15], source.Phases.Select(p => p.DurationMinutes));
|
||||||
|
Assert.Equal([15, 20], continuation.Phases.Select(p => p.DurationMinutes));
|
||||||
|
Assert.Equal("Fotosynthese – Fortsetzung", continuation.Topic);
|
||||||
|
Assert.Null(source.Homework);
|
||||||
|
Assert.Equal("Aufgabe 2", continuation.Homework);
|
||||||
|
Assert.Equal(6, continuation.LessonNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Move_LehntBelegtenZielterminAb()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var source = new Lesson { GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 1 };
|
||||||
|
var occupied = new Lesson { GroupId = groupId, Date = new(2026, 9, 2), LessonNumber = 4 };
|
||||||
|
var repo = new FakeLessons();
|
||||||
|
repo.Add(source); repo.Add(occupied);
|
||||||
|
|
||||||
|
var error = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
new LessonSchedulingService(repo).Move(source, occupied.Date, 4, false));
|
||||||
|
|
||||||
|
Assert.Contains("bereits eine Planung", error.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,513 @@
|
|||||||
|
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_exams", "get_grades", "get_lesson_plans",
|
||||||
|
"get_schedule", "get_students", "get_time_entries", "list_letter_templates",
|
||||||
|
"render_letter",
|
||||||
|
},
|
||||||
|
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllowedWriteTools_EnthaeltGenauDieErwartetenWriteTools()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"add_lesson_attachment", "add_lesson_phase", "create_grade_entry", "create_lesson",
|
||||||
|
"create_time_entry", "create_unit", "remove_lesson_phase", "update_lesson",
|
||||||
|
"update_lesson_phase", "update_student_group_assignment", "update_unit",
|
||||||
|
},
|
||||||
|
McpToolScope.AllowedWriteTools.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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
@@ -294,4 +333,44 @@ public class PlanningTabViewModelTests
|
|||||||
Assert.Single(sessions.GetByGroup(groupId));
|
Assert.Single(sessions.GetByGroup(groupId));
|
||||||
Assert.Equal("Für diese Stunde existiert bereits eine Sitzung.", notified);
|
Assert.Equal("Für diese Stunde existiert bereits eine Sitzung.", notified);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateParticipationSession_AndereStundeAmSelbenTagBereitsVerknuepft_LegtKeineZweiteSitzungAn()
|
||||||
|
{
|
||||||
|
// Nutzer-Feedback: eine dritte Stunde am selben Tag (z.B. Vertretung) soll die bereits
|
||||||
|
// bestehende Sitzung des Tages weiterverwenden statt eine zweite anzulegen.
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var group = new LearningGroup { Id = groupId, Name = "Testgruppe" };
|
||||||
|
var groups = new FakeGroups([group]);
|
||||||
|
var units = new FakeUnits();
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var sessions = new FakeSessions([]);
|
||||||
|
var vm = new PlanningTabViewModel(units, lessons, groups, new FakeSubjects([]),
|
||||||
|
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), sessions);
|
||||||
|
vm.Initialize(groupId);
|
||||||
|
|
||||||
|
var unit = new Unit { GroupId = groupId, Title = "Optik" };
|
||||||
|
units.Add(unit);
|
||||||
|
var date = new DateOnly(2025, 9, 1);
|
||||||
|
var doubleLesson = new Lesson
|
||||||
|
{ UnitId = unit.Id, GroupId = groupId, Date = date, Topic = "Brechung" };
|
||||||
|
var thirdLesson = new Lesson
|
||||||
|
{ UnitId = unit.Id, GroupId = groupId, Date = date, Topic = "Vertretung" };
|
||||||
|
lessons.Add(doubleLesson);
|
||||||
|
lessons.Add(thirdLesson);
|
||||||
|
|
||||||
|
vm.RefreshPlanning(unit.Id, doubleLesson.Id);
|
||||||
|
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == doubleLesson.Id);
|
||||||
|
vm.CreateParticipationSessionCommand.Execute(null);
|
||||||
|
|
||||||
|
string? notified = null;
|
||||||
|
vm.OnNotify = m => notified = m;
|
||||||
|
vm.RefreshPlanning(unit.Id, thirdLesson.Id);
|
||||||
|
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == thirdLesson.Id);
|
||||||
|
vm.CreateParticipationSessionCommand.Execute(null);
|
||||||
|
|
||||||
|
var created = Assert.Single(sessions.GetByGroup(groupId));
|
||||||
|
Assert.Equal(doubleLesson.Id, created.LessonId);
|
||||||
|
Assert.Equal("Für diesen Tag existiert bereits eine Sitzung.", notified);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,67 @@ public sealed class QuickInputViewModelTests
|
|||||||
Assert.Equal(0.4, vm.CurrentStudentContentOpacity);
|
Assert.Equal(0.4, vm.CurrentStudentContentOpacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearRating_SetztVersehentlichGesetzteBewertungAufNichtBewertetZurueck()
|
||||||
|
{
|
||||||
|
var aspects = new List<AspectColumnDef>
|
||||||
|
{
|
||||||
|
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
|
||||||
|
};
|
||||||
|
var rows = new List<ParticipationStudentRow>
|
||||||
|
{
|
||||||
|
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||||
|
};
|
||||||
|
var vm = new QuickInputViewModel(rows, aspects);
|
||||||
|
vm.SetRatingByNumber(4); // aus Versehen eine Note für Qualität vergeben
|
||||||
|
|
||||||
|
vm.ClearRating();
|
||||||
|
|
||||||
|
Assert.Null(vm.AspectRows[0].Value);
|
||||||
|
Assert.Null(rows[0].GetRating("quality"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetDayHighlight_SpeichertAufDerZeileUndAktualisiertDieAnzeige()
|
||||||
|
{
|
||||||
|
var aspects = new List<AspectColumnDef>
|
||||||
|
{
|
||||||
|
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
|
||||||
|
};
|
||||||
|
var rows = new List<ParticipationStudentRow>
|
||||||
|
{
|
||||||
|
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||||
|
};
|
||||||
|
var vm = new QuickInputViewModel(rows, aspects);
|
||||||
|
|
||||||
|
vm.SetDayHighlight(DayHighlightKind.Standout);
|
||||||
|
|
||||||
|
Assert.Equal(DayHighlightKind.Standout, rows[0].DayHighlight);
|
||||||
|
Assert.Equal("👑", vm.CurrentStudentDayHighlightSymbol);
|
||||||
|
Assert.Equal("Spitzentag", vm.CurrentStudentDayHighlightLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ZurueckZuVorherigemSchueler_ZeigtDessenTagesflaggeWiederAn()
|
||||||
|
{
|
||||||
|
var aspects = new List<AspectColumnDef>
|
||||||
|
{
|
||||||
|
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
|
||||||
|
};
|
||||||
|
var rows = new List<ParticipationStudentRow>
|
||||||
|
{
|
||||||
|
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||||
|
new(Guid.NewGuid(), "Ben", new ParticipationEntry(), aspects, []),
|
||||||
|
};
|
||||||
|
var vm = new QuickInputViewModel(rows, aspects);
|
||||||
|
|
||||||
|
vm.SetDayHighlight(DayHighlightKind.Sleepy); // Anna
|
||||||
|
vm.NextStudent(); // zu Ben, keine Flagge
|
||||||
|
vm.PreviousStudent(); // zurück zu Anna
|
||||||
|
|
||||||
|
Assert.Equal(DayHighlightKind.Sleepy, vm.CurrentStudentDayHighlight);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf()
|
public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -256,6 +256,30 @@ public sealed class SeatingPlanViewModelTests
|
|||||||
Assert.Single(sessions.GetByGroup(groupId));
|
Assert.Single(sessions.GetByGroup(groupId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectOrCreateSessionForLesson_AndereStundeAmSelbenTagBereitsVerknuepft_LegtKeineZweiteSitzungAn()
|
||||||
|
{
|
||||||
|
// Nutzer-Feedback: eine dritte Stunde am selben Tag (z.B. durch Vertretung, eigene Lesson-
|
||||||
|
// Id) soll die bereits bestehende Sitzung der Doppelstunde weiterverwenden statt eine
|
||||||
|
// zweite, unabhängige Sitzung für denselben Tag anzulegen.
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var doubleLesson = new Lesson { GroupId = groupId, Date = today, Topic = "Redox" };
|
||||||
|
var thirdLesson = new Lesson { GroupId = groupId, Date = today, Topic = "Vertretung" };
|
||||||
|
var existingSession = new ParticipationSession
|
||||||
|
{ GroupId = groupId, Date = today, LessonId = doubleLesson.Id, Comment = "Redox" };
|
||||||
|
var sessions = new FakeSessions([existingSession]);
|
||||||
|
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
|
||||||
|
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([]),
|
||||||
|
new FakeMemberships([]), sessions, new FakeEntries(), new FakeAspects());
|
||||||
|
vm.Initialize(groupId, isReadOnly: false);
|
||||||
|
|
||||||
|
vm.SelectOrCreateSessionForLesson(thirdLesson);
|
||||||
|
|
||||||
|
Assert.Equal(existingSession.Id, vm.SelectedSession?.Id);
|
||||||
|
Assert.Single(sessions.GetByGroup(groupId));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SelectOrCreateSessionForLesson_KeineSitzungVorhanden_LegtVerknuepfteAnUndWaehltSieAus()
|
public void SelectOrCreateSessionForLesson_KeineSitzungVorhanden_LegtVerknuepfteAnUndWaehltSieAus()
|
||||||
{
|
{
|
||||||
@@ -472,6 +496,7 @@ public sealed class SeatingPlanViewModelTests
|
|||||||
vm.SetRatingByNumber(5);
|
vm.SetRatingByNumber(5);
|
||||||
vm.ApplyAttendanceShortcut(1, clear: false);
|
vm.ApplyAttendanceShortcut(1, clear: false);
|
||||||
vm.ApplyHomeworkShortcut(7, clear: false);
|
vm.ApplyHomeworkShortcut(7, clear: false);
|
||||||
|
vm.ApplyDayHighlightShortcut(1, clear: false);
|
||||||
|
|
||||||
var session = Assert.Single(sessions.GetByGroup(groupId));
|
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||||
Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date);
|
Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date);
|
||||||
@@ -481,6 +506,56 @@ public sealed class SeatingPlanViewModelTests
|
|||||||
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
|
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
|
||||||
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
|
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
|
||||||
Assert.True(entry.HomeworkMissing);
|
Assert.True(entry.HomeworkMissing);
|
||||||
|
Assert.Equal(DayHighlightKind.Standout, entry.DayHighlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SitzplatzBewertung_TagesflaggeKannWiederGeloeschtWerden()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var studentId = Guid.NewGuid();
|
||||||
|
var sessions = new FakeSessions([]);
|
||||||
|
var entries = new FakeEntries();
|
||||||
|
var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(),
|
||||||
|
groupId, studentId, "Beispiel, Anna", canEdit: true);
|
||||||
|
|
||||||
|
vm.ApplyDayHighlightShortcut(3, clear: false); // aus Versehen "Schlechter Tag" gesetzt
|
||||||
|
vm.ApplyDayHighlightShortcut(null, clear: true);
|
||||||
|
|
||||||
|
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||||
|
var entry = entries.GetBySessionAndStudent(session.Id, studentId)!;
|
||||||
|
Assert.Null(entry.DayHighlight);
|
||||||
|
Assert.Equal("Keine Markierung", vm.DayHighlightLabel);
|
||||||
|
Assert.All(vm.DayHighlightChoices, c => Assert.Equal(c.Kind is null, c.IsSelected));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SitzplatzBewertung_SchlaegtQuantitaetAusStrichlisteVor()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var studentId = Guid.NewGuid();
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var session = new ParticipationSession { GroupId = groupId, Date = today };
|
||||||
|
var sessions = new FakeSessions([session]);
|
||||||
|
var entries = new FakeEntries();
|
||||||
|
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = studentId, RaisedHandCount = 3 });
|
||||||
|
|
||||||
|
var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(),
|
||||||
|
groupId, studentId, "Beispiel, Anna", canEdit: true);
|
||||||
|
|
||||||
|
var quantityRow = vm.AspectRows.Single(r => r.Key == "quantity");
|
||||||
|
Assert.True(quantityRow.HasSuggestion);
|
||||||
|
Assert.Equal(0, quantityRow.SuggestedValue); // 3 gemeldet -> "∼" laut ParticipationCountSuggestion
|
||||||
|
Assert.Contains("3× gemeldet", quantityRow.SuggestionLabel);
|
||||||
|
|
||||||
|
var qualityRow = vm.AspectRows.Single(r => r.Key == "quality");
|
||||||
|
Assert.False(qualityRow.HasSuggestion);
|
||||||
|
|
||||||
|
quantityRow.ApplySuggestionCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Equal(0, quantityRow.Value);
|
||||||
|
var updatedEntry = entries.GetBySessionAndStudent(session.Id, studentId)!;
|
||||||
|
Assert.Equal(0, updatedEntry.Ratings.Single(r => r.Key == "quantity").Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -517,6 +592,60 @@ public sealed class SeatingPlanViewModelTests
|
|||||||
Assert.Null(vm.SelectedSession);
|
Assert.Null(vm.SelectedSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TallyRaisedHand_ErhoehtNurGemeldetZaehler()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||||
|
var plan = new SeatingPlan
|
||||||
|
{
|
||||||
|
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||||
|
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||||
|
};
|
||||||
|
var sessions = new FakeSessions([]);
|
||||||
|
var entries = new FakeEntries();
|
||||||
|
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||||
|
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
|
||||||
|
sessions, entries, new FakeAspects());
|
||||||
|
vm.Initialize(groupId, isReadOnly: false);
|
||||||
|
var seat = vm.Seats.Single();
|
||||||
|
|
||||||
|
seat.TallyRaisedHandCommand.Execute(null);
|
||||||
|
seat.TallyRaisedHandCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Equal(2, seat.RaisedHandCount);
|
||||||
|
Assert.Equal(0, seat.CalledOnCount);
|
||||||
|
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||||
|
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
|
||||||
|
Assert.Equal(2, entry.RaisedHandCount);
|
||||||
|
Assert.Equal(0, entry.CalledOnCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TallyCalledOn_ErhoehtBeideZaehler()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||||
|
var plan = new SeatingPlan
|
||||||
|
{
|
||||||
|
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||||
|
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||||
|
};
|
||||||
|
var sessions = new FakeSessions([]);
|
||||||
|
var entries = new FakeEntries();
|
||||||
|
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||||
|
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
|
||||||
|
sessions, entries, new FakeAspects());
|
||||||
|
vm.Initialize(groupId, isReadOnly: false);
|
||||||
|
var seat = vm.Seats.Single();
|
||||||
|
|
||||||
|
seat.TallyRaisedHandCommand.Execute(null); // erste Meldung, nicht drangekommen
|
||||||
|
seat.TallyCalledOnCommand.Execute(null); // zweite Meldung, drangekommen
|
||||||
|
|
||||||
|
Assert.Equal(2, seat.RaisedHandCount);
|
||||||
|
Assert.Equal(1, seat.CalledOnCount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn()
|
public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using LehrerApp.Sync;
|
|||||||
using LehrerApp.Sync.Models;
|
using LehrerApp.Sync.Models;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
@@ -28,12 +29,15 @@ public sealed class SettingsViewModelTests
|
|||||||
return new SettingsViewModel(
|
return new SettingsViewModel(
|
||||||
subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||||
|
new BackupSettingsService(tempPath),
|
||||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||||
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([]),
|
||||||
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 LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||||
|
TestSupport.BuildMcpSettingsService(),
|
||||||
|
TestSupport.BuildMcpClientRegistrationService(),
|
||||||
TestSupport.BuildWebUntisSettingsService(),
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
TestSupport.BuildAnnualPlanSettingsService(),
|
TestSupport.BuildAnnualPlanSettingsService(),
|
||||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
||||||
@@ -217,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()
|
||||||
{
|
{
|
||||||
@@ -314,11 +337,14 @@ public sealed class SettingsViewModelTests
|
|||||||
var vm = new SettingsViewModel(
|
var vm = new SettingsViewModel(
|
||||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||||
|
new BackupSettingsService(tempPath),
|
||||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||||
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 LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
new FakeSupervisionDuties(), new TemplateStore(tempPath), 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(),
|
||||||
@@ -341,11 +367,14 @@ public sealed class SettingsViewModelTests
|
|||||||
var vm = new SettingsViewModel(
|
var vm = new SettingsViewModel(
|
||||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||||
|
new BackupSettingsService(tempPath),
|
||||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||||
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 LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
new FakeSupervisionDuties(), new TemplateStore(tempPath), 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(),
|
||||||
@@ -372,11 +401,14 @@ public sealed class SettingsViewModelTests
|
|||||||
var vm = new SettingsViewModel(
|
var vm = new SettingsViewModel(
|
||||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||||
|
new BackupSettingsService(tempPath),
|
||||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||||
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 LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
new FakeSupervisionDuties(), new TemplateStore(tempPath), 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(),
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ public class StudentListViewModelTests
|
|||||||
Assert.Equal("M", vm.Students.Single(s => s.Id == _ben.Id).AvatarLabel);
|
Assert.Equal("M", vm.Students.Single(s => s.Id == _ben.Id).AvatarLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SucheOhneTreffer_LiefertHilfreichenLeerzustand()
|
||||||
|
{
|
||||||
|
var vm = BuildViewModel();
|
||||||
|
|
||||||
|
vm.SearchText = "nicht vorhanden";
|
||||||
|
|
||||||
|
Assert.True(vm.HasNoStudents);
|
||||||
|
Assert.False(vm.HasStudents);
|
||||||
|
Assert.Contains("Suche", vm.EmptyListMessage);
|
||||||
|
}
|
||||||
|
|
||||||
private StudentListViewModel BuildViewModel() => new(
|
private StudentListViewModel BuildViewModel() => new(
|
||||||
new FakeStudents([_anna, _ben]),
|
new FakeStudents([_anna, _ben]),
|
||||||
new FakeGroups([_group]),
|
new FakeGroups([_group]),
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
/// Tests für den Schüler-Leistungsüberblick (Nutzer-Feedback): Klausuren/Mitarbeit/Sonstige
|
||||||
|
/// im Überblick, berechnete Zeugnisnote, Zielnoten- und Was-wäre-wenn-Rechner sowie der
|
||||||
|
/// Bewerter-/Schülermodus-Unterschied (Kursdurchschnitt, Bearbeitbarkeit).
|
||||||
|
public sealed class StudentPerformanceOverviewViewModelTests
|
||||||
|
{
|
||||||
|
private static readonly Guid GroupId = Guid.NewGuid();
|
||||||
|
private static readonly Guid StudentId = Guid.NewGuid();
|
||||||
|
private static readonly Guid OtherStudentId = Guid.NewGuid();
|
||||||
|
|
||||||
|
private static StudentPerformanceOverviewViewModel BuildViewModel(
|
||||||
|
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
|
||||||
|
FakeSchemes? schemes = null, GradingSystem system = GradingSystem.Grades1To6)
|
||||||
|
{
|
||||||
|
var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
|
||||||
|
var schemesRepo = schemes ?? new FakeSchemes();
|
||||||
|
if (schemes is null) schemesRepo.SetForGroup(GroupId, scheme);
|
||||||
|
|
||||||
|
return new StudentPerformanceOverviewViewModel(
|
||||||
|
grades ?? new FakeGrades(), exams ?? new FakeExams([]), results ?? new FakeResults(),
|
||||||
|
new FakeMemberships([]), schemesRepo, new GradingService(),
|
||||||
|
GroupId, StudentId, GroupType.Class, system, "Beispiel, Anna", "Testkurs", "2025/26");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExamRows_ZeigtEigeneNoteUndKursdurchschnitt()
|
||||||
|
{
|
||||||
|
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||||
|
var exams = new FakeExams([exam]);
|
||||||
|
var results = new FakeResults();
|
||||||
|
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
|
||||||
|
results.Add(new ExamResult { ExamId = exam.Id, StudentId = OtherStudentId, Grade = "4" });
|
||||||
|
|
||||||
|
var vm = BuildViewModel(exams, results);
|
||||||
|
|
||||||
|
var row = Assert.Single(vm.ExamRows);
|
||||||
|
Assert.Equal("2", row.OwnGradeDisplay);
|
||||||
|
Assert.Equal("3.0", row.ClassAverageDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExamHistory_ZeigtEigenenVerlaufUndMarkiertNotenabfall()
|
||||||
|
{
|
||||||
|
var exam1 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||||
|
var exam2 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K2", Date = new DateOnly(2025, 9, 24) };
|
||||||
|
var exams = new FakeExams([exam1, exam2]);
|
||||||
|
var results = new FakeResults();
|
||||||
|
results.Add(new ExamResult { ExamId = exam1.Id, StudentId = StudentId, Grade = "2" });
|
||||||
|
results.Add(new ExamResult { ExamId = exam2.Id, StudentId = StudentId, Grade = "5" }); // Abfall + mangelhaft
|
||||||
|
|
||||||
|
var vm = BuildViewModel(exams, results);
|
||||||
|
|
||||||
|
Assert.Equal(2, vm.ExamHistory.Points.Count);
|
||||||
|
Assert.False(vm.ExamHistory.Points[0].IsWarning);
|
||||||
|
Assert.True(vm.ExamHistory.Points[1].IsWarning);
|
||||||
|
Assert.Contains("Abfall", vm.ExamHistory.Points[1].WarningText);
|
||||||
|
Assert.Contains("Versetzungsgefährdung", vm.ExamHistory.Points[1].WarningText);
|
||||||
|
Assert.True(vm.ExamHistory.Points[1].BarHeight < vm.ExamHistory.Points[0].BarHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AktuelleZeugnisnote_KombiniertAlleDreiBereiche()
|
||||||
|
{
|
||||||
|
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||||
|
var exams = new FakeExams([exam]);
|
||||||
|
var results = new FakeResults();
|
||||||
|
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
|
||||||
|
var grades = new FakeGrades();
|
||||||
|
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||||
|
Date = new DateOnly(2025, 9, 5), Value = "3" });
|
||||||
|
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Other,
|
||||||
|
Date = new DateOnly(2025, 9, 5), Value = "1" });
|
||||||
|
|
||||||
|
var vm = BuildViewModel(exams, results, grades);
|
||||||
|
|
||||||
|
// (2*50 + 3*40 + 1*10) / 100 = 2.3 -> kaufmännisch 2
|
||||||
|
Assert.Equal("Note 2", vm.CurrentReportGradeDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Zielnote_BerechnetNoetigenKlausurschnitt()
|
||||||
|
{
|
||||||
|
var grades = new FakeGrades();
|
||||||
|
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||||
|
Date = new DateOnly(2025, 9, 5), Value = "3" });
|
||||||
|
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Other,
|
||||||
|
Date = new DateOnly(2025, 9, 5), Value = "2" });
|
||||||
|
var vm = BuildViewModel(grades: grades);
|
||||||
|
|
||||||
|
vm.TargetGradeText = "2";
|
||||||
|
vm.TargetBucketName = "Klausuren";
|
||||||
|
|
||||||
|
// Ziel 2,0 = (x*50 + 3*40 + 2*10)/100 => 200 = 50x+120+20 => x = 1,2
|
||||||
|
Assert.Contains("1.2", vm.TargetResultDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WasWaereWenn_ZeigtZeugnisnoteMitZusaetzlicherKlausurAn()
|
||||||
|
{
|
||||||
|
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||||
|
var exams = new FakeExams([exam]);
|
||||||
|
var results = new FakeResults();
|
||||||
|
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
|
||||||
|
var grades = new FakeGrades();
|
||||||
|
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||||
|
Date = new DateOnly(2025, 9, 5), Value = "2" });
|
||||||
|
|
||||||
|
var vm = BuildViewModel(exams, results, grades);
|
||||||
|
|
||||||
|
vm.WhatIfExamGradeText = "6";
|
||||||
|
|
||||||
|
// Klausuren neu: (2+6)/2=4 (50%), Mitarbeit 2 (40%, Sonstige fehlt) -> (4*50+2*40)/90 = 3,11 -> kaufm. 3
|
||||||
|
Assert.Contains("3", vm.WhatIfResultDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveCommand_AufMitarbeitszeile_SpeichertUndAktualisiertDurchschnitt()
|
||||||
|
{
|
||||||
|
var grades = new FakeGrades();
|
||||||
|
var grade = new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||||
|
Date = new DateOnly(2025, 9, 5), Value = "3" };
|
||||||
|
grades.Add(grade);
|
||||||
|
var vm = BuildViewModel(grades: grades);
|
||||||
|
|
||||||
|
var row = Assert.Single(vm.ParticipationRows);
|
||||||
|
row.Value = "1";
|
||||||
|
row.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Equal("1.0", vm.ParticipationAverageDisplay);
|
||||||
|
Assert.Equal("1", grades.GetByStudentAndGroup(StudentId, GroupId).Single().Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OhneWerteImZeitraum_ZeigtHinweisStattZeugnisnote()
|
||||||
|
{
|
||||||
|
var vm = BuildViewModel();
|
||||||
|
|
||||||
|
Assert.Contains("Noch nicht berechenbar", vm.CurrentReportGradeDisplay);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -277,7 +277,23 @@ public sealed class TimetableViewModelTests
|
|||||||
|
|
||||||
Assert.Equal(group.Id, navigatedTo);
|
Assert.Equal(group.Id, navigatedTo);
|
||||||
Assert.False(viewerOpened);
|
Assert.False(viewerOpened);
|
||||||
Assert.Equal("Zur Lerngruppe", vm.TodayItems[0].OpenButtonLabel);
|
Assert.Equal("Stunde anlegen", vm.TodayItems[0].OpenButtonLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenTodayLesson_OhneLesson_StartetDirektanlageMitExaktemTermin()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
|
||||||
|
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||||
|
TimetableLessonRequest? requested = null;
|
||||||
|
vm.OnCreateLesson = request => { requested = request; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
await vm.OpenTodayLessonCommand.ExecuteAsync(vm.TodayItems[0]);
|
||||||
|
|
||||||
|
Assert.Equal(new TimetableLessonRequest(group.Id, today, 4), requested);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Unterrichtsmodus (14.x) ────────────────────────────────────────────────
|
// ── Unterrichtsmodus (14.x) ────────────────────────────────────────────────
|
||||||
@@ -696,6 +712,31 @@ public sealed class TimetableViewModelTests
|
|||||||
Assert.Equal("1", period2.BadgeText);
|
Assert.Equal("1", period2.BadgeText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_Doppelstunde_ZeigtLessonThemaInBeidenKacheln_AuchOhnePhasenUndStundenraster()
|
||||||
|
{
|
||||||
|
var date = DateInCurrentWeek(DayOfWeek.Monday);
|
||||||
|
var group = new LearningGroup { Name = "10c" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 3 });
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 4 });
|
||||||
|
var lesson = new Lesson
|
||||||
|
{
|
||||||
|
GroupId = group.Id, Date = date, LessonNumber = 3, Topic = "Werkstoffprüfung",
|
||||||
|
Phases = [],
|
||||||
|
};
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
|
||||||
|
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
|
||||||
|
|
||||||
|
var first = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 3);
|
||||||
|
var second = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 4);
|
||||||
|
Assert.Equal("Werkstoffprüfung", first.Topic);
|
||||||
|
Assert.Equal("Werkstoffprüfung", second.Topic);
|
||||||
|
Assert.Equal(lesson.Id, second.Lesson?.Id);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_KeineAnstehendenFerien_KeinBadge()
|
public void Load_KeineAnstehendenFerien_KeinBadge()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,111 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -7,6 +7,19 @@
|
|||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
<ResourceInclude Source="avares://LehrerApp.Desktop/Styles/SemanticBrushes.axaml"/>
|
<ResourceInclude Source="avares://LehrerApp.Desktop/Styles/SemanticBrushes.axaml"/>
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
<!-- Plattformunabhängige Vektoricons für die Hauptnavigation (14.10). -->
|
||||||
|
<StreamGeometry x:Key="IconSearch">M10,2 A8,8 0 1 0 10,18 A8,8 0 1 0 10,2 M10,5 A5,5 0 1 1 10,15 A5,5 0 1 1 10,5 M15,14 L22,21 L20,23 L13,16 Z</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconDashboard">M3,3 H10 V10 H3 Z M14,3 H21 V7 H14 Z M14,11 H21 V21 H14 Z M3,14 H10 V21 H3 Z</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconGroups">M3,8 L12,3 L21,8 V10 H3 Z M5,11 H8 V18 H5 Z M10,11 H14 V18 H10 Z M16,11 H19 V18 H16 Z M3,20 H21 V22 H3 Z</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconStudents">M12,3 A4,4 0 1 0 12,11 A4,4 0 1 0 12,3 M4,21 C4,16 7,13 12,13 C17,13 20,16 20,21 Z</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconExams">M5,2 H15 L20,7 V22 H5 Z M7,5 V19 H18 V9 H13 V4 H7 Z M9,11 H16 V13 H9 Z M9,15 H16 V17 H9 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="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="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>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
|
|
||||||
@@ -24,5 +37,17 @@
|
|||||||
<Setter Property="Opacity" Value="0.4"/>
|
<Setter Property="Opacity" Value="0.4"/>
|
||||||
<Setter Property="FontSize" Value="13"/>
|
<Setter Property="FontSize" Value="13"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Button.touchTarget">
|
||||||
|
<Setter Property="MinWidth" Value="40"/>
|
||||||
|
<Setter Property="MinHeight" Value="40"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button:focus-visible">
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="2"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBox:focus-visible, ComboBox:focus-visible">
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="2"/>
|
||||||
|
</Style>
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
</Application>
|
</Application>
|
||||||
|
|||||||
+102
-28
@@ -1,8 +1,11 @@
|
|||||||
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.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Data;
|
using LehrerApp.Data;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
@@ -37,32 +40,44 @@ 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)
|
||||||
|
{
|
||||||
|
// 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;
|
||||||
|
await StartMainAppAsync(desktop, promptWindow);
|
||||||
|
};
|
||||||
|
desktop.MainWindow = promptWindow;
|
||||||
|
promptWindow.Show();
|
||||||
|
splash.Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await StartMainAppAsync(desktop, splash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task StartMainAppAsync(
|
||||||
|
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
|
||||||
{
|
{
|
||||||
_serviceProvider = AppBootstrapper.BuildServices();
|
_serviceProvider = AppBootstrapper.BuildServices();
|
||||||
Services = _serviceProvider;
|
Services = _serviceProvider;
|
||||||
@@ -79,23 +94,32 @@ 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();
|
||||||
|
|
||||||
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
|
// Beide optionalen Erstabgleiche noch unter dem Splashscreen abschließen. Ihre
|
||||||
// Auflösen entstand der Timer des optionalen WebUntis-Abgleichs erst, wenn die
|
// CPU-/Datenbankarbeit läuft innerhalb der Dienste im Threadpool; dadurch öffnet das
|
||||||
// Einstellungen geöffnet oder ein manueller Abruf gestartet wurde. Den Dienst beim
|
// Hauptfenster mit fertigem Datenstand und friert nicht kurz danach ein. Das Auflösen
|
||||||
// Anwendungsstart aktivieren und wie den Jahresplan sofort einmal abgleichen; danach
|
// aktiviert zugleich die periodischen Timer.
|
||||||
// übernimmt sein stündlicher Timer.
|
var initialPolls = new List<Task>();
|
||||||
if (Services.GetService<UntisSyncService>() is { } untisSync)
|
if (Services.GetService<UntisSyncService>() is { } untisSync)
|
||||||
_ = untisSync.PollAsync();
|
initialPolls.Add(untisSync.PollAsync());
|
||||||
|
if (Services.GetService<AnnualPlanSyncService>() is { } annualPlanSync)
|
||||||
|
initialPolls.Add(annualPlanSync.PollAsync());
|
||||||
|
await Task.WhenAll(initialPolls);
|
||||||
|
|
||||||
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();
|
||||||
|
windowToClose?.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
|
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
|
||||||
@@ -153,6 +177,10 @@ public class App : Application
|
|||||||
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
|
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
|
||||||
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
|
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
|
||||||
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
|
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
|
||||||
|
dash.OnAddMissingTeachingTime = item => ShowMissingTeachingTimeDialog(item, dash);
|
||||||
|
|
||||||
|
var examsOverview = Services.GetRequiredService<ViewModels.Exams.ExamsOverviewViewModel>();
|
||||||
|
examsOverview.OnNavigateToGroups = () => main.NavigateToCommand.Execute(NavItem.Groups);
|
||||||
|
|
||||||
// Globale Suche/Schnellerfassung (14.2): Navigation bleibt im MainWindow-VM, die
|
// Globale Suche/Schnellerfassung (14.2): Navigation bleibt im MainWindow-VM, die
|
||||||
// vorhandenen Dialog-Helfer übernehmen Eingabe und Validierung.
|
// vorhandenen Dialog-Helfer übernehmen Eingabe und Validierung.
|
||||||
@@ -170,6 +198,7 @@ public class App : Application
|
|||||||
};
|
};
|
||||||
search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash);
|
search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash);
|
||||||
search.OnQuickAddStudent = ShowAddStudentDialog;
|
search.OnQuickAddStudent = ShowAddStudentDialog;
|
||||||
|
search.OnQuickAddGroupDocumentation = () => ShowQuickGroupDocumentationDialog(dash);
|
||||||
|
|
||||||
// StudentList → StudentDetail + Anlegen
|
// StudentList → StudentDetail + Anlegen
|
||||||
var sl = Services.GetRequiredService<StudentListViewModel>();
|
var sl = Services.GetRequiredService<StudentListViewModel>();
|
||||||
@@ -203,4 +232,49 @@ public class App : Application
|
|||||||
dashboard.RefreshCommand.Execute(null);
|
dashboard.RefreshCommand.Execute(null);
|
||||||
Services.GetRequiredService<WorkTaskListViewModel>().Load();
|
Services.GetRequiredService<WorkTaskListViewModel>().Load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task ShowMissingTeachingTimeDialog(MissingTeachingTimeItem item, DashboardViewModel dashboard)
|
||||||
|
{
|
||||||
|
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||||
|
{ MainWindow: { } owner }) return;
|
||||||
|
|
||||||
|
var tasks = Services.GetRequiredService<IWorkTaskRepository>()
|
||||||
|
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
|
||||||
|
var vm = new AddTimeEntryDialogViewModel(tasks)
|
||||||
|
{
|
||||||
|
DateText = item.Date.ToString("dd.MM.yyyy"),
|
||||||
|
SelectedCategory = "Unterricht",
|
||||||
|
StartTimeText = item.WindowStart.ToString("HH:mm"),
|
||||||
|
EndTimeText = item.WindowEnd.ToString("HH:mm"),
|
||||||
|
};
|
||||||
|
var dialog = new AddTimeEntryDialog { DataContext = vm };
|
||||||
|
await dialog.ShowDialog<bool>(owner);
|
||||||
|
if (vm.Result is null) return;
|
||||||
|
|
||||||
|
Services.GetRequiredService<ITimeEntryRepository>().Save(vm.Result);
|
||||||
|
dashboard.RefreshCommand.Execute(null);
|
||||||
|
Services.GetRequiredService<TimeTrackingViewModel>().Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ShowQuickGroupDocumentationDialog(DashboardViewModel dashboard)
|
||||||
|
{
|
||||||
|
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||||
|
{ MainWindow: { } owner }) return;
|
||||||
|
|
||||||
|
var subjects = Services.GetRequiredService<ISubjectRepository>().GetAll()
|
||||||
|
.ToDictionary(s => s.Id);
|
||||||
|
var options = Services.GetRequiredService<IGroupRepository>().GetAll()
|
||||||
|
.Where(g => g.IsActive)
|
||||||
|
.OrderBy(g => g.Name)
|
||||||
|
.Select(g => new GroupDocumentationOption(g.Id,
|
||||||
|
g.SubjectId is { } subjectId && subjects.TryGetValue(subjectId, out var subject)
|
||||||
|
? $"{g.Name} · {(string.IsNullOrWhiteSpace(subject.ShortName) ? subject.Name : subject.ShortName)}"
|
||||||
|
: g.Name));
|
||||||
|
var vm = new GroupDocumentationQuickViewModel(options);
|
||||||
|
var dialog = new Views.Students.GroupDocumentationQuickDialog { DataContext = vm };
|
||||||
|
if (!await dialog.ShowDialog<bool>(owner) || vm.Result is null) return;
|
||||||
|
|
||||||
|
Services.GetRequiredService<IDocumentationRepository>().Save(vm.Result);
|
||||||
|
dashboard.RefreshCommand.Execute(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -16,6 +18,7 @@ using LehrerApp.Sync;
|
|||||||
using LehrerApp.Sync.Crypto;
|
using LehrerApp.Sync.Crypto;
|
||||||
using LehrerApp.Sync.Models;
|
using LehrerApp.Sync.Models;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop;
|
namespace LehrerApp.Desktop;
|
||||||
|
|
||||||
@@ -117,13 +120,22 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<NotificationService>();
|
services.AddSingleton<NotificationService>();
|
||||||
services.AddSingleton<ExportService>();
|
services.AddSingleton<ExportService>();
|
||||||
services.AddSingleton<PdfExportService>();
|
services.AddSingleton<PdfExportService>();
|
||||||
|
services.AddSingleton<ITemplateLoader, TemplateLoader>();
|
||||||
|
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
|
||||||
|
services.AddSingleton(_ => new TemplateStore(appData));
|
||||||
|
|
||||||
// ── 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 backup = new BackupService(appData);
|
var backupSettings = new BackupSettingsService(appData);
|
||||||
backup.CreateBackup(DbPath);
|
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
|
||||||
|
var backupPath = backup.CreateBackup(DbPath);
|
||||||
|
// 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.
|
||||||
|
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
|
||||||
|
Logger.Warn($"Automatisches Backup {backupPath} lässt sich nicht öffnen/lesen — möglicherweise beschädigt.");
|
||||||
services.AddSingleton(backup);
|
services.AddSingleton(backup);
|
||||||
|
services.AddSingleton(backupSettings);
|
||||||
services.AddSingleton(_ => new AppLockService(appData));
|
services.AddSingleton(_ => new AppLockService(appData));
|
||||||
services.AddSingleton<DatabaseEncryptionService>();
|
services.AddSingleton<DatabaseEncryptionService>();
|
||||||
services.AddSingleton(_ => new PrivacySettingsService(appData));
|
services.AddSingleton(_ => new PrivacySettingsService(appData));
|
||||||
@@ -152,6 +164,7 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<IUnitRepository, UnitRepository>();
|
services.AddSingleton<IUnitRepository, UnitRepository>();
|
||||||
services.AddSingleton<ILessonRepository, LessonRepository>();
|
services.AddSingleton<ILessonRepository, LessonRepository>();
|
||||||
services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
|
services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
|
||||||
|
services.AddSingleton<IVorgangRepository, VorgangRepository>();
|
||||||
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
|
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
|
||||||
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
|
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
|
||||||
services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>();
|
services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>();
|
||||||
@@ -174,6 +187,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>();
|
||||||
@@ -185,16 +199,36 @@ 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(_ => new LetterTemplateService(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<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);
|
||||||
@@ -231,6 +265,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.
|
||||||
@@ -306,6 +341,7 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<WorkloadEvaluationViewModel>();
|
services.AddSingleton<WorkloadEvaluationViewModel>();
|
||||||
services.AddSingleton<WorkloadViewModel>();
|
services.AddSingleton<WorkloadViewModel>();
|
||||||
services.AddSingleton<ClassTeacherDetailsViewModel>();
|
services.AddSingleton<ClassTeacherDetailsViewModel>();
|
||||||
|
services.AddSingleton<ClassTeacherCasesViewModel>();
|
||||||
services.AddSingleton<ClassTeacherOverviewViewModel>();
|
services.AddSingleton<ClassTeacherOverviewViewModel>();
|
||||||
services.AddSingleton<ExamsOverviewViewModel>();
|
services.AddSingleton<ExamsOverviewViewModel>();
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
@@ -0,0 +1,22 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Converters;
|
||||||
|
|
||||||
|
/// <summary>`CalendarDatePicker.SelectedDate` ist (anders als der eingebaute `DatePicker`) vom
|
||||||
|
/// WPF-Toolkit übernommenes `DateTime?`, während Datumsfelder in dieser Codebasis durchgängig
|
||||||
|
/// `DateTimeOffset`(?) sind. Avalonia bietet dafür keine automatische Konvertierung
|
||||||
|
/// (`TypeUtilities.TryConvert`/`TryConvertImplicit` unterstützen `DateTime`↔`DateTimeOffset`
|
||||||
|
/// nicht) — ohne diesen Converter bleibt die Bindung beim Anzeigen stumm leer und wirft beim
|
||||||
|
/// Zurückschreiben eine `InvalidCastException`.</summary>
|
||||||
|
public sealed class DateTimeOffsetToDateTimeConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static readonly DateTimeOffsetToDateTimeConverter Instance = new();
|
||||||
|
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||||
|
value is DateTimeOffset offset ? offset.DateTime : null;
|
||||||
|
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||||
|
value is DateTime dateTime ? new DateTimeOffset(DateTime.SpecifyKind(dateTime, DateTimeKind.Local)) : null;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
|
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
|
||||||
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
|
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
|
||||||
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
|
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
|
||||||
|
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" />
|
<PackageReference Include="Avalonia" />
|
||||||
@@ -20,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\**" />
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -340,6 +350,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 +368,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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,5 +607,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
|
|
||||||
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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ public sealed class AnnualPlanSyncService : IDisposable
|
|||||||
|
|
||||||
public async Task PollAsync()
|
public async Task PollAsync()
|
||||||
{
|
{
|
||||||
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).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,36 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
internal sealed class BackupSettingsConfig
|
||||||
|
{
|
||||||
|
public string? SecondaryDirectory { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Merkt sich einen optionalen zweiten Sicherungsordner (13.3.1 Nachtrag) — z.B. ein
|
||||||
|
/// USB-Stick oder Netzlaufwerk, damit ein Backup nicht ausschließlich neben der Live-Datenbank
|
||||||
|
/// liegt und bei Plattenausfall oder gelöschtem Profilverzeichnis mit verloren geht. Gleiches
|
||||||
|
/// Muster wie <see cref="AppearanceSettingsService"/>. Das eigentliche Kopieren übernimmt
|
||||||
|
/// <see cref="LehrerApp.Core.Services.BackupService"/> bewusst best-effort.</summary>
|
||||||
|
public sealed class BackupSettingsService
|
||||||
|
{
|
||||||
|
private readonly string _configPath;
|
||||||
|
|
||||||
|
public BackupSettingsService(string appDataPath) =>
|
||||||
|
_configPath = Path.Combine(appDataPath, "backupsettings.json");
|
||||||
|
|
||||||
|
public string? LoadSecondaryDirectory()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_configPath))
|
||||||
|
return JsonSerializer.Deserialize<BackupSettingsConfig>(File.ReadAllText(_configPath))
|
||||||
|
?.SecondaryDirectory;
|
||||||
|
}
|
||||||
|
catch { /* beschädigte Konfiguration -> kein zweites Ziel */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveSecondaryDirectory(string? path) =>
|
||||||
|
File.WriteAllText(_configPath, JsonSerializer.Serialize(new BackupSettingsConfig { SecondaryDirectory = path }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
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),
|
||||||
|
["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 ?? ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsEmpty(PlaceholderValue value) => value switch
|
||||||
|
{
|
||||||
|
TextValue x => string.IsNullOrWhiteSpace(x.Value),
|
||||||
|
MultilineValue x => string.IsNullOrWhiteSpace(x.Value),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LehrerApp.Desktop.Views.Shared;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Produktive <see cref="IMcpConfirmationService"/>-Implementierung: zeigt den bestehenden
|
||||||
|
/// <see cref="ConfirmDialog"/> (Views/Shared) ü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"/>.
|
||||||
|
///
|
||||||
|
/// 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 : IMcpConfirmationService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
|
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
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.
|
||||||
|
return await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
var dialog = new ConfirmDialog
|
||||||
|
{
|
||||||
|
DataContext = new ConfirmDialogInfo { Title = title, Message = message, ConfirmText = "Übernehmen" },
|
||||||
|
};
|
||||||
|
var dialogTask = dialog.ShowDialog<bool>(owner);
|
||||||
|
var timeoutTask = Task.Delay(Timeout, ct);
|
||||||
|
var completed = await Task.WhenAny(dialogTask, timeoutTask);
|
||||||
|
if (completed != dialogTask)
|
||||||
|
{
|
||||||
|
dialog.Close(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await dialogTask;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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
|
||||||
|
{
|
||||||
|
/// <returns>true, wenn der Nutzer bestätigt hat; false bei Ablehnung, Timeout oder falls kein
|
||||||
|
/// Hauptfenster verfügbar ist (z.B. während des DB-Passwort-Prompts beim Start).</returns>
|
||||||
|
Task<bool> ConfirmAsync(string title, string message, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -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,198 @@
|
|||||||
|
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 (Phase 1–3, siehe Planungsdokument). 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)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_logger = logger;
|
||||||
|
_serverOptions = BuildServerOptions(
|
||||||
|
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
|
||||||
|
groupMembershipTools, letterTemplateTools);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
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 der Phase-2-Schreiboperationen löscht etwas).
|
||||||
|
void AddWriteTool(Delegate handler, string name, string description)
|
||||||
|
{
|
||||||
|
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Description = description,
|
||||||
|
ReadOnly = false,
|
||||||
|
Destructive = false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
AddReadTool(studentTools.GetStudents, "get_students",
|
||||||
|
"Listet Schüler, optional gefiltert nach Lerngruppe.");
|
||||||
|
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.");
|
||||||
|
|
||||||
|
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).");
|
||||||
|
|
||||||
|
System.Diagnostics.Debug.Assert(
|
||||||
|
toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n)
|
||||||
|
.SequenceEqual(McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools).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,45 @@
|
|||||||
|
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.
|
||||||
|
/// </summary>
|
||||||
|
public static class McpToolScope
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyCollection<string> AllowedReadTools =
|
||||||
|
[
|
||||||
|
"get_students",
|
||||||
|
"get_exams",
|
||||||
|
"get_grades",
|
||||||
|
"get_schedule",
|
||||||
|
"get_time_entries",
|
||||||
|
"get_lesson_plans",
|
||||||
|
"download_lesson_attachment",
|
||||||
|
"list_letter_templates",
|
||||||
|
"render_letter",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <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",
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
public record LessonPhaseDto(Guid Id, string Name, int DurationMinutes, string Activity, string Material, string Shorthand);
|
||||||
|
|
||||||
|
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, LessonStatus Status, 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 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);
|
||||||
@@ -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,301 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
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.</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, 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("Neuer Status: Planned, Conducted, Draft oder Ready. 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 (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 = "",
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
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 (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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LessonDto ToDto(Lesson l) => new(
|
||||||
|
l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.Status,
|
||||||
|
l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand)).ToList(),
|
||||||
|
l.Attachments.Select(a => new LessonAttachmentDto(a.StorageId, a.FileName, a.SizeBytes)).ToList());
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
[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);
|
||||||
|
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,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,88 @@
|
|||||||
|
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>())
|
||||||
|
{ 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,97 @@
|
|||||||
|
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)
|
||||||
|
.OrderBy(g => g.Name)
|
||||||
|
.ToList();
|
||||||
|
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary) =>
|
||||||
|
jobStates.Save(new UntisHubJobState
|
||||||
|
{
|
||||||
|
Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(),
|
||||||
|
Kind = kind, GroupId = groupId, LastRunAt = DateTime.UtcNow, 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)
|
||||||
|
{
|
||||||
|
UntisHubJobState? State(UntisHubJobKind kind, Guid? groupId) =>
|
||||||
|
states.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId);
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ public class UntisSyncService : IDisposable
|
|||||||
|
|
||||||
public async Task PollAsync()
|
public async Task PollAsync()
|
||||||
{
|
{
|
||||||
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).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
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace LehrerApp.Desktop.Services;
|
|||||||
public sealed class WebUntisIntegrationException(string message) : Exception(message);
|
public sealed class WebUntisIntegrationException(string message) : Exception(message);
|
||||||
|
|
||||||
public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate);
|
public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate);
|
||||||
|
public sealed record UntisHolidayDto(int UntisId, string Name, string? LongName, int StartDate, int EndDate);
|
||||||
public sealed record UntisClassDto(int UntisId, string Name, string? LongName);
|
public sealed record UntisClassDto(int UntisId, string Name, string? LongName);
|
||||||
public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title,
|
public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title,
|
||||||
bool Active, IReadOnlyList<int> DepartmentUntisIds)
|
bool Active, IReadOnlyList<int> DepartmentUntisIds)
|
||||||
@@ -81,6 +82,10 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
|
|||||||
async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(token))
|
async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(token))
|
||||||
.Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token);
|
.Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<UntisHolidayDto>> GetHolidaysAsync(CancellationToken token = default) => ExecuteAsync(
|
||||||
|
async client => (IReadOnlyList<UntisHolidayDto>)(await client.GetHolidaysAsync(token))
|
||||||
|
.Select(x => new UntisHolidayDto(x.UntisId, x.Name, x.LongName, x.StartDate, x.EndDate)).ToList(), token);
|
||||||
|
|
||||||
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync(
|
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync(
|
||||||
async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token))
|
async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token))
|
||||||
.Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token);
|
.Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token);
|
||||||
@@ -200,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)); }
|
||||||
|
|||||||
@@ -13,10 +13,17 @@ internal class WebUntisSettingsConfig
|
|||||||
public int? TeacherUntisId { get; set; }
|
public int? TeacherUntisId { get; set; }
|
||||||
public int? HomeroomClassUntisId { get; set; }
|
public int? HomeroomClassUntisId { get; set; }
|
||||||
public string? HomeroomClassName { get; set; }
|
public string? HomeroomClassName { get; set; }
|
||||||
|
public List<CachedUntisHoliday>? CachedHolidays { get; set; }
|
||||||
|
public DateTime? HolidaysFetchedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record WebUntisCredentials(string School, string Host, string Username, string Password);
|
public sealed record WebUntisCredentials(string School, string Host, string Username, string Password);
|
||||||
|
|
||||||
|
/// <summary>Ferienzeitraum aus WebUntis' <c>getHolidays</c>-Bericht, hier auf die für die
|
||||||
|
/// Fehlquoten-Berechnung ("Klassenlehrer"-Feature) relevanten Felder reduziert. Kein Geheimnis
|
||||||
|
/// (anders als iCal-URL/API-Zugangsdaten in dieser Datei), deshalb unverschlüsselt gecacht.</summary>
|
||||||
|
public sealed record CachedUntisHoliday(string Name, DateOnly Start, DateOnly End);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie
|
/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie
|
||||||
/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die
|
/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die
|
||||||
@@ -115,6 +122,16 @@ public class WebUntisSettingsService
|
|||||||
Save();
|
Save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DateTime? HolidaysFetchedAt => _config.HolidaysFetchedAt;
|
||||||
|
public IReadOnlyList<CachedUntisHoliday>? GetCachedHolidays() => _config.CachedHolidays;
|
||||||
|
|
||||||
|
public void SetCachedHolidays(IReadOnlyList<CachedUntisHoliday> holidays, DateTime at)
|
||||||
|
{
|
||||||
|
_config.CachedHolidays = holidays.ToList();
|
||||||
|
_config.HolidaysFetchedAt = at;
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
private byte[] GenerateAndSaveKey()
|
private byte[] GenerateAndSaveKey()
|
||||||
{
|
{
|
||||||
var key = SyncCrypto.GenerateKey();
|
var key = SyncCrypto.GenerateKey();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Vorgänge"-Tab der Klassenlehreransicht: Fallmappen für konkrete, laufende Probleme mit einer/
|
||||||
|
/// einem oder mehreren Schüler*innen der Klasse, die Dokumentation und (eingefrorene) WebUntis-
|
||||||
|
/// Klassenbucheinträge bündeln. Löst wie <see cref="ClassTeacherDetailsViewModel"/> die Untis-
|
||||||
|
/// Roster-Namen der Klasse auf lokale <see cref="Student"/>-Datensätze auf (bewusst ein eigener,
|
||||||
|
/// nicht geteilter Abgleich — dasselbe kleine Muster steckt schon zweimal im Code, siehe
|
||||||
|
/// <see cref="ClassTeacherOverviewViewModel.MatchStudent"/>, eine gemeinsame Abstraktion dafür ist
|
||||||
|
/// hier nicht Teil der Aufgabe).
|
||||||
|
/// </summary>
|
||||||
|
public partial class ClassTeacherCasesViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IVorgangRepository _vorgaenge;
|
||||||
|
private readonly IDocumentationRepository _documentation;
|
||||||
|
private readonly IStudentRepository _students;
|
||||||
|
private readonly UntisReportCacheService _cache;
|
||||||
|
|
||||||
|
private string _className = "";
|
||||||
|
private List<StudentOption> _rosterStudentOptions = [];
|
||||||
|
|
||||||
|
public ObservableCollection<VorgangItem> Cases { get; } = [];
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _onlyOpen = true;
|
||||||
|
[ObservableProperty] private bool _busy;
|
||||||
|
[ObservableProperty] private string _status = "";
|
||||||
|
|
||||||
|
public bool HasCases => Cases.Count > 0;
|
||||||
|
|
||||||
|
public Func<List<StudentOption>, Vorgang?, Task<Vorgang?>>? OnEditVorgang { get; set; }
|
||||||
|
public Func<VorgangItem, Task<bool>>? OnConfirmDeleteVorgang { get; set; }
|
||||||
|
public Func<Guid, List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
|
||||||
|
|
||||||
|
public ClassTeacherCasesViewModel(IVorgangRepository vorgaenge, IDocumentationRepository documentation,
|
||||||
|
IStudentRepository students, UntisReportCacheService cache)
|
||||||
|
{
|
||||||
|
_vorgaenge = vorgaenge;
|
||||||
|
_documentation = documentation;
|
||||||
|
_students = students;
|
||||||
|
_cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Initialize(string className)
|
||||||
|
{
|
||||||
|
_className = className;
|
||||||
|
Cases.Clear();
|
||||||
|
Status = "Wird geladen…";
|
||||||
|
NotifyState();
|
||||||
|
_ = LoadInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnOnlyOpenChanged(bool value) => _ = LoadInternal();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task Refresh() => LoadInternal();
|
||||||
|
|
||||||
|
private async Task LoadInternal()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_className)) return;
|
||||||
|
|
||||||
|
// IsRevealed übersteht den Neuaufbau der Liste nicht automatisch (neue VorgangItem-Instanzen
|
||||||
|
// pro Load) - deshalb hier gemerkt und danach wiederhergestellt, sonst klappt eine gerade
|
||||||
|
// aufgeklappte Karte bei jeder Verknüpfungs-/Status-Aktion (die intern neu lädt) wieder zu.
|
||||||
|
var revealedIds = Cases.Where(c => c.IsRevealed).Select(c => c.Model.Id).ToHashSet();
|
||||||
|
|
||||||
|
Busy = true;
|
||||||
|
Cases.Clear();
|
||||||
|
NotifyState();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var roster = await _cache.GetStudentRosterAsync(_className);
|
||||||
|
var localStudents = _students.GetAll();
|
||||||
|
var matches = roster
|
||||||
|
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
|
||||||
|
.Where(x => x.Student is not null)
|
||||||
|
.Select(x => (StudentId: x.Student!.Id, DisplayName: x.Roster.DisplayName))
|
||||||
|
.DistinctBy(x => x.StudentId)
|
||||||
|
.ToList();
|
||||||
|
_rosterStudentOptions = matches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
|
||||||
|
var rosterIds = matches.Select(m => m.StudentId).ToHashSet();
|
||||||
|
var nameById = matches.ToDictionary(m => m.StudentId, m => m.DisplayName);
|
||||||
|
|
||||||
|
var relevant = _vorgaenge.GetAll().Where(v => v.StudentIds.Any(rosterIds.Contains));
|
||||||
|
if (OnlyOpen) relevant = relevant.Where(v => v.Status == VorgangStatus.Open);
|
||||||
|
|
||||||
|
var allDocs = _documentation.GetAll();
|
||||||
|
foreach (var vorgang in relevant.OrderByDescending(v => v.UpdatedAt))
|
||||||
|
{
|
||||||
|
var names = vorgang.StudentIds.Select(id => nameById.GetValueOrDefault(id, ""))
|
||||||
|
.Where(n => n != "");
|
||||||
|
var item = new VorgangItem(vorgang, string.Join(", ", names), LinkDocumentation,
|
||||||
|
UnlinkDocumentation, RemoveClassRegisterEntry, EditDocumentation)
|
||||||
|
{ IsRevealed = revealedIds.Contains(vorgang.Id) };
|
||||||
|
|
||||||
|
var linkedIds = vorgang.DocumentationIds.ToHashSet();
|
||||||
|
foreach (var doc in allDocs.Where(d => linkedIds.Contains(d.Id)))
|
||||||
|
item.LinkedDocumentation.Add(new DocumentationItem(doc, nameById.GetValueOrDefault(doc.StudentId, "")));
|
||||||
|
foreach (var doc in FilterAvailableDocumentation(allDocs, vorgang))
|
||||||
|
item.AvailableDocumentation.Add(new DocumentationItem(doc, nameById.GetValueOrDefault(doc.StudentId, "")));
|
||||||
|
foreach (var entry in vorgang.ClassRegisterEntries.OrderByDescending(e => e.Date))
|
||||||
|
item.ClassRegisterRows.Add(new VorgangClassRegisterEntryRow(entry));
|
||||||
|
|
||||||
|
Cases.Add(item);
|
||||||
|
}
|
||||||
|
Status = OnlyOpen ? $"{Cases.Count} offene Vorgänge" : $"{Cases.Count} Vorgänge";
|
||||||
|
}
|
||||||
|
finally { Busy = false; NotifyState(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyState() => OnPropertyChanged(nameof(HasCases));
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AddVorgang()
|
||||||
|
{
|
||||||
|
if (OnEditVorgang is null) return;
|
||||||
|
var result = await OnEditVorgang(_rosterStudentOptions, null);
|
||||||
|
if (result is null) return;
|
||||||
|
_vorgaenge.Save(result);
|
||||||
|
await LoadInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task EditVorgang(VorgangItem? item)
|
||||||
|
{
|
||||||
|
if (item is null || OnEditVorgang is null) return;
|
||||||
|
var result = await OnEditVorgang(_rosterStudentOptions, item.Model);
|
||||||
|
if (result is null) return;
|
||||||
|
_vorgaenge.Save(result);
|
||||||
|
await LoadInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ToggleStatus(VorgangItem? item)
|
||||||
|
{
|
||||||
|
if (item is null) return;
|
||||||
|
item.Model.Status = item.Model.Status == VorgangStatus.Open ? VorgangStatus.Closed : VorgangStatus.Open;
|
||||||
|
item.Model.ClosedAt = item.Model.Status == VorgangStatus.Closed ? DateTime.UtcNow : null;
|
||||||
|
_vorgaenge.Save(item.Model);
|
||||||
|
await LoadInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DeleteVorgang(VorgangItem? item)
|
||||||
|
{
|
||||||
|
if (item is null) return;
|
||||||
|
if (OnConfirmDeleteVorgang is not null && !await OnConfirmDeleteVorgang(item)) return;
|
||||||
|
_vorgaenge.Delete(item.Model.Id);
|
||||||
|
await LoadInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CreateAndLinkDocumentation(VorgangItem? item)
|
||||||
|
{
|
||||||
|
if (item is null || OnEditDocumentation is null) return;
|
||||||
|
var studentOptions = _rosterStudentOptions.Where(s => item.Model.StudentIds.Contains(s.Id)).ToList();
|
||||||
|
var defaultStudentId = item.Model.StudentIds.FirstOrDefault();
|
||||||
|
var result = await OnEditDocumentation(defaultStudentId, studentOptions, null);
|
||||||
|
if (result is null) return;
|
||||||
|
_documentation.Save(result);
|
||||||
|
item.Model.DocumentationIds.Add(result.Id);
|
||||||
|
_vorgaenge.Save(item.Model);
|
||||||
|
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 —
|
||||||
|
/// reine, ohne Repository-Zugriff testbare Filterlogik (gleiches Muster wie
|
||||||
|
/// <see cref="ClassTeacherRosterRow.Build"/>).
|
||||||
|
public static List<Documentation> FilterAvailableDocumentation(IReadOnlyList<Documentation> allDocs, Vorgang vorgang)
|
||||||
|
{
|
||||||
|
var linkedIds = vorgang.DocumentationIds.ToHashSet();
|
||||||
|
return allDocs.Where(d => vorgang.StudentIds.Contains(d.StudentId) && !linkedIds.Contains(d.Id)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LinkDocumentation(VorgangItem item, DocumentationItem doc)
|
||||||
|
{
|
||||||
|
item.Model.DocumentationIds.Add(doc.Model.Id);
|
||||||
|
_vorgaenge.Save(item.Model);
|
||||||
|
await LoadInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UnlinkDocumentation(VorgangItem item, DocumentationItem doc)
|
||||||
|
{
|
||||||
|
item.Model.DocumentationIds.Remove(doc.Model.Id);
|
||||||
|
_vorgaenge.Save(item.Model);
|
||||||
|
await LoadInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemoveClassRegisterEntry(VorgangItem item, VorgangClassRegisterEntryRow row)
|
||||||
|
{
|
||||||
|
item.Model.ClassRegisterEntries.Remove(row.Model);
|
||||||
|
_vorgaenge.Save(item.Model);
|
||||||
|
await LoadInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Anheften eines Klassenbuch-Eintrags aus dem Klassenbuch-Tab (ClassTeacherDetailsViewModel,
|
||||||
|
// per Konstruktor-Injection dieser Instanz — kein Func-Hook, da hier keine UI im Spiel ist,
|
||||||
|
// nur Zugriff auf schon geladene Daten dieser Geschwister-ViewModel-Instanz) ────────────────
|
||||||
|
|
||||||
|
public IReadOnlyList<StudentOption> RosterStudentOptions => _rosterStudentOptions;
|
||||||
|
|
||||||
|
/// Offene Vorgänge, an die sich ein Klassenbuch-Eintrag für diese/n Schüler*in anheften lässt
|
||||||
|
/// (Namensabgleich wie beim restlichen Klassenlehrer-Bereich, siehe UntisNameMatching).
|
||||||
|
public List<VorgangItem> OpenCasesForStudent(string untisDisplayName)
|
||||||
|
{
|
||||||
|
var match = _rosterStudentOptions.FirstOrDefault(s =>
|
||||||
|
UntisNameMatching.NamesMatch(s.Name, untisDisplayName));
|
||||||
|
if (match is null) return [];
|
||||||
|
return Cases.Where(c => c.IsOpen && c.Model.StudentIds.Contains(match.Id)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PinClassRegisterEntryAsync(Guid vorgangId, VorgangClassRegisterEntry entry)
|
||||||
|
{
|
||||||
|
var vorgang = _vorgaenge.GetById(vorgangId);
|
||||||
|
if (vorgang is null) return;
|
||||||
|
vorgang.ClassRegisterEntries.Add(entry);
|
||||||
|
_vorgaenge.Save(vorgang);
|
||||||
|
await LoadInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Vorgang> CreateAndPinAsync(string title, Guid studentId, VorgangClassRegisterEntry entry)
|
||||||
|
{
|
||||||
|
var vorgang = new Vorgang { Title = title, StudentIds = [studentId] };
|
||||||
|
vorgang.ClassRegisterEntries.Add(entry);
|
||||||
|
_vorgaenge.Save(vorgang);
|
||||||
|
await LoadInternal();
|
||||||
|
return vorgang;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
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.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
|
|
||||||
@@ -116,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
|
||||||
@@ -130,29 +151,71 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
|
|||||||
public partial class ClassTeacherDetailsViewModel : ObservableObject
|
public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly UntisReportCacheService _cache;
|
private readonly UntisReportCacheService _cache;
|
||||||
|
private readonly IDocumentationRepository _documentation;
|
||||||
|
private readonly IStudentRepository _students;
|
||||||
|
private readonly ClassTeacherCasesViewModel _cases;
|
||||||
private string _className = "";
|
private string _className = "";
|
||||||
|
private DateOnly _loadedStart;
|
||||||
|
private DateOnly _loadedEnd;
|
||||||
|
/// Schüler*innen der Klasse, per Namensabgleich (<see cref="UntisNameMatching"/>) den lokalen
|
||||||
|
/// <see cref="Student"/>-Datensätzen zugeordnet — Grundlage für die "Eigene Dokumentation"-Ansicht,
|
||||||
|
/// die es (anders als der WebUntis-Klassenbuchbericht) nur lokal gibt. Nach jedem <see cref="LoadInternal"/>
|
||||||
|
/// neu aufgebaut.
|
||||||
|
private List<(Guid StudentId, string DisplayName)> _rosterMatches = [];
|
||||||
|
|
||||||
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
|
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
|
||||||
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<ClassTeacherCalendarDay> CalendarDays { get; } = [];
|
||||||
|
|
||||||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6);
|
/// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden).
|
||||||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
[ObservableProperty] private ClassTeacherClassRegisterRow? _selectedEntry;
|
||||||
|
|
||||||
|
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-6);
|
||||||
|
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||||||
[ObservableProperty] private string _status = "Zeitraum wählen und laden.";
|
[ObservableProperty] private string _status = "Zeitraum wählen und laden.";
|
||||||
[ObservableProperty] private bool _busy;
|
[ObservableProperty] private bool _busy;
|
||||||
/// 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.
|
||||||
|
[ObservableProperty] private bool _showOwnDocumentation;
|
||||||
|
[ObservableProperty] private int _ownDocumentationCount;
|
||||||
|
[ObservableProperty] private int _ownDocumentationFollowUpCount;
|
||||||
|
[ObservableProperty] private int _ownDocumentationCriticalCount;
|
||||||
|
|
||||||
|
public Func<List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditOwnDocumentation { get; set; }
|
||||||
|
public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteOwnDocumentation { get; set; }
|
||||||
|
/// Zeigt die "→ Vorgang"-Auswahl für eine Klassenbuchzeile: bestehenden offenen Vorgang wählen
|
||||||
|
/// oder einen neuen Titel eingeben. Liefert null bei Abbruch.
|
||||||
|
public Func<ClassTeacherClassRegisterRow, Task<PinToVorgangChoice?>>? OnPickVorgangForPin { get; set; }
|
||||||
|
|
||||||
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 CanAddOwnDocumentation => !Busy && _rosterMatches.Count > 0;
|
||||||
|
/// Die Kategorien-Chipreihe fasst nur WebUntis-Kategorien zusammen (<see cref="CategoryAggregates"/>)
|
||||||
|
/// — im Modus "Eigene Dokumentation" ausgeblendet, dort gibt es kein Äquivalent zu CategoryGroup.
|
||||||
|
public bool ShowCategoryAggregates => HasCategoryAggregates && !ShowOwnDocumentation;
|
||||||
|
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)
|
public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation,
|
||||||
|
IStudentRepository students, ClassTeacherCasesViewModel cases)
|
||||||
{
|
{
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
_documentation = documentation;
|
||||||
|
_students = students;
|
||||||
|
_cases = cases;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Initialize(string className)
|
public void Initialize(string className)
|
||||||
@@ -161,13 +224,21 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
StudentFilter = "";
|
StudentFilter = "";
|
||||||
Entries.Clear();
|
Entries.Clear();
|
||||||
AbsenceEntries.Clear();
|
AbsenceEntries.Clear();
|
||||||
|
CalendarDays.Clear();
|
||||||
CategoryAggregates.Clear();
|
CategoryAggregates.Clear();
|
||||||
|
OwnDocumentationEntries.Clear();
|
||||||
|
_rosterMatches = [];
|
||||||
Status = "Zeitraum wählen und laden.";
|
Status = "Zeitraum wählen und laden.";
|
||||||
NotifyListState();
|
NotifyListState();
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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)
|
||||||
{
|
{
|
||||||
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
|
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
|
||||||
@@ -178,6 +249,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private Task Load() => LoadInternal(forceRefresh: false);
|
private Task Load() => LoadInternal(forceRefresh: false);
|
||||||
|
|
||||||
|
[RelayCommand] private void ShowUntisRegister() => ShowOwnDocumentation = false;
|
||||||
|
[RelayCommand] private void ShowOwnDocs() => ShowOwnDocumentation = true;
|
||||||
|
|
||||||
/// Umgeht bewusst die Stunden-Sperre von <see cref="UntisReportCacheService"/> — für den Fall,
|
/// Umgeht bewusst die Stunden-Sperre von <see cref="UntisReportCacheService"/> — für den Fall,
|
||||||
/// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat.
|
/// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat.
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -186,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()
|
||||||
{
|
{
|
||||||
@@ -195,17 +300,20 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
|
|
||||||
private async Task LoadInternal(bool forceRefresh)
|
private async Task LoadInternal(bool forceRefresh)
|
||||||
{
|
{
|
||||||
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
|
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||||
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
|
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||||
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; }
|
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; }
|
||||||
|
|
||||||
Busy = true; Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); NotifyListState();
|
Busy = true;
|
||||||
|
Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); OwnDocumentationEntries.Clear();
|
||||||
|
NotifyListState();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
|
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
|
||||||
var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh);
|
var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh);
|
||||||
await Task.WhenAll(classRegisterTask, absencesTask);
|
var rosterTask = _cache.GetStudentRosterAsync(_className);
|
||||||
|
await Task.WhenAll(classRegisterTask, absencesTask, rosterTask);
|
||||||
|
|
||||||
var ordered = classRegisterTask.Result
|
var ordered = classRegisterTask.Result
|
||||||
.Where(MatchesStudentFilter)
|
.Where(MatchesStudentFilter)
|
||||||
@@ -223,14 +331,234 @@ 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();
|
||||||
|
_rosterMatches = rosterTask.Result
|
||||||
|
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
|
||||||
|
.Where(x => x.Student is not null)
|
||||||
|
.Select(x => (StudentId: x.Student!.Id, DisplayName: x.Roster.DisplayName))
|
||||||
|
.DistinctBy(x => x.StudentId)
|
||||||
|
.ToList();
|
||||||
|
_loadedStart = start;
|
||||||
|
_loadedEnd = end;
|
||||||
|
LoadOwnDocumentationEntries();
|
||||||
|
|
||||||
Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
|
Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
|
||||||
$"{AbsenceEntries.Count} Fehlzeiten-Tage im Zeitraum.";
|
$"{AbsenceEntries.Count} Fehlzeiten-Tage, {OwnDocumentationEntries.Count} eigene Dokumentation im Zeitraum.";
|
||||||
NotifyListState();
|
NotifyListState();
|
||||||
}
|
}
|
||||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||||
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
|
||||||
|
/// 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
|
||||||
|
/// Filter-/Sortierlogik steckt in der reinen, ohne Repository-Zugriff testbaren
|
||||||
|
/// <see cref="FilterOwnDocumentation"/> — analog zu <see cref="ClassTeacherRosterRow.Build"/>.
|
||||||
|
private void LoadOwnDocumentationEntries()
|
||||||
|
{
|
||||||
|
OwnDocumentationEntries.Clear();
|
||||||
|
var entries = FilterOwnDocumentation(_documentation.GetAll(), _rosterMatches,
|
||||||
|
_loadedStart, _loadedEnd, StudentFilter);
|
||||||
|
foreach (var d in entries)
|
||||||
|
{
|
||||||
|
var name = DocumentationStudentDisplay(d, _rosterMatches);
|
||||||
|
OwnDocumentationEntries.Add(new DocumentationItem(d, name));
|
||||||
|
}
|
||||||
|
OwnDocumentationCount = OwnDocumentationEntries.Count;
|
||||||
|
OwnDocumentationFollowUpCount = entries.Count(d => d.IsDraft);
|
||||||
|
OwnDocumentationCriticalCount = entries.Count(d =>
|
||||||
|
d.Tags.Any(t => string.Equals(t, "Kritisch", StringComparison.OrdinalIgnoreCase)));
|
||||||
|
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eigene Dokumentation zu Schüler*innen der Klasse im gewählten Zeitraum: nur Einträge
|
||||||
|
/// (nicht gelöschter) Schüler*innen, die sich per Namensabgleich der WebUntis-Klasse zuordnen
|
||||||
|
/// ließen (<paramref name="rosterMatches"/>, siehe <see cref="ClassTeacherOverviewViewModel.MatchStudent"/>),
|
||||||
|
/// mit demselben Schülerfilter wie die WebUntis-Klassenbuchzeilen. Entwürfe ("Nacharbeiten")
|
||||||
|
/// zuerst, danach neueste zuerst.
|
||||||
|
public static List<Documentation> FilterOwnDocumentation(IReadOnlyList<Documentation> all,
|
||||||
|
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches,
|
||||||
|
DateOnly start, DateOnly end, string studentFilter)
|
||||||
|
{
|
||||||
|
var matchedIds = rosterMatches.Select(m => m.StudentId).ToHashSet();
|
||||||
|
return all
|
||||||
|
.Where(d => !d.IsDeleted &&
|
||||||
|
(matchedIds.Contains(d.StudentId) || IsUnassignedClassDocumentation(d)) &&
|
||||||
|
d.Date >= start && d.Date <= end)
|
||||||
|
.Where(d => MatchesOwnDocStudentFilter(d, rosterMatches, studentFilter))
|
||||||
|
.OrderByDescending(d => d.IsDraft).ThenByDescending(d => d.Date)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesOwnDocStudentFilter(Documentation d,
|
||||||
|
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches, string studentFilter)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
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]
|
||||||
|
private async Task AddOwnDocumentation()
|
||||||
|
{
|
||||||
|
if (OnEditOwnDocumentation is null || !CanAddOwnDocumentation) return;
|
||||||
|
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
|
||||||
|
var result = await OnEditOwnDocumentation(options, null);
|
||||||
|
if (result is null) return;
|
||||||
|
_documentation.Save(result);
|
||||||
|
LoadOwnDocumentationEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task EditOwnDocumentation(DocumentationItem? item)
|
||||||
|
{
|
||||||
|
if (item is null || OnEditOwnDocumentation is null) return;
|
||||||
|
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
|
||||||
|
var result = await OnEditOwnDocumentation(options, item.Model);
|
||||||
|
if (result is null) return;
|
||||||
|
_documentation.Save(result);
|
||||||
|
LoadOwnDocumentationEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DeleteOwnDocumentation(DocumentationItem? item)
|
||||||
|
{
|
||||||
|
if (item is null) return;
|
||||||
|
if (OnConfirmDeleteOwnDocumentation is not null && !await OnConfirmDeleteOwnDocumentation(item)) return;
|
||||||
|
_documentation.Delete(item.Model.Id);
|
||||||
|
LoadOwnDocumentationEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Für den "→ Vorgang"-Auswahldialog im Code-behind: offene Vorgänge, an die sich diese
|
||||||
|
/// Klassenbuchzeile anheften lässt (Durchreiche zur Geschwister-ViewModel-Instanz, siehe _cases).
|
||||||
|
public List<VorgangItem> OpenCasesFor(string studentName) => _cases.OpenCasesForStudent(studentName);
|
||||||
|
|
||||||
|
/// Heftet eine WebUntis-Klassenbuchzeile als eingefrorene Kopie an einen (ggf. neuen) Vorgang
|
||||||
|
/// im "Vorgänge"-Tab (<see cref="ClassTeacherCasesViewModel"/>) an — die Zeile selbst hat keine
|
||||||
|
/// stabile ID, deshalb Werte-Kopie statt Referenz (siehe VorgangClassRegisterEntry).
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task PinToVorgang(ClassTeacherClassRegisterRow? row)
|
||||||
|
{
|
||||||
|
if (row is null || OnPickVorgangForPin is null) return;
|
||||||
|
var choice = await OnPickVorgangForPin(row);
|
||||||
|
if (choice is null) return;
|
||||||
|
|
||||||
|
var entry = BuildClassRegisterSnapshot(row);
|
||||||
|
|
||||||
|
if (choice.ExistingVorgangId is { } vorgangId)
|
||||||
|
await _cases.PinClassRegisterEntryAsync(vorgangId, entry);
|
||||||
|
else if (!string.IsNullOrWhiteSpace(choice.NewVorgangTitle))
|
||||||
|
{
|
||||||
|
var student = _cases.RosterStudentOptions
|
||||||
|
.FirstOrDefault(s => UntisNameMatching.NamesMatch(s.Name, row.StudentName));
|
||||||
|
if (student is null) return;
|
||||||
|
await _cases.CreateAndPinAsync(choice.NewVorgangTitle, student.Id, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baut die eingefrorene Klassenbuch-Kopie aus einer Zeile — reine, ohne Repository-Zugriff
|
||||||
|
/// testbare Umwandlung.
|
||||||
|
public static VorgangClassRegisterEntry BuildClassRegisterSnapshot(ClassTeacherClassRegisterRow row) => new()
|
||||||
|
{
|
||||||
|
Date = row.Date, StudentName = row.StudentName, Subject = row.Subject,
|
||||||
|
TeacherUsername = row.TeacherUsername, CategoryName = row.CategoryName,
|
||||||
|
CategoryGroup = row.CategoryGroup, Text = row.Text,
|
||||||
|
};
|
||||||
|
|
||||||
// WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem
|
// WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem
|
||||||
// StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein
|
// StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein
|
||||||
// exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen.
|
// exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen.
|
||||||
@@ -247,6 +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(HasOwnDocumentationEntries));
|
||||||
|
OnPropertyChanged(nameof(CanAddOwnDocumentation));
|
||||||
|
OnPropertyChanged(nameof(UntisCriticalCount));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ 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 }
|
||||||
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -71,31 +75,189 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
|
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
|
||||||
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt nur
|
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage
|
||||||
/// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag.
|
/// abzüglich der über WebUntis geladenen Ferien (<see cref="ClassTeacherOverviewViewModel.CountSchoolWeekdays"/>).
|
||||||
|
/// Zählt bewusst NUR echte Fehltage (unentschuldigt oder entschuldigt abwesend), keine reinen
|
||||||
|
/// Verspätungstage mehr — Nutzer-Feedback: die Quote behandelte "10× 5 Min. verspätet" bisher
|
||||||
|
/// exakt wie "10× ganztägig unentschuldigt gefehlt", obwohl beides für die eigene Reaktion
|
||||||
|
/// (Elterngespräch vs. Achselzucken) grundverschieden ist. Reine Verspätung landet stattdessen
|
||||||
|
/// separat in <see cref="YearLateDayCount"/> (gleiche Kategorisierung wie schon länger im
|
||||||
|
/// Wochentrend, siehe <see cref="ClassTeacherOverviewViewModel.BuildTrend"/>: Unentschuldigt vor
|
||||||
|
/// Verspätet vor Entschuldigt, überschneidungsfrei).
|
||||||
public int YearAbsenceDayCount { get; init; }
|
public int YearAbsenceDayCount { get; init; }
|
||||||
public int YearUnexcusedDayCount { get; init; }
|
public int YearUnexcusedDayCount { get; init; }
|
||||||
|
/// Tage, die WebUntis als Verspätung führt (ohne Unentschuldigt-Status) — zählen bewusst nicht
|
||||||
|
/// in <see cref="YearAbsenceDayCount"/>/<see cref="YearAbsenceRatePercent"/>, tragen aber mit
|
||||||
|
/// reduziertem Gewicht zu <see cref="PatternScore"/> bei.
|
||||||
|
public int YearLateDayCount { get; init; }
|
||||||
|
/// Entschuldigt abwesende Tage (weder unentschuldigt noch nur verspätet) — Kehrwert von
|
||||||
|
/// <see cref="YearUnexcusedDayCount"/> innerhalb von <see cref="YearAbsenceDayCount"/>.
|
||||||
|
public int YearExcusedDayCount { get; init; }
|
||||||
|
/// Klassenbucheinträge anderer Lehrkräfte über das ganze bisherige Schuljahr mit
|
||||||
|
/// <c>CategoryGroup</c> "Negativ" (Nutzer-Feedback: sollen ebenfalls ins Auffälligkeitsbild
|
||||||
|
/// einfließen — "wenn die negativen Einträge durch die Decke gehen"). Anders als
|
||||||
|
/// <see cref="HasRecentClassRegisterEntry"/> (nur letzte 7 Tage, für die "Klassenbuch"-Badges)
|
||||||
|
/// bewusst über das ganze Jahr, damit sich ein Muster über die Zeit zeigen kann.
|
||||||
|
public int YearNegativeClassRegisterCount { get; init; }
|
||||||
|
/// Mindestens ein Klassenbucheintrag mit dem Schlüsselwort "suspendier(t)" im Zeitraum — soll
|
||||||
|
/// den Score unabhängig von allem anderen sofort deutlich nach oben treiben (Nutzer-Feedback:
|
||||||
|
/// "Suspendiert sollte direkt hochschießen").
|
||||||
|
public bool HasSuspensionEntry { get; init; }
|
||||||
|
/// Gewichtete Summe aus <see cref="ClassRegisterEntryWeight"/> über alle Klassenbucheinträge
|
||||||
|
/// des Schuljahres — fließt in <see cref="PatternScore"/> ein.
|
||||||
|
public double ClassRegisterScoreComponent { get; init; }
|
||||||
public int SchoolDaysElapsed { get; init; }
|
public int SchoolDaysElapsed { get; init; }
|
||||||
public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0;
|
/// Nutzer-Feedback: nachdem der Nenner zeitweise Ferientage mitzählte (siehe TODO.md), soll die
|
||||||
|
/// Herkunft der Zahl nachvollziehbar bleiben, ohne dafür die WebUntis-Ferienliste separat
|
||||||
|
/// nachschlagen zu müssen — deshalb hier sichtbar im Tooltip statt nur intern verrechnet.
|
||||||
|
public int HolidayWeekdaysExcluded { get; init; }
|
||||||
|
/// Angenommener erster Unterrichtstag (<see cref="ClassTeacherOverviewViewModel.EstimateTermStart"/>),
|
||||||
|
/// aus demselben Nachvollziehbarkeits-Grund wie <see cref="HolidayWeekdaysExcluded"/> im Tooltip
|
||||||
|
/// sichtbar — WebUntis' Ferienkalender deckt die Sommerferien selbst nicht ab (siehe TODO.md),
|
||||||
|
/// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin.
|
||||||
|
public DateOnly TermStart { get; init; }
|
||||||
|
public bool HasYearSummary => SchoolDaysElapsed > 0 &&
|
||||||
|
(YearAbsenceDayCount > 0 || YearLateDayCount > 0 || YearNegativeClassRegisterCount > 0);
|
||||||
public int YearAbsenceRatePercent =>
|
public int YearAbsenceRatePercent =>
|
||||||
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
|
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
|
||||||
public string YearSummaryLabel => HasYearSummary
|
public string YearSummaryLabel => !HasYearSummary ? "" :
|
||||||
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
|
YearAbsenceDayCount > 0 ? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn"
|
||||||
|
: YearLateDayCount > 0 ? $"{YearLateDayCount}× verspätet, keine Fehltage seit Schuljahresbeginn"
|
||||||
|
: $"{YearNegativeClassRegisterCount} negative Klassenbucheinträge seit Schuljahresbeginn";
|
||||||
public string? YearSummaryTooltip => !HasYearSummary ? null :
|
public string? YearSummaryTooltip => !HasYearSummary ? null :
|
||||||
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen mit Fehlzeit" +
|
(YearAbsenceDayCount > 0
|
||||||
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "");
|
? $"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" +
|
||||||
|
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "")
|
||||||
|
: $"Keine Fehltage seit {TermStart:dd.MM.}") +
|
||||||
|
(YearLateDayCount > 0 ? $" · {YearLateDayCount} Tage verspätet (zählt nicht zur Quote)" : "") +
|
||||||
|
(YearNegativeClassRegisterCount > 0 ? $" · {YearNegativeClassRegisterCount} negative Klassenbucheinträge" : "") +
|
||||||
|
(HasSuspensionEntry ? " · Suspendierung vermerkt" : "") +
|
||||||
|
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : "");
|
||||||
|
|
||||||
|
private static readonly string[] ConcerningKeywords = ["hausaufgabe", "mitarbeit"];
|
||||||
|
private static readonly string[] SuspensionKeywords = ["suspendier"];
|
||||||
|
|
||||||
|
/// Auffälligkeits-Score über das ganze bisherige Schuljahr statt nur "heute" (Nutzer-Feedback:
|
||||||
|
/// wer heute da ist, aber davor 5 Tage unentschuldigt fehlte, verschwand bisher komplett aus der
|
||||||
|
/// Liste, während der Musterschüler beim ersten Fehltag des Jahres auf Rang eins landete, sobald
|
||||||
|
/// er der/die einzige "heute Auffällige" war). Reine Summe ohne zeitliche Abklingung — für die
|
||||||
|
/// vom Nutzer genannten Fälle (Muster über die letzten Tage/Wochen) reicht das, ohne die
|
||||||
|
/// zusätzliche Komplexität einer Verfallskurve. Bezieht neben Fehlzeiten auch Klassenbucheinträge
|
||||||
|
/// anderer Lehrkräfte mit ein (<see cref="ClassRegisterScoreComponent"/>) — beides sind
|
||||||
|
/// unabhängige Auffälligkeits-Achsen (Anwesenheit vs. Verhalten/Leistung), die sich addieren
|
||||||
|
/// statt sich gegenseitig zu verdrängen. Gewichte sind einstellbar (<see cref="PatternScoreWeights"/>,
|
||||||
|
/// Nutzer-Entscheidung nach anfänglich festen Konstanten) und die Bereiche eines geschlossenen
|
||||||
|
/// <see cref="Vorgang"/> sind ab dessen Schließ-Datum aus dem Score ausgeklammert (<see cref="Build"/>),
|
||||||
|
/// deshalb jetzt ein zur Build-Zeit berechneter Wert statt einer reinen Formel über die
|
||||||
|
/// (unveränderten, für die Quote weiter vollständigen) Year*-Zähler.
|
||||||
|
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);
|
||||||
|
|
||||||
|
private static bool IsNegativeClassRegisterEntry(UntisForeignClassRegisterEventDto e) =>
|
||||||
|
e.CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
|
||||||
|
private static bool MatchesAnyKeyword(UntisForeignClassRegisterEventDto e, IReadOnlyList<string> keywords) =>
|
||||||
|
keywords.Any(k =>
|
||||||
|
e.CategoryName?.Contains(k, StringComparison.OrdinalIgnoreCase) == true ||
|
||||||
|
e.Text?.Contains(k, StringComparison.OrdinalIgnoreCase) == true);
|
||||||
|
|
||||||
|
private static double ClassRegisterEntryWeight(UntisForeignClassRegisterEventDto e, PatternScoreWeights weights)
|
||||||
|
{
|
||||||
|
var weight = 0.0;
|
||||||
|
if (IsNegativeClassRegisterEntry(e)) weight += weights.NegativeClassRegisterEntryWeight;
|
||||||
|
if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += weights.ConcerningKeywordBonus;
|
||||||
|
if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += weights.SuspensionKeywordWeight;
|
||||||
|
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,
|
||||||
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
|
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
|
||||||
DateOnly? today = null,
|
DateOnly? today = null,
|
||||||
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
|
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
|
||||||
int schoolDaysElapsed = 0)
|
int schoolDaysElapsed = 0,
|
||||||
|
int holidayWeekdaysExcluded = 0,
|
||||||
|
DateOnly termStart = default,
|
||||||
|
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());
|
||||||
@@ -103,6 +265,12 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
.ToDictionary(g => g.Key, g => g.First());
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
|
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
// Für den Score bewusst über das ganze Schuljahr statt nur die letzten 7 Tage (anders als
|
||||||
|
// recentClassRegisterEntries oben, das für HasRecentClassRegisterEntry/-Badges weiterhin nur
|
||||||
|
// die aktuelle Woche abbildet) — fällt auf recentClassRegisterEntries zurück, wenn der
|
||||||
|
// Aufrufer keine eigene Jahresliste mitgibt (z.B. bestehende Tests).
|
||||||
|
var yearRegisterByName = (yearClassRegisterEntries ?? recentClassRegisterEntries)
|
||||||
|
.GroupBy(e => UntisNameMatching.NameKey(e.StudentName)).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
var yearRows = yearAbsences ?? [];
|
var yearRows = yearAbsences ?? [];
|
||||||
var yearByKey = yearRows.Where(a => a.ExternKey is not null)
|
var yearByKey = yearRows.Where(a => a.ExternKey is not null)
|
||||||
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList());
|
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
@@ -117,15 +285,47 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
|
var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
|
||||||
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) ?? [];
|
||||||
|
|
||||||
|
// 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)
|
||||||
{
|
{
|
||||||
TodayAbsence = absence,
|
TodayAbsence = absence,
|
||||||
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate),
|
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate),
|
||||||
YearAbsenceDayCount = yearEntries.Count,
|
// Unentschuldigt vor Verspätet vor Entschuldigt, überschneidungsfrei — dieselbe
|
||||||
|
// Kategorisierung wie im Wochentrend (BuildTrend). Nur Verspätung (nicht auch
|
||||||
|
// unentschuldigt) fällt bewusst aus YearAbsenceDayCount heraus, siehe dort.
|
||||||
|
YearAbsenceDayCount = yearEntries.Count(r => r.IsUnexcused || !r.IsLate),
|
||||||
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
|
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
|
||||||
|
YearLateDayCount = yearEntries.Count(r => !r.IsUnexcused && r.IsLate),
|
||||||
|
YearExcusedDayCount = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate),
|
||||||
|
YearNegativeClassRegisterCount = yearRegisterEntries.Count(IsNegativeClassRegisterEntry),
|
||||||
|
HasSuspensionEntry = yearRegisterEntries.Any(e => MatchesAnyKeyword(e, SuspensionKeywords)),
|
||||||
|
ClassRegisterScoreComponent = yearRegisterEntries.Sum(e => ClassRegisterEntryWeight(e, effectiveWeights)),
|
||||||
|
PatternScore = patternScore,
|
||||||
|
RegressionScore = regressionScore,
|
||||||
|
RegressionVorgangTitle = mostRecentClosed?.Title,
|
||||||
SchoolDaysElapsed = schoolDaysElapsed,
|
SchoolDaysElapsed = schoolDaysElapsed,
|
||||||
|
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
|
||||||
|
TermStart = termStart,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
|
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
|
||||||
@@ -157,7 +357,7 @@ public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int U
|
|||||||
$"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt";
|
$"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt";
|
||||||
}
|
}
|
||||||
public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
|
public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
|
||||||
ClassTeacherStatusKind Kind)
|
ClassTeacherStatusKind Kind, bool CanCreateReminder = false)
|
||||||
{
|
{
|
||||||
public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger;
|
public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger;
|
||||||
public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning;
|
public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning;
|
||||||
@@ -182,37 +382,59 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date
|
|||||||
|
|
||||||
public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build(
|
public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build(
|
||||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) =>
|
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) =>
|
||||||
absenceDays.Where(a => a.IsUnexcused)
|
// Verspätungen ausgeschlossen: dafür gibt es i.d.R. keine Entschuldigungspflicht, eine
|
||||||
|
// "Entschuldigung fehlt"-Erinnerung wäre hier gegenstandslos. Wiederholte Verspätungen
|
||||||
|
// laufen stattdessen über die Mustererkennung (siehe BuildPatternNotices).
|
||||||
|
absenceDays.Where(a => a.IsUnexcused && !a.IsLate)
|
||||||
.Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date,
|
.Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date,
|
||||||
today.DayNumber - a.Date.DayNumber))
|
today.DayNumber - a.Date.DayNumber))
|
||||||
.OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName)
|
.OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName)
|
||||||
.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;
|
||||||
|
private readonly WebUntisIntegrationService _untis;
|
||||||
private readonly UntisReportCacheService _cache;
|
private readonly UntisReportCacheService _cache;
|
||||||
private readonly SchoolYearService _schoolYear;
|
private readonly SchoolYearService _schoolYear;
|
||||||
private readonly IWorkTaskRepository _workTasks;
|
private readonly IWorkTaskRepository _workTasks;
|
||||||
private readonly IStudentRepository _students;
|
private readonly IStudentRepository _students;
|
||||||
|
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;
|
||||||
|
|
||||||
public ClassTeacherDetailsViewModel DetailsTab { get; }
|
public ClassTeacherDetailsViewModel DetailsTab { get; }
|
||||||
|
public ClassTeacherCasesViewModel CasesTab { get; }
|
||||||
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
|
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
|
||||||
public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = [];
|
public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = [];
|
||||||
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
|
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
|
||||||
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; } = [];
|
||||||
|
|
||||||
[ObservableProperty] private string? _homeroomClassName;
|
[ObservableProperty] private string? _homeroomClassName;
|
||||||
[ObservableProperty] private int _activeTabIndex;
|
[ObservableProperty] private int _activeTabIndex;
|
||||||
[ObservableProperty] private string _status = "";
|
[ObservableProperty] private string _status = "";
|
||||||
[ObservableProperty] private bool _busy;
|
[ObservableProperty] private bool _busy;
|
||||||
[ObservableProperty] private string _searchText = "";
|
[ObservableProperty] private string _searchText = "";
|
||||||
[ObservableProperty] private int _selectedRosterFilter;
|
/// Standardmäßig der Muster-Reiter (3) statt "Auffällig" (0) — Nutzer-Feedback: die reine
|
||||||
|
/// Heute-Ansicht lässt Schüler*innen mit Vorgeschichte (z.B. 5 Tage unentschuldigt letzte
|
||||||
|
/// Woche, heute aber da) komplett verschwinden, während ein erstmaliger Einzelfehltag ganz oben
|
||||||
|
/// steht, sobald er der/die einzige "heute Auffällige" ist.
|
||||||
|
[ObservableProperty] private int _selectedRosterFilter = 3;
|
||||||
[ObservableProperty] private string _primarySectionTitle = "Heute auffällig";
|
[ObservableProperty] private string _primarySectionTitle = "Heute auffällig";
|
||||||
[ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen";
|
[ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen";
|
||||||
[ObservableProperty] private int _studentCount;
|
[ObservableProperty] private int _studentCount;
|
||||||
@@ -225,6 +447,19 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
[ObservableProperty] private int _unexcusedAbsenceCount;
|
[ObservableProperty] private int _unexcusedAbsenceCount;
|
||||||
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
|
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
|
||||||
[ObservableProperty] private int _openExcuseOverflowCount;
|
[ObservableProperty] private int _openExcuseOverflowCount;
|
||||||
|
/// Eigene Dokumentation zu Schüler*innen der Klasse mit "Nacharbeiten"-Status bzw. dem
|
||||||
|
/// "Kritisch"-Tag — Kurzform derselben Zählung wie im Klassenbuch-Tab (siehe
|
||||||
|
/// <see cref="ClassTeacherDetailsViewModel.OwnDocumentationFollowUpCount"/>), hier direkt an
|
||||||
|
/// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte.
|
||||||
|
[ObservableProperty] private int _ownDocumentationFollowUpCount;
|
||||||
|
[ObservableProperty] private int _ownDocumentationCriticalCount;
|
||||||
|
|
||||||
|
// 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;
|
||||||
@@ -233,9 +468,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
public bool HasPatternNotices => PatternNotices.Count > 0;
|
public bool HasPatternNotices => PatternNotices.Count > 0;
|
||||||
public bool HasOpenExcuses => OpenExcuses.Count > 0;
|
public bool HasOpenExcuses => OpenExcuses.Count > 0;
|
||||||
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
|
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
|
||||||
|
public bool HasOwnDocumentationAlerts => OwnDocumentationFollowUpCount > 0 || OwnDocumentationCriticalCount > 0;
|
||||||
public bool AlertsFilterSelected => SelectedRosterFilter == 0;
|
public bool AlertsFilterSelected => SelectedRosterFilter == 0;
|
||||||
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
|
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
|
||||||
public bool AllFilterSelected => SelectedRosterFilter == 2;
|
public bool AllFilterSelected => SelectedRosterFilter == 2;
|
||||||
|
public bool PatternScoreFilterSelected => SelectedRosterFilter == 3;
|
||||||
public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount);
|
public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount);
|
||||||
public int LatePercent => Percent(LateCount);
|
public int LatePercent => Percent(LateCount);
|
||||||
public int PresentPercent => Percent(PresentCount);
|
public int PresentPercent => Percent(PresentCount);
|
||||||
@@ -264,43 +501,84 @@ 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 => DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy",
|
||||||
|
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||||
|
|
||||||
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,
|
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
|
||||||
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
||||||
IStudentRepository students, IParticipationRepository participation,
|
IStudentRepository students, IDocumentationRepository documentation, IVorgangRepository vorgaenge,
|
||||||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab)
|
PatternScoreSettingsService patternScoreSettings, IParticipationRepository participation,
|
||||||
|
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
|
||||||
|
ClassTeacherCasesViewModel casesTab, AppLogger? logger = null)
|
||||||
{
|
{
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
_untis = untis;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_schoolYear = schoolYear;
|
_schoolYear = schoolYear;
|
||||||
_workTasks = workTasks;
|
_workTasks = workTasks;
|
||||||
_students = students;
|
_students = students;
|
||||||
|
_documentation = documentation;
|
||||||
|
_vorgaenge = vorgaenge;
|
||||||
|
_patternScoreSettings = patternScoreSettings;
|
||||||
_participation = participation;
|
_participation = participation;
|
||||||
_participationSessions = participationSessions;
|
_participationSessions = participationSessions;
|
||||||
|
_logger = logger;
|
||||||
DetailsTab = detailsTab;
|
DetailsTab = detailsTab;
|
||||||
|
CasesTab = casesTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
|
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
|
||||||
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 OnOwnDocumentationCriticalCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
|
||||||
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
|
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
|
||||||
partial void OnSelectedRosterFilterChanged(int value)
|
partial void OnSelectedRosterFilterChanged(int value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(AlertsFilterSelected));
|
OnPropertyChanged(nameof(AlertsFilterSelected));
|
||||||
OnPropertyChanged(nameof(ClassRegisterFilterSelected));
|
OnPropertyChanged(nameof(ClassRegisterFilterSelected));
|
||||||
OnPropertyChanged(nameof(AllFilterSelected));
|
OnPropertyChanged(nameof(AllFilterSelected));
|
||||||
|
OnPropertyChanged(nameof(PatternScoreFilterSelected));
|
||||||
ApplyRosterFilter();
|
ApplyRosterFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ResetRosterState()
|
||||||
|
{
|
||||||
|
ActiveTabIndex = 0;
|
||||||
|
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
|
||||||
|
OpenExcuses.Clear(); CompactMonthDays.Clear(); 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.";
|
||||||
@@ -310,55 +588,121 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
|
|
||||||
var className = HomeroomClassName!;
|
var className = HomeroomClassName!;
|
||||||
DetailsTab.Initialize(className);
|
DetailsTab.Initialize(className);
|
||||||
|
CasesTab.Initialize(className);
|
||||||
Busy = true;
|
Busy = true;
|
||||||
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);
|
||||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today);
|
// Seit Schuljahresbeginn statt nur die letzte Woche (Nutzer-Feedback: Klassenbucheinträge
|
||||||
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask);
|
// anderer Lehrkräfte — z.B. fehlende Hausaufgaben, schlechte Mitarbeit, im Extremfall eine
|
||||||
|
// Suspendierung — sollen wie die Fehlzeiten in den Auffälligkeits-Score einfließen, nicht
|
||||||
|
// nur die letzten 7 Tage). Dieselbe "kalte Historie bleibt gecacht"-Logik wie bei den
|
||||||
|
// Fehlzeiten oben, kein zusätzliches Abruf-Risiko. Die "letzte 7 Tage"-Badges
|
||||||
|
// (RecentClassRegisterCount, HasRecentClassRegisterEntry) werden weiterhin unten aus
|
||||||
|
// genau diesem einen Abruf herausgefiltert statt separat erneut abgerufen.
|
||||||
|
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, yearStart, today);
|
||||||
|
var holidaysTask = GetHolidaysAsync();
|
||||||
|
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 weekAbsenceDays = absenceDaysYear.Where(a => a.Date >= sevenDayStart).ToList();
|
_lastClassRegisterEvents = classRegisterTask.Result;
|
||||||
var schoolDaysElapsed = CountWeekdays(yearStart, today);
|
_lastHolidays = holidaysTask.Result;
|
||||||
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
BuildRosterFromCachedData(studentsTask.Result, absencesTask.Result, classRegisterTask.Result,
|
||||||
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed)) Roster.Add(row);
|
holidaysTask.Result, today, yearStart, sevenDayStart, trendDays);
|
||||||
|
|
||||||
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 = classRegisterTask.Result.Count;
|
|
||||||
BuildTrend(absenceDaysYear, trendDays);
|
|
||||||
BuildPatternNotices(weekAbsenceDays);
|
|
||||||
BuildWeekdayPatternNotices(absenceDaysYear);
|
|
||||||
BuildAttendanceParticipationNotices();
|
|
||||||
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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
[RelayCommand] private void ShowPatternScore() => SelectedRosterFilter = 3;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenClassRegister()
|
private void OpenClassRegister()
|
||||||
@@ -376,6 +720,64 @@ 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 = "";
|
||||||
|
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))
|
||||||
|
CompactMonthDays.Add(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<ClassTeacherCompactCalendarDay> BuildCompactMonthDays(DateOnly month,
|
||||||
|
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||||
|
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today)
|
||||||
|
{
|
||||||
|
var first = new DateOnly(month.Year, month.Month, 1);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
@@ -406,6 +808,18 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage";
|
PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage";
|
||||||
foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row);
|
foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row);
|
||||||
}
|
}
|
||||||
|
else if (SelectedRosterFilter == 3)
|
||||||
|
{
|
||||||
|
// Nutzer-Feedback: "heute auffällig" allein lässt Schüler*innen mit echter Vorgeschichte
|
||||||
|
// verschwinden, sobald sie an einem Tag zufällig anwesend sind, und lässt einen
|
||||||
|
// einmaligen Erstfehltag ganz oben stehen, sobald er der/die einzige "heute Auffällige"
|
||||||
|
// ist. Deshalb eigene Sortierung nach dem kumulierten Score übers Schuljahr statt nach
|
||||||
|
// dem heutigen Status — bewusst unabhängig von HasAbsenceToday.
|
||||||
|
PrimarySectionTitle = "Nach Muster seit Schuljahresbeginn";
|
||||||
|
foreach (var row in query.Where(r => r.PatternScore > 0)
|
||||||
|
.OrderByDescending(r => r.PatternScore).ThenBy(r => r.StudentName))
|
||||||
|
PrimaryRoster.Add(row);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
PrimarySectionTitle = "Heute auffällig";
|
PrimarySectionTitle = "Heute auffällig";
|
||||||
@@ -438,6 +852,9 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
(double)day.Unexcused / max, (double)day.LateExcused / max, (double)day.Excused / max));
|
(double)day.Unexcused / max, (double)day.LateExcused / max, (double)day.Excused / max));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryDate(int value, out DateOnly date) =>
|
||||||
|
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||||
|
|
||||||
/// Letzte <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne
|
/// Letzte <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne
|
||||||
/// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine
|
/// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine
|
||||||
/// Schulferien, nur Wochenenden.
|
/// Schulferien, nur Wochenenden.
|
||||||
@@ -451,34 +868,123 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
return days;
|
return days;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nenner für <see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/> — Werktage zwischen
|
/// Nutzer-Feedback: Schüler*innen, die seit Unterrichtsbeginn nachweislich an jedem Tag
|
||||||
/// Schuljahresbeginn und heute, ebenfalls ohne Ferienkalender.
|
/// fehlten, zeigten trotzdem nur ~57 % Fehlquote statt der erwarteten ~100 %. Ursache: der
|
||||||
private static int CountWeekdays(DateOnly start, DateOnly end)
|
/// Nenner zählte bislang jeden Werktag ab dem fest verdrahteten 1. August
|
||||||
|
/// (<see cref="SchoolYearService.SchoolYearStart"/>) als "Schultag" mit — die Sommerferien
|
||||||
|
/// enden je nach Bundesland/Jahr aber erst Wochen später, und genau zu Schuljahresbeginn macht
|
||||||
|
/// diese Ferienzeit einen großen Teil des bis dahin "verstrichenen" Zeitraums aus. WebUntis
|
||||||
|
/// kennt einen Teil des echten Ferienkalenders (<c>getHolidays</c>-Bericht, hier zwischen den
|
||||||
|
/// Werktagen ausgeschlossen) und behebt damit die kleineren Verzerrungen durch Herbst-/
|
||||||
|
/// Weihnachts-/Osterferien im weiteren Jahresverlauf. Die Sommerferien selbst liefert
|
||||||
|
/// <c>getHolidays</c> nach Prüfung der echten Antwort für dieses Konto aber NIE (über 11 Jahre
|
||||||
|
/// zurück kein einziger Sommerferien-Eintrag, siehe <see cref="EstimateTermStart"/>) — sie
|
||||||
|
/// liegen WebUntis-intern vermutlich außerhalb jedes Schuljahres-Datensatzes (der bei 1.8./31.7.
|
||||||
|
/// endet), nicht "in" einem davon. Für die Sommerferien bleibt deshalb weiterhin
|
||||||
|
/// <see cref="EstimateTermStart"/> nötig, das den Startpunkt selbst korrigiert statt Tage
|
||||||
|
/// innerhalb des Zeitraums abzuziehen.
|
||||||
|
public static int CountSchoolWeekdays(DateOnly start, DateOnly end, IReadOnlyList<CachedUntisHoliday> holidays)
|
||||||
{
|
{
|
||||||
if (end < start) return 0;
|
if (end < start) return 0;
|
||||||
var count = 0;
|
var count = 0;
|
||||||
for (var d = start; d <= end; d = d.AddDays(1))
|
for (var d = start; d <= end; d = d.AddDays(1))
|
||||||
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) count++;
|
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday) &&
|
||||||
|
!holidays.Any(h => d >= h.Start && d <= h.End))
|
||||||
|
count++;
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
|
/// Ergänzt <see cref="CountSchoolWeekdays"/> um genau die Lücke, die WebUntis' echter
|
||||||
|
/// Ferienkalender nicht schließt: die Sommerferien. Nimmt den frühesten Tag mit irgendeinem
|
||||||
|
/// Fehlzeiten-Eintrag der ganzen Klasse als Näherung für den tatsächlichen ersten
|
||||||
|
/// Unterrichtstag (ein solcher Eintrag kann nur an einem Tag mit tatsächlich stattfindendem
|
||||||
|
/// Unterricht entstehen) — Rückfall auf <paramref name="fallback"/>, wenn noch keine
|
||||||
|
/// Fehlzeiten vorliegen (dann bleibt <see cref="ClassTeacherRosterRow.HasYearSummary"/>
|
||||||
|
/// ohnehin ausgeblendet).
|
||||||
|
public static DateOnly EstimateTermStart(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly fallback) =>
|
||||||
|
absenceDaysYear.Count > 0 ? absenceDaysYear.Min(a => a.Date) : fallback;
|
||||||
|
|
||||||
|
/// Ferien ändern sich innerhalb eines Schuljahrs praktisch nie (anders als die
|
||||||
|
/// Fehlzeiten-/Klassenbuchberichte, deshalb hier keine der stündlichen "heißes Fenster"-Logik
|
||||||
|
/// aus <see cref="UntisReportCacheService"/>, sondern ein einfacher tagesgenauer Cache über
|
||||||
|
/// <see cref="WebUntisSettingsService"/> — kein zusätzliches LiteDB-Repository nötig). Schlägt
|
||||||
|
/// der Live-Abruf fehl (z. B. kurzzeitig kein Netz), wird der zuletzt bekannte Stand
|
||||||
|
/// weiterverwendet statt die ganze Übersicht mit einem Fehler zu blockieren; ist noch nie
|
||||||
|
/// erfolgreich abgerufen worden, bleibt die Liste leer und <see cref="CountSchoolWeekdays"/>
|
||||||
|
/// verhält sich wie vorher (reine Werktagszählung ohne Ferienabzug).
|
||||||
|
private static readonly TimeSpan HolidaysRefreshInterval = TimeSpan.FromDays(1);
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<CachedUntisHoliday>> GetHolidaysAsync()
|
||||||
{
|
{
|
||||||
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
var cached = _settings.GetCachedHolidays();
|
||||||
|
if (cached is not null && _settings.HolidaysFetchedAt is { } fetchedAt &&
|
||||||
|
DateTime.UtcNow - fetchedAt < HolidaysRefreshInterval)
|
||||||
|
return cached;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fresh = (await _untis.GetHolidaysAsync())
|
||||||
|
.Select(h => new CachedUntisHoliday(h.Name, ParseDate(h.StartDate), ParseDate(h.EndDate)))
|
||||||
|
.ToList();
|
||||||
|
_settings.SetCachedHolidays(fresh, DateTime.UtcNow);
|
||||||
|
_logger?.Info("Klassenlehrer: WebUntis-Ferien geladen — " +
|
||||||
|
string.Join("; ", fresh.Select(h => $"{h.Name} {h.Start:yyyy-MM-dd}..{h.End:yyyy-MM-dd}")));
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
catch (WebUntisIntegrationException ex)
|
||||||
|
{
|
||||||
|
_logger?.Error("Klassenlehrer: WebUntis-Ferienabruf fehlgeschlagen", ex);
|
||||||
|
return cached ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly ParseDate(int value) => DateOnly.ParseExact(value.ToString(), "yyyyMMdd");
|
||||||
|
|
||||||
|
private const int LateYearEscalationThreshold = 5;
|
||||||
|
|
||||||
|
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly sevenDayStart)
|
||||||
|
{
|
||||||
|
var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName))
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().StudentName);
|
||||||
|
foreach (var notice in DetectLatePatterns(absenceDaysYear, displayNames, sevenDayStart))
|
||||||
|
PatternNotices.Add(notice);
|
||||||
|
OnPropertyChanged(nameof(HasPatternNotices));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verspätungen sind i.d.R. nicht entschuldigungsfähig (siehe <see cref="ClassTeacherOpenExcuseRow.Build"/>)
|
||||||
|
/// — eine Häufung ist trotzdem ein Signal, nur eines für ein Elterngespräch/einen Brief statt
|
||||||
|
/// für eine fehlende Entschuldigung. Zwei Stufen, Nutzer-Feedback: ein kurzfristiges Cluster
|
||||||
|
/// (<paramref name="sevenDayStart"/>..heute) bleibt ein sanfter Hinweis ohne Aktion, eine hohe
|
||||||
|
/// Jahressumme eskaliert zu Danger mit Wiedervorlage-Option
|
||||||
|
/// (<see cref="CreateReminderForPatternNoticeCommand"/>). Reine, ohne ViewModel-Zustand
|
||||||
|
/// testbare Kernlogik (gleiches Muster wie <see cref="DetectWeekdayPatterns"/>).
|
||||||
|
public static IReadOnlyList<ClassTeacherPatternNotice> DetectLatePatterns(
|
||||||
|
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear,
|
||||||
|
IReadOnlyDictionary<string, string> displayNamesByKey,
|
||||||
|
DateOnly sevenDayStart,
|
||||||
|
int lateYearEscalationThreshold = LateYearEscalationThreshold)
|
||||||
|
{
|
||||||
|
var notices = new List<ClassTeacherPatternNotice>();
|
||||||
|
foreach (var group in absenceDaysYear.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
||||||
{
|
{
|
||||||
var rows = group.ToList();
|
var rows = group.ToList();
|
||||||
var displayName = Roster.FirstOrDefault(row =>
|
var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName;
|
||||||
UntisNameMatching.NameKey(row.StudentName) == group.Key)?.StudentName ?? rows[0].StudentDisplayName;
|
var weekRows = rows.Where(row => row.Date >= sevenDayStart).ToList();
|
||||||
var lateDays = rows.Count(row => row.IsLate);
|
var lateDaysWeek = weekRows.Count(row => row.IsLate);
|
||||||
var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate);
|
var unexcusedDaysWeek = weekRows.Count(row => row.IsUnexcused && !row.IsLate);
|
||||||
if (unexcusedDays >= 2)
|
var lateDaysYear = rows.Count(row => row.IsLate);
|
||||||
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
|
|
||||||
$"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
|
if (unexcusedDaysWeek >= 2)
|
||||||
else if (lateDays >= 2)
|
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||||||
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
|
$"{unexcusedDaysWeek} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
|
||||||
$"{lateDays}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
|
else if (lateDaysYear >= lateYearEscalationThreshold)
|
||||||
|
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||||||
|
$"{lateDaysYear} Verspätungen seit Schuljahresbeginn – Elterngespräch oder Brief erwägen",
|
||||||
|
ClassTeacherStatusKind.Danger, CanCreateReminder: true));
|
||||||
|
else if (lateDaysWeek >= 2)
|
||||||
|
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||||||
|
$"{lateDaysWeek}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
|
||||||
}
|
}
|
||||||
OnPropertyChanged(nameof(HasPatternNotices));
|
return notices;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten
|
/// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten
|
||||||
@@ -572,6 +1078,44 @@ 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
|
||||||
|
/// 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
|
||||||
|
/// verschwinden, nur weil er nicht mehr taufrisch ist. Selbe Namensabgleich-Logik wie
|
||||||
|
/// <see cref="BuildAttendanceParticipationNotices"/>.
|
||||||
|
private void BuildOwnDocumentationCounts()
|
||||||
|
{
|
||||||
|
var students = _students.GetAll();
|
||||||
|
var matchedIds = Roster
|
||||||
|
.Select(r => MatchStudent(r.StudentName, students))
|
||||||
|
.Where(s => s is not null)
|
||||||
|
.Select(s => s!.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
var docs = _documentation.GetAll().Where(d => !d.IsDeleted && matchedIds.Contains(d.StudentId)).ToList();
|
||||||
|
OwnDocumentationFollowUpCount = docs.Count(d => d.IsDraft);
|
||||||
|
OwnDocumentationCriticalCount = docs.Count(d =>
|
||||||
|
d.Tags.Any(t => string.Equals(t, "Kritisch", StringComparison.OrdinalIgnoreCase)));
|
||||||
|
}
|
||||||
|
|
||||||
/// Reine, ohne Repository-Zugriff testbare Zuordnungslogik. Nutzt <c>FirstName</c>/<c>LastName</c>
|
/// Reine, ohne Repository-Zugriff testbare Zuordnungslogik. Nutzt <c>FirstName</c>/<c>LastName</c>
|
||||||
/// statt <see cref="Student.FullName"/>, weil dessen "Nachname, Vorname"-Format mit Komma den
|
/// statt <see cref="Student.FullName"/>, weil dessen "Nachname, Vorname"-Format mit Komma den
|
||||||
/// leerzeichenbasierten Wortabgleich in <see cref="UntisNameMatching"/> verfälschen würde
|
/// leerzeichenbasierten Wortabgleich in <see cref="UntisNameMatching"/> verfälschen würde
|
||||||
@@ -635,6 +1179,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
urgent: row.IsOverdue);
|
urgent: row.IsOverdue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void CreateReminderForPatternNotice(ClassTeacherPatternNotice? notice)
|
||||||
|
{
|
||||||
|
if (notice is null || !notice.CanCreateReminder) return;
|
||||||
|
SaveReminder(notice.StudentName, notice.Message, urgent: true);
|
||||||
|
}
|
||||||
|
|
||||||
private void SaveReminder(string studentName, string notes, bool urgent)
|
private void SaveReminder(string studentName, string notes, bool urgent)
|
||||||
{
|
{
|
||||||
var task = new WorkTask
|
var task = new WorkTask
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
|
|
||||||
|
// ── Anzeige-Helfer ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static class VorgangTagDisplay
|
||||||
|
{
|
||||||
|
// Vom Nutzer selbst genannte Schlagwörter — eigene Labels bleiben trotzdem frei möglich
|
||||||
|
// (AutoCompleteBox, gleiches Muster wie DocumentationTagDisplay.Suggestions).
|
||||||
|
public static string[] Suggestions { get; } =
|
||||||
|
["Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte", "Mitarbeit", "Elternkontakt", "Eskalation"];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class VorgangStatusDisplay
|
||||||
|
{
|
||||||
|
public static string Label(VorgangStatus status) => status == VorgangStatus.Closed ? "Geschlossen" : "Offen";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ergebnis des "→ Vorgang"-Auswahldialogs beim Anheften einer Klassenbuchzeile: entweder ein
|
||||||
|
/// bestehender, offener Vorgang oder der Titel für einen neu anzulegenden.
|
||||||
|
public sealed record PinToVorgangChoice(Guid? ExistingVorgangId, string? NewVorgangTitle);
|
||||||
|
|
||||||
|
/// Eingefrorene WebUntis-Klassenbuchzeile mit deutscher Anzeige-Aufbereitung — das Core-Modell
|
||||||
|
/// <see cref="VorgangClassRegisterEntry"/> bleibt bewusst framework-frei ohne Formatierungslogik.
|
||||||
|
public sealed record VorgangClassRegisterEntryRow(VorgangClassRegisterEntry Model)
|
||||||
|
{
|
||||||
|
public string DateLabel => Model.Date.ToString("dd.MM.yyyy");
|
||||||
|
public string StudentName => Model.StudentName;
|
||||||
|
public string SummaryLabel => string.Join(" · ", new[] { Model.Subject, Model.CategoryName, Model.Text }
|
||||||
|
.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mehrfachauswahl Schüler*innen (Anlegen/Bearbeiten-Dialog) ────────────────
|
||||||
|
|
||||||
|
public partial class VorgangStudentOption(Guid studentId, string name) : ObservableObject
|
||||||
|
{
|
||||||
|
public Guid StudentId { get; } = studentId;
|
||||||
|
public string Name { get; } = name;
|
||||||
|
[ObservableProperty] private bool _isSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Listen-Eintrag mit aufklappbarer Detailansicht ───────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ein Vorgang in der Liste des "Vorgänge"-Tabs. Verknüpfte Dokumentation/Klassenbucheinträge
|
||||||
|
/// werden von <see cref="ClassTeacherCasesViewModel"/> beim Laden befüllt; Link/Unlink-Aktionen
|
||||||
|
/// laufen über die hier übergebenen Callbacks zurück ins ViewModel (statt eigenem Repository-
|
||||||
|
/// Zugriff hier), damit dieser Wrapper ein reiner Anzeige-Baustein bleibt — analog
|
||||||
|
/// <see cref="DocumentationItem"/>.
|
||||||
|
/// </summary>
|
||||||
|
public partial class VorgangItem : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly Func<VorgangItem, DocumentationItem, Task> _onLink;
|
||||||
|
private readonly Func<VorgangItem, DocumentationItem, Task> _onUnlink;
|
||||||
|
private readonly Func<VorgangItem, VorgangClassRegisterEntryRow, Task> _onRemoveClassRegisterEntry;
|
||||||
|
private readonly Func<DocumentationItem, Task> _onEditDocumentation;
|
||||||
|
|
||||||
|
public Vorgang Model { get; }
|
||||||
|
public string StudentNames { get; }
|
||||||
|
public List<TagChip> TagChips { get; }
|
||||||
|
public string StatusLabel => VorgangStatusDisplay.Label(Model.Status);
|
||||||
|
public bool IsOpen => Model.Status == VorgangStatus.Open;
|
||||||
|
public string CreatedLabel => $"Angelegt {Model.CreatedAt:dd.MM.yyyy}";
|
||||||
|
|
||||||
|
public ObservableCollection<DocumentationItem> LinkedDocumentation { get; } = [];
|
||||||
|
public ObservableCollection<DocumentationItem> AvailableDocumentation { get; } = [];
|
||||||
|
public ObservableCollection<VorgangClassRegisterEntryRow> ClassRegisterRows { get; } = [];
|
||||||
|
|
||||||
|
public bool HasLinkedDocumentation => LinkedDocumentation.Count > 0;
|
||||||
|
public bool HasAvailableDocumentation => AvailableDocumentation.Count > 0;
|
||||||
|
public bool HasClassRegisterRows => ClassRegisterRows.Count > 0;
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _isRevealed;
|
||||||
|
|
||||||
|
public VorgangItem(Vorgang model, string studentNames,
|
||||||
|
Func<VorgangItem, DocumentationItem, Task> onLink,
|
||||||
|
Func<VorgangItem, DocumentationItem, Task> onUnlink,
|
||||||
|
Func<VorgangItem, VorgangClassRegisterEntryRow, Task> onRemoveClassRegisterEntry,
|
||||||
|
Func<DocumentationItem, Task> onEditDocumentation)
|
||||||
|
{
|
||||||
|
Model = model;
|
||||||
|
StudentNames = studentNames;
|
||||||
|
TagChips = model.Tags.Select(t => new TagChip(t)).ToList();
|
||||||
|
_onLink = onLink;
|
||||||
|
_onUnlink = onUnlink;
|
||||||
|
_onRemoveClassRegisterEntry = onRemoveClassRegisterEntry;
|
||||||
|
_onEditDocumentation = onEditDocumentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
[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]
|
||||||
|
private async Task Link(DocumentationItem? doc)
|
||||||
|
{
|
||||||
|
if (doc is not null) await _onLink(this, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task Unlink(DocumentationItem? doc)
|
||||||
|
{
|
||||||
|
if (doc is not null) await _onUnlink(this, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RemoveClassRegisterEntry(VorgangClassRegisterEntryRow? row)
|
||||||
|
{
|
||||||
|
if (row is not null) await _onRemoveClassRegisterEntry(this, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dialog: Vorgang anlegen/bearbeiten ────────────────────────────────────
|
||||||
|
|
||||||
|
public partial class VorgangDialogViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly Vorgang? _editing;
|
||||||
|
|
||||||
|
public ObservableCollection<VorgangStudentOption> StudentOptions { get; }
|
||||||
|
|
||||||
|
[ObservableProperty] private string _title = "";
|
||||||
|
[ObservableProperty] private string _description = "";
|
||||||
|
[ObservableProperty] private string _newTag = "";
|
||||||
|
public ObservableCollection<string> Tags { get; } = [];
|
||||||
|
public string[] TagSuggestions => VorgangTagDisplay.Suggestions;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _titleError = "";
|
||||||
|
[ObservableProperty] private string _studentsError = "";
|
||||||
|
|
||||||
|
public string DialogTitle => _editing is null ? "Vorgang anlegen" : "Vorgang bearbeiten";
|
||||||
|
public Vorgang? Result { get; private set; }
|
||||||
|
|
||||||
|
public VorgangDialogViewModel(List<StudentOption> rosterStudents, Vorgang? editing)
|
||||||
|
{
|
||||||
|
_editing = editing;
|
||||||
|
StudentOptions = new ObservableCollection<VorgangStudentOption>(rosterStudents.Select(s =>
|
||||||
|
new VorgangStudentOption(s.Id, s.Name) { IsSelected = editing?.StudentIds.Contains(s.Id) == true }));
|
||||||
|
if (editing is null) return;
|
||||||
|
|
||||||
|
Title = editing.Title;
|
||||||
|
Description = editing.Description;
|
||||||
|
foreach (var tag in editing.Tags) Tags.Add(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void AddTag()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(NewTag)) return;
|
||||||
|
var tag = NewTag.Trim();
|
||||||
|
if (!Tags.Contains(tag)) Tags.Add(tag);
|
||||||
|
NewTag = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand] private void RemoveTag(string? tag) { if (tag is not null) Tags.Remove(tag); }
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
TitleError = ""; StudentsError = "";
|
||||||
|
var valid = true;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
|
||||||
|
var selectedIds = StudentOptions.Where(s => s.IsSelected).Select(s => s.StudentId).ToList();
|
||||||
|
if (selectedIds.Count == 0) { StudentsError = "Mindestens eine/n Schüler*in auswählen."; valid = false; }
|
||||||
|
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
|
Result = _editing ?? new Vorgang();
|
||||||
|
Result.Title = Title.Trim();
|
||||||
|
Result.Description = (Description ?? "").Trim();
|
||||||
|
Result.StudentIds = selectedIds;
|
||||||
|
Result.Tags = Tags.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dialog: Klassenbuchzeile an (bestehenden oder neuen) Vorgang anheften ────
|
||||||
|
|
||||||
|
public partial class PinToVorgangDialogViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
public string RowSummary { get; }
|
||||||
|
public List<VorgangItem> MatchingOpenCases { get; }
|
||||||
|
public bool HasMatchingOpenCases => MatchingOpenCases.Count > 0;
|
||||||
|
|
||||||
|
[ObservableProperty] private VorgangItem? _selectedCase;
|
||||||
|
[ObservableProperty] private string _newTitle = "";
|
||||||
|
[ObservableProperty] private string _error = "";
|
||||||
|
|
||||||
|
public PinToVorgangChoice? Result { get; private set; }
|
||||||
|
|
||||||
|
public PinToVorgangDialogViewModel(string rowSummary, List<VorgangItem> matchingOpenCases)
|
||||||
|
{
|
||||||
|
RowSummary = rowSummary;
|
||||||
|
MatchingOpenCases = matchingOpenCases;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Confirm()
|
||||||
|
{
|
||||||
|
Error = "";
|
||||||
|
if (SelectedCase is not null) { Result = new PinToVorgangChoice(SelectedCase.Model.Id, null); return; }
|
||||||
|
if (!string.IsNullOrWhiteSpace(NewTitle)) { Result = new PinToVorgangChoice(null, NewTitle.Trim()); return; }
|
||||||
|
Error = "Bestehenden Vorgang wählen oder Titel für einen neuen Vorgang eingeben.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,8 +35,12 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
private readonly PublicHolidayService _publicHolidays;
|
private readonly PublicHolidayService _publicHolidays;
|
||||||
private readonly SchoolCalendarSettingsService _calendarSettings;
|
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||||||
private readonly ISubstitutionEntryRepository _substitutions;
|
private readonly ISubstitutionEntryRepository _substitutions;
|
||||||
|
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;
|
||||||
@@ -50,12 +54,29 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
/// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
|
/// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
|
||||||
/// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize.
|
/// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize.
|
||||||
private const int AttendanceMinSampleSize = 8;
|
private const int AttendanceMinSampleSize = 8;
|
||||||
|
/// Nutzer-Feedback: "ich vergesse es, und fasse die App auch außerhalb der Schule manchmal
|
||||||
|
/// nicht mehr an" — die Erinnerung an fehlende Unterrichtszeit-Erfassung schaut deshalb nicht
|
||||||
|
/// nur auf heute zurück, sondern auf ein paar Tage, damit ein vergessener Tag nicht verloren
|
||||||
|
/// geht, sobald der nächste Schultag anbricht.
|
||||||
|
private const int MissingTeachingTimeLookbackDays = 14;
|
||||||
|
/// Für den heutigen Tag soll die Erinnerung nicht schon mitten im Unterricht auftauchen —
|
||||||
|
/// erst diese Zeitspanne nach dem laut Stundenplan letzten Unterrichtsende.
|
||||||
|
private const int MissingTeachingTimeTodayDelayMinutes = 30;
|
||||||
|
/// Gleiche Puffer-Idee wie TimeTrackingViewModel.ComputeTodaysTeachingWindow (bewusst hier
|
||||||
|
/// noch einmal definiert statt geteilt — der Vorschlag ist nur zwei Zeilen Rechnung).
|
||||||
|
private const int TeachingWindowBufferBeforeMinutes = 15;
|
||||||
|
private const int TeachingWindowBufferAfterMinutes = 10;
|
||||||
|
/// Vorausschau für die Klausurwochen-Karte (Nutzer-Feedback): weit genug, um eine sich
|
||||||
|
/// anbahnende Häufung noch rechtzeitig vor dem Anlegen weiterer Klausuren zu zeigen, aber
|
||||||
|
/// keine Vorschau auf das ganze Schuljahr.
|
||||||
|
private const int ExamLoadLookaheadDays = 60;
|
||||||
|
|
||||||
[ObservableProperty] private string _greeting = "";
|
[ObservableProperty] private string _greeting = "";
|
||||||
[ObservableProperty] private string _currentDate = "";
|
[ObservableProperty] private string _currentDate = "";
|
||||||
[ObservableProperty] private string _currentSchoolYear = "";
|
[ObservableProperty] private string _currentSchoolYear = "";
|
||||||
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
|
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
|
||||||
[ObservableProperty] private string _selectedDayLabel = "";
|
[ObservableProperty] private string _selectedDayLabel = "";
|
||||||
|
[ObservableProperty] private DateOnly _selectedCalendarDate = DateOnly.FromDateTime(DateTime.Today);
|
||||||
[ObservableProperty] private bool _isDashboardSettingsOpen;
|
[ObservableProperty] private bool _isDashboardSettingsOpen;
|
||||||
[ObservableProperty] private bool _isWeatherPanelVisible;
|
[ObservableProperty] private bool _isWeatherPanelVisible;
|
||||||
[ObservableProperty] private string _weatherSummary = "";
|
[ObservableProperty] private string _weatherSummary = "";
|
||||||
@@ -69,18 +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<ExamWeekLoadItem> ExamWeekLoads { get; } = [];
|
||||||
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { 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; }
|
||||||
@@ -95,28 +141,32 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
|
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
|
||||||
// (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
|
// (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
|
||||||
public Action<Guid>? OnNavigateToUnplannedLesson { get; set; }
|
public Action<Guid>? OnNavigateToUnplannedLesson { get; set; }
|
||||||
|
// Öffnet den Nacherfassen-Dialog für einen Tag mit fehlender Unterrichtszeit-Erfassung
|
||||||
|
// (Nutzer-Feedback), vorbelegt mit Datum, Kategorie "Unterricht" und dem laut Stundenplan
|
||||||
|
// berechneten Zeitfenster — echtes Speichern bleibt eine bewusste Bestätigung im Dialog.
|
||||||
|
public Func<MissingTeachingTimeItem, Task>? OnAddMissingTeachingTime { get; set; }
|
||||||
|
|
||||||
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 ExamLoadCard => Card("examload");
|
||||||
public DashboardCardOption UnplannedCard => Card("unplanned");
|
|
||||||
public DashboardCardOption AlertsCard => Card("alerts");
|
|
||||||
public DashboardCardOption AttendanceCard => Card("attendance");
|
|
||||||
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,
|
||||||
@@ -126,8 +176,11 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
|
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
|
||||||
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
|
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
|
||||||
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
|
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
|
||||||
ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null,
|
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
|
||||||
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
|
UntisHubService untisHub, WebUntisIntegrationService webUntis,
|
||||||
|
IAnnualPlanEventRepository? annualPlanEvents = 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;
|
||||||
@@ -136,16 +189,38 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
||||||
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
|
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
|
||||||
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
|
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
|
||||||
|
_timeEntries = timeEntries;
|
||||||
_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);
|
||||||
@@ -154,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();
|
||||||
@@ -196,7 +271,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
IsHighPriority = t.Priority == TaskPriority.High });
|
IsHighPriority = t.Priority == TaskPriority.High });
|
||||||
|
|
||||||
CurrentGroups.Clear();
|
CurrentGroups.Clear();
|
||||||
foreach (var g in groups.Values.OrderBy(g => g.Name))
|
foreach (var g in groups.Values)
|
||||||
CurrentGroups.Add(new()
|
CurrentGroups.Add(new()
|
||||||
{
|
{
|
||||||
GroupId = g.Id,
|
GroupId = g.Id,
|
||||||
@@ -208,11 +283,14 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
LoadCalendar();
|
LoadCalendar();
|
||||||
LoadOpenExcuses(groups.Values.ToList(), today);
|
LoadOpenExcuses(groups.Values.ToList(), today);
|
||||||
LoadAttendanceWarnings(today);
|
LoadAttendanceWarnings(today);
|
||||||
|
LoadExamWeekLoads(groups, today);
|
||||||
|
LoadMissingTeachingTime(today);
|
||||||
LoadSupportPlanReviews(today);
|
LoadSupportPlanReviews(today);
|
||||||
LoadUpcomingDates(groups, today);
|
LoadUpcomingDates(groups, today);
|
||||||
LoadOpenCorrections(groups, today);
|
LoadOpenCorrections(groups, today);
|
||||||
LoadUnplannedLessons(groups, today);
|
LoadUnplannedLessons(groups, today);
|
||||||
LoadAlerts(groups, today);
|
LoadAlerts(groups, today);
|
||||||
|
RebuildAttention();
|
||||||
UpdateDashboardSummary();
|
UpdateDashboardSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,14 +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;
|
ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0;
|
||||||
UnplannedCard.IsEmpty = UnplannedLessons.Count == 0;
|
|
||||||
AlertsCard.IsEmpty = Alerts.Count == 0;
|
|
||||||
AttendanceCard.IsEmpty = AttendanceWarnings.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));
|
||||||
@@ -295,17 +373,21 @@ 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);
|
||||||
|
|
||||||
|
// Ein Bulk-Laden aller Sitzungen vermeidet, für jeden Mitarbeits-Eintrag jedes Schülers
|
||||||
|
// einzeln GetById aufzurufen (N+1 bei vielen Schülern/Einträgen).
|
||||||
|
var sessionDates = _participationSessions.GetAll().ToDictionary(s => s.Id, s => s.Date);
|
||||||
|
|
||||||
var items = new List<AttendanceWarningItem>();
|
var items = new List<AttendanceWarningItem>();
|
||||||
foreach (var student in _students.GetAll())
|
foreach (var student in _students.GetAll())
|
||||||
{
|
{
|
||||||
var entries = _participationEntries.GetByStudent(student.Id)
|
var entries = _participationEntries.GetByStudent(student.Id)
|
||||||
.Select(e => _participationSessions.GetById(e.SessionId) is { } session
|
.Select(e => sessionDates.TryGetValue(e.SessionId, out var date)
|
||||||
? ((DateOnly?)session.Date, e.Attendance) : (null, e.Attendance))
|
? ((DateOnly?)date, e.Attendance) : (null, e.Attendance))
|
||||||
.Where(t => t.Item1.HasValue)
|
.Where(t => t.Item1.HasValue)
|
||||||
.Select(t => (t.Item1!.Value, t.Attendance));
|
.Select(t => (t.Item1!.Value, t.Attendance));
|
||||||
|
|
||||||
@@ -314,14 +396,88 @@ 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) ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Persönliche Klausurlast über alle Kurse hinweg — anders als die klassenbezogene
|
||||||
|
// Kollisionsprüfung, die bereits der schulische Klausurplaner übernimmt (siehe
|
||||||
|
// ExamWeekLoadService). Nur geplante, noch bevorstehende Klausuren zählen: die
|
||||||
|
// Vorausschau soll helfen, eine sich anbahnende Woche zu erkennen, bevor man eine weitere
|
||||||
|
// Klausur genau dort einträgt — bereits durchgeführte/korrigierte Klausuren werden schon
|
||||||
|
// von der Karte "Offene Korrekturen" abgedeckt.
|
||||||
|
|
||||||
|
private void LoadExamWeekLoads(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
||||||
|
{
|
||||||
|
ExamWeekLoads.Clear();
|
||||||
|
var lastDay = today.AddDays(ExamLoadLookaheadDays);
|
||||||
|
var upcoming = groups.Keys
|
||||||
|
.SelectMany(gid => _exams.GetByGroup(gid))
|
||||||
|
.Where(e => e.Status == ExamStatus.Planned && e.Date >= today && e.Date <= lastDay);
|
||||||
|
|
||||||
|
foreach (var week in ExamWeekLoadService.FindOverloadedWeeks(upcoming))
|
||||||
|
ExamWeekLoads.Add(new ExamWeekLoadItem(week.IsoWeek, week.WeekStart, week.WeekEnd, week.ExamCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unterrichtszeit-Erfassung nachholen (Nutzer-Feedback) ─────────────────
|
||||||
|
//
|
||||||
|
// "Ich vergesse es, und fasse die App außerhalb der Schule manchmal nicht mehr an" — schaut
|
||||||
|
// deshalb bewusst ein paar Tage zurück statt nur auf heute. Ein Tag zählt als erfasst, sobald
|
||||||
|
// irgendein TimeEntry der Kategorie "Unterricht" an diesem Datum existiert; die genaue
|
||||||
|
// Zeitüberschneidung mit dem Stundenplan wird nicht geprüft (dieselbe grobe Betrachtung wie
|
||||||
|
// beim bestehenden "Unterrichtszeit übernehmen"-Vorschlag für heute).
|
||||||
|
|
||||||
|
private void LoadMissingTeachingTime(DateOnly today)
|
||||||
|
{
|
||||||
|
_missingTimeItems.Clear();
|
||||||
|
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
|
||||||
|
var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
|
||||||
|
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
||||||
|
.Select(h => h.Date).ToHashSet();
|
||||||
|
var schoolHolidays = _schoolHolidays.GetAll();
|
||||||
|
var nowTime = TimeOnly.FromDateTime(_now());
|
||||||
|
|
||||||
|
var items = new List<MissingTeachingTimeItem>();
|
||||||
|
for (var date = firstDay; date <= today; date = date.AddDays(1))
|
||||||
|
{
|
||||||
|
if (IsFreeDay(date, schoolHolidays, publicHolidayDates)) continue;
|
||||||
|
|
||||||
|
var daySlots = _timetableSlots.GetAll().Where(s => s.Weekday == date.DayOfWeek).ToList();
|
||||||
|
if (daySlots.Count == 0) continue;
|
||||||
|
|
||||||
|
var cancelledPeriods = _substitutions.GetByDate(date)
|
||||||
|
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||||||
|
.Select(s => s.PeriodNumber).ToHashSet();
|
||||||
|
var periodTimes = daySlots.Where(s => !cancelledPeriods.Contains(s.PeriodNumber))
|
||||||
|
.Select(s => _periodSchedule.GetTimes(s.PeriodNumber))
|
||||||
|
.Where(t => t is not null).Select(t => t!.Value).ToList();
|
||||||
|
if (periodTimes.Count == 0) continue; // alle Stunden entfallen oder kein Zeitraster hinterlegt
|
||||||
|
|
||||||
|
var lastPeriodEnd = periodTimes.Max(t => t.End);
|
||||||
|
if (date == today && nowTime < lastPeriodEnd.AddMinutes(MissingTeachingTimeTodayDelayMinutes))
|
||||||
|
continue; // heute: erst 30 Minuten nach Unterrichtsschluss erinnern
|
||||||
|
|
||||||
|
if (_timeEntries.GetByDate(date).Any(e => e.Category == "Unterricht")) continue;
|
||||||
|
|
||||||
|
var windowStart = periodTimes.Min(t => t.Start).AddMinutes(-TeachingWindowBufferBeforeMinutes);
|
||||||
|
var windowEnd = lastPeriodEnd.AddMinutes(TeachingWindowBufferAfterMinutes);
|
||||||
|
items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd));
|
||||||
|
}
|
||||||
|
foreach (var item in items.OrderBy(i => i.Date))
|
||||||
|
_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()
|
||||||
@@ -334,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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,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)
|
||||||
@@ -388,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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,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);
|
||||||
@@ -441,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
|
||||||
@@ -478,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);
|
||||||
@@ -491,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)
|
||||||
@@ -504,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
|
||||||
@@ -515,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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -539,18 +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", _ => 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;
|
||||||
}
|
}
|
||||||
@@ -586,15 +816,24 @@ 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 OpenAttentionItem(AttentionItem? item) => item?.Navigate?.Invoke();
|
||||||
|
|
||||||
[RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item)
|
[RelayCommand]
|
||||||
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
|
private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item)
|
||||||
|
{
|
||||||
|
if (item is null || OnAddMissingTeachingTime is null) return;
|
||||||
|
await OnAddMissingTeachingTime(item);
|
||||||
|
LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today));
|
||||||
|
RebuildAttention();
|
||||||
|
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>();
|
||||||
@@ -613,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)
|
||||||
@@ -623,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,9 +988,35 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
if (day is null) return;
|
if (day is null) return;
|
||||||
foreach (var cell in CalendarDays) cell.IsSelected = cell == day;
|
foreach (var cell in CalendarDays) cell.IsSelected = cell == day;
|
||||||
|
SelectedCalendarDate = day.Date;
|
||||||
SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De);
|
SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De);
|
||||||
SelectedDayEvents.Clear();
|
SelectedDayEvents.Clear();
|
||||||
foreach (var item in day.Events) SelectedDayEvents.Add(item);
|
foreach (var item in day.Events) SelectedDayEvents.Add(item);
|
||||||
|
SortCurrentGroups(day.Date);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SortCurrentGroups(DateOnly date)
|
||||||
|
{
|
||||||
|
var schoolHolidays = _schoolHolidays.GetAll();
|
||||||
|
var publicHolidays = _publicHolidays.GetHolidays(date.Year, _calendarSettings.State)
|
||||||
|
.Select(h => h.Date).ToHashSet();
|
||||||
|
var isFreeDay = IsFreeDay(date, schoolHolidays, publicHolidays);
|
||||||
|
var cancelledPeriods = _substitutions.GetByDate(date)
|
||||||
|
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||||||
|
.Select(s => s.PeriodNumber).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var chip in CurrentGroups)
|
||||||
|
{
|
||||||
|
var hasLesson = _lessons.GetByGroupAndDate(chip.GroupId, date).Count > 0;
|
||||||
|
var hasActiveSlot = !isFreeDay && _timetableSlots.GetByGroup(chip.GroupId)
|
||||||
|
.Any(s => s.Weekday == date.DayOfWeek && !cancelledPeriods.Contains(s.PeriodNumber));
|
||||||
|
chip.IsOnSelectedDay = hasLesson || hasActiveSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sorted = CurrentGroups.OrderByDescending(g => g.IsOnSelectedDay)
|
||||||
|
.ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||||
|
CurrentGroups.Clear();
|
||||||
|
foreach (var chip in sorted) CurrentGroups.Add(chip);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -798,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);
|
||||||
@@ -891,7 +1150,13 @@ public class LessonItem
|
|||||||
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
|
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
|
||||||
}
|
}
|
||||||
public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } public bool IsHighPriority { get; set; } }
|
public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } public bool IsHighPriority { get; set; } }
|
||||||
public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; }
|
public class GroupChip
|
||||||
|
{
|
||||||
|
public Guid GroupId { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Subject { get; set; } = "";
|
||||||
|
public bool IsOnSelectedDay { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ────────────────────────
|
// ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ────────────────────────
|
||||||
|
|
||||||
@@ -929,24 +1194,21 @@ public class AttendanceWarningItem(Guid studentId, string studentName, double ab
|
|||||||
public double AbsenceRatePercent { get; } = absenceRatePercent;
|
public double AbsenceRatePercent { get; } = absenceRatePercent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────────
|
public class ExamWeekLoadItem(int isoWeek, DateOnly weekStart, DateOnly weekEnd, int examCount)
|
||||||
|
|
||||||
public class SupportPlanDueItem
|
|
||||||
{
|
{
|
||||||
public Guid StudentId { get; }
|
public int ExamCount { get; } = examCount;
|
||||||
public string StudentName { get; }
|
public string Label => $"KW {isoWeek} ({weekStart:dd.MM.}–{weekEnd:dd.MM.})";
|
||||||
public string Title { get; }
|
public string CountDisplay => ExamCount == 1 ? "1 Klausur" : $"{ExamCount} Klausuren";
|
||||||
public string ReviewDateDisplay { get; }
|
}
|
||||||
public bool IsOverdue { get; }
|
|
||||||
|
|
||||||
public SupportPlanDueItem(Guid studentId, string studentName, string title, DateOnly reviewDate, DateOnly today)
|
public class MissingTeachingTimeItem(DateOnly date, TimeOnly windowStart, TimeOnly windowEnd)
|
||||||
{
|
{
|
||||||
StudentId = studentId;
|
private static readonly CultureInfo De = new("de-DE");
|
||||||
StudentName = studentName;
|
|
||||||
Title = title;
|
public DateOnly Date { get; } = date;
|
||||||
ReviewDateDisplay = reviewDate.ToString("dd.MM.yyyy");
|
public TimeOnly WindowStart { get; } = windowStart;
|
||||||
IsOverdue = reviewDate < today;
|
public TimeOnly WindowEnd { get; } = windowEnd;
|
||||||
}
|
public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De);
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class CalendarDayCell : ObservableObject
|
public partial class CalendarDayCell : ObservableObject
|
||||||
@@ -1024,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;
|
||||||
@@ -1076,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1092,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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ public partial class ExamsOverviewViewModel : ObservableObject
|
|||||||
public Func<Exam, Task>? OnGradeExam { get; set; }
|
public Func<Exam, Task>? OnGradeExam { get; set; }
|
||||||
public Func<Exam, Task>? OnEvaluateExam { get; set; }
|
public Func<Exam, Task>? OnEvaluateExam { get; set; }
|
||||||
public Action<Guid>? OnNavigateToGroup { get; set; }
|
public Action<Guid>? OnNavigateToGroup { get; set; }
|
||||||
|
public Action? OnNavigateToGroups { get; set; }
|
||||||
|
public bool HasNoExams => Rows.Count == 0;
|
||||||
|
|
||||||
public ExamsOverviewViewModel(IExamRepository exams, IExamResultRepository examResults,
|
public ExamsOverviewViewModel(IExamRepository exams, IExamResultRepository examResults,
|
||||||
IGroupRepository groups, IGroupMembershipRepository memberships, GradingService grading,
|
IGroupRepository groups, IGroupMembershipRepository memberships, GradingService grading,
|
||||||
@@ -85,6 +87,7 @@ public partial class ExamsOverviewViewModel : ObservableObject
|
|||||||
Rows.Add(row);
|
Rows.Add(row);
|
||||||
|
|
||||||
EmptyHint = Rows.Count == 0 ? "Keine Klausuren angelegt." : "";
|
EmptyHint = Rows.Count == 0 ? "Keine Klausuren angelegt." : "";
|
||||||
|
OnPropertyChanged(nameof(HasNoExams));
|
||||||
SelectedRow = selectedId is { } id
|
SelectedRow = selectedId is { } id
|
||||||
? Rows.FirstOrDefault(r => r.Exam.Id == id) ?? Rows.FirstOrDefault()
|
? Rows.FirstOrDefault(r => r.Exam.Id == id) ?? Rows.FirstOrDefault()
|
||||||
: Rows.FirstOrDefault();
|
: Rows.FirstOrDefault();
|
||||||
@@ -173,6 +176,9 @@ public partial class ExamsOverviewViewModel : ObservableObject
|
|||||||
OnNavigateToGroup?.Invoke(SelectedRow.GroupId);
|
OnNavigateToGroup?.Invoke(SelectedRow.GroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void GoToGroups() => OnNavigateToGroups?.Invoke();
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ToggleApproval() => ToggleDate(SelectedRow?.Exam, e => e.ApprovalGrantedAt,
|
private void ToggleApproval() => ToggleDate(SelectedRow?.Exam, e => e.ApprovalGrantedAt,
|
||||||
(e, v) => e.ApprovalGrantedAt = v);
|
(e, v) => e.ApprovalGrantedAt = v);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public partial class GlobalSearchViewModel : ObservableObject
|
|||||||
|
|
||||||
private readonly IStudentRepository _students;
|
private readonly IStudentRepository _students;
|
||||||
private readonly IGroupRepository _groups;
|
private readonly IGroupRepository _groups;
|
||||||
|
private readonly ISubjectRepository _subjects;
|
||||||
private readonly IExamRepository _exams;
|
private readonly IExamRepository _exams;
|
||||||
private readonly IWorkTaskRepository _tasks;
|
private readonly IWorkTaskRepository _tasks;
|
||||||
|
|
||||||
@@ -30,13 +31,15 @@ public partial class GlobalSearchViewModel : ObservableObject
|
|||||||
public Action<GlobalSearchResult>? OnNavigate { get; set; }
|
public Action<GlobalSearchResult>? OnNavigate { get; set; }
|
||||||
public Func<bool, Task>? OnQuickAddTask { get; set; }
|
public Func<bool, Task>? OnQuickAddTask { get; set; }
|
||||||
public Func<Task>? OnQuickAddStudent { get; set; }
|
public Func<Task>? OnQuickAddStudent { get; set; }
|
||||||
|
public Func<Task>? OnQuickAddGroupDocumentation { get; set; }
|
||||||
public Action? OnClose { get; set; }
|
public Action? OnClose { get; set; }
|
||||||
|
|
||||||
public GlobalSearchViewModel(IStudentRepository students, IGroupRepository groups,
|
public GlobalSearchViewModel(IStudentRepository students, IGroupRepository groups,
|
||||||
IExamRepository exams, IWorkTaskRepository tasks)
|
ISubjectRepository subjects, IExamRepository exams, IWorkTaskRepository tasks)
|
||||||
{
|
{
|
||||||
_students = students;
|
_students = students;
|
||||||
_groups = groups;
|
_groups = groups;
|
||||||
|
_subjects = subjects;
|
||||||
_exams = exams;
|
_exams = exams;
|
||||||
_tasks = tasks;
|
_tasks = tasks;
|
||||||
RefreshResults();
|
RefreshResults();
|
||||||
@@ -59,23 +62,34 @@ public partial class GlobalSearchViewModel : ObservableObject
|
|||||||
|
|
||||||
if (query.Length > 0)
|
if (query.Length > 0)
|
||||||
{
|
{
|
||||||
var groups = _groups.GetAll(includeInactive: true);
|
var groups = _groups.GetAll().Where(g => g.IsActive).ToList();
|
||||||
var groupNames = groups.ToDictionary(g => g.Id, g => g.Name);
|
var groupNames = groups.ToDictionary(g => g.Id, g => g.Name);
|
||||||
|
var subjects = _subjects.GetAll().ToDictionary(s => s.Id);
|
||||||
|
Subject? GroupSubject(LearningGroup group) => group.SubjectId is { } subjectId
|
||||||
|
? subjects.GetValueOrDefault(subjectId)
|
||||||
|
: null;
|
||||||
|
static string SubjectLabel(Subject? subject) => subject is null ? ""
|
||||||
|
: string.IsNullOrWhiteSpace(subject.ShortName)
|
||||||
|
|| subject.Name.Equals(subject.ShortName, StringComparison.CurrentCultureIgnoreCase)
|
||||||
|
? subject.Name
|
||||||
|
: $"{subject.ShortName} – {subject.Name}";
|
||||||
var candidates = new List<GlobalSearchResult>();
|
var candidates = new List<GlobalSearchResult>();
|
||||||
|
|
||||||
candidates.AddRange(_students.GetAll(includeInactive: true)
|
candidates.AddRange(_students.GetAll().Where(s => s.IsActive)
|
||||||
.Where(s => Matches(s.FullName, query))
|
.Where(s => Matches(s.FullName, query))
|
||||||
.Select(s => GlobalSearchResult.ForStudent(s)));
|
.Select(s => GlobalSearchResult.ForStudent(s)));
|
||||||
|
|
||||||
candidates.AddRange(groups
|
candidates.AddRange(groups
|
||||||
.Where(g => Matches($"{g.Name} {g.SchoolYear} {g.GradeLevel}", query))
|
.Where(g => Matches($"{g.Name} {GroupSubject(g)?.Name} {GroupSubject(g)?.ShortName} {g.SchoolYear} {g.GradeLevel}", query))
|
||||||
.Select(GlobalSearchResult.ForGroup));
|
.Select(g => GlobalSearchResult.ForGroup(g, SubjectLabel(GroupSubject(g)))));
|
||||||
|
|
||||||
candidates.AddRange(_exams.GetAll()
|
candidates.AddRange(_exams.GetAll()
|
||||||
|
.Where(e => groupNames.ContainsKey(e.GroupId))
|
||||||
.Where(e => Matches($"{e.Title} {groupNames.GetValueOrDefault(e.GroupId)}", query))
|
.Where(e => Matches($"{e.Title} {groupNames.GetValueOrDefault(e.GroupId)}", query))
|
||||||
.Select(e => GlobalSearchResult.ForExam(e, groupNames.GetValueOrDefault(e.GroupId) ?? "")));
|
.Select(e => GlobalSearchResult.ForExam(e, groupNames.GetValueOrDefault(e.GroupId) ?? "")));
|
||||||
|
|
||||||
candidates.AddRange(_tasks.GetAll()
|
candidates.AddRange(_tasks.GetAll()
|
||||||
|
.Where(t => t.GroupId is null || groupNames.ContainsKey(t.GroupId.Value))
|
||||||
.Where(t => Matches($"{t.Title} {t.Notes} {groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty)}", query))
|
.Where(t => Matches($"{t.Title} {t.Notes} {groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty)}", query))
|
||||||
.Select(t => GlobalSearchResult.ForTask(t, groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty) ?? "")));
|
.Select(t => GlobalSearchResult.ForTask(t, groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty) ?? "")));
|
||||||
|
|
||||||
@@ -99,6 +113,7 @@ public partial class GlobalSearchViewModel : ObservableObject
|
|||||||
GlobalSearchResult.ForAction(GlobalSearchAction.NewTask, "Aufgabe anlegen", "Mit Fälligkeit, Gruppe und Priorität", "+"),
|
GlobalSearchResult.ForAction(GlobalSearchAction.NewTask, "Aufgabe anlegen", "Mit Fälligkeit, Gruppe und Priorität", "+"),
|
||||||
GlobalSearchResult.ForAction(GlobalSearchAction.NewReminder, "Erinnerung anlegen", "Kurze Notiz ohne Zeiterfassung", "◷"),
|
GlobalSearchResult.ForAction(GlobalSearchAction.NewReminder, "Erinnerung anlegen", "Kurze Notiz ohne Zeiterfassung", "◷"),
|
||||||
GlobalSearchResult.ForAction(GlobalSearchAction.NewStudent, "Schüler anlegen", "Neue Stammdaten erfassen", "+"),
|
GlobalSearchResult.ForAction(GlobalSearchAction.NewStudent, "Schüler anlegen", "Neue Stammdaten erfassen", "+"),
|
||||||
|
GlobalSearchResult.ForAction(GlobalSearchAction.NewGroupDocumentation, "Lerngruppen-Eintrag", "Planung, Klausur oder Erinnerung dokumentieren", "N"),
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var action in actions.Where(a => query.Length == 0 || Matches(a.Title, query)))
|
foreach (var action in actions.Where(a => query.Length == 0 || Matches(a.Title, query)))
|
||||||
@@ -124,6 +139,9 @@ public partial class GlobalSearchViewModel : ObservableObject
|
|||||||
case GlobalSearchAction.NewStudent:
|
case GlobalSearchAction.NewStudent:
|
||||||
if (OnQuickAddStudent is not null) await OnQuickAddStudent();
|
if (OnQuickAddStudent is not null) await OnQuickAddStudent();
|
||||||
break;
|
break;
|
||||||
|
case GlobalSearchAction.NewGroupDocumentation:
|
||||||
|
if (OnQuickAddGroupDocumentation is not null) await OnQuickAddGroupDocumentation();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
OnNavigate?.Invoke(result);
|
OnNavigate?.Invoke(result);
|
||||||
break;
|
break;
|
||||||
@@ -134,7 +152,7 @@ public partial class GlobalSearchViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
public enum GlobalSearchResultKind { Action, Student, Group, Exam, Task }
|
public enum GlobalSearchResultKind { Action, Student, Group, Exam, Task }
|
||||||
public enum GlobalSearchAction { None, NewTask, NewReminder, NewStudent }
|
public enum GlobalSearchAction { None, NewTask, NewReminder, NewStudent, NewGroupDocumentation }
|
||||||
|
|
||||||
public sealed class GlobalSearchResult
|
public sealed class GlobalSearchResult
|
||||||
{
|
{
|
||||||
@@ -171,10 +189,13 @@ public sealed class GlobalSearchResult
|
|||||||
Title = student.FullName, Subtitle = student.IsActive ? "Aktiv" : "Inaktiv", Icon = "P",
|
Title = student.FullName, Subtitle = student.IsActive ? "Aktiv" : "Inaktiv", Icon = "P",
|
||||||
};
|
};
|
||||||
|
|
||||||
public static GlobalSearchResult ForGroup(LearningGroup group) => new()
|
public static GlobalSearchResult ForGroup(LearningGroup group, string subjectName) => new()
|
||||||
{
|
{
|
||||||
Kind = GlobalSearchResultKind.Group, EntityId = group.Id, GroupId = group.Id,
|
Kind = GlobalSearchResultKind.Group, EntityId = group.Id, GroupId = group.Id,
|
||||||
Title = group.Name, Subtitle = $"{group.SchoolYear} · Stufe {group.GradeLevel}", Icon = "G",
|
Title = group.Name,
|
||||||
|
Subtitle = string.Join(" · ", new[] { subjectName, group.SchoolYear, $"Stufe {group.GradeLevel}" }
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))),
|
||||||
|
Icon = "G",
|
||||||
};
|
};
|
||||||
|
|
||||||
public static GlobalSearchResult ForExam(Exam exam, string groupName) => new()
|
public static GlobalSearchResult ForExam(Exam exam, string groupName) => new()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _titleError = "";
|
[ObservableProperty] private string _titleError = "";
|
||||||
[ObservableProperty] private string _dateTextError = "";
|
[ObservableProperty] private string _dateTextError = "";
|
||||||
[ObservableProperty] private string _returnedAtTextError = "";
|
[ObservableProperty] private string _returnedAtTextError = "";
|
||||||
|
[ObservableProperty] private string _weekLoadHint = "";
|
||||||
[ObservableProperty] private double _totalPoints;
|
[ObservableProperty] private double _totalPoints;
|
||||||
[ObservableProperty] private bool _hasCompetencyCatalog;
|
[ObservableProperty] private bool _hasCompetencyCatalog;
|
||||||
[ObservableProperty] private bool _useWeighting;
|
[ObservableProperty] private bool _useWeighting;
|
||||||
@@ -108,6 +109,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
foreach (var t in Tasks) t.ShowWeight = UseWeighting;
|
foreach (var t in Tasks) t.ShowWeight = UseWeighting;
|
||||||
RecomputeTotals();
|
RecomputeTotals();
|
||||||
|
UpdateWeekLoadHint();
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnUseWeightingChanged(bool value)
|
partial void OnUseWeightingChanged(bool value)
|
||||||
@@ -115,6 +117,26 @@ public partial class ExamDialogViewModel : ObservableObject
|
|||||||
foreach (var t in Tasks) t.ShowWeight = value;
|
foreach (var t in Tasks) t.ShowWeight = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnDateTextChanged(string value) => UpdateWeekLoadHint();
|
||||||
|
|
||||||
|
/// Nutzer-Feedback: persönliche Klausurlast pro Woche — die klassenbezogene
|
||||||
|
/// Kollisionsprüfung übernimmt bereits der schulische Klausurplaner (siehe
|
||||||
|
/// ExamWeekLoadService), hier geht es um die eigene Häufung über alle Kurse hinweg.
|
||||||
|
private void UpdateWeekLoadHint()
|
||||||
|
{
|
||||||
|
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
||||||
|
{
|
||||||
|
WeekLoadHint = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = ExamWeekLoadService.CountInSameWeek(_exams.GetAll(), date, _editingExam?.Id);
|
||||||
|
var totalIfSaved = existing + 1;
|
||||||
|
WeekLoadHint = totalIfSaved >= ExamWeekLoadService.WarningThreshold
|
||||||
|
? $"Damit hättest du {totalIfSaved} Klausuren in dieser Woche."
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void AddTask()
|
private void AddTask()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject
|
|||||||
public Func<GradeOverviewRow, Task>? OnManageStudentGrades { get; set; }
|
public Func<GradeOverviewRow, Task>? OnManageStudentGrades { get; set; }
|
||||||
public Func<Task>? OnCollectiveGrade { get; set; }
|
public Func<Task>? OnCollectiveGrade { get; set; }
|
||||||
public Func<Task>? OnReportGrades { get; set; }
|
public Func<Task>? OnReportGrades { get; set; }
|
||||||
|
public Func<GradeOverviewRow, Task>? OnShowPerformanceOverview { get; set; }
|
||||||
|
|
||||||
public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams,
|
public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams,
|
||||||
IExamResultRepository results, IStudentRepository students,
|
IExamResultRepository results, IStudentRepository students,
|
||||||
@@ -107,6 +108,14 @@ public partial class GradeOverviewTabViewModel : ObservableObject
|
|||||||
Recompute();
|
Recompute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ShowPerformanceOverview()
|
||||||
|
{
|
||||||
|
if (SelectedRow is null || OnShowPerformanceOverview is null) return;
|
||||||
|
await OnShowPerformanceOverview(SelectedRow);
|
||||||
|
Recompute();
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task CollectiveGrade()
|
private async Task CollectiveGrade()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
|||||||
private List<StudentOption> _groupStudents = [];
|
private List<StudentOption> _groupStudents = [];
|
||||||
|
|
||||||
public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler");
|
public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler");
|
||||||
|
public static readonly StudentOption WholeGroupOption = new(Guid.Empty, "Gesamte Lerngruppe");
|
||||||
|
|
||||||
public ObservableCollection<DocumentationItem> Entries { get; } = [];
|
public ObservableCollection<DocumentationItem> Entries { get; } = [];
|
||||||
public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption];
|
public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption];
|
||||||
@@ -62,8 +63,6 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
|||||||
private void Load()
|
private void Load()
|
||||||
{
|
{
|
||||||
Entries.Clear();
|
Entries.Clear();
|
||||||
if (_groupStudents.Count == 0) return;
|
|
||||||
|
|
||||||
var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name);
|
var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name);
|
||||||
var groupNameCache = new Dictionary<Guid, string>();
|
var groupNameCache = new Dictionary<Guid, string>();
|
||||||
string GroupLabel(Guid id)
|
string GroupLabel(Guid id)
|
||||||
@@ -80,6 +79,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
|||||||
|
|
||||||
var all = relevantStudentIds
|
var all = relevantStudentIds
|
||||||
.SelectMany(id => _docs.GetByStudent(id))
|
.SelectMany(id => _docs.GetByStudent(id))
|
||||||
|
.Concat(SelectedStudentFilter.Id == Guid.Empty
|
||||||
|
? _docs.GetAll().Where(d => d.StudentId == Guid.Empty && d.GroupId == _groupId)
|
||||||
|
: [])
|
||||||
|
.DistinctBy(d => d.Id)
|
||||||
.Where(d => !OnlyThisGroup || d.GroupId == _groupId)
|
.Where(d => !OnlyThisGroup || d.GroupId == _groupId)
|
||||||
.OrderByDescending(d => d.IsDraft)
|
.OrderByDescending(d => d.IsDraft)
|
||||||
.ThenByDescending(d => d.Date)
|
.ThenByDescending(d => d.Date)
|
||||||
@@ -90,7 +93,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
var isOwnGroup = d.GroupId is null || d.GroupId == _groupId;
|
var isOwnGroup = d.GroupId is null || d.GroupId == _groupId;
|
||||||
var otherGroupLabel = isOwnGroup ? "" : GroupLabel(d.GroupId!.Value);
|
var otherGroupLabel = isOwnGroup ? "" : GroupLabel(d.GroupId!.Value);
|
||||||
Entries.Add(new DocumentationItem(d, studentNameById.GetValueOrDefault(d.StudentId, ""),
|
var studentName = d.StudentId == Guid.Empty
|
||||||
|
? WholeGroupOption.Name
|
||||||
|
: studentNameById.GetValueOrDefault(d.StudentId, "");
|
||||||
|
Entries.Add(new DocumentationItem(d, studentName,
|
||||||
isOwnGroup, otherGroupLabel));
|
isOwnGroup, otherGroupLabel));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,7 +106,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task AddDocumentation()
|
private async Task AddDocumentation()
|
||||||
{
|
{
|
||||||
if (OnEditDocumentation is null || _groupStudents.Count == 0) return;
|
if (OnEditDocumentation is null) return;
|
||||||
var result = await OnEditDocumentation(_groupId, _groupStudents, null);
|
var result = await OnEditDocumentation(_groupId, _groupStudents, null);
|
||||||
if (result is null) return;
|
if (result is null) return;
|
||||||
_docs.Save(result);
|
_docs.Save(result);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user