Untis-API: Fehlzeiten abgleich

This commit is contained in:
2026-08-25 00:21:53 +02:00
parent 17475a781f
commit 87a7badb44
21 changed files with 593 additions and 298 deletions
+6
View File
@@ -20,6 +20,12 @@ public class LearningGroup
/// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus.
/// </summary>
public bool RequiresLessonPlanning { get; set; } = true;
/// <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.
/// </summary>
public int? WebUntisLessonId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -60,6 +60,8 @@ public sealed class GroupRolloverService(
IsActive = true,
IsOwnClass = source.IsOwnClass,
IsDifferentiated = source.IsDifferentiated,
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
};
var sourceWasActive = source.IsActive;
@@ -27,9 +27,9 @@ public sealed record UntisStudentDto(int UntisId, int ExternKey, string ClassNam
string? EntryDateRaw, int? ExitDate, string? ExitDateRaw, string? Text, string? MedicalReportDuty,
string? Schulpflicht, string? Majority, UntisStudentAddressDto Address, string? AttributeIL);
public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, IReadOnlyList<UntisStudentDto> Students);
public sealed record UntisStudentAbsenceDto(int StudentKey, int Date, int StartTime, int EndTime, int AbsentMinutes,
bool Checked, string? AbsenceReason, string? ExcuseStatus, int? SubjectId, IReadOnlyList<int> TeacherIds,
string? StudentGroup);
public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int AbsentPeriods,
int UnexcusedAbsentPeriods, int AbsentMinutes, int UnexcusedAbsentMinutes, int? StartTime, int? EndTime,
string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts);
/// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
@@ -107,13 +107,14 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList());
}, token);
public Task<IReadOnlyList<UntisStudentAbsenceDto>> GetAbsencesAsync(DateOnly start, DateOnly end,
CancellationToken token = default) => ExecuteAsync(async client =>
public Task<IReadOnlyList<UntisLessonAbsenceDto>> GetLessonAbsencesAsync(int lessonId,
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
{
var absences = await client.GetAbsencesAsync(Date(start), Date(end), token);
return (IReadOnlyList<UntisStudentAbsenceDto>)absences.Select(x => new UntisStudentAbsenceDto(
x.StudentKey, x.Date, x.StartTime, x.EndTime, x.AbsentMinutes, x.Checked, x.AbsenceReason,
x.ExcuseStatus, x.SubjectId, x.TeacherIds, x.StudentGroup)).ToList();
var absences = await client.GetLessonAbsencesAsync(lessonId, Date(start), Date(end), token);
return (IReadOnlyList<UntisLessonAbsenceDto>)absences.Select(x => new UntisLessonAbsenceDto(
x.StudentName, x.Date, x.AbsentPeriods, x.UnexcusedAbsentPeriods, x.AbsentMinutes,
x.UnexcusedAbsentMinutes, x.StartTime, x.EndTime, x.Reason, x.ExternKey, x.ExternKeyInParentheses,
x.HandledOn, x.Counts)).ToList();
}, token);
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
@@ -878,6 +878,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private bool _isOwnClass;
[ObservableProperty] private bool _isDifferentiated;
[ObservableProperty] private bool _requiresLessonPlanning = true;
[ObservableProperty] private int? _webUntisLessonId;
[ObservableProperty] private string _nameError = "";
[ObservableProperty] private string _gradeLevelError = "";
@@ -917,6 +918,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
IsOwnClass = group.IsOwnClass;
IsDifferentiated = group.IsDifferentiated;
RequiresLessonPlanning = group.RequiresLessonPlanning;
WebUntisLessonId = group.WebUntisLessonId;
OnPropertyChanged(nameof(DialogTitle));
OnPropertyChanged(nameof(SaveButtonText));
}
@@ -963,6 +965,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result.IsOwnClass = IsOwnClass;
Result.IsDifferentiated = IsDifferentiated;
Result.RequiresLessonPlanning = RequiresLessonPlanning;
Result.WebUntisLessonId = WebUntisLessonId;
_groups.Save(Result);
}
}
@@ -1,128 +0,0 @@
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 linked = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
var absences = await _untis.GetAbsencesAsync(start, end);
var loaded = absences.Where(absence => linked.ContainsKey(absence.StudentKey))
.Select(absence => (Student: linked[absence.StudentKey], Absence: absence))
.OrderBy(x => x.Absence.Date).ThenBy(x => x.Student.FullName);
foreach (var item in loaded)
{
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,202 @@
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;
/// <summary>Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar (statt stillschweigend
/// weggefiltert zu werden) - <see cref="AssignedStudent"/> kann manuell per Auswahlliste gesetzt
/// werden, wenn weder `ENr` noch der Name eindeutig auf ein Kursmitglied passen.</summary>
public partial class WebUntisLessonAbsenceRow : ObservableObject
{
public required string UntisStudentName { get; init; }
public required DateOnly Date { get; init; }
public required string TimeLabel { get; init; }
public required string UntisStatus { get; init; }
public required AttendanceStatus TargetStatus { get; init; }
public required IReadOnlyList<Student> Candidates { get; init; }
internal Action<WebUntisLessonAbsenceRow>? OnAssignmentChanged { get; init; }
public string? Reason { get; init; }
public string DateLabel => Date.ToString("dd.MM.yyyy");
public bool CanApply => SessionId is not null;
[ObservableProperty] private Student? _assignedStudent;
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
[ObservableProperty] private Guid? _sessionId;
[ObservableProperty] private bool _selected;
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
}
/// <summary>Fehlzeitenabgleich über den "Fehlzeiten pro Unterricht"-Bericht (<see cref="LearningGroup.WebUntisLessonId"/>).
/// Der früher genutzte, pro Schüler abgerufene Fehlzeiten-Report (getTimetableWithAbsences) brauchte
/// weitergehende WebUntis-Rechte als dieser Lerngruppen-Bericht und wurde deshalb entfernt.</summary>
public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
{
private readonly LearningGroup _group;
private readonly WebUntisIntegrationService _untis;
private readonly IStudentRepository _students;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation;
public ObservableCollection<WebUntisLessonAbsenceRow> 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 WebUntisLessonAbsenceComparisonViewModel(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 lessonId = _group.WebUntisLessonId;
if (lessonId is null)
{
Status = "Für diese Lerngruppe ist noch keine WebUntis-Unterrichtsnummer hinterlegt " +
"(Gruppe bearbeiten).";
return;
}
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());
// Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt
// per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb
// der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten.
var byKey = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
var byName = courseStudents
.SelectMany(student => new[]
{
NameKey($"{student.LastName} {student.FirstName}"),
NameKey($"{student.FirstName} {student.LastName}"),
}.Select(key => (Key: key, Student: student)))
.GroupBy(x => x.Key)
.Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
.ToDictionary(group => group.Key, group => group.First().Student);
var absences = await _untis.GetLessonAbsencesAsync(lessonId.Value, start, end);
var ordered = absences
.Select(absence => (Absence: absence, Date: TryDate(absence.Date, out var date) ? date : (DateOnly?)null))
.Where(x => x.Date is not null)
.OrderBy(x => x.Date).ThenBy(x => x.Absence.StudentName);
void ResolveLocalMatch(WebUntisLessonAbsenceRow row)
{
if (row.AssignedStudent is not { } student)
{
row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false;
return;
}
localSessions.TryGetValue(row.Date, out var session);
var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id);
row.SessionId = session?.Id;
row.LocalStatus = session is null ? "keine lokale Stunde" : AttendanceDisplay.Label(entry?.Attendance);
row.Selected = session is not null && entry?.Attendance != row.TargetStatus;
}
foreach (var (absence, date) in ordered)
{
var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent)
? byKeyStudent
: byName.GetValueOrDefault(NameKey(absence.StudentName));
var row = new WebUntisLessonAbsenceRow
{
UntisStudentName = absence.StudentName, Date = date!.Value,
TimeLabel = TimeLabel(absence.StartTime, absence.EndTime),
UntisStatus = DisplayUntisStatus(absence),
TargetStatus = MapStatus(absence), Reason = absence.Reason,
Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch,
};
Rows.Add(row);
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
}
var unresolved = Rows.Count(x => x.AssignedStudent is null);
Status = $"{absences.Count} Fehlzeiten von WebUntis erhalten, {Rows.Count - unresolved} automatisch zugeordnet" +
(unresolved > 0 ? $", {unresolved} bitte manuell zuordnen" : "") +
$". {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar.";
}
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 && x.AssignedStudent is not null).ToList();
foreach (var row in selected)
{
var studentId = row.AssignedStudent!.Id;
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = 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;
}
// Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht
// dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen
// werden beim Aufbau von `byName` registriert; hier wird nur normalisiert.
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
private const int FullLessonMinutes = 45;
// Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die
// (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in
// Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist
// wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der
// Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung
// oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist
// laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt.
private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence)
{
if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass;
if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late;
if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending;
if (absence.ExternKey is null) return AttendanceStatus.ExcusePending;
return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused;
}
private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) =>
absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true;
// Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung
// (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden.
private static string DisplayUntisStatus(UntisLessonAbsenceDto absence) =>
string.Join(" · ", new[] { AttendanceDisplay.Label(MapStatus(absence)), absence.Reason }
.Where(x => !string.IsNullOrWhiteSpace(x)));
private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
private static string TimeLabel(int? start, int? end) =>
start is null || end is null ? "" : $"{Time(start.Value)}{Time(end.Value)}";
private static string Time(int value) => $"{value / 100:00}:{value % 100:00}";
}
@@ -77,6 +77,13 @@
<CheckBox Content="Benötigt Unterrichtsplanung" IsChecked="{Binding RequiresLessonPlanning}"
ToolTip.Tip="Deaktivieren für Gruppen ohne inhaltlichen Verlaufsplan (z.B. Klassenrat, Willkommenskreis) - blendet für diese Gruppe die Dashboard-Erinnerung &quot;Ungeplante Stunden&quot; aus."/>
<StackPanel Spacing="4">
<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."/>
</StackPanel>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
@@ -27,7 +27,7 @@
IsEnabled="{Binding IsEditable}"/>
<Button Content="↻ Aus WebUntis…" Click="OnImportParticipantsFromWebUntisClick"
IsEnabled="{Binding IsEditable}"/>
<Button Content="Fehlzeiten abgleichen…" Click="OnCompareWebUntisAbsencesClick"/>
<Button Content="Fehlzeiten je Unterricht…" Click="OnCompareWebUntisLessonAbsencesClick"/>
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
Command="{Binding WithdrawStudentCommand}"
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
@@ -153,16 +153,16 @@ public partial class GroupDetailView : UserControl
}
}
private async void OnCompareWebUntisAbsencesClick(object? sender, RoutedEventArgs e)
private async void OnCompareWebUntisLessonAbsencesClick(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || DataContext is not GroupDetailViewModel vm || vm.Group is null) return;
var dialogVm = new WebUntisAbsenceComparisonViewModel(vm.Group,
var dialogVm = new WebUntisLessonAbsenceComparisonViewModel(vm.Group,
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>());
await new WebUntisAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
await new WebUntisLessonAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
vm.ParticipationTab.RefreshCurrentGrid();
}
@@ -1,42 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisAbsenceComparisonDialog"
x:DataType="vm:WebUntisAbsenceComparisonViewModel"
Title="Fehlzeiten mit WebUntis abgleichen" Width="850" Height="620"
MinWidth="700" MinHeight="450" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24" RowSpacing="12">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="Fehlzeiten mit WebUntis abgleichen" Classes="dialogtitle"/>
<TextBlock Text="Nur markierte Zeilen mit einer vorhandenen lokalen Kursstunde werden übernommen."
FontSize="12" Opacity="0.65"/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<DatePicker SelectedDate="{Binding StartDate}"/>
<TextBlock Text="bis" VerticalAlignment="Center"/>
<DatePicker SelectedDate="{Binding EndDate}"/>
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisAbsenceRow">
<Grid ColumnDefinitions="Auto,1.5*,90,90,1.5*,1.5*" ColumnSpacing="8" Margin="0,3">
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
<TextBlock Grid.Column="1" Text="{Binding StudentName}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding TimeLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="4" Text="{Binding UntisStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<TextBlock Grid.Column="5" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Schließen" Click="OnClose"/>
<Button Grid.Column="2" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,53 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisLessonAbsenceComparisonDialog"
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640"
MinWidth="800" MinHeight="450" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="24" RowSpacing="8">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="Fehlzeiten je Unterricht mit WebUntis abgleichen" Classes="dialogtitle"/>
<TextBlock Text="Nur markierte Zeilen mit einer vorhandenen lokalen Kursstunde werden übernommen. Zeilen ohne automatische Zuordnung bitte manuell einem Kursmitglied zuweisen."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<DatePicker SelectedDate="{Binding StartDate}"/>
<TextBlock Text="bis" VerticalAlignment="Center"/>
<DatePicker SelectedDate="{Binding EndDate}"/>
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<Grid Grid.Row="2" ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="4,0">
<TextBlock Grid.Column="1" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="2" Text="Zuordnung" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="3" Text="Datum" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="4" Text="Zeit" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="5" Text="WebUntis-Status" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="6" Text="Lokaler Status" FontSize="11" Opacity="0.6"/>
</Grid>
<ScrollViewer Grid.Row="3">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisLessonAbsenceRow">
<Grid ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="0,3">
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
<TextBlock Grid.Column="1" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<ComboBox Grid.Column="2" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
DisplayMemberBinding="{Binding FullName}" PlaceholderText="Schüler wählen…"
HorizontalAlignment="Stretch"/>
<TextBlock Grid.Column="3" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="4" Text="{Binding TimeLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="5" Text="{Binding UntisStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<TextBlock Grid.Column="6" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<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="Schließen" Click="OnClose"/>
<Button Grid.Column="2" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
</Grid>
</Window>
@@ -3,8 +3,8 @@ using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Groups;
public partial class WebUntisAbsenceComparisonDialog : Window
public partial class WebUntisLessonAbsenceComparisonDialog : Window
{
public WebUntisAbsenceComparisonDialog() => InitializeComponent();
public WebUntisLessonAbsenceComparisonDialog() => InitializeComponent();
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
+2 -1
View File
@@ -14,7 +14,7 @@ public sealed class GroupRolloverServiceTests
{
Name = "8a", SchoolYear = "2025/26", GradeLevel = 8,
Type = GroupType.Class, SubjectId = Guid.NewGuid(), GradingSystem = GradingSystem.Grades1To6,
HoursPerWeek = 4, IsOwnClass = true, IsDifferentiated = true,
HoursPerWeek = 4, IsOwnClass = true, IsDifferentiated = true, WebUntisLessonId = 38262,
};
var oldMembership = new GroupMembership
{
@@ -38,6 +38,7 @@ public sealed class GroupRolloverServiceTests
Assert.Equal(source.HoursPerWeek, target.HoursPerWeek);
Assert.True(target.IsOwnClass);
Assert.True(target.IsDifferentiated);
Assert.Null(target.WebUntisLessonId);
Assert.False(source.IsActive);
var copied = Assert.Single(memberships.GetByGroup(target.Id));
+37 -16
View File
@@ -143,16 +143,10 @@ public sealed class WebUntisClientTests
}
[Fact]
public async Task FehlzeitenUndKlassenbuch_WerdenTypisiertAbgerufen()
public async Task Klassenbuch_WirdTypisiertAbgerufen()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":{\"periodsWithAbsences\":[" +
"{\"studentId\":9001,\"date\":20260901,\"startTime\":800,\"endTime\":845," +
"\"absentTime\":45,\"checked\":true,\"absenceReason\":\"Krank\"," +
"\"excuseStatus\":\"entschuldigt\",\"subjectId\":12,\"teacherIds\":[\"7\"]}," +
"{\"studentId\":9999,\"date\":20260901,\"startTime\":800,\"endTime\":845," +
"\"absentTime\":45,\"checked\":false}]}}"),
Json("{\"result\":[{\"studentid\":9001,\"surname\":\"Müller\",\"forname\":\"Ada\"," +
"\"date\":20260902,\"subject\":\"MA\",\"categoryId\":3,\"reason\":\"Material\"," +
"\"text\":\"Buch vergessen\"}]}"),
@@ -162,25 +156,18 @@ public sealed class WebUntisClientTests
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var absences = await client.GetAbsencesAsync(20260801, 20270731, CancellationToken.None);
var entries = await client.GetClassRegisterEntriesAsync(17, 20260801, 20270731,
CancellationToken.None);
var categories = await client.GetClassRegisterCategoriesAsync(CancellationToken.None);
var groups = await client.GetClassRegisterCategoryGroupsAsync(CancellationToken.None);
Assert.Equal(2, absences.Count);
var absence = Assert.Single(absences, x => x.StudentKey == 9001);
Assert.Equal(45, absence.AbsentMinutes);
Assert.Equal("entschuldigt", absence.ExcuseStatus);
Assert.Equal(7, Assert.Single(absence.TeacherIds));
var entry = Assert.Single(entries);
Assert.Equal("Ada Müller", entry.DisplayName);
Assert.Equal("Buch vergessen", entry.Text);
Assert.Equal(3, Assert.Single(categories).Id);
Assert.Equal("Organisation", Assert.Single(groups).Name);
Assert.Contains("\"method\":\"getTimetableWithAbsences\"", handler.Requests[1].Body);
Assert.Contains("\"method\":\"getClassregEvents\"", handler.Requests[2].Body);
Assert.Contains("\"id\":17,\"type\":5", handler.Requests[2].Body);
Assert.Contains("\"method\":\"getClassregEvents\"", handler.Requests[1].Body);
Assert.Contains("\"id\":17,\"type\":5", handler.Requests[1].Body);
Assert.Single(handler.Requests,
request => request.Body.Contains("\"method\":\"authenticate\""));
@@ -188,6 +175,40 @@ public sealed class WebUntisClientTests
Assert.Contains("\"method\":\"logout\"", handler.Requests[^1].Body);
}
[Fact]
public async Task GetLessonAbsencesAsync_LoestTeacherIdAusDerEigenenSessionAufUndTypisiertDasErgebnis()
{
var csv = Encoding.UTF8.GetBytes(
"Schüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\t" +
"Abwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
"Erika Mustermann\t09.12.25\t1\t1\t45\t45\t09:40-10:25\tKrank\t(185120)\t09.12.25\ttrue\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}"),
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt1.tmp&name=AbsencePerLesson&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var absences = await client.GetLessonAbsencesAsync(38262, 20250811, 20260710, CancellationToken.None);
var absence = Assert.Single(absences);
Assert.Equal("Erika Mustermann", absence.StudentName);
Assert.Equal(20251209, absence.Date);
Assert.Equal(185120, absence.ExternKey);
Assert.True(absence.ExternKeyInParentheses);
Assert.Equal(940, absence.StartTime);
Assert.True(absence.Counts);
Assert.Contains("reports.do?name=AbsencePerLesson", handler.Requests[1].Uri);
Assert.Contains("lsid=38262", handler.Requests[1].Uri);
Assert.Contains("teacherId=89", handler.Requests[1].Uri);
Assert.Contains("rpt_sd=20250811", handler.Requests[1].Uri);
Assert.Contains("rpt_ed=20260710", handler.Requests[1].Uri);
Assert.EndsWith("reports.do?get=rpt1.tmp&name=AbsencePerLesson&format=csv", handler.Requests[2].Uri);
await client.DisposeAsync();
}
private static WebUntisClient CreateClient(HttpMessageHandler handler) => new(
new HttpClient(handler),
new WebUntisOptions
@@ -0,0 +1,54 @@
using Xunit;
using LehrerApp.WebUntis;
namespace LehrerApp.WebUntis.Tests;
public sealed class WebUntisLessonAbsenceParserTests
{
[Fact]
public void Parse_UebernimmtAlleFelderUndZweistelligesJahr()
{
const string report = "\uFEFFSchüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\tAbwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
"Erika Mustermann\t09.12.25\t1\t1\t45\t45\t09:40-10:25\tKrank\t(185120)\t09.12.25\ttrue\r\n";
var absence = Assert.Single(WebUntisLessonAbsenceParser.Parse(report));
Assert.Equal("Erika Mustermann", absence.StudentName);
Assert.Equal(20251209, absence.Date);
Assert.Equal(1, absence.AbsentPeriods);
Assert.Equal(1, absence.UnexcusedAbsentPeriods);
Assert.Equal(45, absence.AbsentMinutes);
Assert.Equal(45, absence.UnexcusedAbsentMinutes);
Assert.Equal(940, absence.StartTime);
Assert.Equal(1025, absence.EndTime);
Assert.Equal("Krank", absence.Reason);
Assert.Equal(185120, absence.ExternKey);
Assert.True(absence.ExternKeyInParentheses);
Assert.Equal("09.12.25", absence.HandledOn);
Assert.True(absence.Counts);
}
[Fact]
public void Parse_AkzeptiertExterneSchuelerkennungOhneKlammern()
{
const string report = "Schüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\tAbwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
"Max Muster\t27.01.26\t1\t0\t45\t0\t10:25-11:10\t\t178156\t\tfalse\r\n";
var absence = Assert.Single(WebUntisLessonAbsenceParser.Parse(report));
Assert.Equal(178156, absence.ExternKey);
Assert.False(absence.ExternKeyInParentheses);
Assert.Null(absence.Reason);
Assert.Null(absence.HandledOn);
Assert.False(absence.Counts);
}
[Fact]
public void Parse_LehntUngueltigesDatumAb()
{
const string report = "Schüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\tAbwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
"Max Muster\tkein-datum\t1\t0\t45\t0\t10:25-11:10\t\t178156\t\tfalse\r\n";
Assert.Throws<InvalidDataException>(() => WebUntisLessonAbsenceParser.Parse(report));
}
}
@@ -0,0 +1,55 @@
using System.Text;
namespace LehrerApp.WebUntis;
internal static class TabSeparatedTextReader
{
public static List<List<string>> ParseRows(string content, char separator)
{
var rows = new List<List<string>>();
var row = new List<string>();
var field = new StringBuilder();
var inQuotes = false;
for (var index = 0; index < content.Length; index++)
{
var character = content[index];
if (character == '"')
{
if (inQuotes && index + 1 < content.Length && content[index + 1] == '"')
{
field.Append('"');
index++;
}
else
{
inQuotes = !inQuotes;
}
continue;
}
if (!inQuotes && character == separator)
{
row.Add(field.ToString());
field.Clear();
continue;
}
if (!inQuotes && character == '\n')
{
row.Add(field.ToString());
rows.Add(row);
row = [];
field.Clear();
continue;
}
if (!inQuotes && character == '\r') continue;
field.Append(character);
}
row.Add(field.ToString());
if (row.Count > 1 || !string.IsNullOrWhiteSpace(row[0])) rows.Add(row);
return rows;
}
}
+27 -33
View File
@@ -15,6 +15,7 @@ public sealed class WebUntisClient : IAsyncDisposable
private readonly SemaphoreSlim _sessionGate = new(1, 1);
private readonly Timer _sessionExpiryTimer;
private string? _sessionId;
private int? _myTeacherUntisId;
private DateTimeOffset _sessionExpiresAt;
private int _activeRequests;
private bool _disposed;
@@ -114,7 +115,8 @@ public sealed class WebUntisClient : IAsyncDisposable
public Task<UntisStudentReport> GetStudentReportAsync(string? classNameFilter,
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var reportData = await RequestReportAsync(sessionId, cancellationToken)
var reportData = await RequestReportAsync(sessionId,
"name=Student&format=csv&klasseId=-1&studentsForDate=true&context=klasseId", cancellationToken)
?? await PollReportAsync(sessionId, cancellationToken);
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
var allStudents = WebUntisStudentReportParser.Parse(reportText);
@@ -126,6 +128,26 @@ public sealed class WebUntisClient : IAsyncDisposable
return new UntisStudentReport(students.Count, normalizedFilter, students);
}, cancellationToken);
// Undokumentierter interner Bericht der WebUntis-WebApp (Unterricht -> Berichte zum Unterricht ->
// "Fehlzeiten pro Unterricht pro Schüler*in"). lessonId entspricht der lsid, die pro Schuljahr neu
// vergeben wird (WebUntis-interne Unterrichtsnummer) und deshalb pro Lerngruppe gepflegt werden muss;
// eine JSON-RPC-Methode dafür existiert nicht. teacherId ist dagegen immer die eigene Lehrkraft-Kennung
// aus der Session — der Bericht zeigt ohnehin nur eigenen Unterricht, auch bei Doppelbesetzung.
public Task<IReadOnlyList<UntisLessonAbsence>> GetLessonAbsencesAsync(int lessonId,
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var teacherId = _myTeacherUntisId
?? throw new WebUntisException("WebUntis hat keine Lehrer-Kennung (personId) für diese Sitzung geliefert.");
var query = "name=AbsencePerLesson&format=csv" +
$"&lsid={lessonId}&lessonId={lessonId}&teacherId={teacherId}&reportElementType=2" +
"&_withoutPageBreaks=on&_empty=on&_usingMarkNames=on" +
$"&rpt_sd={startDate}&rpt_ed={endDate}&rpt_syid=-1&rpt_drdtype=CUSTOM";
var reportData = await RequestReportAsync(sessionId, query, cancellationToken)
?? await PollReportAsync(sessionId, cancellationToken);
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
return WebUntisLessonAbsenceParser.Parse(reportText);
}, cancellationToken);
public Task<IReadOnlyList<UntisSubstitution>> GetSubstitutionsAsync(int startDate, int endDate,
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
@@ -178,35 +200,6 @@ public sealed class WebUntisClient : IAsyncDisposable
Entities(entry, "ro"), entry.Clone())).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisStudentAbsence>> GetAbsencesAsync(int startDate, int endDate,
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var result = await RpcAsync("getTimetableWithAbsences", new
{
options = new { startDate, endDate },
}, sessionId, cancellationToken);
if (!TryProperty(result, "periodsWithAbsences", out var periods) || periods.ValueKind != JsonValueKind.Array)
throw new WebUntisException("WebUntis hat keine gültigen Fehlzeiten-Daten geliefert.");
return (IReadOnlyList<UntisStudentAbsence>)periods.EnumerateArray()
.Select(entry => new UntisStudentAbsence(
RequiredInt(entry, "studentId"),
RequiredInt(entry, "date"),
RequiredInt(entry, "startTime"),
RequiredInt(entry, "endTime"),
OptionalInt(entry, "absentTime") ?? 0,
OptionalBoolean(entry, "checked"),
OptionalString(entry, "absenceReason"),
OptionalString(entry, "excuseStatus"),
OptionalInt(entry, "subjectId"),
IntArray(entry, "teacherIds"),
OptionalString(entry, "studentGroup"),
entry.Clone()))
.OrderBy(entry => entry.Date)
.ThenBy(entry => entry.StartTime)
.ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisClassRegisterEntry>> GetClassRegisterEntriesAsync(int studentId,
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
@@ -287,6 +280,7 @@ public sealed class WebUntisClient : IAsyncDisposable
$"WebUntis-Login fehlgeschlagen: {configuration.Host} hat für die Schulkennung " +
$"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " +
"Schulkennung mit der WebUntis-Anmeldeseite vergleichen.");
_myTeacherUntisId = OptionalInt(result, "personId");
}
_activeRequests++;
@@ -398,10 +392,10 @@ public sealed class WebUntisClient : IAsyncDisposable
return result.Clone();
}
private async Task<ReportData?> RequestReportAsync(string sessionId, CancellationToken cancellationToken)
private async Task<ReportData?> RequestReportAsync(string sessionId, string query,
CancellationToken cancellationToken)
{
var uri = $"https://{GetConfiguration().Host}/WebUntis/reports.do" +
"?name=Student&format=csv&klasseId=-1&studentsForDate=true&context=klasseId";
var uri = $"https://{GetConfiguration().Host}/WebUntis/reports.do?{query}";
using var request = ReportRequest(uri, sessionId, acceptJson: true);
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
var payload = await ReadJsonAsync(response, cancellationToken);
@@ -0,0 +1,94 @@
using System.Globalization;
namespace LehrerApp.WebUntis;
public static class WebUntisLessonAbsenceParser
{
public static IReadOnlyList<UntisLessonAbsence> Parse(string content)
{
var rows = TabSeparatedTextReader.ParseRows(content, '\t');
if (rows.Count == 0) return [];
var headers = rows[0]
.Select((header, index) => (index == 0 ? header.TrimStart('\uFEFF') : header).Trim())
.ToArray();
var result = new List<UntisLessonAbsence>();
foreach (var row in rows.Skip(1))
{
if (row.All(string.IsNullOrWhiteSpace)) continue;
var values = new Dictionary<string, string?>(StringComparer.Ordinal);
for (var index = 0; index < headers.Length; index++)
values[headers[index]] = index < row.Count ? row[index].Trim() : null;
var (start, end) = ParseTimeRange(Get(values, "Zeit"));
var (externKey, inParentheses) = ParseExternKey(Get(values, "ENr"));
result.Add(new UntisLessonAbsence(
Get(values, "Schüler*innen")?.Trim() ?? "",
GermanShortDate(Get(values, "Datum")) ?? throw new InvalidDataException(
"Ungültiges Datum in Spalte \"Datum\"."),
RequiredInt(Get(values, "Fehlstd."), "Fehlstd."),
RequiredInt(Get(values, "Unentsch. Fehlstd."), "Unentsch. Fehlstd."),
RequiredInt(Get(values, "Fehlmin."), "Fehlmin."),
RequiredInt(Get(values, "Unentsch. Fehlmin."), "Unentsch. Fehlmin."),
start,
end,
Optional(Get(values, "Abwesenheitsgrund")),
externKey,
inParentheses,
Optional(Get(values, "Erledigt")),
ParseBoolean(Get(values, "Abwesenheit zählt"))));
}
return result;
}
private static (int? Start, int? End) ParseTimeRange(string? value)
{
var parts = value?.Split('-', 2);
return parts is { Length: 2 } ? (ParseClock(parts[0]), ParseClock(parts[1])) : (null, null);
}
private static int? ParseClock(string value) =>
TimeOnly.TryParseExact(value.Trim(), "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None,
out var time)
? time.Hour * 100 + time.Minute
: null;
// WebUntis klammert die externe Schülerkennung in dieser Spalte teils ein, teils nicht - die
// Klammer selbst ist das Signal (siehe Statuszuordnung im Desktop-Client), nicht nur Formatierung,
// und wird deshalb hier separat zurückgegeben statt beim Parsen verworfen zu werden.
private static (int? Key, bool InParentheses) ParseExternKey(string? value)
{
var trimmed = value?.Trim();
if (string.IsNullOrEmpty(trimmed)) return (null, false);
var inParentheses = trimmed.StartsWith('(') && trimmed.EndsWith(')');
var digits = trimmed.Trim('(', ')');
return int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out var key)
? (key, inParentheses)
: (null, false);
}
private static bool ParseBoolean(string? value) =>
string.Equals(value?.Trim(), "true", StringComparison.OrdinalIgnoreCase);
private static string? Get(IReadOnlyDictionary<string, string?> values, string key) =>
values.TryGetValue(key, out var value) ? value : null;
private static string? Optional(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static int RequiredInt(string? value, string field) =>
int.TryParse(value?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: throw new InvalidDataException($"Ungültige Zahl in Spalte \"{field}\".");
private static int? GermanShortDate(string? value)
{
if (!DateOnly.TryParseExact(value?.Trim(), "dd.MM.yy", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date))
return null;
return date.Year * 10_000 + date.Month * 100 + date.Day;
}
}
+12 -12
View File
@@ -132,20 +132,20 @@ public sealed record UntisTimetablePeriod(
IReadOnlyList<UntisEntity> Rooms,
JsonElement Raw);
public sealed record UntisStudentAbsence(
int StudentKey,
public sealed record UntisLessonAbsence(
string StudentName,
int Date,
int StartTime,
int EndTime,
int AbsentPeriods,
int UnexcusedAbsentPeriods,
int AbsentMinutes,
bool Checked,
string? AbsenceReason,
string? ExcuseStatus,
int? SubjectId,
IReadOnlyList<int> TeacherIds,
string? StudentGroup,
JsonElement Raw);
int UnexcusedAbsentMinutes,
int? StartTime,
int? EndTime,
string? Reason,
int? ExternKey,
bool ExternKeyInParentheses,
string? HandledOn,
bool Counts);
public sealed record UntisClassRegisterEntry(
int? StudentKey,
@@ -1,5 +1,4 @@
using System.Globalization;
using System.Text;
namespace LehrerApp.WebUntis;
@@ -7,7 +6,7 @@ public static class WebUntisStudentReportParser
{
public static IReadOnlyList<UntisStudent> Parse(string content)
{
var rows = ParseSeparatedRows(content, '\t');
var rows = TabSeparatedTextReader.ParseRows(content, '\t');
if (rows.Count == 0) return [];
var headers = rows[0]
@@ -63,55 +62,6 @@ public static class WebUntisStudentReportParser
return result;
}
private static List<List<string>> ParseSeparatedRows(string content, char separator)
{
var rows = new List<List<string>>();
var row = new List<string>();
var field = new StringBuilder();
var inQuotes = false;
for (var index = 0; index < content.Length; index++)
{
var character = content[index];
if (character == '"')
{
if (inQuotes && index + 1 < content.Length && content[index + 1] == '"')
{
field.Append('"');
index++;
}
else
{
inQuotes = !inQuotes;
}
continue;
}
if (!inQuotes && character == separator)
{
row.Add(field.ToString());
field.Clear();
continue;
}
if (!inQuotes && character == '\n')
{
row.Add(field.ToString());
rows.Add(row);
row = [];
field.Clear();
continue;
}
if (!inQuotes && character == '\r') continue;
field.Append(character);
}
row.Add(field.ToString());
if (row.Count > 1 || !string.IsNullOrWhiteSpace(row[0])) rows.Add(row);
return rows;
}
private static string? Get(IReadOnlyDictionary<string, string?> values, string key) =>
values.TryGetValue(key, out var value) ? value : null;
+22
View File
@@ -1242,6 +1242,28 @@ Zugangsdaten; Schülerdaten, Fehlzeiten und der unverschlüsselte CSV-Report pas
eine lokale `ParticipationSession` dieses Kurses existiert, können als offen, entschuldigt oder
unentschuldigt übernommen werden; fremde/ganz­tägige Abwesenheiten erzeugen keine lokale Stunde.
**Nachtrag zu 4.3, Fehlzeiten je Unterricht (August 2026):** Der ursprüngliche Fehlzeitenabgleich
rief `getTimetableWithAbsences` ohne Element auf und bekam damit den kompletten Lehrer-Stundenplan
zurück (einmal pro Kursmitglied, siehe damalige Ineffizienz-Korrektur) — das erfordert mehr
WebUntis-Rechte, als ein Lehrkraft-Konto standardmäßig hat, und schlug deshalb in der Praxis fehl.
Ersetzt durch den undokumentierten internen Bericht "Fehlzeiten pro Unterricht pro Schüler\*in"
(`reports.do?name=AbsencePerLesson`, per Netzwerk-Mitschnitt aus der WebUntis-WebApp
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`)
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.
- Der Bericht liefert keinen Klartext-Entschuldigungsstatus, nur Minutenwerte und ein
Bearbeitet-Datum. Statuszuordnung (Heuristik, nicht durch WebUntis-Dokumentation bestätigt):
fehlende Zeit kürzer als die Unterrichtsdauer → Verspätung; sonst ohne Bearbeitet-Datum →
ausstehend, mit unentschuldigtem Minutenanteil → unentschuldigt, sonst entschuldigt.
- Der alte, jetzt entfernte Fehlzeitenabgleich (`WebUntisAbsenceComparisonViewModel`) sowie der
zugehörige Client-Aufruf `getTimetableWithAbsences` wurden ersatzlos gestrichen statt behoben,
da der neue Bericht denselben Zweck ohne die Rechteproblematik erfüllt.
### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.