Files
LehrerApp/LehrerApp.Desktop/AppBootstrapper.cs
T
adminandClaude Sonnet 5 bfe214ecf2
CI / build-and-test (push) Canceled after 0s
feat: Untis-Hub-Kadenzkopplung, anpassbarer Statusvorschlag + KI-/MCP-Unterstuetzung beim Fehlzeitenabgleich (Nutzer-Feedback)
- UntisHubService.RecordRun: ein abgeschlossener Langzeit-Fehlzeitenabgleich schliesst die
  kurzfristige Kadenz derselben Gruppe automatisch mit ab (nicht umgekehrt).
- Fehlzeitenabgleich-Dialog: neue "Uebernahme als"-ComboBox statt starrem Zielstatus, vorbelegt
  mit dem berechneten Vorschlag, aber frei aenderbar.
- Neuer ai-backend-Endpunkt untis-status.php + AiPlanningService.RequestUntisStatusSuggestionsAsync:
  gebuendelter, anonymisierter KI-Statusvorschlag (nur Positions-Id + Rohsignale, nie Name/Klasse/
  Datum), mit hartem Id-Mengen-Abgleich gegen Verwechslung.
- Neue MCP-Tools (UntisComparisonTools): get_untis_hub_status, get_untis_absence_rows/
  apply_untis_absence_status (anonymer Weg ueber ENr-Zuordnung) sowie
  get_named_untis_absence_pattern als bewusste, eng begrenzte Ausnahme (Name+Fehlzeiten fuer
  explizit angegebene Schueler-IDs, mit Bestaetigung ohne Sitzungsfreigabe - dafuer
  IMcpConfirmationService.ConfirmAsync um allowSessionTrust erweitert).
- MapStatus/ENr-Zuordnung aus dem ViewModel in das neue, geteilte UntisLessonAbsenceHelper
  gezogen, damit Dialog und MCP-Tool nie unterschiedliche Statusvorschlaege berechnen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 22:55:33 +02:00

