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."/>
-
+
diff --git a/LehrerApp.Desktop/Views/Planning/WebUntisTimetableImportDialog.axaml b/LehrerApp.Desktop/Views/Planning/WebUntisTimetableImportDialog.axaml
index 7a17452..901df79 100644
--- a/LehrerApp.Desktop/Views/Planning/WebUntisTimetableImportDialog.axaml
+++ b/LehrerApp.Desktop/Views/Planning/WebUntisTimetableImportDialog.axaml
@@ -5,15 +5,15 @@
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.Planning.WebUntisTimetableImportDialog"
x:DataType="vm:WebUntisTimetableImportViewModel"
- Title="Stundenplan aus WebUntis" Width="820" Height="650"
+ Title="WebUntis-Zuordnung abgleichen" Width="980" Height="750"
MinWidth="680" MinHeight="480" WindowStartupLocation="CenterOwner">
-
+
-
-
+
-
+
@@ -22,23 +22,35 @@
-
+
+
+
+
+
+
-
-
+
+
-
-
+
+
+
+
+
+
+
@@ -47,7 +59,7 @@
-
+