Untis-API: Fehlzeiten abgleich
This commit is contained in:
@@ -20,6 +20,12 @@ public class LearningGroup
|
|||||||
/// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus.
|
/// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool RequiresLessonPlanning { get; set; } = true;
|
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 CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ public sealed class GroupRolloverService(
|
|||||||
IsActive = true,
|
IsActive = true,
|
||||||
IsOwnClass = source.IsOwnClass,
|
IsOwnClass = source.IsOwnClass,
|
||||||
IsDifferentiated = source.IsDifferentiated,
|
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;
|
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? EntryDateRaw, int? ExitDate, string? ExitDateRaw, string? Text, string? MedicalReportDuty,
|
||||||
string? Schulpflicht, string? Majority, UntisStudentAddressDto Address, string? AttributeIL);
|
string? Schulpflicht, string? Majority, UntisStudentAddressDto Address, string? AttributeIL);
|
||||||
public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, IReadOnlyList<UntisStudentDto> Students);
|
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,
|
public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int AbsentPeriods,
|
||||||
bool Checked, string? AbsenceReason, string? ExcuseStatus, int? SubjectId, IReadOnlyList<int> TeacherIds,
|
int UnexcusedAbsentPeriods, int AbsentMinutes, int UnexcusedAbsentMinutes, int? StartTime, int? EndTime,
|
||||||
string? StudentGroup);
|
string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts);
|
||||||
|
|
||||||
/// <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>
|
||||||
@@ -107,13 +107,14 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
|
|||||||
x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList());
|
x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList());
|
||||||
}, token);
|
}, token);
|
||||||
|
|
||||||
public Task<IReadOnlyList<UntisStudentAbsenceDto>> GetAbsencesAsync(DateOnly start, DateOnly end,
|
public Task<IReadOnlyList<UntisLessonAbsenceDto>> GetLessonAbsencesAsync(int lessonId,
|
||||||
CancellationToken token = default) => ExecuteAsync(async client =>
|
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
||||||
{
|
{
|
||||||
var absences = await client.GetAbsencesAsync(Date(start), Date(end), token);
|
var absences = await client.GetLessonAbsencesAsync(lessonId, Date(start), Date(end), token);
|
||||||
return (IReadOnlyList<UntisStudentAbsenceDto>)absences.Select(x => new UntisStudentAbsenceDto(
|
return (IReadOnlyList<UntisLessonAbsenceDto>)absences.Select(x => new UntisLessonAbsenceDto(
|
||||||
x.StudentKey, x.Date, x.StartTime, x.EndTime, x.AbsentMinutes, x.Checked, x.AbsenceReason,
|
x.StudentName, x.Date, x.AbsentPeriods, x.UnexcusedAbsentPeriods, x.AbsentMinutes,
|
||||||
x.ExcuseStatus, x.SubjectId, x.TeacherIds, x.StudentGroup)).ToList();
|
x.UnexcusedAbsentMinutes, x.StartTime, x.EndTime, x.Reason, x.ExternKey, x.ExternKeyInParentheses,
|
||||||
|
x.HandledOn, x.Counts)).ToList();
|
||||||
}, token);
|
}, 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)
|
||||||
|
|||||||
@@ -878,6 +878,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _isOwnClass;
|
[ObservableProperty] private bool _isOwnClass;
|
||||||
[ObservableProperty] private bool _isDifferentiated;
|
[ObservableProperty] private bool _isDifferentiated;
|
||||||
[ObservableProperty] private bool _requiresLessonPlanning = true;
|
[ObservableProperty] private bool _requiresLessonPlanning = true;
|
||||||
|
[ObservableProperty] private int? _webUntisLessonId;
|
||||||
[ObservableProperty] private string _nameError = "";
|
[ObservableProperty] private string _nameError = "";
|
||||||
[ObservableProperty] private string _gradeLevelError = "";
|
[ObservableProperty] private string _gradeLevelError = "";
|
||||||
|
|
||||||
@@ -917,6 +918,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
IsOwnClass = group.IsOwnClass;
|
IsOwnClass = group.IsOwnClass;
|
||||||
IsDifferentiated = group.IsDifferentiated;
|
IsDifferentiated = group.IsDifferentiated;
|
||||||
RequiresLessonPlanning = group.RequiresLessonPlanning;
|
RequiresLessonPlanning = group.RequiresLessonPlanning;
|
||||||
|
WebUntisLessonId = group.WebUntisLessonId;
|
||||||
OnPropertyChanged(nameof(DialogTitle));
|
OnPropertyChanged(nameof(DialogTitle));
|
||||||
OnPropertyChanged(nameof(SaveButtonText));
|
OnPropertyChanged(nameof(SaveButtonText));
|
||||||
}
|
}
|
||||||
@@ -963,6 +965,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
Result.IsOwnClass = IsOwnClass;
|
Result.IsOwnClass = IsOwnClass;
|
||||||
Result.IsDifferentiated = IsDifferentiated;
|
Result.IsDifferentiated = IsDifferentiated;
|
||||||
Result.RequiresLessonPlanning = RequiresLessonPlanning;
|
Result.RequiresLessonPlanning = RequiresLessonPlanning;
|
||||||
|
Result.WebUntisLessonId = WebUntisLessonId;
|
||||||
_groups.Save(Result);
|
_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}"
|
<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 "Ungeplante Stunden" aus."/>
|
ToolTip.Tip="Deaktivieren für Gruppen ohne inhaltlichen Verlaufsplan (z.B. Klassenrat, Willkommenskreis) - blendet für diese Gruppe die Dashboard-Erinnerung "Ungeplante Stunden" 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 -> Mein Unterricht -> Berichte-Symbol der Zeile). Wird von WebUntis pro Schuljahr neu vergeben und muss deshalb jedes Schuljahr aktualisiert werden."/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
IsEnabled="{Binding IsEditable}"/>
|
IsEnabled="{Binding IsEditable}"/>
|
||||||
<Button Content="↻ Aus WebUntis…" Click="OnImportParticipantsFromWebUntisClick"
|
<Button Content="↻ Aus WebUntis…" Click="OnImportParticipantsFromWebUntisClick"
|
||||||
IsEnabled="{Binding IsEditable}"/>
|
IsEnabled="{Binding IsEditable}"/>
|
||||||
<Button Content="Fehlzeiten abgleichen…" Click="OnCompareWebUntisAbsencesClick"/>
|
<Button Content="Fehlzeiten je Unterricht…" Click="OnCompareWebUntisLessonAbsencesClick"/>
|
||||||
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
|
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
|
||||||
Command="{Binding WithdrawStudentCommand}"
|
Command="{Binding WithdrawStudentCommand}"
|
||||||
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
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;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
if (owner is null || DataContext is not GroupDetailViewModel vm || vm.Group is null) return;
|
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<WebUntisIntegrationService>(),
|
||||||
App.Services.GetRequiredService<IStudentRepository>(),
|
App.Services.GetRequiredService<IStudentRepository>(),
|
||||||
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||||
App.Services.GetRequiredService<IParticipationRepository>());
|
App.Services.GetRequiredService<IParticipationRepository>());
|
||||||
await new WebUntisAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
|
await new WebUntisLessonAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
|
||||||
vm.ParticipationTab.RefreshCurrentGrid();
|
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>
|
||||||
+2
-2
@@ -3,8 +3,8 @@ using Avalonia.Interactivity;
|
|||||||
|
|
||||||
namespace LehrerApp.Desktop.Views.Groups;
|
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();
|
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ public sealed class GroupRolloverServiceTests
|
|||||||
{
|
{
|
||||||
Name = "8a", SchoolYear = "2025/26", GradeLevel = 8,
|
Name = "8a", SchoolYear = "2025/26", GradeLevel = 8,
|
||||||
Type = GroupType.Class, SubjectId = Guid.NewGuid(), GradingSystem = GradingSystem.Grades1To6,
|
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
|
var oldMembership = new GroupMembership
|
||||||
{
|
{
|
||||||
@@ -38,6 +38,7 @@ public sealed class GroupRolloverServiceTests
|
|||||||
Assert.Equal(source.HoursPerWeek, target.HoursPerWeek);
|
Assert.Equal(source.HoursPerWeek, target.HoursPerWeek);
|
||||||
Assert.True(target.IsOwnClass);
|
Assert.True(target.IsOwnClass);
|
||||||
Assert.True(target.IsDifferentiated);
|
Assert.True(target.IsDifferentiated);
|
||||||
|
Assert.Null(target.WebUntisLessonId);
|
||||||
Assert.False(source.IsActive);
|
Assert.False(source.IsActive);
|
||||||
|
|
||||||
var copied = Assert.Single(memberships.GetByGroup(target.Id));
|
var copied = Assert.Single(memberships.GetByGroup(target.Id));
|
||||||
|
|||||||
@@ -143,16 +143,10 @@ public sealed class WebUntisClientTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FehlzeitenUndKlassenbuch_WerdenTypisiertAbgerufen()
|
public async Task Klassenbuch_WirdTypisiertAbgerufen()
|
||||||
{
|
{
|
||||||
var handler = new QueueHandler(
|
var handler = new QueueHandler(
|
||||||
Json("{\"result\":{\"sessionId\":\"s\"}}"),
|
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\"," +
|
Json("{\"result\":[{\"studentid\":9001,\"surname\":\"Müller\",\"forname\":\"Ada\"," +
|
||||||
"\"date\":20260902,\"subject\":\"MA\",\"categoryId\":3,\"reason\":\"Material\"," +
|
"\"date\":20260902,\"subject\":\"MA\",\"categoryId\":3,\"reason\":\"Material\"," +
|
||||||
"\"text\":\"Buch vergessen\"}]}"),
|
"\"text\":\"Buch vergessen\"}]}"),
|
||||||
@@ -162,25 +156,18 @@ public sealed class WebUntisClientTests
|
|||||||
Json("{\"result\":{}}"));
|
Json("{\"result\":{}}"));
|
||||||
var client = CreateClient(handler);
|
var client = CreateClient(handler);
|
||||||
|
|
||||||
var absences = await client.GetAbsencesAsync(20260801, 20270731, CancellationToken.None);
|
|
||||||
var entries = await client.GetClassRegisterEntriesAsync(17, 20260801, 20270731,
|
var entries = await client.GetClassRegisterEntriesAsync(17, 20260801, 20270731,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
var categories = await client.GetClassRegisterCategoriesAsync(CancellationToken.None);
|
var categories = await client.GetClassRegisterCategoriesAsync(CancellationToken.None);
|
||||||
var groups = await client.GetClassRegisterCategoryGroupsAsync(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);
|
var entry = Assert.Single(entries);
|
||||||
Assert.Equal("Ada Müller", entry.DisplayName);
|
Assert.Equal("Ada Müller", entry.DisplayName);
|
||||||
Assert.Equal("Buch vergessen", entry.Text);
|
Assert.Equal("Buch vergessen", entry.Text);
|
||||||
Assert.Equal(3, Assert.Single(categories).Id);
|
Assert.Equal(3, Assert.Single(categories).Id);
|
||||||
Assert.Equal("Organisation", Assert.Single(groups).Name);
|
Assert.Equal("Organisation", Assert.Single(groups).Name);
|
||||||
Assert.Contains("\"method\":\"getTimetableWithAbsences\"", handler.Requests[1].Body);
|
Assert.Contains("\"method\":\"getClassregEvents\"", handler.Requests[1].Body);
|
||||||
Assert.Contains("\"method\":\"getClassregEvents\"", handler.Requests[2].Body);
|
Assert.Contains("\"id\":17,\"type\":5", handler.Requests[1].Body);
|
||||||
Assert.Contains("\"id\":17,\"type\":5", handler.Requests[2].Body);
|
|
||||||
Assert.Single(handler.Requests,
|
Assert.Single(handler.Requests,
|
||||||
request => request.Body.Contains("\"method\":\"authenticate\""));
|
request => request.Body.Contains("\"method\":\"authenticate\""));
|
||||||
|
|
||||||
@@ -188,6 +175,40 @@ public sealed class WebUntisClientTests
|
|||||||
Assert.Contains("\"method\":\"logout\"", handler.Requests[^1].Body);
|
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(
|
private static WebUntisClient CreateClient(HttpMessageHandler handler) => new(
|
||||||
new HttpClient(handler),
|
new HttpClient(handler),
|
||||||
new WebUntisOptions
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ public sealed class WebUntisClient : IAsyncDisposable
|
|||||||
private readonly SemaphoreSlim _sessionGate = new(1, 1);
|
private readonly SemaphoreSlim _sessionGate = new(1, 1);
|
||||||
private readonly Timer _sessionExpiryTimer;
|
private readonly Timer _sessionExpiryTimer;
|
||||||
private string? _sessionId;
|
private string? _sessionId;
|
||||||
|
private int? _myTeacherUntisId;
|
||||||
private DateTimeOffset _sessionExpiresAt;
|
private DateTimeOffset _sessionExpiresAt;
|
||||||
private int _activeRequests;
|
private int _activeRequests;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
@@ -114,7 +115,8 @@ public sealed class WebUntisClient : IAsyncDisposable
|
|||||||
public Task<UntisStudentReport> GetStudentReportAsync(string? classNameFilter,
|
public Task<UntisStudentReport> GetStudentReportAsync(string? classNameFilter,
|
||||||
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
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);
|
?? await PollReportAsync(sessionId, cancellationToken);
|
||||||
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
|
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
|
||||||
var allStudents = WebUntisStudentReportParser.Parse(reportText);
|
var allStudents = WebUntisStudentReportParser.Parse(reportText);
|
||||||
@@ -126,6 +128,26 @@ public sealed class WebUntisClient : IAsyncDisposable
|
|||||||
return new UntisStudentReport(students.Count, normalizedFilter, students);
|
return new UntisStudentReport(students.Count, normalizedFilter, students);
|
||||||
}, cancellationToken);
|
}, 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,
|
public Task<IReadOnlyList<UntisSubstitution>> GetSubstitutionsAsync(int startDate, int endDate,
|
||||||
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||||
{
|
{
|
||||||
@@ -178,35 +200,6 @@ public sealed class WebUntisClient : IAsyncDisposable
|
|||||||
Entities(entry, "ro"), entry.Clone())).ToList();
|
Entities(entry, "ro"), entry.Clone())).ToList();
|
||||||
}, cancellationToken);
|
}, 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,
|
public Task<IReadOnlyList<UntisClassRegisterEntry>> GetClassRegisterEntriesAsync(int studentId,
|
||||||
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
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 " +
|
$"WebUntis-Login fehlgeschlagen: {configuration.Host} hat für die Schulkennung " +
|
||||||
$"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " +
|
$"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " +
|
||||||
"Schulkennung mit der WebUntis-Anmeldeseite vergleichen.");
|
"Schulkennung mit der WebUntis-Anmeldeseite vergleichen.");
|
||||||
|
_myTeacherUntisId = OptionalInt(result, "personId");
|
||||||
}
|
}
|
||||||
|
|
||||||
_activeRequests++;
|
_activeRequests++;
|
||||||
@@ -398,10 +392,10 @@ public sealed class WebUntisClient : IAsyncDisposable
|
|||||||
return result.Clone();
|
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" +
|
var uri = $"https://{GetConfiguration().Host}/WebUntis/reports.do?{query}";
|
||||||
"?name=Student&format=csv&klasseId=-1&studentsForDate=true&context=klasseId";
|
|
||||||
using var request = ReportRequest(uri, sessionId, acceptJson: true);
|
using var request = ReportRequest(uri, sessionId, acceptJson: true);
|
||||||
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
|
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
|
||||||
var payload = await ReadJsonAsync(response, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,20 +132,20 @@ public sealed record UntisTimetablePeriod(
|
|||||||
IReadOnlyList<UntisEntity> Rooms,
|
IReadOnlyList<UntisEntity> Rooms,
|
||||||
JsonElement Raw);
|
JsonElement Raw);
|
||||||
|
|
||||||
public sealed record UntisStudentAbsence(
|
public sealed record UntisLessonAbsence(
|
||||||
int StudentKey,
|
string StudentName,
|
||||||
int Date,
|
int Date,
|
||||||
int StartTime,
|
int AbsentPeriods,
|
||||||
int EndTime,
|
int UnexcusedAbsentPeriods,
|
||||||
int AbsentMinutes,
|
int AbsentMinutes,
|
||||||
bool Checked,
|
int UnexcusedAbsentMinutes,
|
||||||
string? AbsenceReason,
|
int? StartTime,
|
||||||
string? ExcuseStatus,
|
int? EndTime,
|
||||||
int? SubjectId,
|
string? Reason,
|
||||||
IReadOnlyList<int> TeacherIds,
|
int? ExternKey,
|
||||||
string? StudentGroup,
|
bool ExternKeyInParentheses,
|
||||||
JsonElement Raw);
|
string? HandledOn,
|
||||||
|
bool Counts);
|
||||||
|
|
||||||
public sealed record UntisClassRegisterEntry(
|
public sealed record UntisClassRegisterEntry(
|
||||||
int? StudentKey,
|
int? StudentKey,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace LehrerApp.WebUntis;
|
namespace LehrerApp.WebUntis;
|
||||||
|
|
||||||
@@ -7,7 +6,7 @@ public static class WebUntisStudentReportParser
|
|||||||
{
|
{
|
||||||
public static IReadOnlyList<UntisStudent> Parse(string content)
|
public static IReadOnlyList<UntisStudent> Parse(string content)
|
||||||
{
|
{
|
||||||
var rows = ParseSeparatedRows(content, '\t');
|
var rows = TabSeparatedTextReader.ParseRows(content, '\t');
|
||||||
if (rows.Count == 0) return [];
|
if (rows.Count == 0) return [];
|
||||||
|
|
||||||
var headers = rows[0]
|
var headers = rows[0]
|
||||||
@@ -63,55 +62,6 @@ public static class WebUntisStudentReportParser
|
|||||||
return result;
|
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) =>
|
private static string? Get(IReadOnlyDictionary<string, string?> values, string key) =>
|
||||||
values.TryGetValue(key, out var value) ? value : null;
|
values.TryGetValue(key, out var value) ? value : null;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
eine lokale `ParticipationSession` dieses Kurses existiert, können als offen, entschuldigt oder
|
||||||
unentschuldigt übernommen werden; fremde/ganztägige Abwesenheiten erzeugen keine lokale Stunde.
|
unentschuldigt übernommen werden; fremde/ganztä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
|
### 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
|
||||||
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
|
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
|
||||||
|
|||||||
Reference in New Issue
Block a user