396 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Importing;
using LehrerApp.Core.Services;
using LehrerApp.Data;
using LehrerApp.Data.Repositories;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.Services.Mcp;
using LehrerApp.Desktop.Services.Mcp.Tools;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Exams;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.ViewModels.Workload;
using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
using LehrerApp.Sync.Models;
using Microsoft.Extensions.DependencyInjection;
using LehrerApp.Templating;
namespace LehrerApp.Desktop;
/// <summary>
/// Konfiguriert den DI-Container für den Desktop-Client.
///
/// WICHTIG: Alle Repositories sind Singleton eine LiteDB-Datei pro Nutzer.
/// Sync-Services werden nur registriert wenn eine Server-URL konfiguriert ist.
/// </summary>
public static class AppBootstrapper
{
public static string DbPath { get; private set; } = "";
public static string AppDataPath { get; private set; } = "";
/// <summary>
/// Feste URL des KI-Backends (ai-backend/, TODO 4.5.9) — bewusst nicht in den Einstellungen
/// editierbar, siehe Planungsdokument. Vor dem ersten produktiven Einsatz durch die tatsächlich
/// deployte Domain ersetzen.
/// </summary>
public const string AiBackendUrl = "https://backapi.science-teaching.de/";
/// <summary>
/// Vor <see cref="BuildServices"/> gesetzt, wenn die Datenbank passwortgeschützt ist
/// (siehe App.axaml.cs: Passwort-Abfrage vor dem Öffnen der Datenbank).
/// </summary>
public static string? DbPassword { get; set; }
/// <summary>
/// Vor der eigentlichen DI-Konfiguration verfügbar (z.B. für den globalen
/// Exception-Handler in Program.cs, der schon vor <see cref="BuildServices"/> greifen muss).
/// </summary>
public static AppLogger Logger { get; private set; } = null!;
public static string ResolveAppDataPath()
{
if (!string.IsNullOrEmpty(AppDataPath)) return AppDataPath;
var appData = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"LehrerApp");
Directory.CreateDirectory(appData);
AppDataPath = appData;
return appData;
}
/// Vor dem Öffnen der Datenbank verfügbar (z.B. für die Passwort-Abfrage beim Start).
public static string ResolveDbPath()
{
if (string.IsNullOrEmpty(DbPath))
DbPath = Path.Combine(ResolveAppDataPath(), "lehrerapp.db");
return DbPath;
}
public static AppLogger EnsureLogger()
{
Logger ??= new AppLogger(ResolveAppDataPath());
return Logger;
}
public static bool IsDbEncrypted() =>
new DatabaseEncryptionService().IsEncrypted(ResolveDbPath());
/// Beendet den Prozess und startet ihn neu (z.B. nach Wiederherstellung eines Backups
/// oder einer Änderung der Datenbank-Verschlüsselung — die laufende LiteDB-Verbindung
/// kann nicht sicher "heiß" auf eine andere Datei umgehängt werden).
public static void RestartApplication()
{
var exePath = Environment.ProcessPath;
if (!string.IsNullOrEmpty(exePath))
{
var psi = new System.Diagnostics.ProcessStartInfo(exePath) { UseShellExecute = false };
// Unter "dotnet run"/IDE-Debug zeigt ProcessPath auf den SDK-Host (dotnet[.exe]) statt
// auf die App selbst - ein Neustart ohne Argumente würde nur die dotnet-CLI-Hilfe
// anzeigen statt die App neu zu starten. Die ursprünglichen Kommandozeilenargumente
// (u.a. der DLL-Pfad) erneut mitgeben deckt auch diesen Fall ab.
if (Path.GetFileNameWithoutExtension(exePath).Equals("dotnet", StringComparison.OrdinalIgnoreCase))
foreach (var arg in Environment.GetCommandLineArgs())
psi.ArgumentList.Add(arg);
System.Diagnostics.Process.Start(psi);
}
Environment.Exit(0);
}
public static ServiceProvider BuildServices()
{
var services = new ServiceCollection();
// ── Pfade ─────────────────────────────────────────────────────────────
var appData = ResolveAppDataPath();
ResolveDbPath();
var queuePath = Path.Combine(appData, "syncqueue.db");
var keyPath = Path.Combine(appData, "sync.key");
// Muss VOR jedem möglichen Zugriff auf keyPath erfasst werden (siehe SyncKeyStatus unten) -
// File.Exists ist hier synchron und unabhängig von der Lazy-Auflösung der DI-Factories.
var keyExistedBefore = File.Exists(keyPath);
// ── Logging & Benachrichtigungen ─────────────────────────────────────────
EnsureLogger();
services.AddSingleton(Logger);
services.AddSingleton<NotificationService>();
services.AddSingleton<ExportService>();
services.AddSingleton<PdfExportService>();
services.AddSingleton<ITemplateLoader, TemplateLoader>();
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
services.AddSingleton(_ => new TemplateStore(appData));
// ── Datensicherheit (13.3) ───────────────────────────────────────────
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
var backupSettings = new BackupSettingsService(appData);
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(backupSettings);
services.AddSingleton(_ => new AppLockService(appData));
services.AddSingleton<DatabaseEncryptionService>();
services.AddSingleton(_ => new PrivacySettingsService(appData));
services.AddSingleton<AttendanceBalanceService>();
services.AddSingleton<PersonalDataExportService>();
services.AddSingleton<IImportHandler<ImportedStudent>, StudentMasterDataCsvImportHandler>();
services.AddSingleton<IImportHandler<ImportedStudent>, LessonStudentListCsvImportHandler>();
services.AddSingleton<IImportHandler<ImportedStudent>, MarksPerLessonCsvImportHandler>();
services.AddSingleton<StudentImportService>();
// ── Datenbank ─────────────────────────────────────────────────────────
services.AddSingleton(_ => new LiteDbContext(DbPath, DbPassword));
// ── Repositories ──────────────────────────────────────────────────────
services.AddSingleton<IStudentRepository, StudentRepository>();
services.AddSingleton<IGroupRepository, GroupRepository>();
services.AddSingleton<ISeatingPlanRepository, SeatingPlanRepository>();
services.AddSingleton<ITrashRepository, TrashRepository>();
services.AddSingleton<IGroupMembershipRepository, GroupMembershipRepository>();
services.AddSingleton<IExamRepository, ExamRepository>();
services.AddSingleton<IExamResultRepository, ExamResultRepository>();
services.AddSingleton<IGradeRepository, GradeRepository>();
services.AddSingleton<IGradingSchemeRepository, GradingSchemeRepository>();
services.AddSingleton<IReportGradeRepository, ReportGradeRepository>();
services.AddSingleton<IGradingKeyTemplateRepository, GradingKeyTemplateRepository>();
services.AddSingleton<IUnitRepository, UnitRepository>();
services.AddSingleton<ILessonRepository, LessonRepository>();
services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
services.AddSingleton<IVorgangRepository, VorgangRepository>();
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>();
services.AddSingleton<IParticipationRepository, ParticipationRepository>();
services.AddSingleton<IParticipationAspectRepository, ParticipationAspectRepository>();
services.AddSingleton<IParticipationSectionRepository, ParticipationSectionRepository>();
services.AddSingleton<ISubjectRepository, SubjectRepository>();
services.AddSingleton<ICompetencyDomainRepository, CompetencyDomainRepository>();
services.AddSingleton<IShorthandCodeRepository, ShorthandCodeRepository>();
services.AddSingleton<IAlternativeLessonPathRepository, AlternativeLessonPathRepository>();
services.AddSingleton<IAttachmentStorage, LiteAttachmentStorage>();
services.AddSingleton<ITimetableSlotRepository, TimetableSlotRepository>();
services.AddSingleton<ISchoolHolidayRepository, SchoolHolidayRepository>();
services.AddSingleton<ISupervisionDutyRepository, SupervisionDutyRepository>();
services.AddSingleton<ISubstitutionEntryRepository, SubstitutionEntryRepository>();
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
services.AddSingleton<IAnnualPlanEventRepository, AnnualPlanEventRepository>();
services.AddSingleton<IUntisAbsenceCacheRepository, UntisAbsenceCacheRepository>();
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
services.AddSingleton<IUntisHubJobStateRepository, UntisHubJobStateRepository>();
// ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>();
services.AddSingleton<CompetencyAnalysisService>();
services.AddSingleton<SchoolYearService>();
services.AddSingleton<GroupRolloverService>();
services.AddSingleton<PublicHolidayService>();
services.AddSingleton(_ => new SchoolCalendarSettingsService(appData));
services.AddSingleton(_ => new PeriodScheduleService(appData));
services.AddSingleton(_ => new WorkloadSettingsService(appData));
services.AddSingleton(_ => new DashboardSettingsService(appData));
services.AddSingleton(_ => new PatternScoreSettingsService(appData));
services.AddSingleton(_ => new WindowSettingsService(appData));
services.AddSingleton(_ => new AppearanceSettingsService(appData));
services.AddSingleton<PlanningExchangeService>();
// ── KI-Unterstützung (4.5.9, optional nur wenn in den Einstellungen aktiviert) ──────
services.AddSingleton(_ => new AiSettingsService(appData));
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>();
// ── 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<CompetencyTools>();
services.AddSingleton<UntisComparisonTools>();
services.AddSingleton<McpServerHostedService>();
services.AddSingleton<McpClientRegistrationService>();
// ── WebUntis-iCal-Abgleich (optional nur wenn URL hinterlegt und aktiviert) ─────────
var untisSettings = new WebUntisSettingsService(appData);
services.AddSingleton(untisSettings);
services.AddSingleton<UntisMatchingService>();
services.AddSingleton<UntisDiffService>();
if (untisSettings.Enabled && !string.IsNullOrEmpty(untisSettings.GetIcalUrl()))
{
services.AddSingleton(sp => new UntisSyncService(
new HttpClient(), untisSettings,
sp.GetRequiredService<IUntisSnapshotRepository>(), sp.GetRequiredService<IUntisSlotMappingRepository>(),
sp.GetRequiredService<ISubstitutionEntryRepository>(), sp.GetRequiredService<IGroupRepository>(),
sp.GetRequiredService<ITimetableSlotRepository>(), sp.GetRequiredService<ISupervisionDutyRepository>(),
sp.GetRequiredService<ISchoolHolidayRepository>(), sp.GetRequiredService<PublicHolidayService>(),
sp.GetRequiredService<SchoolCalendarSettingsService>(), sp.GetRequiredService<PeriodScheduleService>(),
sp.GetRequiredService<UntisMatchingService>(), sp.GetRequiredService<UntisDiffService>(),
sp.GetRequiredService<AppLogger>()));
}
// ── Schulweiter Jahresplan (informativer ClassyPlan-iCal, kein Stundenplan-Abgleich) ──
var annualPlanSettings = new AnnualPlanSettingsService(appData);
services.AddSingleton(annualPlanSettings);
if (annualPlanSettings.Enabled && !string.IsNullOrEmpty(annualPlanSettings.GetIcalUrl()))
{
services.AddSingleton(sp => new AnnualPlanSyncService(
new HttpClient(), annualPlanSettings,
sp.GetRequiredService<IAnnualPlanEventRepository>(),
sp.GetRequiredService<AppLogger>()));
}
// ── Sync (optional nur wenn Server konfiguriert) ────────────────────
var syncSettings = new SyncSettingsService(appData);
services.AddSingleton(syncSettings);
services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
services.AddSingleton<UntisReportCacheService>();
services.AddSingleton<UntisHubService>();
// 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
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
// Bei einem Gerät, das noch nie eingeloggt war, ist ein fehlender Schlüssel dagegen der
// normale Erstlauf. Siehe SettingsViewModel (Warnbanner) und SyncKeyRecoveryService.
services.AddSingleton(new SyncKeyStatus(!keyExistedBefore && syncSettings.IsLoggedIn));
services.AddSingleton(_ => new EventQueue(queuePath));
services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>()));
services.AddSingleton<byte[]>(_ =>
{
var key = SyncCrypto.LoadKey(keyPath) ?? SyncCrypto.GenerateKey();
SyncCrypto.SaveKey(key, keyPath);
return key;
});
// Rein lokal/offline (kein Server-Zugriff nötig), deshalb unconditional registriert - im
// Unterschied zu SnapshotService unten, das eine konfigurierte Server-URL voraussetzt.
services.AddSingleton(sp => new SyncKeyRecoveryService(sp.GetRequiredService<byte[]>(), keyPath));
var serverUrl = syncSettings.ServerUrl;
var deviceId = LoadOrCreateDeviceId(appData);
if (!string.IsNullOrEmpty(serverUrl))
{
services.AddSingleton(sp => new EventApplier(
sp.GetRequiredService<LiteDbContext>(), sp.GetRequiredService<byte[]>(),
BuildHttp(serverUrl, syncSettings), sp.GetRequiredService<AppLogger>(),
sp.GetRequiredService<EventQueue>()));
services.AddSingleton(sp => new SyncEventPublisher(
sp.GetRequiredService<EventQueue>(), deviceId, sp.GetRequiredService<byte[]>(),
sp.GetRequiredService<AppLogger>()));
services.AddSingleton(sp => new AttachmentSyncer(
sp.GetRequiredService<LiteDbContext>(), BuildHttp(serverUrl, syncSettings),
sp.GetRequiredService<byte[]>()));
services.AddSingleton<SyncEngine>(sp => new SyncEngine(
sp.GetRequiredService<EventQueue>(),
sp.GetRequiredService<ConflictResolver>(),
sp.GetRequiredService<EventApplier>(),
sp.GetRequiredService<AttachmentSyncer>(),
BuildHttp(serverUrl, syncSettings),
new SyncConfig
{
ServerUrl = serverUrl,
DeviceId = deviceId,
DeviceType = DeviceType.Desktop,
AutoSyncIntervalMinutes = 5,
},
sp.GetRequiredService<AppLogger>()));
services.AddSingleton<SnapshotService>(sp => new SnapshotService(
BuildHttp(serverUrl, syncSettings),
sp.GetRequiredService<LiteDbContext>(),
sp.GetRequiredService<byte[]>(),
DeviceType.Desktop, DbPath, keyPath));
}
// ── ViewModels ────────────────────────────────────────────────────────
// Singleton: einmal erstellt, überall dieselbe Instanz
services.AddSingleton<AppLockViewModel>();
services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<GlobalSearchViewModel>();
services.AddSingleton<DashboardViewModel>();
services.AddSingleton(sp =>
new SyncStatusViewModel(
sp.GetService<SyncEngine>(),
sp.GetRequiredService<NotificationService>()));
services.AddSingleton<GroupListViewModel>();
services.AddSingleton<StudentListViewModel>();
services.AddSingleton<TimetableViewModel>();
services.AddSingleton<WorkTaskListViewModel>();
services.AddSingleton<TimeTrackingViewModel>();
services.AddSingleton<WorkloadEvaluationViewModel>();
services.AddSingleton<WorkloadViewModel>();
services.AddSingleton<ClassTeacherDetailsViewModel>();
services.AddSingleton<ClassTeacherCasesViewModel>();
services.AddSingleton<ClassTeacherOverviewViewModel>();
services.AddSingleton<ExamsOverviewViewModel>();
// Transient: neue Instanz pro Navigation (für Detailseiten)
services.AddTransient<GroupDetailViewModel>();
services.AddTransient<StudentDetailViewModel>();
services.AddTransient<GroupOverviewViewModel>();
services.AddTransient<ParticipationTabViewModel>();
services.AddTransient<GradeOverviewTabViewModel>();
services.AddTransient<PlanningTabViewModel>();
services.AddTransient<CompetencyOverviewTabViewModel>();
services.AddTransient<SeatingPlanTabViewModel>();
services.AddTransient<GroupDocumentationTabViewModel>();
services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<TrashViewModel>();
services.AddTransient<SettingsViewModel>();
var provider = services.BuildServiceProvider();
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden.
if (!string.IsNullOrEmpty(serverUrl))
provider.GetRequiredService<LiteDbContext>().OnChange =
provider.GetRequiredService<SyncEventPublisher>().Publish;
return provider;
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static HttpClient BuildHttp(string url, SyncSettingsService syncSettings)
{
var http = new HttpClient { BaseAddress = new Uri(url) };
var token = syncSettings.GetToken();
if (token is not null)
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
return http;
}
private static string LoadOrCreateDeviceId(string appData)
{
var p = Path.Combine(appData, "device.id");
if (File.Exists(p)) return File.ReadAllText(p).Trim();
var id = Guid.NewGuid().ToString();
File.WriteAllText(p, id);
return id;
}
}