CI / build-and-test (push) Canceled after 0s
Verspätungen sind i.d.R. nicht entschuldigungsfähig - "Offene Entschuldigungen" erinnert dafür nicht mehr, stattdessen eskaliert die Mustererkennung ab 5 Verspätungen im Schuljahr zu einem Elterngespräch/Brief-Hinweis mit Wiedervorlage-Option. Die Jahresfehlquote nutzte bislang den 1. August als fest verdrahteten Schuljahresbeginn und zählte damit noch laufende Sommerferien als Schultage mit, was die Quote kurz nach Schuljahresbeginn stark verfälschte (~57% statt ~100% bei durchgehend fehlenden Schülern). Nutzt jetzt den echten WebUntis-Ferienkalender (GetHolidaysAsync, gecacht in WebUntisSettingsService). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
244 lines
16 KiB
C#
244 lines
16 KiB
C#
using LehrerApp.WebUntis;
|
|
|
|
namespace LehrerApp.Desktop.Services;
|
|
|
|
public sealed class WebUntisIntegrationException(string message) : Exception(message);
|
|
|
|
public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate);
|
|
public sealed record UntisHolidayDto(int UntisId, string Name, string? LongName, int StartDate, int EndDate);
|
|
public sealed record UntisClassDto(int UntisId, string Name, string? LongName);
|
|
public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title,
|
|
bool Active, IReadOnlyList<int> DepartmentUntisIds)
|
|
{
|
|
public string DisplayName => string.IsNullOrWhiteSpace(LongName)
|
|
? Name
|
|
: $"{ForeName} {LongName} ({Name})".Trim();
|
|
}
|
|
public sealed record UntisEntityDto(int Id, string Name, int? OriginalId, string? OriginalName, string? ExternalKey);
|
|
public sealed record UntisTimeUnitDto(string Name, int StartTime, int EndTime);
|
|
public sealed record UntisTimeGridDayDto(int Day, IReadOnlyList<UntisTimeUnitDto> TimeUnits);
|
|
public sealed record UntisTimetablePeriodDto(int Id, int Date, int StartTime, int EndTime, string? Code,
|
|
string? ActivityType, string? Info, string? LessonText, string? SubstitutionText, string? StudentGroup,
|
|
IReadOnlyList<UntisEntityDto> Classes, IReadOnlyList<UntisEntityDto> Teachers,
|
|
IReadOnlyList<UntisEntityDto> Subjects, IReadOnlyList<UntisEntityDto> Rooms);
|
|
public sealed record UntisStudentAddressDto(string? Email, string? Mobile, string? Phone, string? City,
|
|
string? PostCode, string? Street);
|
|
public sealed record UntisStudentDto(int UntisId, int? ExternKey, string ClassName, string? Name, string? LongName,
|
|
string? ForeName, string DisplayName, string? Gender, int? BirthDate, string? BirthDateRaw, int? EntryDate,
|
|
string? EntryDateRaw, int? ExitDate, string? ExitDateRaw, string? Text, string? MedicalReportDuty,
|
|
string? Schulpflicht, string? Majority, UntisStudentAddressDto Address, string? AttributeIL);
|
|
public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, IReadOnlyList<UntisStudentDto> Students);
|
|
public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int AbsentPeriods,
|
|
int UnexcusedAbsentPeriods, int AbsentMinutes, int UnexcusedAbsentMinutes, int? StartTime, int? EndTime,
|
|
string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts);
|
|
public sealed record UntisClassRegisterEventDto(string ClassName, int Date, string? Subject,
|
|
string StudentName, string? CategoryName, string? CategoryGroup, string? Text);
|
|
public sealed record UntisForeignClassRegisterEventDto(string ClassName, int Date, string? Subject,
|
|
string StudentName, string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text);
|
|
public sealed record UntisClassAbsenceEntryDto(string StudentName, int? ExternKey, string ClassName, int Date,
|
|
int AbsentPeriods, int AbsentMinutes, string? TeacherUsernames, string? Subject, string? AbsenceReason,
|
|
string? Note, int? EntryId, string? HandledOn, bool Counts, string? ExcuseNote, int? PeriodNumber,
|
|
string? Status, bool CountsAsFullDay);
|
|
|
|
/// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
|
|
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
|
|
public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettingsService settings) : IAsyncDisposable
|
|
{
|
|
private readonly SemaphoreSlim _clientGate = new(1, 1);
|
|
private WebUntisClient? _client;
|
|
|
|
public bool IsAvailable => settings.ApiIsConfigured;
|
|
|
|
public async Task ConnectAsync(WebUntisCredentials credentials, CancellationToken token = default)
|
|
{
|
|
var candidate = CreateClient(credentials);
|
|
try
|
|
{
|
|
await candidate.GetSchoolYearsAsync(token);
|
|
}
|
|
catch (Exception exception) when (IsUntisError(exception))
|
|
{
|
|
await candidate.DisposeAsync();
|
|
throw Translate(exception);
|
|
}
|
|
|
|
WebUntisClient? previous;
|
|
await _clientGate.WaitAsync(token).ConfigureAwait(false);
|
|
try { previous = _client; _client = candidate; }
|
|
finally { _clientGate.Release(); }
|
|
if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task DisconnectAsync(CancellationToken token = default)
|
|
{
|
|
WebUntisClient? previous;
|
|
await _clientGate.WaitAsync(token).ConfigureAwait(false);
|
|
try { previous = _client; _client = null; }
|
|
finally { _clientGate.Release(); }
|
|
if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
public Task<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) => ExecuteAsync(
|
|
async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(token))
|
|
.Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token);
|
|
|
|
public Task<IReadOnlyList<UntisHolidayDto>> GetHolidaysAsync(CancellationToken token = default) => ExecuteAsync(
|
|
async client => (IReadOnlyList<UntisHolidayDto>)(await client.GetHolidaysAsync(token))
|
|
.Select(x => new UntisHolidayDto(x.UntisId, x.Name, x.LongName, x.StartDate, x.EndDate)).ToList(), token);
|
|
|
|
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync(
|
|
async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token))
|
|
.Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token);
|
|
|
|
public Task<IReadOnlyList<UntisTeacherDto>> GetTeachersAsync(CancellationToken token = default) => ExecuteAsync(
|
|
async client => (IReadOnlyList<UntisTeacherDto>)(await client.GetTeachersAsync(token))
|
|
.Select(x => new UntisTeacherDto(x.UntisId, x.Name, x.ForeName, x.LongName, x.Title, x.Active,
|
|
x.DepartmentUntisIds)).ToList(), token);
|
|
|
|
public Task<IReadOnlyList<UntisTimeGridDayDto>> GetTimeGridAsync(CancellationToken token = default) => ExecuteAsync(
|
|
async client => (IReadOnlyList<UntisTimeGridDayDto>)(await client.GetTimeGridAsync(token))
|
|
.Select(x => new UntisTimeGridDayDto(x.Day,
|
|
x.TimeUnits.Select(t => new UntisTimeUnitDto(t.Name, t.StartTime, t.EndTime)).ToList())).ToList(), token);
|
|
|
|
public Task<IReadOnlyList<UntisTimetablePeriodDto>> GetTimetableAsync(int teacherId, DateOnly start,
|
|
DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
|
(IReadOnlyList<UntisTimetablePeriodDto>)(await client.GetTimetableAsync(
|
|
UntisTimetableElementType.Teacher, teacherId, Date(start), Date(end), token))
|
|
.Select(x => new UntisTimetablePeriodDto(x.Id, x.Date, x.StartTime, x.EndTime, x.Code,
|
|
x.ActivityType, x.Info, x.LessonText, x.SubstitutionText, x.StudentGroup,
|
|
Entities(x.Classes), Entities(x.Teachers), Entities(x.Subjects), Entities(x.Rooms))).ToList(), token);
|
|
|
|
public Task<UntisStudentReportDto> GetStudentsAsync(string className, CancellationToken token = default) => ExecuteAsync(
|
|
async client =>
|
|
{
|
|
var report = await client.GetStudentReportAsync(className, token);
|
|
return new UntisStudentReportDto(report.Count, report.ClassNameFilter, report.Students.Select(x =>
|
|
new UntisStudentDto(x.UntisId, x.ExternKey, x.ClassName, x.Name, x.LongName, x.ForeName,
|
|
x.DisplayName, x.Gender, x.BirthDate, x.BirthDateRaw, x.EntryDate, x.EntryDateRaw,
|
|
x.ExitDate, x.ExitDateRaw, x.Text, x.MedicalReportDuty, x.Schulpflicht, x.Majority,
|
|
new UntisStudentAddressDto(x.Address.Email, x.Address.Mobile, x.Address.Phone, x.Address.City,
|
|
x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList());
|
|
}, token);
|
|
|
|
public Task<IReadOnlyList<UntisLessonAbsenceDto>> GetLessonAbsencesAsync(int lessonId,
|
|
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
|
{
|
|
var absences = await client.GetLessonAbsencesAsync(lessonId, Date(start), Date(end), token);
|
|
return (IReadOnlyList<UntisLessonAbsenceDto>)absences.Select(x => new UntisLessonAbsenceDto(
|
|
x.StudentName, x.Date, x.AbsentPeriods, x.UnexcusedAbsentPeriods, x.AbsentMinutes,
|
|
x.UnexcusedAbsentMinutes, x.StartTime, x.EndTime, x.Reason, x.ExternKey, x.ExternKeyInParentheses,
|
|
x.HandledOn, x.Counts)).ToList();
|
|
}, token);
|
|
|
|
// "-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);
|
|
|
|
// Spiegelbild von GetOwnClassRegisterEventsAsync für das "Klassenlehrer"-Feature (siehe TODO.md):
|
|
// dieselben "-alle-"-Rohdaten, aber Einträge anderer Lehrkräfte statt der eigenen, zusätzlich auf
|
|
// die übergebene Klasse eingeschränkt (der "-alle-"-Bericht liefert sonst auch andere Klassen mit,
|
|
// sobald eine Lehrkraft mehrere Klassen unterrichtet).
|
|
public Task<IReadOnlyList<UntisForeignClassRegisterEventDto>> GetForeignClassRegisterEventsAsync(
|
|
string className, 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<UntisForeignClassRegisterEventDto>)entries
|
|
.Where(x => string.Equals(x.ClassName, className, StringComparison.OrdinalIgnoreCase)
|
|
// Nicht einfach "!=" gegen ownUsername: wäre ownUsername unbekannt (null), würde
|
|
// ein ebenfalls fehlender TeacherUsername sonst fälschlich als "eigen" erkannt
|
|
// (Equals(null,null)==true) und rausgefiltert statt als fremd angezeigt zu werden.
|
|
&& !(ownUsername is not null
|
|
&& string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase)))
|
|
.Select(x => new UntisForeignClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName,
|
|
x.TeacherUsername, x.CategoryName, x.CategoryGroup, x.Text))
|
|
.ToList();
|
|
}, token);
|
|
|
|
// Fehlzeiten der ganzen Klasse über alle Fächer, Ergänzung zu GetForeignClassRegisterEventsAsync
|
|
// für das "Klassenlehrer"-Feature. Der Bericht adressiert die Klasse über "KL"+getKlassen-ID statt
|
|
// eines Namens, siehe ResolveClassIdAsync.
|
|
public Task<IReadOnlyList<UntisClassAbsenceEntryDto>> GetClassAbsencesAsync(string className,
|
|
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
|
{
|
|
var classId = await ResolveClassIdAsync(client, className, token);
|
|
var entries = await client.GetClassAbsencesAsync(classId, Date(start), Date(end), token);
|
|
return (IReadOnlyList<UntisClassAbsenceEntryDto>)entries.Select(x => new UntisClassAbsenceEntryDto(
|
|
x.StudentName, x.ExternKey, x.ClassName, x.Date, x.AbsentPeriods, x.AbsentMinutes,
|
|
x.TeacherUsernames, x.Subject, x.AbsenceReason, x.Note, x.EntryId, x.HandledOn, x.Counts,
|
|
x.ExcuseNote, x.PeriodNumber, x.Status, x.CountsAsFullDay))
|
|
.ToList();
|
|
}, token);
|
|
|
|
// "KL" + getKlassen-ID, dasselbe Element-Kürzel-Schema wie kl/te/su/ro bei anderen Berichten
|
|
// (siehe Entities(...) unten). Unbestätigt (kein offizieller Report-Parameter dokumentiert), aber
|
|
// aus einem echten Browser-Request rückgeschlossen - schlägt die Zuordnung fehl, liefert der
|
|
// Bericht erkennbar leere/falsche Daten statt eines stillen Fehlers.
|
|
private static async Task<string> ResolveClassIdAsync(WebUntisClient client, string className,
|
|
CancellationToken token)
|
|
{
|
|
var years = await client.GetSchoolYearsAsync(token);
|
|
var today = Date(DateOnly.FromDateTime(DateTime.Today));
|
|
var year = years.FirstOrDefault(y => y.StartDate <= today && y.EndDate >= today)
|
|
?? years.OrderByDescending(y => y.StartDate).FirstOrDefault()
|
|
?? throw new WebUntisIntegrationException("WebUntis hat kein Schuljahr geliefert.");
|
|
var classes = await client.GetClassesAsync(year.UntisId, token);
|
|
var matches = classes.Where(c => string.Equals(c.Name, className, StringComparison.OrdinalIgnoreCase)).ToList();
|
|
return matches.Count switch
|
|
{
|
|
1 => $"KL{matches[0].UntisId}",
|
|
0 => throw new WebUntisIntegrationException($"WebUntis kennt keine Klasse namens \"{className}\"."),
|
|
_ => throw new WebUntisIntegrationException(
|
|
$"Mehrere WebUntis-Klassen heißen \"{className}\" - bitte manuell prüfen."),
|
|
};
|
|
}
|
|
|
|
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
|
|
{
|
|
try { return await operation(await GetClientAsync(token)); }
|
|
catch (Exception exception) when (IsUntisError(exception)) { throw Translate(exception); }
|
|
}
|
|
|
|
private async Task<WebUntisClient> GetClientAsync(CancellationToken token)
|
|
{
|
|
if (_client is not null) return _client;
|
|
await _clientGate.WaitAsync(token);
|
|
try
|
|
{
|
|
if (_client is not null) return _client;
|
|
var credentials = settings.GetApiCredentials()
|
|
?? throw new WebUntisIntegrationException("Bitte zuerst die WebUntis-Anmeldedaten einrichten.");
|
|
return _client = CreateClient(credentials);
|
|
}
|
|
finally { _clientGate.Release(); }
|
|
}
|
|
|
|
private WebUntisClient CreateClient(WebUntisCredentials credentials) => new(http, new WebUntisOptions
|
|
{
|
|
School = credentials.School, Host = credentials.Host, Username = credentials.Username,
|
|
Password = credentials.Password, Client = "LehrerApp-Desktop", SessionIdleTimeoutMinutes = 10,
|
|
});
|
|
|
|
private static int Date(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
|
|
private static IReadOnlyList<UntisEntityDto> Entities(IReadOnlyList<UntisEntity> values) => values
|
|
.Select(x => new UntisEntityDto(x.Id, x.Name, x.OriginalId, x.OriginalName, x.ExternalKey)).ToList();
|
|
private static bool IsUntisError(Exception exception) => exception is WebUntisException
|
|
or WebUntisConfigurationException or InvalidDataException;
|
|
private static WebUntisIntegrationException Translate(Exception exception) =>
|
|
new(exception.Message);
|
|
|
|
public async ValueTask DisposeAsync() => await DisconnectAsync().ConfigureAwait(false);
|
|
}
|