Untis API Integration

This commit is contained in:
2026-08-24 21:52:00 +02:00
parent 5841d96c5b
commit 0f10f754d0
26 changed files with 1237 additions and 58 deletions
@@ -0,0 +1,131 @@
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Importing;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class WebUntisAbsenceRow : ObservableObject
{
public required string StudentName { get; init; }
public required DateOnly Date { get; init; }
public required string TimeLabel { get; init; }
public required string UntisStatus { get; init; }
public required string LocalStatus { get; init; }
public required AttendanceStatus TargetStatus { get; init; }
public required Guid StudentId { get; init; }
public required Guid? SessionId { get; init; }
public string? Reason { get; init; }
public string DateLabel => Date.ToString("dd.MM.yyyy");
public bool CanApply => SessionId is not null;
[ObservableProperty] private bool _selected;
}
public partial class WebUntisAbsenceComparisonViewModel : ObservableObject
{
private readonly LearningGroup _group;
private readonly WebUntisIntegrationService _untis;
private readonly IStudentRepository _students;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation;
public ObservableCollection<WebUntisAbsenceRow> Rows { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
[ObservableProperty] private bool _busy;
public WebUntisAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
IStudentRepository students, IParticipationSessionRepository sessions,
IParticipationRepository participation)
{
_group = group; _untis = untis; _students = students; _sessions = sessions;
_participation = participation;
}
[RelayCommand]
private async Task Load()
{
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
Busy = true; Rows.Clear();
try
{
var courseStudents = _students.GetByGroup(_group.Id);
var localSessions = _sessions.GetByGroup(_group.Id)
.Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date)
.ToDictionary(x => x.Key, x => x.First());
var loaded = new ConcurrentBag<(Student Student, UntisStudentAbsenceDto Absence)>();
var linked = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
.Where(x => x.Key is not null).ToList();
await Parallel.ForEachAsync(linked, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (item, token) =>
{
var report = await _untis.GetAbsencesAsync(item.Key!.Value, start, end, token);
foreach (var absence in report.Absences) loaded.Add((item.Student, absence));
});
foreach (var item in loaded.OrderBy(x => x.Absence.Date).ThenBy(x => x.Student.FullName))
{
if (!TryDate(item.Absence.Date, out var date)) continue;
localSessions.TryGetValue(date, out var session);
var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, item.Student.Id);
var target = MapStatus(item.Absence.ExcuseStatus);
Rows.Add(new WebUntisAbsenceRow
{
StudentName = item.Student.FullName, StudentId = item.Student.Id, Date = date,
TimeLabel = $"{Time(item.Absence.StartTime)}{Time(item.Absence.EndTime)}",
UntisStatus = DisplayUntisStatus(item.Absence),
LocalStatus = entry?.Attendance?.ToString() ?? (session is null ? "keine lokale Stunde" : "nicht erfasst"),
TargetStatus = target, SessionId = session?.Id, Reason = item.Absence.AbsenceReason,
Selected = session is not null && entry?.Attendance != target,
});
}
var withoutKey = courseStudents.Count - linked.Count;
Status = $"{Rows.Count} Untis-Fehlzeiten gefunden; {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar."
+ (withoutKey > 0 ? $" {withoutKey} Schüler haben noch keine WebUntis-Kennung." : "");
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private void Apply()
{
var selected = Rows.Where(x => x.Selected && x.SessionId is not null).ToList();
foreach (var row in selected)
{
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, row.StudentId)
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = row.StudentId };
entry.Attendance = row.TargetStatus;
entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry);
}
Status = $"{selected.Count} Anwesenheitsstatus übernommen.";
foreach (var row in selected) row.Selected = false;
}
private static int? StudentKey(Student student)
{
student.ExternalIds ??= [];
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
&& int.TryParse(value, out var key) ? key : null;
}
private static AttendanceStatus MapStatus(string? value)
{
var text = value?.Trim().ToLowerInvariant() ?? "";
if (text.Contains("unexcused") || text.Contains("unentschuldigt") || text.Contains("nicht entschuldigt"))
return AttendanceStatus.Unexcused;
if (text.Contains("excused") || text.Contains("entschuldigt")) return AttendanceStatus.Excused;
return AttendanceStatus.ExcusePending;
}
private static string DisplayUntisStatus(UntisStudentAbsenceDto absence) =>
string.Join(" · ", new[] { absence.ExcuseStatus, absence.AbsenceReason }.Where(x => !string.IsNullOrWhiteSpace(x)))
is { Length: > 0 } text ? text : "offen";
private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
private static string Time(int value) => $"{value / 100:00}:{value % 100:00}";
}
@@ -0,0 +1,56 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class WebUntisClassSelectionViewModel(WebUntisIntegrationService untis) : ObservableObject
{
public ObservableCollection<UntisSchoolYearDto> SchoolYears { get; } = [];
public ObservableCollection<UntisClassDto> Classes { get; } = [];
[ObservableProperty] private UntisSchoolYearDto? _selectedSchoolYear;
[ObservableProperty] private UntisClassDto? _selectedClass;
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _busy;
public bool CanConfirm => SelectedClass is not null && !Busy;
partial void OnSelectedClassChanged(UntisClassDto? value) => OnPropertyChanged(nameof(CanConfirm));
partial void OnBusyChanged(bool value) => OnPropertyChanged(nameof(CanConfirm));
public async Task InitializeAsync()
{
Busy = true;
try
{
foreach (var year in (await untis.GetSchoolYearsAsync()).OrderByDescending(x => x.StartDate))
SchoolYears.Add(year);
SelectedSchoolYear = SchoolYears.FirstOrDefault(x => x.StartDate <= Today() && x.EndDate >= Today())
?? SchoolYears.FirstOrDefault();
await LoadClasses();
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task LoadClasses()
{
if (SelectedSchoolYear is null) return;
Busy = true; Classes.Clear(); SelectedClass = null;
try
{
foreach (var entry in (await untis.GetClassesAsync(SelectedSchoolYear.UntisId)).OrderBy(x => x.Name))
Classes.Add(entry);
Status = Classes.Count == 0 ? "Keine Klassen gefunden." : $"{Classes.Count} Klassen gefunden.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
private static int Today()
{
var date = DateOnly.FromDateTime(DateTime.Today);
return date.Year * 10000 + date.Month * 100 + date.Day;
}
}
@@ -103,6 +103,7 @@ public partial class TimetableViewModel : ObservableObject
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; }
public Func<Task>? OnAddSubstitution { get; set; }
public Func<Task>? OnImportWebUntisTimetable { get; set; }
public Action<SettingsTab>? OnNavigateToSettings { get; set; }
public Func<Lesson, Task>? OnOpenLessonViewer { get; set; }
public Func<Lesson, Task>? OnOpenTeachingMode { get; set; }
@@ -143,6 +144,14 @@ public partial class TimetableViewModel : ObservableObject
Load();
}
[RelayCommand]
private async Task ImportWebUntisTimetable()
{
if (OnImportWebUntisTimetable is null) return;
await OnImportWebUntisTimetable();
Load();
}
public void Load()
{
var today = DateOnly.FromDateTime(DateTime.Today);
@@ -0,0 +1,159 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Planning;
public sealed record WebUntisGroupOption(Guid Id, string DisplayName);
public partial class WebUntisTimetableRow : ObservableObject
{
public required DayOfWeek Weekday { get; init; }
public required int PeriodNumber { get; init; }
public required string TimeLabel { get; init; }
public required string UntisLabel { get; init; }
public required string SuggestedGroupName { get; init; }
public string? SubjectName { get; init; }
public string? Room { get; init; }
public ObservableCollection<WebUntisGroupOption> GroupOptions { get; } = [];
[ObservableProperty] private WebUntisGroupOption? _selectedGroup;
public string WeekdayLabel => Weekday switch
{
DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi",
DayOfWeek.Thursday => "Do", DayOfWeek.Friday => "Fr", _ => Weekday.ToString()[..2],
};
}
public partial class WebUntisTimetableImportViewModel : ObservableObject
{
private readonly WebUntisIntegrationService _untis;
private readonly WebUntisSettingsService _settings;
private readonly ITimetableSlotRepository _slots;
private readonly IGroupRepository _groups;
public ObservableCollection<UntisTeacherDto> Teachers { get; } = [];
public ObservableCollection<WebUntisTimetableRow> Rows { get; } = [];
[ObservableProperty] private UntisTeacherDto? _selectedTeacher;
[ObservableProperty] private DateTimeOffset _weekDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Lehrkraft auswählen und Untis-Woche laden.";
[ObservableProperty] private bool _busy;
public bool Saved { get; private set; }
public Func<WebUntisTimetableRow, Task<LearningGroup?>>? OnCreateGroup { get; set; }
public WebUntisTimetableImportViewModel(WebUntisIntegrationService untis, WebUntisSettingsService settings,
ITimetableSlotRepository slots, IGroupRepository groups)
{
_untis = untis; _settings = settings; _slots = slots; _groups = groups;
}
public async Task InitializeAsync()
{
Busy = true;
try
{
foreach (var teacher in (await _untis.GetTeachersAsync()).Where(x => x.Active).OrderBy(x => x.DisplayName))
Teachers.Add(teacher);
SelectedTeacher = Teachers.FirstOrDefault(x => x.UntisId == _settings.TeacherUntisId)
?? Teachers.FirstOrDefault();
Status = Teachers.Count == 0 ? "WebUntis hat keine Lehrkräfte geliefert." : "Bereit zum Laden.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task Load()
{
if (SelectedTeacher is null) { Status = "Bitte eine Lehrkraft auswählen."; return; }
Busy = true; Rows.Clear();
try
{
var selected = DateOnly.FromDateTime(WeekDate.LocalDateTime);
var monday = selected.AddDays(-(((int)selected.DayOfWeek + 6) % 7));
var periods = await _untis.GetTimetableAsync(SelectedTeacher.UntisId, monday, monday.AddDays(6));
var grid = await _untis.GetTimeGridAsync();
var groupOptions = BuildGroupOptions();
var localSlots = _slots.GetAll().ToDictionary(x => (x.Weekday, x.PeriodNumber));
foreach (var period in periods.Where(x => string.IsNullOrWhiteSpace(x.Code) || x.Code != "cancelled")
.GroupBy(x => (x.Date, x.StartTime, x.EndTime, x.StudentGroup,
Class: string.Join("/", x.Classes.Select(c => c.Name)),
Subject: string.Join("/", x.Subjects.Select(s => s.Name)),
Room: string.Join("/", x.Rooms.Select(r => r.Name))))
.Select(x => x.First()).OrderBy(x => x.Date).ThenBy(x => x.StartTime))
{
if (!TryDate(period.Date, out var date) || date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
continue;
var dayGrid = grid.FirstOrDefault(x => x.Day == UntisDay(date.DayOfWeek));
var number = dayGrid?.TimeUnits.ToList().FindIndex(x => x.StartTime == period.StartTime) + 1 ?? 0;
if (number <= 0) continue;
var className = period.Classes.FirstOrDefault()?.Name ?? "";
var subject = period.Subjects.FirstOrDefault()?.Name;
var suggested = !string.IsNullOrWhiteSpace(period.StudentGroup) ? period.StudentGroup! : className;
var row = new WebUntisTimetableRow
{
Weekday = date.DayOfWeek, PeriodNumber = number,
TimeLabel = $"{Time(period.StartTime)}{Time(period.EndTime)}",
UntisLabel = string.Join(" · ", new[] { subject, suggested, period.Rooms.FirstOrDefault()?.Name }
.Where(x => !string.IsNullOrWhiteSpace(x))),
SuggestedGroupName = suggested, SubjectName = subject,
Room = period.Rooms.FirstOrDefault()?.Name,
};
foreach (var option in groupOptions) row.GroupOptions.Add(option);
if (localSlots.TryGetValue((row.Weekday, row.PeriodNumber), out var existing))
row.SelectedGroup = groupOptions.FirstOrDefault(x => x.Id == existing.GroupId);
row.SelectedGroup ??= BestMatch(groupOptions, suggested, className, subject);
Rows.Add(row);
}
_settings.SetTeacherUntisId(SelectedTeacher.UntisId);
Status = Rows.Count == 0 ? "In dieser Woche wurde kein Unterricht gefunden."
: $"{Rows.Count} regelmäßige Termine gefunden. Zuordnung prüfen und übernehmen.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task CreateGroup(WebUntisTimetableRow row)
{
if (OnCreateGroup is null) return;
var group = await OnCreateGroup(row);
if (group is null) return;
var option = new WebUntisGroupOption(group.Id, group.Name);
foreach (var item in Rows.Where(x => x.SuggestedGroupName == row.SuggestedGroupName))
{
item.GroupOptions.Add(option);
item.SelectedGroup = option;
}
}
[RelayCommand]
private void Save()
{
var selected = Rows.Where(x => x.SelectedGroup is not null).ToList();
foreach (var row in selected)
{
var existing = _slots.GetAll().FirstOrDefault(x => x.Weekday == row.Weekday && x.PeriodNumber == row.PeriodNumber);
var slot = existing ?? new TimetableSlot { Weekday = row.Weekday, PeriodNumber = row.PeriodNumber };
slot.GroupId = row.SelectedGroup!.Id;
slot.Room = string.IsNullOrWhiteSpace(row.Room) ? null : row.Room;
_slots.Save(slot);
}
Saved = true;
Status = $"{selected.Count} Stundenplan-Einträge übernommen.";
}
private List<WebUntisGroupOption> BuildGroupOptions() => _groups.GetAll().OrderBy(x => x.Name)
.Select(x => new WebUntisGroupOption(x.Id, x.Name)).ToList();
private static WebUntisGroupOption? BestMatch(IEnumerable<WebUntisGroupOption> options, params string?[] terms) =>
options.FirstOrDefault(x => terms.Any(term => !string.IsNullOrWhiteSpace(term) &&
(x.DisplayName.Equals(term, StringComparison.OrdinalIgnoreCase) ||
x.DisplayName.Contains(term, StringComparison.OrdinalIgnoreCase))));
private static int UntisDay(DayOfWeek day) => day == DayOfWeek.Sunday ? 7 : (int)day;
private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
private static string Time(int value) => $"{value / 100:00}:{value % 100:00}";
}
@@ -210,6 +210,13 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _untisUrlError = "";
[ObservableProperty] private string _untisStatusDisplay = "";
[ObservableProperty] private bool _untisFetchBusy;
[ObservableProperty] private bool _untisApiIsConfigured;
[ObservableProperty] private string _untisSchool = "";
[ObservableProperty] private string _untisHost = "";
[ObservableProperty] private string _untisUsername = "";
[ObservableProperty] private string _untisPassword = "";
[ObservableProperty] private string _untisApiStatus = "";
[ObservableProperty] private bool _untisApiBusy;
public Func<Task>? OnReviewUntisMapping { get; set; }
// ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
@@ -299,6 +306,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly AiSettingsService _aiSettings;
private readonly AiPlanningService _aiPlanning;
private readonly WebUntisSettingsService _untisSettings;
private readonly WebUntisIntegrationService? _untisIntegration;
private readonly UntisSyncService? _untisSync;
private readonly AnnualPlanSettingsService _annualPlanSettings;
private readonly AnnualPlanSyncService? _annualPlanSync;
@@ -330,7 +338,7 @@ public partial class SettingsViewModel : ObservableObject
AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null,
SchoolWeatherService? schoolWeather = null)
SchoolWeatherService? schoolWeather = null, WebUntisIntegrationService? untisIntegration = null)
{
_logger = logger;
_syncKeyRecovery = syncKeyRecovery;
@@ -360,6 +368,7 @@ public partial class SettingsViewModel : ObservableObject
_aiSettings = aiSettings;
_aiPlanning = aiPlanning;
_untisSettings = untisSettings;
_untisIntegration = untisIntegration;
_untisSync = untisSync;
_annualPlanSettings = annualPlanSettings;
_annualPlanSync = annualPlanSync;
@@ -542,6 +551,60 @@ public partial class SettingsViewModel : ObservableObject
UntisStatusDisplay = _untisSettings.LastSyncAt is { } at
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_untisSettings.LastSyncStatus}"
: "Noch kein Abgleich durchgeführt.";
UntisApiIsConfigured = _untisSettings.ApiIsConfigured;
if (_untisSettings.GetApiCredentials() is { } credentials)
{
UntisSchool = credentials.School;
UntisHost = credentials.Host;
UntisUsername = credentials.Username;
UntisApiStatus = $"API-Zugang für {credentials.Username} ist lokal verschlüsselt gespeichert.";
}
}
[RelayCommand]
private async Task UntisSaveApi()
{
UntisApiStatus = "";
if (_untisIntegration is null)
{
UntisApiStatus = "Die WebUntis-Integration ist nicht verfügbar.";
return;
}
if (string.IsNullOrWhiteSpace(UntisSchool) || string.IsNullOrWhiteSpace(UntisUsername) ||
string.IsNullOrWhiteSpace(UntisPassword))
{
UntisApiStatus = "Schule, Benutzername und Passwort sind erforderlich.";
return;
}
UntisApiBusy = true;
try
{
var credentials = new WebUntisCredentials(UntisSchool.Trim(), UntisHost.Trim(),
UntisUsername.Trim(), UntisPassword);
await _untisIntegration.ConnectAsync(credentials);
_untisSettings.SetApiCredentials(credentials);
UntisPassword = "";
UntisApiIsConfigured = true;
UntisApiStatus = "Anmeldung erfolgreich. Die WebUntis-Session bleibt bei Nutzung bis zu 10 Minuten offen.";
}
catch (WebUntisIntegrationException ex) { UntisApiStatus = ex.Message; }
finally { UntisApiBusy = false; }
}
[RelayCommand]
private async Task UntisRemoveApi()
{
UntisApiBusy = true;
try { if (_untisIntegration is not null) await _untisIntegration.DisconnectAsync(); }
catch (WebUntisIntegrationException) { /* lokale Zugangsdaten trotzdem sicher entfernen */ }
finally
{
_untisSettings.ClearApiCredentials();
UntisPassword = "";
UntisApiIsConfigured = false;
UntisApiStatus = "WebUntis-API-Zugang entfernt.";
UntisApiBusy = false;
}
}
[RelayCommand]