Manuellen WebUntis-Abgleich für Unterrichtsnummern und Stundenplan ergänzen
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
admin committed 2026-10-06 13:29:05 +02:00
1 parent d9eac0d1d2
commit f56caced32
10 files changed
+273 -24

No files matched your search

+2 -1
View File
@@ -31,7 +31,8 @@ public class LearningGroup
/// <summary>
/// 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.
/// </summary>
public int? WebUntisLessonId { get; set; }
/// <summary>
@@ -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<HttpResponseMessage> 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") };
}
}
}
@@ -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<IReadOnlyDictionary<int, int>> GetWeeklyLessonIdsAsync(int teacherId, DateOnly date,
CancellationToken token = default) => ExecuteAsync(
client => client.GetWeeklyLessonIdsAsync(teacherId, date, token), token);
public Task<IReadOnlyList<UntisTimetableGridEntryDto>> GetOwnTimetableGridAsync(DateOnly start, DateOnly end,
CancellationToken token = default) => ExecuteAsync(async client =>
{
@@ -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<WebUntisGroupOption> 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<WebUntisTimetableRow, Task<LearningGroup?>>? 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<WebUntisGroupOption> 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<WebUntisGroupOption> options, params string?[] terms) =>
options.FirstOrDefault(x => terms.Any(term => !string.IsNullOrWhiteSpace(term) &&
@@ -96,7 +96,7 @@
<TextBlock Text="WebUntis-Unterrichtsnummer (optional)" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding WebUntisLessonId}" Minimum="1" FormatString="0"
HorizontalAlignment="Stretch"
ToolTip.Tip="lsid aus WebUntis (Unterricht -&gt; Mein Unterricht -&gt; Berichte-Symbol der Zeile). Wird von WebUntis pro Schuljahr neu vergeben und muss deshalb jedes Schuljahr aktualisiert werden."/>
ToolTip.Tip="Kann im Stundenplan über ‚WebUntis-Zuordnung abgleichen…‘ übernommen werden. Alternativ: lsid aus WebUntis (Unterricht -&gt; Mein Unterricht -&gt; Berichte-Symbol der Zeile). Wird pro Schuljahr neu vergeben."/>
</StackPanel>
<CheckBox Content="Nicht im Untis-Hub verfolgen" IsChecked="{Binding ExcludedFromUntisHub}"
@@ -383,7 +383,7 @@
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,6">
<Button Content="Aus WebUntis laden…" Command="{Binding ImportWebUntisTimetableCommand}"/>
<Button Content="WebUntis-Zuordnung abgleichen…" Command="{Binding ImportWebUntisTimetableCommand}"/>
<TextBlock Text="Importiert oder vergleicht eine typische Unterrichtswoche; Vertretungen bleiben im iCal-Abgleich."
FontSize="11" Opacity="0.6" VerticalAlignment="Center"/>
</StackPanel>
@@ -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">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24" RowSpacing="14">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="24" RowSpacing="14">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="Stundenplan aus WebUntis" Classes="dialogtitle"/>
<TextBlock Text="Wähle eine typische Unterrichtswoche. Vorhandene Einträge werden vorgeschlagen und erst nach Bestätigung ersetzt."
<TextBlock Text="WebUntis-Zuordnung abgleichen" Classes="dialogtitle"/>
<TextBlock Text="Wähle eine typische Unterrichtswoche und prüfe die Zuordnung. Unterrichtsnummern und dauerhafter Stundenplan werden nur beim Übernehmen angepasst."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="2*,*,Auto" ColumnSpacing="10">
<Grid Grid.Row="1" ColumnDefinitions="2*,*,Auto" ColumnSpacing="10" IsEnabled="{Binding !Busy}">
<ComboBox Grid.Column="0" ItemsSource="{Binding Teachers}" SelectedItem="{Binding SelectedTeacher}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
@@ -22,23 +22,35 @@
<CalendarDatePicker Grid.Column="1" SelectedDate="{Binding WeekDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
<Button Grid.Column="2" Content="Woche laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
<ScrollViewer Grid.Row="2">
<StackPanel Grid.Row="2" Spacing="4" IsEnabled="{Binding !Busy}">
<CheckBox Content="Unterrichtsnummern der Lerngruppen aktualisieren" IsChecked="{Binding UpdateLessonIds}"/>
<CheckBox Content="Dauerhaften Stundenplan aktualisieren (zugeordnete Stunden ersetzen oder ergänzen)" IsChecked="{Binding UpdateTimetable}"/>
<CheckBox Content="Auch bisherige Stunden entfernen, die in der Auswahl fehlen (gesamten Stundenplan ersetzen)"
IsChecked="{Binding ReplaceTimetable}" IsEnabled="{Binding UpdateTimetable}"/>
</StackPanel>
<ScrollViewer Grid.Row="3" IsEnabled="{Binding !Busy}">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisTimetableRow">
<Grid ColumnDefinitions="48,85,2*,2*,Auto" ColumnSpacing="8" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding WeekdayLabel}" VerticalAlignment="Center" FontWeight="SemiBold"/>
<Grid ColumnDefinitions="65,85,2*,2*,Auto" ColumnSpacing="8" Margin="0,3">
<CheckBox Grid.Column="0" Content="{Binding WeekdayLabel}" IsChecked="{Binding Include}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding PeriodNumber, StringFormat={}{0}. Std.}" FontSize="12"/>
<TextBlock Text="{Binding TimeLabel}" FontSize="10" Opacity="0.6"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding UntisLabel}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<ComboBox Grid.Column="3" ItemsSource="{Binding GroupOptions}" SelectedItem="{Binding SelectedGroup}"
<StackPanel Grid.Column="2" Spacing="3">
<TextBlock Text="{Binding UntisLabel}" TextWrapping="Wrap"/>
<CheckBox Content="{Binding LessonIdLabel}" IsChecked="{Binding ApplyLessonId}" FontSize="11"/>
</StackPanel>
<StackPanel Grid.Column="3" Spacing="3">
<ComboBox ItemsSource="{Binding GroupOptions}" SelectedItem="{Binding SelectedGroup}"
PlaceholderText="nicht übernehmen">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisGroupOption"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="{Binding ExistingLessonIdLabel}" FontSize="11" Opacity="0.65"/>
</StackPanel>
<Button Grid.Column="4" Content="Neue Gruppe…" FontSize="11"
Command="{Binding $parent[ItemsControl].((vm:WebUntisTimetableImportViewModel)DataContext).CreateGroupCommand}"
CommandParameter="{Binding}"/>
@@ -47,7 +59,7 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<Grid Grid.Row="4" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Abbrechen" Click="OnCancel"/>
<Button Grid.Column="2" Content="Zuordnung übernehmen" Command="{Binding SaveCommand}" Click="OnSave"
@@ -8,6 +8,20 @@ namespace LehrerApp.WebUntis.Tests;
public sealed class WebUntisClientTests
{
[Fact]
public async Task WeeklyLessonIds_RejectConflictingPeriodAssignments()
{
var handler = new QueueHandler(
Json("""{"result":{"sessionId":"s"}}"""),
Json("""{"data":{"result":{"data":{"elementPeriods":{"89":[{"id":10,"lessonId":100},{"id":10,"lessonId":200}]}}}}}"""),
Json("""{"result":{}}"""));
await using var client = CreateClient(handler);
await Assert.ThrowsAsync<WebUntisException>(() =>
client.GetWeeklyLessonIdsAsync(89, new DateOnly(2026, 10, 5), default));
Assert.Contains("elementType=2&elementId=89&date=2026-10-05&formatId=3", handler.Requests[1].Uri);
Assert.Contains("JSESSIONID=s", handler.Requests[1].Cookie);
}
[Fact]
public async Task AufeinanderfolgendeAbrufe_VerwendenDieselbeSessionBisZumDispose()
{
+36
View File
@@ -273,6 +273,42 @@ public sealed partial class WebUntisClient : IAsyncDisposable
Entities(entry, "ro"), entry.Clone())).ToList();
}, cancellationToken);
/// <summary>Unterrichtsnummern des Wochenstundenplans für den manuell ausgelösten Abgleich.
/// Die REST-Grid-IDs sind Perioden-IDs; erst dieser Endpunkt liefert die zugehörige lsid.</summary>
public Task<IReadOnlyDictionary<int, int>> GetWeeklyLessonIdsAsync(int teacherId, DateOnly date,
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var configuration = GetConfiguration();
using var request = new HttpRequestMessage(HttpMethod.Get,
$"https://{configuration.Host}/WebUntis/api/public/timetable/weekly/data" +
$"?elementType=2&elementId={teacherId}&date={date:yyyy-MM-dd}&formatId=3");
ApplyBrowserHeaders(request, configuration.Host);
request.Headers.Add("Cookie", SessionCookie(sessionId));
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
var payload = await ReadJsonAsync(response, cancellationToken);
if (!response.IsSuccessStatusCode ||
!TryProperty(payload, "data", out var data) || !TryProperty(data, "result", out var result) ||
!TryProperty(result, "data", out var timetable) ||
!TryProperty(timetable, "elementPeriods", out var elementPeriods) ||
elementPeriods.ValueKind != JsonValueKind.Object)
throw new WebUntisException("WebUntis hat keine gültigen Unterrichtsnummern geliefert.");
var ids = new Dictionary<int, int>();
foreach (var element in elementPeriods.EnumerateObject())
{
if (element.Value.ValueKind != JsonValueKind.Array)
throw new WebUntisException("WebUntis hat ungültige Wochenstundenplan-Daten geliefert.");
foreach (var period in element.Value.EnumerateArray())
{
var periodId = RequiredInt(period, "id");
if (OptionalInt(period, "lessonId") is not { } lessonId || lessonId <= 0) continue;
if (ids.TryGetValue(periodId, out var existing) && existing != lessonId)
throw new WebUntisException("WebUntis liefert widersprüchliche Unterrichtsnummern.");
ids[periodId] = lessonId;
}
}
return (IReadOnlyDictionary<int, int>)ids;
}, cancellationToken);
/// <summary>Ruft den neuen, von der WebUntis-Weboberfläche verwendeten Stundenplan ab. Der
/// kurzlebige Bearer wird aus der vorhandenen JSON-RPC-Sitzung erzeugt und nie persistiert.</summary>
public Task<IReadOnlyList<UntisTimetableGridEntry>> GetTimetableGridAsync(int teacherId, int schoolYearId,
+17 -2
View File
@@ -1594,8 +1594,9 @@ Ersetzt durch den undokumentierten internen Bericht "Fehlzeiten pro Unterricht p
reverse-engineered, da die offizielle Reports-API eine Partner-Freigabe braucht), der nur den
eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funktioniert.
- `LearningGroup.WebUntisLessonId` (die WebUntis-interne Unterrichtsnummer/lsid) muss dafür pro
Lerngruppe von Hand hinterlegt werden — sie wird von WebUntis pro Schuljahr neu vergeben (kein
Auslesen über JSON-RPC möglich) und wird deshalb beim Schuljahreswechsel (`GroupRolloverService`)
Lerngruppe hinterlegt werden — manuell oder über „WebUntis-Zuordnung abgleichen…“ im Stundenplan.
Sie wird von WebUntis pro Schuljahr neu vergeben (kein Auslesen über JSON-RPC möglich)
und wird deshalb beim Schuljahreswechsel (`GroupRolloverService`)
bewusst nicht in die Folgegruppe übernommen.
- Die Lehrkraft-Kennung (`teacherId`) kommt aus `personId` der `authenticate`-Antwort und muss nicht
gepflegt werden — der Bericht zeigt ohnehin nur eigenen Unterricht, auch bei Doppelbesetzung.
@@ -5290,3 +5291,17 @@ Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbe
- [x] Optionale iCal-Erstabgleiche starten nach dem Hauptfenster; lokale Daten sind sofort nutzbar.
Laufende Erstabgleiche werden vor der Freigabe der Datenbank beendet; HTTP-Abrufe beim Beenden abgebrochen.
Die Zeit bis zum Hauptfenster wird im App-Log protokolliert.
### Manueller WebUntis-Zuordnungsabgleich (Oktober 2026)
- [x] Stundenplan → „WebUntis-Zuordnung abgleichen…“: Unterrichtswoche laden, Lerngruppen-Zuordnung
sowie neue/bisherige Unterrichtsnummern prüfen und explizit übernehmen. Der zusätzliche
Wochenstundenplan-Endpunkt `/api/public/timetable/weekly/data` liefert `lessonId` zu den
Perioden-IDs; durch echten Abruf mit dem REST-Grid verglichen (29 von 29 IDs zugeordnet).
Kein automatischer LessonId-Abgleich im Hintergrund-Sync.
- [x] Unterrichtsnummern und dauerhaften Stundenplan unabhängig auswählen. Standardmäßig werden
nur Unterrichtsnummern übernommen; alte Stunden verschwinden nur bei expliziter Auswahl
„gesamten Stundenplan ersetzen“. Einzelne Zeilen und deren ID-Übernahme sind abwählbar.
Widersprüchliche IDs derselben Lerngruppe und doppelt belegte Stunden blockieren das Speichern;
fehlende IDs löschen keine bestehenden Nummern. Das Laden selbst schreibt keine Zuordnungen.
Prüfung: Desktop-Build, fünf Abgleich-Tests sowie 29 WebUntis-Client-/Parser-Tests erfolgreich.