WIP (unstable): WebUntis-iCal-Abgleich für Vertretungen/Ausfälle
Erkennt Vertretungen, Ausfälle und Zusatzaufsichten aus dem persönlichen WebUntis-iCal-Feed und schreibt sie automatisch als SubstitutionEntry. Bekannter offener Bug: es tauchen weiterhin falsche Vertretungen für Stunden auf, die real unverändert sind — wird in einem Folge-Commit untersucht, deshalb vorerst auf diesem Branch statt main. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>Ergebnis eines einzelnen Verarbeitungsdurchlaufs (Abruf oder Test-Text) — für
|
||||
/// Statusanzeige/Logging.</summary>
|
||||
public sealed record UntisPollResult(int EventCount, int SubstitutionCount);
|
||||
|
||||
/// <summary>Für den Zuordnungs-Review-Dialog: das Rohergebnis der Musteranalyse (Stufe 1) für
|
||||
/// einen konkreten Abruf, ohne dass dabei schon etwas gespeichert wird.</summary>
|
||||
public sealed record UntisMatchPreview(int EventCount, UntisMatchResult Matches);
|
||||
|
||||
/// <summary>
|
||||
/// Orchestriert den periodischen WebUntis-iCal-Abgleich (siehe TODO.md/Planungsdokument): Abruf
|
||||
/// per HTTP, Parsen (IcsParser), Musterabgleich (UntisMatchingService) sowie laufender
|
||||
/// Schnappschuss-Abgleich (UntisDiffService), dessen Ergebnis über die Repositories geschrieben
|
||||
/// wird. Gleiches Timer/Gate/Dispose-Muster wie LehrerApp.Sync.SyncEngine.
|
||||
///
|
||||
/// Der reine Verarbeitungskern (<see cref="ProcessIcsText"/>) ist bewusst ohne HTTP-Zugriff
|
||||
/// gehalten (public statt internal, da diese Codebasis kein InternalsVisibleTo nutzt), damit er
|
||||
/// direkt mit vorgefertigtem ICS-Text getestet werden kann, ohne einen echten Abruf zu brauchen.
|
||||
/// </summary>
|
||||
public class UntisSyncService : IDisposable
|
||||
{
|
||||
private const int PollIntervalMinutes = 60;
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly WebUntisSettingsService _settings;
|
||||
private readonly IUntisSnapshotRepository _snapshots;
|
||||
private readonly IUntisSlotMappingRepository _mappings;
|
||||
private readonly ISubstitutionEntryRepository _substitutions;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly ITimetableSlotRepository _timetableSlots;
|
||||
private readonly ISupervisionDutyRepository _supervisionDuties;
|
||||
private readonly ISchoolHolidayRepository _schoolHolidays;
|
||||
private readonly PublicHolidayService _publicHolidays;
|
||||
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||||
private readonly PeriodScheduleService _periodSchedule;
|
||||
private readonly UntisMatchingService _matchingService;
|
||||
private readonly UntisDiffService _diffService;
|
||||
private readonly AppLogger? _logger;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly Timer _timer;
|
||||
|
||||
/// Feuert, wenn ein Poll tatsächlich Vertretungen geschrieben hat — Timetable/Dashboard können
|
||||
/// dann bei Bedarf neu laden (EventApplier-Muster: direktes Schreiben an ViewModels vorbei).
|
||||
public event Action? DataChanged;
|
||||
|
||||
public UntisSyncService(HttpClient http, WebUntisSettingsService settings, IUntisSnapshotRepository snapshots,
|
||||
IUntisSlotMappingRepository mappings, ISubstitutionEntryRepository substitutions, IGroupRepository groups,
|
||||
ITimetableSlotRepository timetableSlots, ISupervisionDutyRepository supervisionDuties,
|
||||
ISchoolHolidayRepository schoolHolidays, PublicHolidayService publicHolidays,
|
||||
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||||
UntisMatchingService matchingService, UntisDiffService diffService, AppLogger? logger = null)
|
||||
{
|
||||
_http = http; _settings = settings; _snapshots = snapshots; _mappings = mappings;
|
||||
_substitutions = substitutions; _groups = groups; _timetableSlots = timetableSlots;
|
||||
_supervisionDuties = supervisionDuties;
|
||||
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
|
||||
_periodSchedule = periodSchedule; _matchingService = matchingService; _diffService = diffService;
|
||||
_logger = logger;
|
||||
_timer = new Timer(async _ => await PollAsync(), null,
|
||||
TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes));
|
||||
}
|
||||
|
||||
public async Task PollAsync()
|
||||
{
|
||||
if (!await _gate.WaitAsync(0)) return;
|
||||
try
|
||||
{
|
||||
var url = _settings.GetIcalUrl();
|
||||
if (string.IsNullOrEmpty(url)) return;
|
||||
|
||||
string icsText;
|
||||
try
|
||||
{
|
||||
icsText = await _http.GetStringAsync(url);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Error("WebUntis-Abgleich: Abruf fehlgeschlagen", ex);
|
||||
_settings.SetLastSync(DateTime.UtcNow, $"Fehler beim Abruf: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
UntisPollResult result;
|
||||
try
|
||||
{
|
||||
result = ProcessIcsText(icsText);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Darf NICHT aus PollAsync herausfallen: der Timer-Callback fängt keine
|
||||
// Ausnahmen ab, eine unbehandelte Exception hier würde den gesamten Prozess
|
||||
// beenden (gleiche Begründung wie EventApplier in LehrerApp.Sync).
|
||||
_logger?.Error("WebUntis-Abgleich: Verarbeitung fehlgeschlagen", ex);
|
||||
_settings.SetLastSync(DateTime.UtcNow, $"Fehler bei der Verarbeitung: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
_settings.SetLastSync(DateTime.UtcNow,
|
||||
$"{result.SubstitutionCount} Änderung(en) erkannt ({result.EventCount} Termine geprüft).");
|
||||
_logger?.Info($"WebUntis-Abgleich: {result.SubstitutionCount} Änderung(en) aus {result.EventCount} Terminen.");
|
||||
if (result.SubstitutionCount > 0) DataChanged?.Invoke();
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
// Reiner Kern ohne HTTP - direkt mit vorgefertigtem ICS-Text testbar (kein InternalsVisibleTo
|
||||
// in dieser Codebasis üblich, siehe CLAUDE.md - deshalb public statt internal).
|
||||
public UntisPollResult ProcessIcsText(string icsText)
|
||||
{
|
||||
var events = IcsParser.Parse(icsText);
|
||||
var previousSnapshot = _snapshots.GetAll();
|
||||
var confirmedMappings = _mappings.GetAll();
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var diffResult = _diffService.Diff(events, previousSnapshot, confirmedMappings, today,
|
||||
existingSupervisionDuties: _supervisionDuties.GetAll(), freeDates: BuildFreeDates(today));
|
||||
|
||||
foreach (var candidate in diffResult.SubstitutionsToSave)
|
||||
{
|
||||
var existing = candidate.ExternalId is not null ? _substitutions.GetByExternalId(candidate.ExternalId) : null;
|
||||
if (existing is not null) candidate.Id = existing.Id;
|
||||
_substitutions.Save(candidate);
|
||||
}
|
||||
foreach (var snapshot in diffResult.SnapshotToSave) _snapshots.Save(snapshot);
|
||||
foreach (var id in diffResult.SnapshotIdsToDelete) _snapshots.Delete(id);
|
||||
|
||||
return new UntisPollResult(events.Count, diffResult.SubstitutionsToSave.Count);
|
||||
}
|
||||
|
||||
// Ferien-/Feiertagstage im relevanten Zeitfenster (deutlich über das Lookahead-Fenster
|
||||
// hinaus, kostet bei kleinen Ferienlisten nichts) - verhindert, dass die aktive
|
||||
// "fehlt komplett im Feed"-Prüfung in UntisDiffService Ferientage fälschlich als Ausfall
|
||||
// meldet, an denen WebUntis ohnehin keine Termine führt. Gleiche Logik wie
|
||||
// TimetableViewModel.IsFreeDay, hier separat gehalten statt geteilt, da UntisDiffService
|
||||
// (LehrerApp.Core) bewusst framework-frei bleibt und keine Desktop-ViewModels referenziert.
|
||||
private HashSet<DateOnly> BuildFreeDates(DateOnly today)
|
||||
{
|
||||
var horizonEnd = today.AddDays(90);
|
||||
var freeDates = new HashSet<DateOnly>();
|
||||
foreach (var year in new[] { today.Year, today.Year + 1 })
|
||||
foreach (var h in _publicHolidays.GetHolidays(year, _calendarSettings.State))
|
||||
freeDates.Add(h.Date);
|
||||
|
||||
var schoolHolidays = _schoolHolidays.GetAll();
|
||||
for (var date = today; date <= horizonEnd; date = date.AddDays(1))
|
||||
if (schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate))
|
||||
freeDates.Add(date);
|
||||
|
||||
return freeDates;
|
||||
}
|
||||
|
||||
/// Für den Zuordnungs-Review-Dialog (Stufe 1, ohne etwas zu speichern).
|
||||
public UntisMatchPreview BuildMatchPreview(string icsText)
|
||||
{
|
||||
var events = IcsParser.Parse(icsText);
|
||||
var groups = _groups.GetAll();
|
||||
var timetableSlots = _timetableSlots.GetAll();
|
||||
var matches = _matchingService.BuildMatches(events, groups, timetableSlots, _periodSchedule);
|
||||
return new UntisMatchPreview(events.Count, matches);
|
||||
}
|
||||
|
||||
public async Task<UntisMatchPreview?> FetchAndBuildMatchPreviewAsync()
|
||||
{
|
||||
var url = _settings.GetIcalUrl();
|
||||
if (string.IsNullOrEmpty(url)) return null;
|
||||
var icsText = await _http.GetStringAsync(url);
|
||||
return BuildMatchPreview(icsText);
|
||||
}
|
||||
|
||||
public void ConfirmMappings(IEnumerable<UntisSlotMapping> confirmed)
|
||||
{
|
||||
foreach (var mapping in confirmed)
|
||||
{
|
||||
mapping.Confirmed = true;
|
||||
_mappings.Save(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_timer.Dispose();
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
internal class WebUntisSettingsConfig
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string? EncryptedIcalUrl { get; set; }
|
||||
public DateTime? LastSyncAt { get; set; }
|
||||
public string LastSyncStatus { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie
|
||||
/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die
|
||||
/// Verschlüsselung über <see cref="SyncCrypto"/> aus LehrerApp.Sync läuft — Core bleibt bewusst
|
||||
/// frei von Abhängigkeiten außerhalb von .NET selbst (siehe CLAUDE.md).
|
||||
///
|
||||
/// Die iCal-URL trägt ein eingebettetes Auth-Token und wird deshalb wie ein Passwort behandelt:
|
||||
/// nie im Klartext persistiert, nur AES-256-GCM-verschlüsselt (gleicher Mechanismus wie beim
|
||||
/// KI-Backend-Token) mit einem eigenen, dateirechte-geschützten Schlüssel.
|
||||
/// </summary>
|
||||
public class WebUntisSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
private readonly string _keyPath;
|
||||
private readonly byte[] _urlKey;
|
||||
private WebUntisSettingsConfig _config;
|
||||
|
||||
public bool Enabled => _config.Enabled;
|
||||
public bool IsConfigured => _config.EncryptedIcalUrl is not null;
|
||||
public DateTime? LastSyncAt => _config.LastSyncAt;
|
||||
public string LastSyncStatus => _config.LastSyncStatus;
|
||||
|
||||
public WebUntisSettingsService(string appDataPath)
|
||||
{
|
||||
_configPath = Path.Combine(appDataPath, "webuntis-settings.json");
|
||||
_keyPath = Path.Combine(appDataPath, "webuntis-url.key");
|
||||
_urlKey = SyncCrypto.LoadKey(_keyPath) ?? GenerateAndSaveKey();
|
||||
_config = Load();
|
||||
}
|
||||
|
||||
public void SetEnabled(bool enabled)
|
||||
{
|
||||
_config.Enabled = enabled;
|
||||
Save();
|
||||
}
|
||||
|
||||
public void SetIcalUrl(string url)
|
||||
{
|
||||
_config.EncryptedIcalUrl = SyncCrypto.EncryptObject(url, _urlKey);
|
||||
Save();
|
||||
}
|
||||
|
||||
public string? GetIcalUrl() =>
|
||||
_config.EncryptedIcalUrl is null ? null : SyncCrypto.DecryptObject<string>(_config.EncryptedIcalUrl, _urlKey);
|
||||
|
||||
public void ClearIcalUrl()
|
||||
{
|
||||
_config.EncryptedIcalUrl = null;
|
||||
_config.Enabled = false;
|
||||
Save();
|
||||
}
|
||||
|
||||
public void SetLastSync(DateTime at, string status)
|
||||
{
|
||||
_config.LastSyncAt = at;
|
||||
_config.LastSyncStatus = status;
|
||||
Save();
|
||||
}
|
||||
|
||||
private byte[] GenerateAndSaveKey()
|
||||
{
|
||||
var key = SyncCrypto.GenerateKey();
|
||||
SyncCrypto.SaveKey(key, _keyPath);
|
||||
return key;
|
||||
}
|
||||
|
||||
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
|
||||
|
||||
private WebUntisSettingsConfig Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
return JsonSerializer.Deserialize<WebUntisSettingsConfig>(File.ReadAllText(_configPath))
|
||||
?? new WebUntisSettingsConfig();
|
||||
}
|
||||
catch { /* beschädigte Konfiguration -> Standardwert */ }
|
||||
return new WebUntisSettingsConfig();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user