Klassenbucheinträge abgleich und poll
This commit is contained in:
@@ -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">
|
||||||
|
<Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick"
|
||||||
VerticalAlignment="Center"/>
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user