Compare commits
2
Commits
34a9fdf73b
...
17475a781f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17475a781f | ||
|
|
1db16f8b69 |
@@ -100,11 +100,12 @@ public class App : Application
|
||||
|
||||
private static void DisposeServices()
|
||||
{
|
||||
if (_serviceProvider is null) return;
|
||||
var serviceProvider = Interlocked.Exchange(ref _serviceProvider, null);
|
||||
if (serviceProvider is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
_serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
||||
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -112,8 +113,19 @@ public class App : Application
|
||||
}
|
||||
finally
|
||||
{
|
||||
_serviceProvider.Dispose();
|
||||
_serviceProvider = null;
|
||||
// Der Exit-Handler läuft synchron auf dem Avalonia-UI-Thread. Die asynchrone
|
||||
// Entsorgung darf dort nicht mit GetResult() gestartet werden: Fortsetzungen aus
|
||||
// WebUntis/HttpClient könnten sonst auf den blockierten UI-Kontext zurückwarten.
|
||||
try
|
||||
{
|
||||
Task.Run(async () =>
|
||||
await serviceProvider.DisposeAsync().ConfigureAwait(false))
|
||||
.GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppBootstrapper.Logger.Error("Dienste konnten beim Beenden nicht vollständig freigegeben werden.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,6 @@ public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, I
|
||||
public sealed record UntisStudentAbsenceDto(int StudentKey, int Date, int StartTime, int EndTime, int AbsentMinutes,
|
||||
bool Checked, string? AbsenceReason, string? ExcuseStatus, int? SubjectId, IReadOnlyList<int> TeacherIds,
|
||||
string? StudentGroup);
|
||||
public sealed record UntisStudentAbsenceReportDto(int StudentKey, int StartDate, int EndDate, int EntryCount,
|
||||
int AbsentMinutes, IReadOnlyList<UntisStudentAbsenceDto> Absences);
|
||||
|
||||
/// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
|
||||
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
|
||||
@@ -56,19 +54,19 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
|
||||
}
|
||||
|
||||
WebUntisClient? previous;
|
||||
await _clientGate.WaitAsync(token);
|
||||
await _clientGate.WaitAsync(token).ConfigureAwait(false);
|
||||
try { previous = _client; _client = candidate; }
|
||||
finally { _clientGate.Release(); }
|
||||
if (previous is not null) await previous.DisposeAsync();
|
||||
if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(CancellationToken token = default)
|
||||
{
|
||||
WebUntisClient? previous;
|
||||
await _clientGate.WaitAsync(token);
|
||||
await _clientGate.WaitAsync(token).ConfigureAwait(false);
|
||||
try { previous = _client; _client = null; }
|
||||
finally { _clientGate.Release(); }
|
||||
if (previous is not null) await previous.DisposeAsync();
|
||||
if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) => ExecuteAsync(
|
||||
@@ -109,14 +107,13 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
|
||||
x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList());
|
||||
}, token);
|
||||
|
||||
public Task<UntisStudentAbsenceReportDto> GetAbsencesAsync(int studentKey, DateOnly start, DateOnly end,
|
||||
public Task<IReadOnlyList<UntisStudentAbsenceDto>> GetAbsencesAsync(DateOnly start, DateOnly end,
|
||||
CancellationToken token = default) => ExecuteAsync(async client =>
|
||||
{
|
||||
var report = await client.GetStudentAbsencesAsync(studentKey, Date(start), Date(end), token);
|
||||
return new UntisStudentAbsenceReportDto(report.StudentKey, report.StartDate, report.EndDate,
|
||||
report.EntryCount, report.AbsentMinutes, report.Absences.Select(x => new UntisStudentAbsenceDto(
|
||||
x.StudentKey, x.Date, x.StartTime, x.EndTime, x.AbsentMinutes, x.Checked, x.AbsenceReason,
|
||||
x.ExcuseStatus, x.SubjectId, x.TeacherIds, x.StudentGroup)).ToList());
|
||||
var absences = await client.GetAbsencesAsync(Date(start), Date(end), token);
|
||||
return (IReadOnlyList<UntisStudentAbsenceDto>)absences.Select(x => new UntisStudentAbsenceDto(
|
||||
x.StudentKey, x.Date, x.StartTime, x.EndTime, x.AbsentMinutes, x.Checked, x.AbsenceReason,
|
||||
x.ExcuseStatus, x.SubjectId, x.TeacherIds, x.StudentGroup)).ToList();
|
||||
}, token);
|
||||
|
||||
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
|
||||
@@ -153,5 +150,5 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
|
||||
private static WebUntisIntegrationException Translate(Exception exception) =>
|
||||
new(exception.Message);
|
||||
|
||||
public async ValueTask DisposeAsync() => await DisconnectAsync();
|
||||
public async ValueTask DisposeAsync() => await DisconnectAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
@@ -60,16 +59,14 @@ public partial class WebUntisAbsenceComparisonViewModel : ObservableObject
|
||||
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 loaded = new ConcurrentBag<(Student Student, UntisStudentAbsenceDto Absence)>();
|
||||
var linked = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
|
||||
.Where(x => x.Key is not null).ToList();
|
||||
await Parallel.ForEachAsync(linked, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (item, token) =>
|
||||
{
|
||||
var report = await _untis.GetAbsencesAsync(item.Key!.Value, start, end, token);
|
||||
foreach (var absence in report.Absences) loaded.Add((item.Student, absence));
|
||||
});
|
||||
.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.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);
|
||||
|
||||
@@ -13,6 +13,7 @@ using LehrerApp.Desktop.Views.Shared;
|
||||
using LehrerApp.Desktop.Views.Students;
|
||||
using LehrerApp.Desktop.Views.Workload;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
@@ -174,7 +175,7 @@ public partial class GroupDetailView : UserControl
|
||||
var values = new[]
|
||||
{
|
||||
student.LongName ?? student.Name, student.ForeName, student.Gender,
|
||||
student.BirthDate?.ToString() ?? student.BirthDateRaw, student.ClassName,
|
||||
FormatBirthDate(student), student.ClassName,
|
||||
student.ExternKey.ToString(), student.Address.Email, student.Address.Mobile,
|
||||
student.Address.Phone, student.Address.City, student.Address.PostCode, student.Address.Street,
|
||||
};
|
||||
@@ -183,6 +184,17 @@ public partial class GroupDetailView : UserControl
|
||||
return new ImportFile("webuntis-students.csv", Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
}
|
||||
|
||||
private static string? FormatBirthDate(UntisStudentDto student)
|
||||
{
|
||||
if (student.BirthDate is not { } normalizedDate) return student.BirthDateRaw;
|
||||
|
||||
var value = normalizedDate.ToString("D8", CultureInfo.InvariantCulture);
|
||||
return DateOnly.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var date)
|
||||
? date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)
|
||||
: student.BirthDateRaw;
|
||||
}
|
||||
|
||||
private static string SafeTsv(string? value) => (value ?? "").Replace('\t', ' ')
|
||||
.Replace('\r', ' ').Replace('\n', ' ');
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using LehrerApp.WebUntis;
|
||||
using Xunit;
|
||||
@@ -16,7 +17,7 @@ public sealed class WebUntisClientTests
|
||||
"2\t20\t8b\tMeier\tBerta\r\n");
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"jsonrpc\":\"2.0\",\"result\":{\"sessionId\":\"session-1\"}}"),
|
||||
Json("{\"data\":{\"finished\":true,\"reportParams\":\"foo=bar\"}}"),
|
||||
Json("{\"data\":{\"finished\":true,\"error\":false,\"reportParams\":\"foo=bar\"}}"),
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(latin1),
|
||||
@@ -84,6 +85,20 @@ public sealed class WebUntisClientTests
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_BegrenztEinenNichtAntwortendenLogout()
|
||||
{
|
||||
var handler = new HangingLogoutHandler();
|
||||
var client = CreateClient(handler);
|
||||
await client.GetSchoolYearsAsync(CancellationToken.None);
|
||||
|
||||
var elapsed = Stopwatch.StartNew();
|
||||
await client.DisposeAsync();
|
||||
|
||||
Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(5));
|
||||
Assert.True(handler.LogoutWasCancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VollstaendigeLoginUrl_TrenntRegionalenServerUndSchulkennung()
|
||||
{
|
||||
@@ -128,7 +143,7 @@ public sealed class WebUntisClientTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FehlzeitenUndKlassenbuch_WerdenNachSchuelerAbgerufenUndTypisiert()
|
||||
public async Task FehlzeitenUndKlassenbuch_WerdenTypisiertAbgerufen()
|
||||
{
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"s\"}}"),
|
||||
@@ -147,15 +162,15 @@ public sealed class WebUntisClientTests
|
||||
Json("{\"result\":{}}"));
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var report = await client.GetStudentAbsencesAsync(9001, 20260801, 20270731,
|
||||
CancellationToken.None);
|
||||
var absences = await client.GetAbsencesAsync(20260801, 20270731, CancellationToken.None);
|
||||
var entries = await client.GetClassRegisterEntriesAsync(17, 20260801, 20270731,
|
||||
CancellationToken.None);
|
||||
var categories = await client.GetClassRegisterCategoriesAsync(CancellationToken.None);
|
||||
var groups = await client.GetClassRegisterCategoryGroupsAsync(CancellationToken.None);
|
||||
|
||||
var absence = Assert.Single(report.Absences);
|
||||
Assert.Equal(45, report.AbsentMinutes);
|
||||
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);
|
||||
@@ -207,5 +222,33 @@ public sealed class WebUntisClientTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class HangingLogoutHandler : HttpMessageHandler
|
||||
{
|
||||
public bool LogoutWasCancelled { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var body = request.Content is null
|
||||
? ""
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (body.Contains("\"method\":\"authenticate\""))
|
||||
return Json("{\"result\":{\"sessionId\":\"s\"}}");
|
||||
if (body.Contains("\"method\":\"getSchoolyears\""))
|
||||
return Json("{\"result\":[]}");
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
throw new InvalidOperationException("Der simulierte Logout darf nicht regulär enden.");
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
LogoutWasCancelled = true;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CapturedRequest(string Uri, string Body, string Cookie);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,20 @@ public sealed class WebUntisStudentReportParserTests
|
||||
Assert.Equal("MUS\"T", student.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_AkzeptiertIsoDatumswerteOhneTrennzeichen()
|
||||
{
|
||||
const string report = "id\texternKey\tklasse.name\tbirthDate\tentryDate\texitDate\r\n" +
|
||||
"17\t9001\t10a\t20100203\t20210801\t20270731\r\n";
|
||||
|
||||
var student = Assert.Single(WebUntisStudentReportParser.Parse(report));
|
||||
|
||||
Assert.Equal(20100203, student.BirthDate);
|
||||
Assert.Equal("20100203", student.BirthDateRaw);
|
||||
Assert.Equal(20210801, student.EntryDate);
|
||||
Assert.Equal(20270731, student.ExitDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_LehntUngueltigePflichtIdAb()
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
|
||||
private static readonly TimeSpan LogoutTimeout = TimeSpan.FromSeconds(2);
|
||||
private readonly HttpClient _http;
|
||||
private readonly WebUntisOptions _options;
|
||||
private readonly SemaphoreSlim _sessionGate = new(1, 1);
|
||||
@@ -177,7 +178,7 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
Entities(entry, "ro"), entry.Clone())).ToList();
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<UntisStudentAbsenceReport> GetStudentAbsencesAsync(int studentKey, int startDate, int endDate,
|
||||
public Task<IReadOnlyList<UntisStudentAbsence>> GetAbsencesAsync(int startDate, int endDate,
|
||||
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var result = await RpcAsync("getTimetableWithAbsences", new
|
||||
@@ -187,8 +188,7 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
if (!TryProperty(result, "periodsWithAbsences", out var periods) || periods.ValueKind != JsonValueKind.Array)
|
||||
throw new WebUntisException("WebUntis hat keine gültigen Fehlzeiten-Daten geliefert.");
|
||||
|
||||
var absences = periods.EnumerateArray()
|
||||
.Where(entry => OptionalInt(entry, "studentId") == studentKey)
|
||||
return (IReadOnlyList<UntisStudentAbsence>)periods.EnumerateArray()
|
||||
.Select(entry => new UntisStudentAbsence(
|
||||
RequiredInt(entry, "studentId"),
|
||||
RequiredInt(entry, "date"),
|
||||
@@ -205,9 +205,6 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
.OrderBy(entry => entry.Date)
|
||||
.ThenBy(entry => entry.StartTime)
|
||||
.ToList();
|
||||
|
||||
return new UntisStudentAbsenceReport(studentKey, startDate, endDate, absences.Count,
|
||||
absences.Sum(entry => entry.AbsentMinutes), absences);
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<UntisClassRegisterEntry>> GetClassRegisterEntriesAsync(int studentId,
|
||||
@@ -346,14 +343,15 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
|
||||
private async Task TryLogoutAsync(string sessionId)
|
||||
{
|
||||
try { await RpcAsync("logout", new { }, sessionId, CancellationToken.None); }
|
||||
using var timeout = new CancellationTokenSource(LogoutTimeout);
|
||||
try { await RpcAsync("logout", new { }, sessionId, timeout.Token).ConfigureAwait(false); }
|
||||
catch { /* Ein fehlgeschlagener Logout darf Abrufe und Shutdown nicht fehlschlagen lassen. */ }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
string? sessionId;
|
||||
await _sessionGate.WaitAsync();
|
||||
await _sessionGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -368,8 +366,8 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
|
||||
// Außerhalb des Gates warten: ein bereits laufender Timer-Callback könnte selbst gerade
|
||||
// auf dieses Gate warten und würde sonst den Shutdown blockieren.
|
||||
await _sessionExpiryTimer.DisposeAsync();
|
||||
if (sessionId is not null) await TryLogoutAsync(sessionId);
|
||||
await _sessionExpiryTimer.DisposeAsync().ConfigureAwait(false);
|
||||
if (sessionId is not null) await TryLogoutAsync(sessionId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private TimeSpan SessionIdleTimeout =>
|
||||
@@ -417,7 +415,7 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
}
|
||||
|
||||
if (!TryProperty(payload, "data", out var data) || data.ValueKind != JsonValueKind.Object ||
|
||||
(TryProperty(data, "error", out var reportError) && reportError.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined))
|
||||
(TryProperty(data, "error", out var reportError) && HasErrorValue(reportError)))
|
||||
throw new WebUntisException($"Report-Anfrage fehlgeschlagen: {ErrorMessage(payload, response)}");
|
||||
if (!OptionalBoolean(data, "finished")) return null;
|
||||
return ReportData.From(data);
|
||||
@@ -646,6 +644,19 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
(string.Equals(value.GetString(), "true", StringComparison.OrdinalIgnoreCase) || value.GetString() == "1");
|
||||
}
|
||||
|
||||
private static bool HasErrorValue(JsonElement value) => value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null or JsonValueKind.Undefined or JsonValueKind.False => false,
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.String => !string.IsNullOrWhiteSpace(value.GetString()) &&
|
||||
!string.Equals(value.GetString(), "false", StringComparison.OrdinalIgnoreCase) &&
|
||||
value.GetString() != "0",
|
||||
JsonValueKind.Number => !value.TryGetInt32(out var number) || number != 0,
|
||||
JsonValueKind.Array => value.GetArrayLength() > 0,
|
||||
JsonValueKind.Object => value.EnumerateObject().Any(),
|
||||
_ => true,
|
||||
};
|
||||
|
||||
private static string ErrorMessage(JsonElement payload, HttpResponseMessage response)
|
||||
{
|
||||
if (payload.ValueKind == JsonValueKind.String) return payload.GetString() ?? "Unerwartete Antwort.";
|
||||
@@ -654,8 +665,25 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
if (TryProperty(payload, "error", out var error) && OptionalString(error, "message") is { } errorMessage)
|
||||
return errorMessage;
|
||||
if (OptionalString(payload, "message") is { } message) return message;
|
||||
if (TryProperty(payload, "data", out var data) && OptionalString(data, "message") is { } dataMessage)
|
||||
return dataMessage;
|
||||
if (TryProperty(payload, "data", out var data))
|
||||
{
|
||||
if (OptionalString(data, "message") is { } dataMessage) return dataMessage;
|
||||
if (data.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var dataFields = data.EnumerateObject().Select(property => property.Name).Take(8).ToArray();
|
||||
if (dataFields.Length > 0)
|
||||
return $"HTTP {(int)response.StatusCode}; unerwartete data-Felder: " +
|
||||
$"{string.Join(", ", dataFields)}.";
|
||||
}
|
||||
else
|
||||
{
|
||||
return $"HTTP {(int)response.StatusCode}; data hat den Typ {data.ValueKind}.";
|
||||
}
|
||||
}
|
||||
var fields = payload.EnumerateObject().Select(property => property.Name).Take(8).ToArray();
|
||||
return fields.Length == 0
|
||||
? $"HTTP {(int)response.StatusCode}; leeres JSON-Objekt."
|
||||
: $"HTTP {(int)response.StatusCode}; unerwartete JSON-Felder: {string.Join(", ", fields)}.";
|
||||
}
|
||||
return response.IsSuccessStatusCode ? "Unerwartete JSON-Antwort." : $"HTTP {(int)response.StatusCode}";
|
||||
}
|
||||
|
||||
@@ -146,13 +146,6 @@ public sealed record UntisStudentAbsence(
|
||||
string? StudentGroup,
|
||||
JsonElement Raw);
|
||||
|
||||
public sealed record UntisStudentAbsenceReport(
|
||||
int StudentKey,
|
||||
int StartDate,
|
||||
int EndDate,
|
||||
int EntryCount,
|
||||
int AbsentMinutes,
|
||||
IReadOnlyList<UntisStudentAbsence> Absences);
|
||||
|
||||
public sealed record UntisClassRegisterEntry(
|
||||
int? StudentKey,
|
||||
|
||||
@@ -125,7 +125,7 @@ public static class WebUntisStudentReportParser
|
||||
|
||||
private static int? GermanDate(string? value)
|
||||
{
|
||||
if (!DateOnly.TryParseExact(value?.Trim(), "dd.MM.yyyy", CultureInfo.InvariantCulture,
|
||||
if (!DateOnly.TryParseExact(value?.Trim(), ["dd.MM.yyyy", "yyyyMMdd"], CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var date))
|
||||
return null;
|
||||
return date.Year * 10_000 + date.Month * 100 + date.Day;
|
||||
|
||||
Reference in New Issue
Block a user