Klassenbucheinträge abgleich und poll

This commit is contained in:
2026-08-25 09:31:41 +02:00
parent 87a7badb44
commit 7fa8a93c82
14 changed files with 584 additions and 9 deletions
@@ -30,6 +30,8 @@ public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, I
public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int AbsentPeriods, public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int AbsentPeriods,
int UnexcusedAbsentPeriods, int AbsentMinutes, int UnexcusedAbsentMinutes, int? StartTime, int? EndTime, int UnexcusedAbsentPeriods, int AbsentMinutes, int UnexcusedAbsentMinutes, int? StartTime, int? EndTime,
string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts); string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts);
public sealed record UntisClassRegisterEventDto(string ClassName, int Date, string? Subject,
string StudentName, string? CategoryName, string? CategoryGroup, string? Text);
/// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der /// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary> /// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
@@ -117,6 +119,22 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
x.HandledOn, x.Counts)).ToList(); x.HandledOn, x.Counts)).ToList();
}, token); }, token);
// "-alle-"-Bericht, hier auf eigene Einträge gefiltert (Benutzer == eigener WebUntis-Login) - Einträge
// anderer Lehrkräfte zu Schülern der eigenen Klasse gehören zu einem eigenständigen, noch nicht
// gebauten "Klassenlehrer"-Feature (siehe TODO.md), nicht zum reinen Dokumentations-Abgleich.
public Task<IReadOnlyList<UntisClassRegisterEventDto>> GetOwnClassRegisterEventsAsync(DateOnly start,
DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
{
var ownUsername = settings.GetApiCredentials()?.Username;
var entries = await client.GetClassRegisterEventsReportAsync(Date(start), Date(end), token);
return (IReadOnlyList<UntisClassRegisterEventDto>)entries
.Where(x => ownUsername is not null
&& string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase))
.Select(x => new UntisClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName,
x.CategoryName, x.CategoryGroup, x.Text))
.ToList();
}, token);
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token) private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
{ {
try { return await operation(await GetClientAsync(token)); } try { return await operation(await GetClientAsync(token)); }
@@ -43,11 +43,16 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
private readonly IParticipationSessionRepository _sessions; private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation; private readonly IParticipationRepository _participation;
private IReadOnlyList<Student> _loadedStudents = [];
private IReadOnlyDictionary<DateOnly, ParticipationSession> _loadedSessions =
new Dictionary<DateOnly, ParticipationSession>();
public ObservableCollection<WebUntisLessonAbsenceRow> Rows { get; } = []; public ObservableCollection<WebUntisLessonAbsenceRow> Rows { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2); [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden."; [ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
[ObservableProperty] private bool _busy; [ObservableProperty] private bool _busy;
[ObservableProperty] private bool _markUnknownAsPresent;
public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis, public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
IStudentRepository students, IParticipationSessionRepository sessions, IStudentRepository students, IParticipationSessionRepository sessions,
@@ -78,6 +83,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
var localSessions = _sessions.GetByGroup(_group.Id) var localSessions = _sessions.GetByGroup(_group.Id)
.Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date) .Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date)
.ToDictionary(x => x.Key, x => x.First()); .ToDictionary(x => x.Key, x => x.First());
_loadedStudents = courseStudents;
_loadedSessions = localSessions;
// Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt // 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 // per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb
// der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten. // der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten.
@@ -152,10 +159,39 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
entry.UpdatedAt = DateTime.UtcNow; entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry); _participation.Save(entry);
} }
Status = $"{selected.Count} Anwesenheitsstatus übernommen.";
var presentCount = MarkUnknownAsPresent ? FillUnknownAsPresent() : 0;
Status = $"{selected.Count} Anwesenheitsstatus übernommen." +
(MarkUnknownAsPresent ? $" {presentCount} unbekannte Status auf anwesend gesetzt." : "");
foreach (var row in selected) row.Selected = false; foreach (var row in selected) row.Selected = false;
} }
// "Identifiziert" heißt hier: WebUntis hat für diesen Schüler an diesem Tag überhaupt eine Zeile
// gemeldet - unabhängig davon, ob die Zeile markiert/übernommen wurde. Nur wer für den geladenen
// Zeitraum weder von WebUntis gemeldet noch lokal schon kontrolliert wurde, gilt als "unbekannt"
// und wird auf anwesend gesetzt; bereits erfasste Einträge (auch ohne Anwesenheitsstatus, z.B. nur
// mit Notiz) werden nicht überschrieben, wenn ihr Anwesenheitsstatus schon gesetzt ist.
private int FillUnknownAsPresent()
{
var identified = Rows.Where(x => x.AssignedStudent is not null)
.Select(x => (x.Date, StudentId: x.AssignedStudent!.Id))
.ToHashSet();
var filled = 0;
foreach (var session in _loadedSessions.Values)
foreach (var student in _loadedStudents)
{
if (identified.Contains((session.Date, student.Id))) continue;
var entry = _participation.GetBySessionAndStudent(session.Id, student.Id);
if (entry?.Attendance is not null) continue;
entry ??= new ParticipationEntry { SessionId = session.Id, StudentId = student.Id };
entry.Attendance = AttendanceStatus.Present;
entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry);
filled++;
}
return filled;
}
private static int? StudentKey(Student student) private static int? StudentKey(Student student)
{ {
student.ExternalIds ??= []; student.ExternalIds ??= [];
@@ -0,0 +1,191 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Students;
/// <summary>Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar - <see cref="AssignedStudent"/>
/// kann manuell per Auswahlliste gesetzt werden, wenn der Name nicht eindeutig auf ein/e Schüler*in
/// passt (siehe WebUntisLessonAbsenceComparisonViewModel für dasselbe Muster).</summary>
public partial class WebUntisDocumentationRow : ObservableObject
{
public required string ClassName { get; init; }
public required DateOnly Date { get; init; }
public required string UntisStudentName { get; init; }
public string? Subject { get; init; }
public string? CategoryName { get; init; }
public string? CategoryGroup { get; init; }
public string? Text { get; init; }
public required IReadOnlyList<Student> Candidates { get; init; }
public string DateLabel => Date.ToString("dd.MM.yyyy");
public bool CanApply => AssignedStudent is not null;
[ObservableProperty] private Student? _assignedStudent;
/// <summary>Titel/Text eines lokalen Eintrags, der am selben Tag für diese/n Schüler*in schon
/// existiert - nur nach Datum+Schüler*in erkannt, nicht nach Wortlaut (der unterscheidet sich oft
/// von der WebUntis-Kategorie). Deshalb Anzeige zum Vergleichen statt automatischem Ausblenden.</summary>
[ObservableProperty] private string? _existingLocalEntry;
[ObservableProperty] private bool _selected;
}
/// <summary>Lokaler Dokumentationseintrag im geladenen Zeitraum ohne passenden WebUntis-Eintrag - kann
/// nicht automatisch nach WebUntis geschrieben werden (bewusst keine schreibenden Aufrufe gegen die
/// undokumentierte API), deshalb nur als Kopiervorlage für die manuelle Nacherfassung dort.</summary>
public sealed record LocalOnlyDocumentationRow(
string StudentName, DateOnly Date, string? GroupName, string Title, string Content)
{
public string DateLabel => Date.ToString("dd.MM.yyyy");
public string ClipboardText =>
$"{DateLabel} {StudentName}" + (GroupName is null ? "" : $" ({GroupName})") +
$"\n{Title}\n{Content}";
}
/// <summary>Abgleich der WebUntis-Klassenbucheinträge (eigene, siehe
/// <see cref="WebUntisIntegrationService.GetOwnClassRegisterEventsAsync"/>) gegen die lokale
/// <see cref="Documentation"/> - Dashboard-weit statt pro Lerngruppe, weil der WebUntis-"-alle-"-
/// Bericht ebenfalls klassenübergreifend ist (siehe TODO.md, Nachtrag zu 4.3).</summary>
public partial class WebUntisDocumentationComparisonViewModel : ObservableObject
{
private readonly WebUntisIntegrationService _untis;
private readonly IStudentRepository _students;
private readonly IGroupRepository _groups;
private readonly IDocumentationRepository _documentation;
private readonly SchoolYearService _schoolYears;
public ObservableCollection<WebUntisDocumentationRow> Rows { get; } = [];
public ObservableCollection<LocalOnlyDocumentationRow> LocalOnlyRows { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-7);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden.";
[ObservableProperty] private bool _busy;
public WebUntisDocumentationComparisonViewModel(WebUntisIntegrationService untis,
IStudentRepository students, IGroupRepository groups, IDocumentationRepository documentation,
SchoolYearService schoolYears)
{
_untis = untis; _students = students; _groups = groups; _documentation = documentation;
_schoolYears = schoolYears;
}
[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(); LocalOnlyRows.Clear();
try
{
var ownStudents = _students.GetAll();
var globalIndex = BuildNameIndex(ownStudents);
// Zusätzlich pro Klasse (aktuelles Schuljahr) indiziert: löst den Fall "gleicher Name in
// verschiedenen Klassen" auf, den ein rein globaler Namensabgleich nicht unterscheiden könnte.
var currentSchoolYear = _schoolYears.CurrentSchoolYear();
var classIndexes = _groups.GetAll()
.Where(g => g.Type == GroupType.Class && g.SchoolYear == currentSchoolYear)
.GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()),
StringComparer.OrdinalIgnoreCase);
var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end);
var localDocs = _documentation.GetAll().Where(d => d.Date >= start && d.Date <= end).ToList();
var ordered = entries
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
.Where(x => x.Date is not null)
.OrderBy(x => x.Date).ThenBy(x => x.Entry.StudentName);
// Nur nach Schüler*in+Datum erkannt, nicht nach Wortlaut: ein lokaler Eintrag von vor dieser
// Funktion (oder frei formuliert) hat selten denselben Titel wie die WebUntis-Kategorie.
List<Documentation> LocalDocsFor(Guid studentId, DateOnly date) =>
localDocs.Where(d => d.StudentId == studentId && d.Date == date).ToList();
foreach (var (entry, date) in ordered)
{
var nameKey = NameKey(entry.StudentName);
var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex)
? classIndex.GetValueOrDefault(nameKey)
: null)
?? globalIndex.GetValueOrDefault(nameKey);
var existing = match is null ? [] : LocalDocsFor(match.Id, date!.Value);
Rows.Add(new WebUntisDocumentationRow
{
ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName,
Subject = entry.Subject, CategoryName = entry.CategoryName, CategoryGroup = entry.CategoryGroup,
Text = entry.Text, Candidates = ownStudents, AssignedStudent = match,
ExistingLocalEntry = existing.Count == 0 ? null
: string.Join(" | ", existing.Select(d => $"{d.Title}: {d.Content}")),
Selected = match is not null && existing.Count == 0,
});
}
foreach (var doc in localDocs)
{
var coveredByReport = Rows.Any(r => r.AssignedStudent?.Id == doc.StudentId && r.Date == doc.Date);
if (coveredByReport) continue;
var student = ownStudents.FirstOrDefault(s => s.Id == doc.StudentId);
if (student is null) continue;
LocalOnlyRows.Add(new LocalOnlyDocumentationRow(student.FullName, doc.Date,
doc.GroupId is { } groupId ? _groups.GetById(groupId)?.Name : null, doc.Title, doc.Content));
}
var unresolved = Rows.Count(x => x.AssignedStudent is null);
var possibleDuplicates = Rows.Count(x => x.ExistingLocalEntry is not null);
Status = $"{Rows.Count} WebUntis-Einträge erhalten, {possibleDuplicates} mit lokalem Eintrag am " +
"selben Tag (bitte vergleichen)" +
(unresolved > 0 ? $", {unresolved} bitte manuell zuordnen" : "") +
$". {LocalOnlyRows.Count} lokale Einträge ohne WebUntis-Gegenstück.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private void Apply()
{
var selected = Rows.Where(x => x.Selected && x.CanApply).ToList();
foreach (var row in selected)
{
_documentation.Save(new Documentation
{
StudentId = row.AssignedStudent!.Id,
Date = row.Date,
Type = DocumentationType.Incident,
Title = row.CategoryName ?? "WebUntis-Klassenbucheintrag",
Content = row.Text ?? "",
Tags = row.CategoryGroup is { Length: > 0 } group ? [group] : [],
});
}
foreach (var row in selected)
{
row.ExistingLocalEntry = $"{row.CategoryName}: {row.Text}";
row.Selected = false;
}
Status = $"{selected.Count} Einträge aus WebUntis übernommen.";
}
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
// Wie beim Fehlzeiten-Abgleich: WebUntis liefert Namen nicht einheitlich in einer Reihenfolge,
// deshalb werden beide Reihenfolgen registriert, aber nur falls innerhalb der Kandidaten eindeutig.
private static Dictionary<string, Student> BuildNameIndex(IReadOnlyList<Student> candidates) =>
candidates
.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);
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
}
@@ -25,8 +25,12 @@
<TextBlock Text="{Binding Greeting}" FontSize="14" Opacity="0.6"/> <TextBlock Text="{Binding Greeting}" FontSize="14" Opacity="0.6"/>
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/> <TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
</StackPanel> </StackPanel>
<Button Grid.Column="1" Content="Dashboard anpassen" Command="{Binding ToggleDashboardSettingsCommand}" <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
VerticalAlignment="Center"/> <Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick"
VerticalAlignment="Center"/>
<Button Content="Dashboard anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
VerticalAlignment="Center"/>
</StackPanel>
</Grid> </Grid>
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8" <Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8"
@@ -1,7 +1,14 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Students;
using LehrerApp.Desktop.Views.Workload; using LehrerApp.Desktop.Views.Workload;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Dashboard; namespace LehrerApp.Desktop.Views.Dashboard;
@@ -22,4 +29,17 @@ public partial class DashboardView : UserControl
if (owner is null) return null; if (owner is null) return null;
return await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder); return await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder);
} }
private async void OnCompareWebUntisDocumentationClick(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return;
var dialogVm = new WebUntisDocumentationComparisonViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<IDocumentationRepository>(),
App.Services.GetRequiredService<SchoolYearService>());
await new WebUntisDocumentationComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
}
} }
@@ -5,7 +5,7 @@
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel" x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640" Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640"
MinWidth="800" MinHeight="450" WindowStartupLocation="CenterOwner"> MinWidth="800" MinHeight="450" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="24" RowSpacing="8"> <Grid RowDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="24" RowSpacing="8">
<StackPanel Grid.Row="0" Spacing="4"> <StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="Fehlzeiten je Unterricht mit WebUntis abgleichen" Classes="dialogtitle"/> <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." <TextBlock Text="Nur markierte Zeilen mit einer vorhandenen lokalen Kursstunde werden übernommen. Zeilen ohne automatische Zuordnung bitte manuell einem Kursmitglied zuweisen."
@@ -44,7 +44,10 @@
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</ScrollViewer> </ScrollViewer>
<Grid Grid.Row="4" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8"> <CheckBox Grid.Row="4" Content="Unbekannte im geladenen Zeitraum auf „Anwesend“ setzen"
IsChecked="{Binding MarkUnknownAsPresent}"
ToolTip.Tip="Gilt nur für Schüler*innen, die WebUntis für den jeweiligen Tag nicht gemeldet hat und die lokal noch keinen Anwesenheitsstatus haben."/>
<Grid Grid.Row="5" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/> <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="1" Content="Schließen" Click="OnClose"/>
<Button Grid.Column="2" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/> <Button Grid.Column="2" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
@@ -0,0 +1,88 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.WebUntisDocumentationComparisonDialog"
x:DataType="vm:WebUntisDocumentationComparisonViewModel"
Title="Klassenbucheinträge mit WebUntis abgleichen" Width="1150" Height="720"
MinWidth="900" MinHeight="500" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,Auto,2*,Auto,Auto,1*,Auto" Margin="24" RowSpacing="8">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="Klassenbucheinträge mit WebUntis abgleichen" Classes="dialogtitle"/>
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.65"
Text="Nur eigene WebUntis-Einträge (Benutzer = eigener Login). Zeilen ohne automatische Zuordnung bitte manuell einem/einer Schüler*in zuweisen. Bereits lokal vorhandene Einträge sind gesperrt."/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<DatePicker SelectedDate="{Binding StartDate}"/>
<TextBlock Text="bis" VerticalAlignment="Center"/>
<DatePicker SelectedDate="{Binding EndDate}"/>
<Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<Grid Grid.Row="2" ColumnDefinitions="Auto,70,55,65,1.1*,1.1*,1*,1.3*,1.3*" ColumnSpacing="8" Margin="4,0">
<TextBlock Grid.Column="1" Text="Datum" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="2" Text="Klasse" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="3" Text="Fach" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="4" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="5" Text="Zuordnung" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="6" Text="Kategorie" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="7" Text="Text" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="8" Text="Lokal am selben Tag" FontSize="11" Opacity="0.6"/>
</Grid>
<ScrollViewer Grid.Row="3">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisDocumentationRow">
<Grid ColumnDefinitions="Auto,70,55,65,1.1*,1.1*,1*,1.3*,1.3*" ColumnSpacing="8" Margin="0,3">
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
<TextBlock Grid.Column="1" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding ClassName}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding Subject}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="4" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<ComboBox Grid.Column="5" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
DisplayMemberBinding="{Binding FullName}" PlaceholderText="Schüler*in wählen…"
HorizontalAlignment="Stretch"/>
<StackPanel Grid.Column="6" Spacing="0">
<TextBlock Text="{Binding CategoryName}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding CategoryGroup}" FontSize="11" Opacity="0.6"/>
</StackPanel>
<TextBlock Grid.Column="7" Text="{Binding Text}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<TextBlock Grid.Column="8" Text="{Binding ExistingLocalEntry}" TextWrapping="Wrap"
Foreground="DarkOrange" VerticalAlignment="Center"
IsVisible="{Binding ExistingLocalEntry, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid Grid.Row="4" ColumnDefinitions="*,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
<StackPanel Grid.Row="5" Spacing="2">
<TextBlock Text="Lokale Einträge im Zeitraum ohne WebUntis-Gegenstück" FontWeight="SemiBold" FontSize="13"/>
<TextBlock FontSize="12" Opacity="0.65" TextWrapping="Wrap"
Text="Kein Schreibzugriff auf WebUntis — Text zum manuellen Nacherfassen in die Zwischenablage kopieren."/>
</StackPanel>
<ScrollViewer Grid.Row="6">
<ItemsControl ItemsSource="{Binding LocalOnlyRows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LocalOnlyDocumentationRow">
<Grid ColumnDefinitions="80,1.2*,1.5*,2.5*,Auto" ColumnSpacing="8" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding StudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<TextBlock Grid.Column="2" Text="{Binding GroupName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<StackPanel Grid.Column="3">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Content}" TextWrapping="Wrap" FontSize="12" Opacity="0.8"/>
</StackPanel>
<Button Grid.Column="4" Content="In Zwischenablage" Click="OnCopyToClipboardClick"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Button Grid.Row="7" Content="Schließen" Click="OnClose" HorizontalAlignment="Right"/>
</Grid>
</Window>
@@ -0,0 +1,24 @@
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Students;
public partial class WebUntisDocumentationComparisonDialog : Window
{
public WebUntisDocumentationComparisonDialog() => InitializeComponent();
private void OnClose(object? sender, RoutedEventArgs e) => Close();
private async void OnCopyToClipboardClick(object? sender, RoutedEventArgs e)
{
if (sender is not Button { DataContext: LocalOnlyDocumentationRow row }) return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
await clipboard.SetTextAsync(row.ClipboardText);
App.Services.GetRequiredService<NotificationService>().ShowSuccess("In die Zwischenablage kopiert.");
}
}
@@ -0,0 +1,45 @@
using Xunit;
using LehrerApp.WebUntis;
namespace LehrerApp.WebUntis.Tests;
public sealed class WebUntisClassRegisterEventReportParserTests
{
[Fact]
public void Parse_UebernimmtAlleFelderUndZweistelligesJahr()
{
const string report = "\uFEFFKlasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
"10c\t24.08.26\tEng_G\tMuster Erika\ttownsend\tFehlende HA\tNegativ\tKeine Hausaufgaben gemacht.\r\n";
var entry = Assert.Single(WebUntisClassRegisterEventReportParser.Parse(report));
Assert.Equal("10c", entry.ClassName);
Assert.Equal(20260824, entry.Date);
Assert.Equal("Eng_G", entry.Subject);
Assert.Equal("Muster Erika", entry.StudentName);
Assert.Equal("townsend", entry.TeacherUsername);
Assert.Equal("Fehlende HA", entry.CategoryName);
Assert.Equal("Negativ", entry.CategoryGroup);
Assert.Equal("Keine Hausaufgaben gemacht.", entry.Text);
}
[Fact]
public void Parse_UeberspringtLeereZeilen()
{
const string report = "Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
"6a\t24.08.26\tNAT\tMuster Max\thedtrich\tMitarb. über Erwart.\tPositiv\tGut.\r\n" +
"\t\t\t\t\t\t\t\r\n";
var entry = Assert.Single(WebUntisClassRegisterEventReportParser.Parse(report));
Assert.Equal("Muster Max", entry.StudentName);
}
[Fact]
public void Parse_LehntUngueltigesDatumAb()
{
const string report = "Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
"6a\tkein-datum\tNAT\tMuster Max\thedtrich\tMitarb. über Erwart.\tPositiv\tGut.\r\n";
Assert.Throws<InvalidDataException>(() => WebUntisClassRegisterEventReportParser.Parse(report));
}
}
@@ -209,6 +209,41 @@ public sealed class WebUntisClientTests
await client.DisposeAsync(); await client.DisposeAsync();
} }
[Fact]
public async Task GetClassRegisterEventsReportAsync_BautDenBerichtsAbrufAufUndTypisiertDasErgebnis()
{
var csv = Encoding.UTF8.GetBytes(
"Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
"6a\t24.08.26\tNAT\tMuster Max\thedtrich\tMitarb. über Erwart.\tPositiv\tArbeitet gut mit.\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}"),
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt2.tmp&name=ClassregEventPerStudent&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var report = await client.GetClassRegisterEventsReportAsync(20260824, 20260828, CancellationToken.None);
var entry = Assert.Single(report);
Assert.Equal("6a", entry.ClassName);
Assert.Equal(20260824, entry.Date);
Assert.Equal("NAT", entry.Subject);
Assert.Equal("Muster Max", entry.StudentName);
Assert.Equal("hedtrich", entry.TeacherUsername);
Assert.Equal("Mitarb. über Erwart.", entry.CategoryName);
Assert.Equal("Positiv", entry.CategoryGroup);
Assert.Equal("Arbeitet gut mit.", entry.Text);
Assert.Contains("reports.do?name=ClassregEventPerStudent", handler.Requests[1].Uri);
Assert.Contains("klasseOrStudentgroupId=-1", handler.Requests[1].Uri);
Assert.Contains("studentId=-1", handler.Requests[1].Uri);
Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri);
Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri);
Assert.EndsWith("reports.do?get=rpt2.tmp&name=ClassregEventPerStudent&format=csv", handler.Requests[2].Uri);
await client.DisposeAsync();
}
private static WebUntisClient CreateClient(HttpMessageHandler handler) => new( private static WebUntisClient CreateClient(HttpMessageHandler handler) => new(
new HttpClient(handler), new HttpClient(handler),
new WebUntisOptions new WebUntisOptions
@@ -0,0 +1,53 @@
using System.Globalization;
namespace LehrerApp.WebUntis;
public static class WebUntisClassRegisterEventReportParser
{
public static IReadOnlyList<UntisClassRegisterEventReportEntry> 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<UntisClassRegisterEventReportEntry>();
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;
result.Add(new UntisClassRegisterEventReportEntry(
Get(values, "Klasse")?.Trim() ?? "",
GermanShortDate(Get(values, "Datum")) ?? throw new InvalidDataException(
"Ungültiges Datum in Spalte \"Datum\"."),
Optional(Get(values, "Fach")),
Get(values, "Name")?.Trim() ?? "",
Optional(Get(values, "Benutzer")),
Optional(Get(values, "Eintragskategorie")),
Optional(Get(values, "Kategoriegruppe")),
Optional(Get(values, "Text"))));
}
return result;
}
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? 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;
}
}
+27
View File
@@ -148,6 +148,33 @@ public sealed class WebUntisClient : IAsyncDisposable
return WebUntisLessonAbsenceParser.Parse(reportText); return WebUntisLessonAbsenceParser.Parse(reportText);
}, cancellationToken); }, cancellationToken);
// Undokumentierter interner Bericht der WebUntis-WebApp für Klassenbucheinträge ("-alle-"-Bereich,
// damit auch Einträge zu fremden Klassen aus dem eigenen Unterricht mit dabei sind - die
// Oberfläche bietet sonst nur "eigene Klasse" oder "-alle-" an). klasseOrStudentgroupId/
// studentId=-1 entspricht "-alle-" und wird lokal auf eigene Lerngruppen gefiltert.
// Query-Parameter 1:1 aus einem echten Browser-Request übernommen (bis auf das nicht benötigte
// selectedDateRange/_csrf, siehe GetLessonAbsencesAsync). Anders als AbsencePerLesson liefert
// dieser Bericht keine externe Schülerkennung, nur den Namen (siehe UntisClassRegisterEventReportEntry).
public Task<IReadOnlyList<UntisClassRegisterEventReportEntry>> GetClassRegisterEventsReportAsync(
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var query = "name=ClassregEventPerStudent&format=csv&klasseOrStudentgroupId=-1&studentId=-1" +
"&calendarChange=0&withAbsences=true&_withAbsences=on&withLateness=true&_withLateness=on" +
"&excludeNotCountingAbsences=true&_excludeNotCountingAbsences=on&_allStudents=on" +
"&absStudGroup=1&studExcuseStatusId=-1&_withoutPageBreaksAbsPerStudent=on&absClassGroup=1" +
"&classExcuseStatusId=-1&_absClassSorted=on&_separateAbsentDays=on&_filterAbsencesKlasse=on" +
"&absSubjectGroup=7&absSubjectId=-1&absSubjectGroupGroup=7&subjectGroupId=-1&_totalDays=on" +
"&absentPeriodsLimit=16&reportWeeks=4&dayLimit=3&excuseGroup=1&_groupPerWeek=on" +
"&_excuseStatusAll=on&studEventReasonId=-1&classEventReasonId=-1&_evntClassSorted=on" +
"&_withoutPageBreaks=on&withStudentAbsences=true&_withStudentAbsences=on" +
"&examinationTypeId=-1&teachingMethodId=-1&examTypeId=-1&_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 WebUntisClassRegisterEventReportParser.Parse(reportText);
}, cancellationToken);
public Task<IReadOnlyList<UntisSubstitution>> GetSubstitutionsAsync(int startDate, int endDate, public Task<IReadOnlyList<UntisSubstitution>> GetSubstitutionsAsync(int startDate, int endDate,
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId => int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{ {
+12
View File
@@ -147,6 +147,18 @@ public sealed record UntisLessonAbsence(
string? HandledOn, string? HandledOn,
bool Counts); bool Counts);
// "-alle-"-Bericht ohne externe Schülerkennung - anders als bei AbsencePerLesson bleibt für den
// Abgleich mit lokalen Schülern nur der Name (siehe Namens-Fallback im Desktop-Abgleich).
public sealed record UntisClassRegisterEventReportEntry(
string ClassName,
int Date,
string? Subject,
string StudentName,
string? TeacherUsername,
string? CategoryName,
string? CategoryGroup,
string? Text);
public sealed record UntisClassRegisterEntry( public sealed record UntisClassRegisterEntry(
int? StudentKey, int? StudentKey,
string? Surname, string? Surname,
+23 -4
View File
@@ -1256,13 +1256,32 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
bewusst nicht in die Folgegruppe übernommen. bewusst nicht in die Folgegruppe übernommen.
- Die Lehrkraft-Kennung (`teacherId`) kommt aus `personId` der `authenticate`-Antwort und muss nicht - 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. gepflegt werden — der Bericht zeigt ohnehin nur eigenen Unterricht, auch bei Doppelbesetzung.
- Der Bericht liefert keinen Klartext-Entschuldigungsstatus, nur Minutenwerte und ein - Der Bericht liefert keinen Klartext-Entschuldigungsstatus, nur Minutenwerte, ein
Bearbeitet-Datum. Statuszuordnung (Heuristik, nicht durch WebUntis-Dokumentation bestätigt): Bearbeitet-Datum und die externe Schülerkennung. Statuszuordnung (Heuristik, an echten Daten der
fehlende Zeit kürzer als die Unterrichtsdauer → Verspätung; sonst ohne Bearbeitet-Datum → Schule korrigiert, nicht durch WebUntis-Dokumentation bestätigt): Abwesenheitsgrund enthält
ausstehend, mit unentschuldigtem Minutenanteil → unentschuldigt, sonst entschuldigt. "entlassen" → vorzeitig entlassen, unabhängig von der Dauer; sonst Fehlminuten unter 45 (eine volle
Stunde) → Verspätung/Teilverlust; sonst ohne Bearbeitet-Datum → ausstehend; sonst externe
Schülerkennung in Klammern → unentschuldigt, ohne Klammern → entschuldigt (das Vorzeichen der
Kennung war die ursprüngliche, falsche Annahme — es gibt kein Minuszeichen in den echten Daten).
- Der alte, jetzt entfernte Fehlzeitenabgleich (`WebUntisAbsenceComparisonViewModel`) sowie der - Der alte, jetzt entfernte Fehlzeitenabgleich (`WebUntisAbsenceComparisonViewModel`) sowie der
zugehörige Client-Aufruf `getTimetableWithAbsences` wurden ersatzlos gestrichen statt behoben, zugehörige Client-Aufruf `getTimetableWithAbsences` wurden ersatzlos gestrichen statt behoben,
da der neue Bericht denselben Zweck ohne die Rechteproblematik erfüllt. da der neue Bericht denselben Zweck ohne die Rechteproblematik erfüllt.
- Namensabgleich als Fallback, wenn ein Schüler keine externe WebUntis-Kennung hat: WebUntis liefert
Namen teils als "Nachname Vorname", teils uneinheitlich — deshalb werden lokal beide Reihenfolgen
registriert, aber nur falls eindeutig innerhalb der Kandidaten; sonst manuelle Zuordnung per Auswahl
in der Vergleichszeile, statt zu raten.
- Zweiter, undokumentierter Bericht `reports.do?name=ClassregEventPerStudent` liefert Klassenbuch-
einträge (Klasse, Datum, Fach, Name, Benutzer, Eintragskategorie, Kategoriegruppe, Text) im
"-alle-"-Bereich, da die Oberfläche sonst nur "eigene Klasse" oder "-alle-" anbietet
(`WebUntisClient.GetClassRegisterEventsReportAsync`, `WebUntisClassRegisterEventReportParser`).
Auch hier keine externe Schülerkennung, nur der Name — Namensabgleich soll deshalb zusätzlich auf
Klasse+Fach aus der Zeile scopen (→ genau eine lokale Lerngruppe), nicht global über alle Schüler.
Abgleichsdialog (Diff gegen `Documentation`, Übernehmen in beide Richtungen) noch nicht gebaut.
- **Idee, noch nicht geplant ("Klassenlehrer"-Feature):** Der "-alle-"-Bericht enthält für die
eigene Klasse auch Einträge, die andere Lehrkräfte in deren Fächern angelegt haben (`Benutzer`
≠ eigener Login) — für den reinen Dokumentations-Abgleich (eigene Einträge synchron halten) werden
die herausgefiltert. Eine Klassenlehrer-Übersicht, die stattdessen genau diese fremden Einträge zur
eigenen Klasse zeigt, wäre ein eigenständiges, größeres Feature und ist bewusst zurückgestellt.
### 4.4 Wochen-/Tagesansicht ### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3 - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3