Manuellen WebUntis-Abgleich für Unterrichtsnummern und Stundenplan ergänzen
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
This commit is contained in:
1 parent
d9eac0d1d2
commit
f56caced32
10 files changed
+273
-24
No files matched your search
@@ -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 -> Mein Unterricht -> 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 -> Mein Unterricht -> 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()
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user