diff --git a/LehrerApp.Core/Models/LearningGroup.cs b/LehrerApp.Core/Models/LearningGroup.cs index 7cc20db..3dc490b 100644 --- a/LehrerApp.Core/Models/LearningGroup.cs +++ b/LehrerApp.Core/Models/LearningGroup.cs @@ -31,7 +31,8 @@ public class LearningGroup /// /// WebUntis-interne Unterrichtsnummer (lsid) dieser Lerngruppe, Grundlage für den Abruf des /// "Fehlzeiten pro Unterricht"-Berichts. Wird von WebUntis pro Schuljahr neu vergeben und muss - /// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie. + /// deshalb je Lerngruppe/Schuljahr gepflegt werden. Der manuelle WebUntis-Zuordnungsabgleich + /// kann sie aus dem Wochenstundenplan übernehmen. /// public int? WebUntisLessonId { get; set; } /// diff --git a/LehrerApp.Desktop.Tests/WebUntisTimetableImportTests.cs b/LehrerApp.Desktop.Tests/WebUntisTimetableImportTests.cs new file mode 100644 index 0000000..ca8ee4e --- /dev/null +++ b/LehrerApp.Desktop.Tests/WebUntisTimetableImportTests.cs @@ -0,0 +1,111 @@ +using System.Net; +using System.Text; +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.ViewModels.Planning; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class WebUntisTimetableImportTests +{ + [Fact] + public async Task Load_IsReadOnly_AndSaveUpdatesOnlyLessonIdsByDefault() + { + await using var fixture = new Fixture(); + await fixture.Vm.LoadCommand.ExecuteAsync(null); + Assert.Null(fixture.Group.WebUntisLessonId); + Assert.Null(fixture.Settings.TeacherUntisId); + Assert.Single(fixture.Slots.GetAll()); + Assert.Equal(38926, Assert.Single(fixture.Vm.Rows).LessonId); + fixture.Vm.SaveCommand.Execute(null); + Assert.True(fixture.Vm.Saved); + Assert.Equal(38926, fixture.Group.WebUntisLessonId); + Assert.Equal(DayOfWeek.Friday, Assert.Single(fixture.Slots.GetAll()).Weekday); + } + + [Theory] + [InlineData(false, 2)] + [InlineData(true, 1)] + public async Task TimetableUpdates_OnlyRemoveOldSlotsWhenExplicitlySelected(bool replace, int expected) + { + await using var fixture = new Fixture(); + await fixture.Vm.LoadCommand.ExecuteAsync(null); + fixture.Vm.UpdateLessonIds = false; + fixture.Vm.UpdateTimetable = true; + fixture.Vm.ReplaceTimetable = replace; + fixture.Vm.SaveCommand.Execute(null); + Assert.True(fixture.Vm.Saved); + Assert.Null(fixture.Group.WebUntisLessonId); + Assert.Equal(expected, fixture.Slots.GetAll().Count); + Assert.Contains(fixture.Slots.GetAll(), slot => slot.Weekday == DayOfWeek.Monday); + } + + [Fact] + public async Task ConflictingLessonIds_BlockAllChanges_UntilExcluded() + { + await using var fixture = new Fixture(); + await fixture.Vm.LoadCommand.ExecuteAsync(null); + fixture.Vm.Rows.Add(new WebUntisTimetableRow + { + Weekday = DayOfWeek.Tuesday, PeriodNumber = 1, TimeLabel = "07:50", UntisLabel = "7a", + SuggestedGroupName = "7a", LessonId = 999, SelectedGroup = fixture.Vm.Rows[0].SelectedGroup, + }); + fixture.Vm.UpdateTimetable = true; + fixture.Vm.SaveCommand.Execute(null); + Assert.False(fixture.Vm.Saved); + Assert.Null(fixture.Group.WebUntisLessonId); + Assert.Single(fixture.Slots.GetAll()); + Assert.Contains("Mehrere Unterrichtsnummern", fixture.Vm.Status); + fixture.Vm.Rows[1].ApplyLessonId = false; + fixture.Vm.SaveCommand.Execute(null); + Assert.True(fixture.Vm.Saved); + Assert.Equal(38926, fixture.Group.WebUntisLessonId); + } + + [Fact] + public async Task SaveWithoutLoadedWeek_DoesNotClearTimetable() + { + await using var fixture = new Fixture(); + fixture.Vm.UpdateTimetable = fixture.Vm.ReplaceTimetable = true; + fixture.Vm.SaveCommand.Execute(null); + Assert.False(fixture.Vm.Saved); + Assert.Single(fixture.Slots.GetAll()); + } + + private sealed class Fixture : IAsyncDisposable + { + public LearningGroup Group { get; } = new() { Name = "7a" }; + public FakeTimetableSlots Slots { get; } = new(); + public WebUntisSettingsService Settings { get; } = TestSupport.BuildWebUntisSettingsService(); + private readonly WebUntisIntegrationService _service; + public WebUntisTimetableImportViewModel Vm { get; } + public Fixture() + { + Settings.SetApiCredentials(new("school", "example.webuntis.com", "teacher", "password")); + _service = new(new HttpClient(new Handler()), Settings); + Slots.Add(new() { Weekday = DayOfWeek.Friday, PeriodNumber = 4, GroupId = Group.Id }); + Vm = new(_service, Settings, Slots, new FakeGroups([Group])) + { + SelectedTeacher = new(89, "Teacher", null, null, null, true, []), + WeekDate = new DateTimeOffset(2026, 10, 5, 0, 0, 0, TimeSpan.Zero), + }; + } + public ValueTask DisposeAsync() => _service.DisposeAsync(); + } + + private sealed class Handler : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken token) + { + var body = request.Content is null ? "" : await request.Content.ReadAsStringAsync(token); + var json = request.RequestUri!.AbsolutePath.Contains("weekly/data") + ? """{"data":{"result":{"data":{"elementPeriods":{"89":[{"id":2013854,"lessonId":38926},{"id":2013854,"lessonId":38926}]}}}}}""" + : body.Contains("authenticate") ? """{"result":{"sessionId":"s"}}""" + : body.Contains("getTimetable") ? """{"result":[{"id":2013854,"date":20261005,"startTime":750,"endTime":835,"kl":[{"id":4,"name":"7a"}],"te":[],"su":[],"ro":[]}]}""" + : body.Contains("getTimegridUnits") ? """{"result":[{"day":1,"timeUnits":[{"name":"1","startTime":750,"endTime":835}]}]}""" + : """{"result":{}}"""; + return new(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + } + } +} diff --git a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs index d6c28bd..d2c7f04 100644 --- a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs +++ b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs @@ -114,6 +114,10 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings x.ActivityType, x.Info, x.LessonText, x.SubstitutionText, x.StudentGroup, Entities(x.Classes), Entities(x.Teachers), Entities(x.Subjects), Entities(x.Rooms))).ToList(), token); + public Task> GetWeeklyLessonIdsAsync(int teacherId, DateOnly date, + CancellationToken token = default) => ExecuteAsync( + client => client.GetWeeklyLessonIdsAsync(teacherId, date, token), token); + public Task> GetOwnTimetableGridAsync(DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client => { diff --git a/LehrerApp.Desktop/ViewModels/Planning/WebUntisTimetableImportViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/WebUntisTimetableImportViewModel.cs index 517796b..fe3c784 100644 --- a/LehrerApp.Desktop/ViewModels/Planning/WebUntisTimetableImportViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Planning/WebUntisTimetableImportViewModel.cs @@ -7,7 +7,7 @@ using LehrerApp.Desktop.Services; namespace LehrerApp.Desktop.ViewModels.Planning; -public sealed record WebUntisGroupOption(Guid Id, string DisplayName); +public sealed record WebUntisGroupOption(Guid Id, string DisplayName, int? LessonId = null); public partial class WebUntisTimetableRow : ObservableObject { @@ -18,8 +18,14 @@ public partial class WebUntisTimetableRow : ObservableObject public required string SuggestedGroupName { get; init; } public string? SubjectName { get; init; } public string? Room { get; init; } + public int? LessonId { get; init; } + public string LessonIdLabel => LessonId is { } id ? $"Unterrichtsnummer: {id}" : "Unterrichtsnummer nicht gefunden"; + [ObservableProperty] private bool _applyLessonId = true; + [ObservableProperty] private bool _include = true; public ObservableCollection GroupOptions { get; } = []; [ObservableProperty] private WebUntisGroupOption? _selectedGroup; + public string ExistingLessonIdLabel => SelectedGroup?.LessonId is { } id ? $"Bisher: {id}" : "Bisher: keine Unterrichtsnummer"; + partial void OnSelectedGroupChanged(WebUntisGroupOption? value) => OnPropertyChanged(nameof(ExistingLessonIdLabel)); public string WeekdayLabel => Weekday switch { DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi", @@ -40,6 +46,11 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject [ObservableProperty] private DateTimeOffset? _weekDate = DateTimeOffset.Now; [ObservableProperty] private string _status = "Lehrkraft auswählen und Untis-Woche laden."; [ObservableProperty] private bool _busy; + [ObservableProperty] private bool _updateLessonIds = true; + [ObservableProperty] private bool _updateTimetable; + [ObservableProperty] private bool _replaceTimetable; + private bool _loaded; + private int? _loadedTeacherId; public bool Saved { get; private set; } public Func>? OnCreateGroup { get; set; } @@ -68,18 +79,21 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject private async Task Load() { if (SelectedTeacher is null) { Status = "Bitte eine Lehrkraft auswählen."; return; } - Busy = true; Rows.Clear(); + var teacherId = SelectedTeacher.UntisId; + Busy = true; Rows.Clear(); _loaded = false; Saved = false; try { var selected = DateOnly.FromDateTime((WeekDate ?? DateTimeOffset.Now).LocalDateTime); var monday = selected.AddDays(-(((int)selected.DayOfWeek + 6) % 7)); - var periods = await _untis.GetTimetableAsync(SelectedTeacher.UntisId, monday, monday.AddDays(6)); + var periods = await _untis.GetTimetableAsync(teacherId, monday, monday.AddDays(6)); + var lessonIds = await _untis.GetWeeklyLessonIdsAsync(teacherId, monday); 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, + LessonId: lessonIds.TryGetValue(x.Id, out var id) ? (int?)id : null, 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)))) @@ -101,6 +115,7 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject .Where(x => !string.IsNullOrWhiteSpace(x))), SuggestedGroupName = suggested, SubjectName = subject, Room = period.Rooms.FirstOrDefault()?.Name, + LessonId = lessonIds.TryGetValue(period.Id, out var lessonId) ? lessonId : null, }; foreach (var option in groupOptions) row.GroupOptions.Add(option); if (localSlots.TryGetValue((row.Weekday, row.PeriodNumber), out var existing)) @@ -108,9 +123,12 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject row.SelectedGroup ??= BestMatch(groupOptions, suggested, className, subject); Rows.Add(row); } - _settings.SetTeacherUntisId(SelectedTeacher.UntisId); + _loaded = true; + _loadedTeacherId = teacherId; Status = Rows.Count == 0 ? "In dieser Woche wurde kein Unterricht gefunden." : $"{Rows.Count} regelmäßige Termine gefunden. Zuordnung prüfen und übernehmen."; + if (Rows.Any(row => row.LessonId is null)) + Status += " Für einzelne Termine wurde keine Unterrichtsnummer gefunden; deren bisherige Nummer bleibt erhalten."; } catch (WebUntisIntegrationException ex) { Status = ex.Message; } finally { Busy = false; } @@ -133,8 +151,44 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject [RelayCommand] private void Save() { - var selected = Rows.Where(x => x.SelectedGroup is not null).ToList(); - foreach (var row in selected) + Saved = false; + if (Busy || !_loaded || Rows.Count == 0) { Status = "Bitte zuerst eine Unterrichtswoche laden."; return; } + if (!UpdateLessonIds && !UpdateTimetable) { Status = "Bitte auswählen, was übernommen werden soll."; return; } + var selected = Rows.Where(x => x.Include && x.SelectedGroup is not null).ToList(); + if (selected.Count == 0) { Status = "Bitte mindestens eine Lerngruppe zuordnen."; return; } + var idRows = selected.Where(x => x.ApplyLessonId && x.LessonId is > 0).ToList(); + if (UpdateLessonIds) + { + var conflict = idRows.GroupBy(x => x.SelectedGroup!.Id) + .FirstOrDefault(g => g.Select(x => x.LessonId).Distinct().Count() > 1); + if (conflict is not null) + { + Status = $"Mehrere Unterrichtsnummern für {conflict.First().SelectedGroup!.DisplayName}. " + + "Zuordnung korrigieren oder bei den betreffenden Zeilen die ID-Übernahme abwählen."; + return; + } + if (!UpdateTimetable && idRows.Count == 0) { Status = "Keine Unterrichtsnummern zur Übernahme ausgewählt."; return; } + } + if (UpdateTimetable && selected.GroupBy(x => (x.Weekday, x.PeriodNumber)).Any(g => g.Count() > 1)) + { + Status = "Mehrere Einträge für dieselbe Stunde. Bitte jeweils nur einen zuordnen."; + return; + } + var groupChanges = 0; + if (UpdateLessonIds) + foreach (var assignment in idRows.GroupBy(x => x.SelectedGroup!.Id)) + { + var group = _groups.GetById(assignment.Key); + if (group is null || group.WebUntisLessonId == assignment.First().LessonId) continue; + group.WebUntisLessonId = assignment.First().LessonId; + _groups.Save(group); + groupChanges++; + } + if (UpdateTimetable && ReplaceTimetable) + foreach (var slot in _slots.GetAll().Where(slot => + !selected.Any(row => row.Weekday == slot.Weekday && row.PeriodNumber == slot.PeriodNumber))) + _slots.Delete(slot.Id); + foreach (var row in selected.Where(_ => UpdateTimetable)) { 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 }; @@ -143,11 +197,13 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject _slots.Save(slot); } Saved = true; - Status = $"{selected.Count} Stundenplan-Einträge übernommen."; + _settings.SetTeacherUntisId(_loadedTeacherId); + Status = $"{groupChanges} Unterrichtsnummern aktualisiert, " + + $"{(UpdateTimetable ? selected.Count : 0)} Stundenplan-Einträge übernommen."; } private List BuildGroupOptions() => _groups.GetAll().OrderBy(x => x.Name) - .Select(x => new WebUntisGroupOption(x.Id, x.Name)).ToList(); + .Where(x => x.IsActive).Select(x => new WebUntisGroupOption(x.Id, x.Name, x.WebUntisLessonId)).ToList(); private static WebUntisGroupOption? BestMatch(IEnumerable options, params string?[] terms) => options.FirstOrDefault(x => terms.Any(term => !string.IsNullOrWhiteSpace(term) && diff --git a/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml index f59bcc1..a50ee08 100644 --- a/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml @@ -96,7 +96,7 @@ + ToolTip.Tip="Kann im Stundenplan über ‚WebUntis-Zuordnung abgleichen…‘ übernommen werden. Alternativ: lsid aus WebUntis (Unterricht -> Mein Unterricht -> Berichte-Symbol der Zeile). Wird pro Schuljahr neu vergeben."/> -