feat: improve WebUntis sync and competency insights
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
admin committed 2026-09-20 22:17:29 +02:00
1 parent cdb4637c65
commit f0c0479bcb
22 files changed
+1099 -76

No files matched your search

+3
View File
@@ -15,6 +15,9 @@ public class Exam
public string? Notes { get; set; }
public DateOnly? ApprovalGrantedAt { get; set; }
public DateOnly? AnnouncedAt { get; set; }
/// <summary>Stabile Kennung einer automatisch importierten Arbeit. Bleibt bei manuell
/// angelegten Klausuren null; aktuell wird der Präfix "webuntis-rest-exam:" verwendet.</summary>
public string? ExternalId { get; set; }
/// Verknüpft Parallelklausuren (gleiche Arbeit, mehrere Kurse) für den Kurs-Umschalter
/// auf der Klausuren-Hauptseite — Id der "Ursprungs"-Klausur, gesetzt beim Duplizieren
/// (siehe ExamDialogViewModel.Save).
@@ -3,7 +3,8 @@ using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public sealed record CompetencyScore(
double AchievedPoints, double MaxPoints, int EvidenceCount, int ParticipantCount)
double AchievedPoints, double MaxPoints, int EvidenceCount, int ParticipantCount,
int ExamEvidenceCount = 0, int ParticipationEvidenceCount = 0)
{
public double Percent => MaxPoints <= 0 ? 0 : AchievedPoints / MaxPoints * 100.0;
}
@@ -18,6 +19,7 @@ public sealed class CompetencyAnalysisItem
public int TaughtUnitCount { get; init; }
public int PlannedExamCount { get; init; }
public int AssessedExamCount { get; init; }
public int ParticipationSessionCount { get; init; }
public CompetencyScore? GroupScore { get; init; }
public IReadOnlyDictionary<Guid, CompetencyScore> StudentScores { get; init; } =
new Dictionary<Guid, CompetencyScore>();
@@ -29,7 +31,10 @@ public sealed class CompetencyAnalysisService
IReadOnlyList<CompetencyDomain> catalog,
IReadOnlyList<Unit> units,
IReadOnlyList<Exam> exams,
IReadOnlyList<ExamResult> results)
IReadOnlyList<ExamResult> results,
IReadOnlyList<ParticipationSession>? participationSessions = null,
IReadOnlyList<ParticipationEntry>? participationEntries = null,
IReadOnlyList<ParticipationAspect>? participationAspects = null)
{
var builders = new List<ItemBuilder>();
var byId = new Dictionary<Guid, ItemBuilder>();
@@ -105,7 +110,36 @@ public sealed class CompetencyAnalysisService
if (task.MaxPoints <= 0) continue;
var achieved = Math.Clamp(result.Points[taskIndex], 0, task.MaxPoints);
foreach (var reference in NormalizeReferences(task.Competencies))
GetOrAdd(reference).AddScore(result.StudentId, achieved, task.MaxPoints);
GetOrAdd(reference).AddExamScore(result.StudentId, achieved, task.MaxPoints);
}
}
var entriesBySession = (participationEntries ?? [])
.GroupBy(x => x.SessionId)
.ToDictionary(x => x.Key, x => x.ToList());
var aspects = participationAspects ?? [];
foreach (var session in participationSessions ?? [])
{
var references = NormalizeReferences(session.Competencies).ToList();
foreach (var reference in references)
GetOrAdd(reference).ParticipationSessions++;
if (references.Count == 0 || !entriesBySession.TryGetValue(session.Id, out var entries))
continue;
foreach (var entry in entries)
{
var estimated = ParticipationCompetencyEstimate.Estimate(
entry.Ratings, aspects, entry.DayHighlight);
foreach (var reference in references)
{
var explicitRating = entry.CompetencyRatings
.FirstOrDefault(x => x.ItemId == reference.ItemId)?.Value;
var rating = explicitRating ?? estimated;
if (rating is not null)
GetOrAdd(reference).AddParticipationScore(entry.StudentId, rating.Value);
}
}
}
@@ -124,14 +158,23 @@ public sealed class CompetencyAnalysisService
public int TaughtUnits { get; set; }
public int PlannedExams { get; set; }
public int AssessedExams { get; set; }
public int ParticipationSessions { get; set; }
public void AddScore(Guid studentId, double achieved, double maximum)
public void AddExamScore(Guid studentId, double achieved, double maximum) =>
AddScore(studentId, achieved, maximum, isParticipation: false);
public void AddParticipationScore(Guid studentId, int rating) =>
AddScore(studentId, Math.Clamp(rating, -2, 2) + 2, 4, isParticipation: true);
private void AddScore(Guid studentId, double achieved, double maximum, bool isParticipation)
{
if (!_scores.TryGetValue(studentId, out var score))
_scores[studentId] = score = new MutableScore();
score.Achieved += achieved;
score.Maximum += maximum;
score.EvidenceCount++;
if (isParticipation) score.ParticipationEvidenceCount++;
else score.ExamEvidenceCount++;
}
public CompetencyAnalysisItem Build()
@@ -140,6 +183,8 @@ public sealed class CompetencyAnalysisService
var achieved = _scores.Values.Sum(x => x.Achieved);
var maximum = _scores.Values.Sum(x => x.Maximum);
var evidence = _scores.Values.Sum(x => x.EvidenceCount);
var examEvidence = _scores.Values.Sum(x => x.ExamEvidenceCount);
var participationEvidence = _scores.Values.Sum(x => x.ParticipationEvidenceCount);
return new CompetencyAnalysisItem
{
DomainName = domainName,
@@ -150,8 +195,10 @@ public sealed class CompetencyAnalysisService
TaughtUnitCount = TaughtUnits,
PlannedExamCount = PlannedExams,
AssessedExamCount = AssessedExams,
ParticipationSessionCount = ParticipationSessions,
GroupScore = maximum > 0
? new CompetencyScore(achieved, maximum, evidence, _scores.Count)
? new CompetencyScore(achieved, maximum, evidence, _scores.Count,
examEvidence, participationEvidence)
: null,
StudentScores = studentScores,
};
@@ -163,7 +210,10 @@ public sealed class CompetencyAnalysisService
public double Achieved { get; set; }
public double Maximum { get; set; }
public int EvidenceCount { get; set; }
public int ExamEvidenceCount { get; set; }
public int ParticipationEvidenceCount { get; set; }
public CompetencyScore ToScore(int participantCount) =>
new(Achieved, Maximum, EvidenceCount, participantCount);
new(Achieved, Maximum, EvidenceCount, participantCount,
ExamEvidenceCount, ParticipationEvidenceCount);
}
}
@@ -0,0 +1,36 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// <summary>
/// Leitet aus den allgemeinen Mitarbeitsaspekten einen Kompetenzwert auf der gemeinsamen
/// Skala von -2 bis +2 ab. Explizite Kompetenzbewertungen werden vom Aufrufer bevorzugt.
/// </summary>
public static class ParticipationCompetencyEstimate
{
public static int? Estimate(IEnumerable<AspectRating> ratings,
IReadOnlyCollection<ParticipationAspect> aspects, DayHighlightKind? dayHighlight)
{
var aspectsByKey = aspects
.GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(x => x.Key, x => x.Last(), StringComparer.OrdinalIgnoreCase);
var normalized = ratings
.Where(x => aspectsByKey.ContainsKey(x.Key))
.Select(x =>
{
var aspect = aspectsByKey[x.Key];
return ParticipationRatingScale.Normalize(aspect.ValueType, x.Value, aspect.MaxPoints);
})
.ToList();
if (normalized.Count == 0) return null;
var adjustment = dayHighlight switch
{
DayHighlightKind.Standout => 1.0,
DayHighlightKind.Sleepy or DayHighlightKind.Rough => -1.0,
_ => 0.0,
};
return Math.Clamp((int)Math.Round(normalized.Average() + adjustment,
MidpointRounding.AwayFromZero), -2, 2);
}
}
+33 -19
View File
@@ -72,18 +72,19 @@ public class UntisDiffService
if (string.Equals(evt.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase))
{
substitutions.Add(mapping.Kind == SubstitutionKind.Lesson
? BuildCancelledLesson(evt.Date, mapping, evt.Uid)
: BuildSupervisionNote(evt.Date, mapping, evt.Uid, "Laut WebUntis abgesagt."));
if (mapping.Kind == SubstitutionKind.Lesson)
substitutions.AddRange(BuildCancelledLessons(evt.Date, mapping, evt.Uid));
else
substitutions.Add(BuildSupervisionNote(evt.Date, mapping, evt.Uid, "Laut WebUntis abgesagt."));
continue;
}
if (mapping.Kind == SubstitutionKind.Lesson)
{
if (DeviationReason(evt, mapping) is { } reason)
substitutions.Add(BuildChangedLesson(evt, mapping, reason));
substitutions.AddRange(BuildChangedLessons(evt, mapping, reason));
else
externalIdsToDelete.Add(evt.Uid); // war ggf. vorher fälschlich als Vertretung erkannt
externalIdsToDelete.AddRange(ExternalIdsForPeriods(evt.Uid, mapping)); // ggf. frühere Vertretung entfernen
}
else if (!regularDutyKeys.Contains((mapping.Weekday, mapping.AfterPeriod!.Value)))
{
@@ -143,11 +144,11 @@ public class UntisDiffService
{
// War evtl. an einem früheren Poll (noch) als fehlend gemeldet - jetzt
// wieder im Feed vorhanden, die alte Meldung räumt sich damit selbst ab.
externalIdsToDelete.Add(BuildMissingExternalId(date, mapping));
externalIdsToDelete.AddRange(ExternalIdsForPeriods(BuildMissingExternalId(date, mapping), mapping));
continue;
}
substitutions.Add(BuildCancelledLesson(date, mapping, BuildMissingExternalId(date, mapping)));
substitutions.AddRange(BuildCancelledLessons(date, mapping, BuildMissingExternalId(date, mapping)));
}
}
}
@@ -192,19 +193,32 @@ public class UntisDiffService
private static string RemoveWhitespace(string value) =>
new(value.Where(c => !char.IsWhiteSpace(c)).ToArray());
private static SubstitutionEntry BuildCancelledLesson(DateOnly date, UntisSlotMapping mapping, string externalId) => new()
{
Date = date, Kind = SubstitutionKind.Cancelled, PeriodNumber = mapping.PeriodNumber,
Description = "Automatisch über WebUntis-Abgleich erkannt.", ExternalId = externalId,
};
private static IEnumerable<SubstitutionEntry> BuildCancelledLessons(DateOnly date, UntisSlotMapping mapping,
string externalId) => Periods(mapping).Select((period, index) => new SubstitutionEntry
{
Date = date, Kind = SubstitutionKind.Cancelled, PeriodNumber = period,
Description = "Automatisch über WebUntis-Abgleich erkannt.",
ExternalId = PeriodExternalId(externalId, period, index),
});
private static SubstitutionEntry BuildChangedLesson(UntisIcsEvent evt, UntisSlotMapping mapping, string reason) => new()
{
Date = evt.Date, Kind = SubstitutionKind.Lesson, PeriodNumber = mapping.PeriodNumber,
GroupId = mapping.GroupId, GroupLabel = mapping.ClassToken,
Description = $"WebUntis: {evt.Summary ?? "?"} · {evt.Location} (regulär: {mapping.Summary ?? "?"}) — {reason}",
ExternalId = evt.Uid,
};
private static IEnumerable<SubstitutionEntry> BuildChangedLessons(UntisIcsEvent evt, UntisSlotMapping mapping,
string reason) => Periods(mapping).Select((period, index) => new SubstitutionEntry
{
Date = evt.Date, Kind = SubstitutionKind.Lesson, PeriodNumber = period,
GroupId = mapping.GroupId, GroupLabel = mapping.ClassToken,
Description = $"WebUntis: {evt.Summary ?? "?"} · {evt.Location} (regulär: {mapping.Summary ?? "?"}) — {reason}",
ExternalId = PeriodExternalId(evt.Uid, period, index),
});
private static List<int> Periods(UntisSlotMapping mapping) => mapping.CoveredPeriods.Count > 0
? mapping.CoveredPeriods.Distinct().Order().ToList()
: mapping.PeriodNumber is { } period ? [period] : [];
private static IEnumerable<string> ExternalIdsForPeriods(string externalId, UntisSlotMapping mapping) =>
Periods(mapping).Select((period, index) => PeriodExternalId(externalId, period, index));
private static string PeriodExternalId(string externalId, int period, int index) =>
index == 0 ? externalId : $"{externalId}-period-{period}";
// Aufsichten haben keinen TimetableSlot im Hintergrund (siehe SubstitutionEntry.Kind-Doc) -
// deshalb Kind=Supervision statt Cancelled, auch wenn der Termin verschwunden ist.
@@ -40,7 +40,8 @@ public sealed class CompetencyOverviewViewModelTests
Items = [k1],
});
var vm = new CompetencyOverviewTabViewModel(units, new FakeExams([exam]), results,
catalog, versions, new FakeStudents([student]), new CompetencyAnalysisService());
catalog, versions, new FakeStudents([student]), new FakeSessions([]), new FakeEntries(),
new FakeAspects(), new CompetencyAnalysisService());
vm.Initialize(group);
@@ -70,4 +71,52 @@ public sealed class CompetencyOverviewViewModelTests
Assert.Equal("Stand solide", row.Recommendation);
}
[Fact]
public void Initialize_AutomatischeMitarbeitsschaetzung_FuelltKompetenzprofil()
{
var subjectId = Guid.NewGuid();
var group = new LearningGroup { SubjectId = subjectId, GradeLevel = 8 };
var student = new Student { FirstName = "Ada", LastName = "Lovelace" };
var competency = new CompetencyItem { Code = "K1", Description = "Kompetenz" };
var versions = new FakeCompetencyCatalogVersions();
var version = versions.GetOrCreateDefault(subjectId);
var catalog = new FakeCompetencyDomains();
catalog.Add(new CompetencyDomain
{
SubjectId = subjectId,
GradeLevel = 8,
VersionId = version.Id,
Name = "Bereich",
Items = [competency],
});
var session = new ParticipationSession
{
GroupId = group.Id,
Competencies = [new CompetencyReference
{
ItemId = competency.Id,
Code = competency.Code,
Description = competency.Description,
}],
};
var entries = new FakeEntries();
entries.Add(new ParticipationEntry
{
SessionId = session.Id,
StudentId = student.Id,
Ratings = [new AspectRating { Key = "quality", Value = 1 }],
});
var vm = new CompetencyOverviewTabViewModel(new FakeUnits(), new FakeExams([]),
new FakeResults(), catalog, versions, new FakeStudents([student]),
new FakeSessions([session]), entries, new FakeAspects(), new CompetencyAnalysisService());
vm.Initialize(group);
var row = Assert.Single(vm.Rows);
Assert.Equal("1× bewertet", row.ParticipationCoverage);
Assert.Contains("75 %", row.GroupResult);
Assert.Contains("1 Mitarbeitswert", row.StudentResult);
}
}
@@ -26,7 +26,8 @@ public sealed class GroupDetailViewModelTests
new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects,
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), TestSupport.BuildAiSettingsService(), new FakeSessions([])),
new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(),
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), students, new CompetencyAnalysisService()),
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), students,
new FakeSessions([]), new FakeEntries(), new FakeAspects(), new CompetencyAnalysisService()),
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects()),
new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups));
@@ -0,0 +1,139 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class WebUntisRestSyncProcessorTests
{
private static readonly DateOnly Monday = new(2026, 9, 28);
[Fact]
public void Process_AusfallImDoppelblock_SchreibtBeideStundenUndIstIdempotent()
{
var group = new LearningGroup { Id = Guid.NewGuid(), Name = "8c" };
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Tuesday, StartTime = new TimeOnly(9, 40), Summary = "NAT",
ClassToken = "8c", Kind = SubstitutionKind.Lesson, GroupId = group.Id,
PeriodNumber = 3, CoveredPeriods = [3, 4], Confirmed = true,
});
var substitutions = new FakeSubstitutionEntries();
var processor = BuildProcessor(mappings, substitutions, new RecordingExams(), [group]);
var cancelled = Entry([100, 101], "2026-09-29T09:40", "2026-09-29T11:10",
"NORMAL_TEACHING_PERIOD", "CANCELLED", "NAT", "8c");
var first = processor.Process([cancelled], Monday, Monday.AddDays(13), Monday.AddDays(27));
var second = processor.Process([cancelled], Monday, Monday.AddDays(13), Monday.AddDays(27));
Assert.Equal(2, first.SubstitutionChanges);
Assert.Equal(0, second.SubstitutionChanges);
Assert.Equal([3, 4], substitutions.GetAll().Select(entry => entry.PeriodNumber).Order().ToArray());
Assert.All(substitutions.GetAll(), entry => Assert.Equal(SubstitutionKind.Cancelled, entry.Kind));
}
[Fact]
public void Process_Exam_LegtArbeitMitTitelAnUndEntferntSieNachVerschwinden()
{
var group = new LearningGroup { Id = Guid.NewGuid(), Name = "Mat_E" };
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Tuesday, StartTime = new TimeOnly(9, 40), Summary = "Mat_E",
ClassToken = "10a;10b;10c;Gastro", Kind = SubstitutionKind.Lesson, GroupId = group.Id,
PeriodNumber = 3, CoveredPeriods = [3, 4], Confirmed = true,
});
var exams = new RecordingExams();
var processor = BuildProcessor(mappings, new FakeSubstitutionEntries(), exams, [group]);
var exam = Entry([2002979, 2002982], "2026-09-29T09:40", "2026-09-29T11:10",
"EXAM", "REGULAR", "Mat_E", "10a", "Arbeit 1. Mathearbeit");
var imported = processor.Process([exam], Monday, Monday.AddDays(13), Monday.AddDays(27));
var unchanged = processor.Process([exam], Monday, Monday.AddDays(13), Monday.AddDays(27));
var removed = processor.Process([], Monday, Monday.AddDays(13), Monday.AddDays(27));
Assert.Equal(1, imported.ExamChanges);
Assert.Equal(0, unchanged.ExamChanges);
Assert.Equal(1, removed.ExamChanges);
Assert.Empty(exams.GetAll());
}
[Fact]
public void Process_ManuelleArbeitAmSelbenTag_WirdNichtDupliziertOderGeloescht()
{
var group = new LearningGroup { Id = Guid.NewGuid(), Name = "Mat_E" };
var manual = new Exam { GroupId = group.Id, Date = new DateOnly(2026, 9, 29), Title = "1. Arbeit" };
var exams = new RecordingExams([manual]);
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Tuesday, StartTime = new TimeOnly(9, 40), Summary = "Mat_E",
ClassToken = "10a", GroupId = group.Id, PeriodNumber = 3, CoveredPeriods = [3, 4], Confirmed = true,
});
var processor = BuildProcessor(mappings, new FakeSubstitutionEntries(), exams, [group]);
processor.Process([Entry([1], "2026-09-29T09:40", "2026-09-29T11:10", "EXAM", "REGULAR",
"Mat_E", "10a", "Arbeit aus WebUntis")], Monday, Monday.AddDays(13), Monday.AddDays(27));
Assert.Same(manual, Assert.Single(exams.GetAll()));
Assert.Null(manual.ExternalId);
}
[Fact]
public void Process_UebernommeneRegulaereAufsicht_WirdAlsAenderungErfasst()
{
var duties = new FakeSupervisionDuties();
duties.Add(new SupervisionDuty { Weekday = DayOfWeek.Wednesday, AfterPeriod = 2, Location = "PH grün" });
var substitutions = new FakeSubstitutionEntries();
var processor = BuildProcessor(new FakeUntisSlotMappings(), substitutions, new RecordingExams(), [], duties);
var entry = new UntisTimetableGridEntryDto([17], new DateTime(2026, 9, 30, 9, 20, 0),
new DateTime(2026, 9, 30, 9, 40, 0), "BREAK_SUPERVISION", "CHANGED", "SUBSTITUTED",
null, null, null, null,
[new UntisGridResourceChangeDto(null,
new UntisGridResourceDto("TEACHER", "REMOVED", "HED", null, "HED"))]);
processor.Process([entry], Monday, Monday.AddDays(13), Monday.AddDays(27));
var change = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.Supervision, change.Kind);
Assert.Equal(2, change.AfterPeriod);
Assert.Contains("übernommen", change.Description);
}
private static WebUntisRestSyncProcessor BuildProcessor(FakeUntisSlotMappings mappings,
FakeSubstitutionEntries substitutions, IExamRepository exams, List<LearningGroup> groups,
FakeSupervisionDuties? supervisionDuties = null)
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-rest-sync-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
var periods = new PeriodScheduleService(path);
periods.SetPeriods([
new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 35), End = new TimeOnly(9, 20) },
new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 40), End = new TimeOnly(10, 25) },
new PeriodTimeEntry { PeriodNumber = 4, Start = new TimeOnly(10, 25), End = new TimeOnly(11, 10) },
]);
return new WebUntisRestSyncProcessor(mappings, substitutions, exams, new FakeGroups(groups),
supervisionDuties ?? new FakeSupervisionDuties(), periods);
}
private static UntisTimetableGridEntryDto Entry(IReadOnlyList<int> ids, string start, string end,
string type, string status, string subject, string className, string? lessonInfo = null) => new(
ids, DateTime.Parse(start), DateTime.Parse(end), type, status, null, null, null, lessonInfo, null,
[
new UntisGridResourceChangeDto(new UntisGridResourceDto("CLASS", "REGULAR", className, null, className), null),
new UntisGridResourceChangeDto(new UntisGridResourceDto("SUBJECT", "REGULAR", subject, null, subject), null),
]);
private sealed class RecordingExams(List<Exam>? initial = null) : IExamRepository
{
private readonly List<Exam> _all = initial ?? [];
public Exam? GetById(Guid id) => _all.FirstOrDefault(exam => exam.Id == id);
public List<Exam> GetAll() => _all.ToList();
public List<Exam> GetByGroup(Guid groupId) => _all.Where(exam => exam.GroupId == groupId).ToList();
public void Save(Exam exam) { _all.RemoveAll(item => item.Id == exam.Id); _all.Add(exam); }
public void Delete(Guid id) => _all.RemoveAll(exam => exam.Id == id);
}
}
+13 -14
View File
@@ -238,23 +238,23 @@ public static class AppBootstrapper
services.AddSingleton<McpServerHostedService>();
services.AddSingleton<McpClientRegistrationService>();
// ── WebUntis-iCal-Abgleich (optional – nur wenn URL hinterlegt und aktiviert) ─────────
// ── WebUntis-Stundenplan-Abgleich (REST bei API-Zugang, sonst optionaler iCal-Fallback) ─
var untisSettings = new WebUntisSettingsService(appData);
services.AddSingleton(untisSettings);
services.AddSingleton<UntisMatchingService>();
services.AddSingleton<UntisDiffService>();
if (untisSettings.Enabled && !string.IsNullOrEmpty(untisSettings.GetIcalUrl()))
{
services.AddSingleton(sp => new UntisSyncService(
new HttpClient(), untisSettings,
sp.GetRequiredService<IUntisSnapshotRepository>(), sp.GetRequiredService<IUntisSlotMappingRepository>(),
sp.GetRequiredService<ISubstitutionEntryRepository>(), sp.GetRequiredService<IGroupRepository>(),
sp.GetRequiredService<ITimetableSlotRepository>(), sp.GetRequiredService<ISupervisionDutyRepository>(),
sp.GetRequiredService<ISchoolHolidayRepository>(), sp.GetRequiredService<PublicHolidayService>(),
sp.GetRequiredService<SchoolCalendarSettingsService>(), sp.GetRequiredService<PeriodScheduleService>(),
sp.GetRequiredService<UntisMatchingService>(), sp.GetRequiredService<UntisDiffService>(),
sp.GetRequiredService<AppLogger>()));
}
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
services.AddSingleton<WebUntisRestSyncProcessor>();
services.AddSingleton(sp => new UntisSyncService(
new HttpClient(), untisSettings,
sp.GetRequiredService<IUntisSnapshotRepository>(), sp.GetRequiredService<IUntisSlotMappingRepository>(),
sp.GetRequiredService<ISubstitutionEntryRepository>(), sp.GetRequiredService<IGroupRepository>(),
sp.GetRequiredService<ITimetableSlotRepository>(), sp.GetRequiredService<ISupervisionDutyRepository>(),
sp.GetRequiredService<ISchoolHolidayRepository>(), sp.GetRequiredService<PublicHolidayService>(),
sp.GetRequiredService<SchoolCalendarSettingsService>(), sp.GetRequiredService<PeriodScheduleService>(),
sp.GetRequiredService<UntisMatchingService>(), sp.GetRequiredService<UntisDiffService>(),
sp.GetRequiredService<AppLogger>(), sp.GetRequiredService<WebUntisIntegrationService>(),
sp.GetRequiredService<WebUntisRestSyncProcessor>()));
// ── Schulweiter Jahresplan (informativer ClassyPlan-iCal, kein Stundenplan-Abgleich) ──
var annualPlanSettings = new AnnualPlanSettingsService(appData);
@@ -272,7 +272,6 @@ public static class AppBootstrapper
services.AddSingleton(syncSettings);
services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
services.AddSingleton<UntisReportCacheService>();
services.AddSingleton<UntisHubService>();
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
+72 -2
View File
@@ -40,6 +40,8 @@ public class UntisSyncService : IDisposable
private readonly PeriodScheduleService _periodSchedule;
private readonly UntisMatchingService _matchingService;
private readonly UntisDiffService _diffService;
private readonly WebUntisIntegrationService? _restIntegration;
private readonly WebUntisRestSyncProcessor? _restProcessor;
private readonly AppLogger? _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly Timer _timer;
@@ -53,14 +55,15 @@ public class UntisSyncService : IDisposable
ITimetableSlotRepository timetableSlots, ISupervisionDutyRepository supervisionDuties,
ISchoolHolidayRepository schoolHolidays, PublicHolidayService publicHolidays,
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
UntisMatchingService matchingService, UntisDiffService diffService, AppLogger? logger = null)
UntisMatchingService matchingService, UntisDiffService diffService, AppLogger? logger = null,
WebUntisIntegrationService? restIntegration = null, WebUntisRestSyncProcessor? restProcessor = null)
{
_http = http; _settings = settings; _snapshots = snapshots; _mappings = mappings;
_substitutions = substitutions; _groups = groups; _timetableSlots = timetableSlots;
_supervisionDuties = supervisionDuties;
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_periodSchedule = periodSchedule; _matchingService = matchingService; _diffService = diffService;
_logger = logger;
_logger = logger; _restIntegration = restIntegration; _restProcessor = restProcessor;
_timer = new Timer(async _ => await PollAsync(), null,
TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes));
}
@@ -70,6 +73,12 @@ public class UntisSyncService : IDisposable
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
try
{
if (_settings.ApiIsConfigured && _restIntegration is not null && _restProcessor is not null)
{
await PollRestAsync(cancellationToken).ConfigureAwait(false);
return;
}
var url = _settings.GetIcalUrl();
if (string.IsNullOrEmpty(url)) return;
@@ -107,6 +116,67 @@ public class UntisSyncService : IDisposable
finally { _gate.Release(); }
}
private async Task PollRestAsync(CancellationToken cancellationToken)
{
var restIntegration = _restIntegration
?? throw new InvalidOperationException("WebUntis-REST-Integration fehlt.");
var restProcessor = _restProcessor
?? throw new InvalidOperationException("WebUntis-REST-Verarbeitung fehlt.");
var today = DateOnly.FromDateTime(DateTime.Today);
var daysSinceMonday = ((int)today.DayOfWeek + 6) % 7;
var firstMonday = today.DayOfWeek switch
{
DayOfWeek.Saturday => today.AddDays(2),
DayOfWeek.Sunday => today.AddDays(1),
_ => today.AddDays(-daysSinceMonday),
};
var entries = new List<UntisTimetableGridEntryDto>();
try
{
var years = await restIntegration.GetSchoolYearsAsync(cancellationToken).ConfigureAwait(false);
// WebUntis selbst lädt das Grid wochenweise. Vier kleine Wochenabrufe halten dieselbe
// Semantik ein und teilen sich innerhalb der Sitzung denselben kurzlebigen Bearer.
for (var week = 0; week < 4; week++)
{
var monday = firstMonday.AddDays(week * 7);
var numericDate = monday.Year * 10000 + monday.Month * 100 + monday.Day;
var schoolYear = years.FirstOrDefault(year =>
year.StartDate <= numericDate && year.EndDate >= numericDate)
?? throw new WebUntisIntegrationException(
$"WebUntis hat für die Woche ab {monday:dd.MM.yyyy} kein Schuljahr geliefert.");
entries.AddRange(await restIntegration.GetOwnTimetableGridAsync(schoolYear.UntisId,
monday, monday.AddDays(4),
cancellationToken).ConfigureAwait(false));
}
}
catch (Exception ex)
{
// REST ist bei konfigurierten API-Zugangsdaten die autoritative Quelle. Kein stiller
// iCal-Fallback: zwei konkurrierende Abgleiche würden wieder doppelte/widersprüchliche
// Ausnahmen erzeugen. Der letzte erfolgreiche lokale Stand bleibt stattdessen erhalten.
_logger?.Error("WebUntis-REST-Abgleich: Abruf fehlgeschlagen", ex);
_settings.SetLastSync(DateTime.UtcNow, $"REST-Fehler beim Abruf: {ex.Message}");
return;
}
try
{
var result = restProcessor.Process(entries, today, firstMonday.AddDays(13), firstMonday.AddDays(27));
var totalChanges = result.SubstitutionChanges + result.ExamChanges;
_settings.SetLastSync(DateTime.UtcNow,
$"{result.SubstitutionChanges} Stundenplanänderung(en), {result.ExamChanges} Arbeit(en) geändert " +
$"({result.EntryCount} REST-Termine geprüft)." );
_logger?.Info($"WebUntis-REST-Abgleich: {result.SubstitutionChanges} Stundenplanänderung(en), " +
$"{result.ExamChanges} Arbeit(en) aus {result.EntryCount} Grid-Einträgen.");
if (totalChanges > 0) DataChanged?.Invoke();
}
catch (Exception ex)
{
_logger?.Error("WebUntis-REST-Abgleich: Verarbeitung fehlgeschlagen", ex);
_settings.SetLastSync(DateTime.UtcNow, $"REST-Fehler bei der Verarbeitung: {ex.Message}");
}
}
// Reiner Kern ohne HTTP - direkt mit vorgefertigtem ICS-Text testbar (kein InternalsVisibleTo
// in dieser Codebasis üblich, siehe CLAUDE.md - deshalb public statt internal).
public UntisPollResult ProcessIcsText(string icsText)
@@ -21,6 +21,12 @@ public sealed record UntisTimetablePeriodDto(int Id, int Date, int StartTime, in
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 UntisGridResourceDto(string Type, string? Status, string? ShortName, string? LongName,
string? DisplayName);
public sealed record UntisGridResourceChangeDto(UntisGridResourceDto? Current, UntisGridResourceDto? Removed);
public sealed record UntisTimetableGridEntryDto(IReadOnlyList<int> Ids, DateTime Start, DateTime End, string Type,
string? Status, string? StatusDetail, string? Name, string? LessonText, string? LessonInfo,
string? SubstitutionText, IReadOnlyList<UntisGridResourceChangeDto> Resources);
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,
@@ -108,6 +114,31 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
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<IReadOnlyList<UntisTimetableGridEntryDto>> GetOwnTimetableGridAsync(DateOnly start, DateOnly end,
CancellationToken token = default) => ExecuteAsync(async client =>
{
var years = await client.GetSchoolYearsAsync(token);
var numericStart = Date(start);
var year = years.FirstOrDefault(item => item.StartDate <= numericStart && item.EndDate >= numericStart)
?? throw new WebUntisException("WebUntis hat für den REST-Stundenplan kein passendes Schuljahr geliefert.");
return await GridEntries(client, year.UntisId, start, end, token);
}, token);
public Task<IReadOnlyList<UntisTimetableGridEntryDto>> GetOwnTimetableGridAsync(int schoolYearId,
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(
client => GridEntries(client, schoolYearId, start, end, token), token);
private static async Task<IReadOnlyList<UntisTimetableGridEntryDto>> GridEntries(WebUntisClient client,
int schoolYearId, DateOnly start, DateOnly end, CancellationToken token)
{
var entries = await client.GetOwnTimetableGridAsync(schoolYearId, start, end, token);
return (IReadOnlyList<UntisTimetableGridEntryDto>)entries.Select(entry => new UntisTimetableGridEntryDto(
entry.Ids, entry.Start, entry.End, entry.Type, entry.Status, entry.StatusDetail, entry.Name,
entry.LessonText, entry.LessonInfo, entry.SubstitutionText,
entry.Resources.Select(change => new UntisGridResourceChangeDto(
GridResource(change.Current), GridResource(change.Removed))).ToList())).ToList();
}
public Task<UntisStudentReportDto> GetStudentsAsync(string className, CancellationToken token = default) => ExecuteAsync(
async client =>
{
@@ -241,6 +272,8 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
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 UntisGridResourceDto? GridResource(UntisGridResource? value) => value is null ? null
: new UntisGridResourceDto(value.Type, value.Status, value.ShortName, value.LongName, value.DisplayName);
private static bool IsUntisError(Exception exception) => exception is WebUntisException
or WebUntisConfigurationException or InvalidDataException;
private static WebUntisIntegrationException Translate(Exception exception) =>
@@ -0,0 +1,264 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.Services;
public sealed record WebUntisRestSyncResult(int EntryCount, int SubstitutionChanges, int ExamChanges);
/// <summary>Verarbeitet den expliziten Ist-Stundenplan der neuen WebUntis-REST-Oberfläche.
/// Zeitspannen werden auf jede überdeckte lokale Stunde aufgefächert; damit gehen bei einem
/// Doppelblock weder die zweite Vertretungs- noch die zweite Ausfallstunde verloren.</summary>
public sealed class WebUntisRestSyncProcessor(
IUntisSlotMappingRepository mappings,
ISubstitutionEntryRepository substitutions,
IExamRepository exams,
IGroupRepository groups,
ISupervisionDutyRepository supervisionDuties,
PeriodScheduleService periodSchedule)
{
public const string SubstitutionPrefix = "webuntis-rest-sub:";
public const string ExamPrefix = "webuntis-rest-exam:";
public WebUntisRestSyncResult Process(IReadOnlyList<UntisTimetableGridEntryDto> entries,
DateOnly today, DateOnly operationalEnd, DateOnly examEnd)
{
var confirmedMappings = mappings.GetAll().Where(mapping => mapping.Confirmed).ToList();
var desiredSubstitutions = entries
.Where(entry => Date(entry) >= today && Date(entry) <= operationalEnd)
.SelectMany(entry => BuildSubstitutions(entry, confirmedMappings))
.GroupBy(entry => entry.ExternalId)
.Select(group => group.First())
.ToList();
var substitutionChanges = ReconcileSubstitutions(desiredSubstitutions, today, operationalEnd);
var examChanges = ReconcileExams(entries.Where(entry => entry.Type == "EXAM" &&
Date(entry) >= today && Date(entry) <= examEnd), confirmedMappings, today, examEnd);
return new WebUntisRestSyncResult(entries.Count, substitutionChanges, examChanges);
}
private IEnumerable<SubstitutionEntry> BuildSubstitutions(UntisTimetableGridEntryDto entry,
List<UntisSlotMapping> confirmedMappings)
{
var date = Date(entry);
var start = TimeOnly.FromDateTime(entry.Start);
var periods = ResolveCoveredPeriods(entry.Start, entry.End);
var mapping = FindMapping(confirmedMappings, date.DayOfWeek, start, periods, entry);
var cancelled = IsCancelled(entry);
if (entry.Type == "EXAM") yield break;
if (entry.Type == "BREAK_SUPERVISION")
{
var afterPeriod = ResolveAfterPeriod(start);
var regular = supervisionDuties.GetAll().Any(duty =>
duty.Weekday == date.DayOfWeek && duty.AfterPeriod == afterPeriod);
var supervisionChanged = !string.Equals(entry.Status, "REGULAR", StringComparison.OrdinalIgnoreCase) ||
entry.Resources.Any(resource => resource.Removed is not null);
if (!cancelled && !supervisionChanged && regular) yield break;
yield return new SubstitutionEntry
{
Date = date,
Kind = SubstitutionKind.Supervision,
AfterPeriod = afterPeriod,
Description = cancelled || supervisionChanged && regular
? "WebUntis: Aufsicht entfällt oder wurde übernommen."
: $"Zusätzliche Aufsicht laut WebUntis{LocationSuffix(entry)}.",
ExternalId = ExternalId(entry, $"supervision-{afterPeriod}"),
};
yield break;
}
var changed = entry.Type == "ADDITIONAL_PERIOD" || cancelled ||
!string.Equals(entry.Status, "REGULAR", StringComparison.OrdinalIgnoreCase) ||
entry.Resources.Any(resource => resource.Removed is not null);
if (!changed || periods.Count == 0) yield break;
foreach (var period in periods)
{
if (cancelled)
{
yield return new SubstitutionEntry
{
Date = date,
Kind = SubstitutionKind.Cancelled,
PeriodNumber = period,
Description = "Automatisch über den WebUntis-REST-Stundenplan erkannt.",
ExternalId = ExternalId(entry, $"cancelled-{period}"),
};
continue;
}
var classNames = CurrentNames(entry, "CLASS");
var subject = CurrentNames(entry, "SUBJECT").FirstOrDefault();
var group = ResolveGroup(mapping, classNames, subject, period, date.DayOfWeek, confirmedMappings);
yield return new SubstitutionEntry
{
Date = date,
Kind = SubstitutionKind.Lesson,
PeriodNumber = period,
GroupId = group?.Id ?? mapping?.GroupId,
GroupLabel = classNames.Count > 0 ? string.Join("; ", classNames) : mapping?.ClassToken ?? "",
Description = BuildDescription(entry, subject),
ExternalId = ExternalId(entry, $"lesson-{period}"),
};
}
}
private int ReconcileSubstitutions(List<SubstitutionEntry> desired, DateOnly from, DateOnly to)
{
var changes = 0;
var desiredIds = desired.Select(entry => entry.ExternalId!).ToHashSet();
var existingAutomatic = substitutions.GetAll()
.Where(entry => entry.Date >= from && entry.Date <= to && entry.ExternalId is not null)
.ToList();
foreach (var stale in existingAutomatic.Where(entry => !desiredIds.Contains(entry.ExternalId!)))
{
substitutions.Delete(stale.Id);
changes++;
}
foreach (var candidate in desired)
{
var existing = substitutions.GetByExternalId(candidate.ExternalId!);
if (existing is not null)
{
candidate.Id = existing.Id;
if (SameSubstitution(existing, candidate)) continue;
}
substitutions.Save(candidate);
changes++;
}
return changes;
}
private int ReconcileExams(IEnumerable<UntisTimetableGridEntryDto> examEntries,
List<UntisSlotMapping> confirmedMappings, DateOnly from, DateOnly to)
{
var changes = 0;
var desiredIds = new HashSet<string>();
var allExams = exams.GetAll();
foreach (var entry in examEntries)
{
var periods = ResolveCoveredPeriods(entry.Start, entry.End);
var mapping = FindMapping(confirmedMappings, Date(entry).DayOfWeek, TimeOnly.FromDateTime(entry.Start),
periods, entry);
var classNames = CurrentNames(entry, "CLASS");
var subject = CurrentNames(entry, "SUBJECT").FirstOrDefault();
var group = ResolveGroup(mapping, classNames, subject, periods.FirstOrDefault(),
Date(entry).DayOfWeek, confirmedMappings);
var groupId = group?.Id ?? mapping?.GroupId;
if (groupId is null) continue; // keine riskante Klausurzuordnung ohne bestätigte/eindeutige Gruppe
var externalId = ExamPrefix + StableEntryKey(entry);
desiredIds.Add(externalId);
var title = entry.LessonInfo ?? entry.Name ??
(subject is null ? "Arbeit laut WebUntis" : $"Arbeit {subject}");
var existing = allExams.FirstOrDefault(exam => exam.ExternalId == externalId);
if (existing is null)
{
// Eine bereits manuell gepflegte Klausur am selben Tag/in derselben Gruppe hat
// Vorrang und wird weder dupliziert noch nachträglich zum Auto-Datensatz gemacht.
if (allExams.Any(exam => exam.ExternalId is null && exam.GroupId == groupId && exam.Date == Date(entry)))
continue;
existing = new Exam { GroupId = groupId.Value, Date = Date(entry), Title = title, ExternalId = externalId };
exams.Save(existing);
allExams.Add(existing);
changes++;
continue;
}
if (existing.GroupId == groupId && existing.Date == Date(entry) && existing.Title == title) continue;
existing.GroupId = groupId.Value;
existing.Date = Date(entry);
existing.Title = title;
exams.Save(existing);
changes++;
}
foreach (var stale in allExams.Where(exam => exam.ExternalId?.StartsWith(ExamPrefix) == true &&
exam.Date >= from && exam.Date <= to && exam.Status == ExamStatus.Planned &&
!desiredIds.Contains(exam.ExternalId)).ToList())
{
exams.Delete(stale.Id);
changes++;
}
return changes;
}
private UntisSlotMapping? FindMapping(List<UntisSlotMapping> candidates, DayOfWeek weekday, TimeOnly start,
List<int> periods, UntisTimetableGridEntryDto entry)
{
var exact = candidates.Where(mapping => mapping.Kind == SubstitutionKind.Lesson &&
mapping.Weekday == weekday && mapping.StartTime == start)
.OrderByDescending(mapping => mapping.CreatedAt).FirstOrDefault();
if (exact is not null) return exact;
var subject = CurrentNames(entry, "SUBJECT").FirstOrDefault();
return candidates.Where(mapping => mapping.Kind == SubstitutionKind.Lesson &&
mapping.Weekday == weekday &&
(mapping.CoveredPeriods.Any(periods.Contains) ||
mapping.PeriodNumber is { } number && periods.Contains(number)) &&
(subject is null || string.Equals(mapping.Summary, subject,
StringComparison.OrdinalIgnoreCase)))
.OrderByDescending(mapping => mapping.CreatedAt).FirstOrDefault();
}
private LearningGroup? ResolveGroup(UntisSlotMapping? mapping, List<string> classNames, string? subject,
int period, DayOfWeek weekday, List<UntisSlotMapping> confirmedMappings)
{
if (mapping?.GroupId is { } mappedId) return groups.GetById(mappedId);
var bySubjectAndClass = confirmedMappings.Where(item => item.GroupId is not null &&
(subject is null || string.Equals(item.Summary, subject, StringComparison.OrdinalIgnoreCase)) &&
classNames.Any(name => ContainsClassToken(item.ClassToken, name)))
.Select(item => groups.GetById(item.GroupId!.Value)).Where(group => group is not null).Distinct().ToList();
if (bySubjectAndClass.Count == 1) return bySubjectAndClass[0];
var direct = groups.GetAll().Where(group => classNames.Any(name =>
string.Equals(group.Name, name, StringComparison.OrdinalIgnoreCase))).ToList();
return direct.Count == 1 ? direct[0] : null;
}
private List<int> ResolveCoveredPeriods(DateTime start, DateTime end)
{
var startTime = TimeOnly.FromDateTime(start).ToTimeSpan();
var endTime = TimeOnly.FromDateTime(end).ToTimeSpan();
var tolerance = TimeSpan.FromMinutes(3);
return periodSchedule.Periods.Where(period => period.Start.ToTimeSpan() >= startTime - tolerance &&
period.End.ToTimeSpan() <= endTime + tolerance)
.OrderBy(period => period.PeriodNumber).Select(period => period.PeriodNumber).ToList();
}
private int ResolveAfterPeriod(TimeOnly start) => periodSchedule.Periods
.Where(period => period.End <= start).OrderByDescending(period => period.End)
.Select(period => (int?)period.PeriodNumber).FirstOrDefault() ?? 0;
private static bool IsCancelled(UntisTimetableGridEntryDto entry) =>
string.Equals(entry.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase) ||
string.Equals(entry.Status, "CANCELED", StringComparison.OrdinalIgnoreCase) ||
entry.StatusDetail?.Contains("CANCEL", StringComparison.OrdinalIgnoreCase) == true;
private static DateOnly Date(UntisTimetableGridEntryDto entry) => DateOnly.FromDateTime(entry.Start);
private static List<string> CurrentNames(UntisTimetableGridEntryDto entry, string type) => entry.Resources
.Select(change => change.Current).Where(resource => resource is not null && resource.Type == type)
.Select(resource => resource!.ShortName ?? resource.DisplayName).Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name!).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
private static string ExternalId(UntisTimetableGridEntryDto entry, string suffix) =>
SubstitutionPrefix + StableEntryKey(entry) + ":" + suffix;
private static string StableEntryKey(UntisTimetableGridEntryDto entry) => entry.Ids.Count > 0
? string.Join("-", entry.Ids.Order())
: $"{entry.Start:yyyyMMddHHmm}-{entry.End:HHmm}-{entry.Type}";
private static bool ContainsClassToken(string tokenList, string value) => tokenList
.Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(token => string.Equals(token, value, StringComparison.OrdinalIgnoreCase));
private static string LocationSuffix(UntisTimetableGridEntryDto entry) =>
CurrentNames(entry, "ROOM").FirstOrDefault() is { } room ? $" in {room}" : "";
private static string BuildDescription(UntisTimetableGridEntryDto entry, string? subject)
{
var label = entry.LessonInfo ?? entry.SubstitutionText ?? entry.LessonText;
var head = subject is null ? "Vertretung laut WebUntis" : $"WebUntis: {subject}";
return string.IsNullOrWhiteSpace(label) ? head + LocationSuffix(entry)
: $"{head} · {label}{LocationSuffix(entry)}";
}
private static bool SameSubstitution(SubstitutionEntry left, SubstitutionEntry right) =>
left.Date == right.Date && left.Kind == right.Kind && left.PeriodNumber == right.PeriodNumber &&
left.AfterPeriod == right.AfterPeriod && left.GroupId == right.GroupId && left.GroupLabel == right.GroupLabel &&
left.Description == right.Description && left.ExternalId == right.ExternalId;
}
@@ -15,6 +15,9 @@ public partial class CompetencyOverviewTabViewModel : ObservableObject
private readonly ICompetencyDomainRepository _catalog;
private readonly ICompetencyCatalogVersionRepository _versions;
private readonly IStudentRepository _students;
private readonly IParticipationSessionRepository _participationSessions;
private readonly IParticipationRepository _participationEntries;
private readonly IParticipationAspectRepository _participationAspects;
private readonly CompetencyAnalysisService _analysis;
private LearningGroup? _group;
@@ -35,7 +38,9 @@ public partial class CompetencyOverviewTabViewModel : ObservableObject
public CompetencyOverviewTabViewModel(IUnitRepository units, IExamRepository exams,
IExamResultRepository results, ICompetencyDomainRepository catalog,
ICompetencyCatalogVersionRepository versions,
IStudentRepository students, CompetencyAnalysisService analysis)
IStudentRepository students, IParticipationSessionRepository participationSessions,
IParticipationRepository participationEntries, IParticipationAspectRepository participationAspects,
CompetencyAnalysisService analysis)
{
_units = units;
_exams = exams;
@@ -43,6 +48,9 @@ public partial class CompetencyOverviewTabViewModel : ObservableObject
_catalog = catalog;
_versions = versions;
_students = students;
_participationSessions = participationSessions;
_participationEntries = participationEntries;
_participationAspects = participationAspects;
_analysis = analysis;
}
@@ -72,7 +80,12 @@ public partial class CompetencyOverviewTabViewModel : ObservableObject
var units = _units.GetByGroup(_group.Id);
var exams = _exams.GetByGroup(_group.Id);
var results = exams.SelectMany(x => _results.GetByExam(x.Id)).ToList();
var analysis = _analysis.Analyze(domains, units, exams, results);
var sessions = _participationSessions.GetByGroup(_group.Id);
var entries = sessions.SelectMany(x => _participationEntries.GetBySession(x.Id)).ToList();
var aspects = _participationAspects.GetDefaults()
.Concat(_participationAspects.GetByGroup(_group.Id)).ToList();
if (aspects.Count == 0) aspects = [.. DefaultParticipationAspects.All];
var analysis = _analysis.Analyze(domains, units, exams, results, sessions, entries, aspects);
StudentOptions.Clear();
foreach (var student in _students.GetByGroup(_group.Id)
@@ -130,6 +143,9 @@ public partial class CompetencyOverviewRow : ObservableObject
public string ExamCoverage => CoverageText(
_item.AssessedExamCount, "geprüft", _item.PlannedExamCount, "geplant");
public string GroupResult => ScoreText(_item.GroupScore, includeParticipants: true);
public string ParticipationCoverage => _item.ParticipationSessionCount == 0
? "–"
: $"{_item.ParticipationSessionCount}× bewertet";
public CompetencyOverviewRow(CompetencyAnalysisItem item, Guid? studentId, double threshold)
{
@@ -182,7 +198,18 @@ public partial class CompetencyOverviewRow : ObservableObject
private static string ScoreText(CompetencyScore? score, bool includeParticipants)
{
if (score is null) return "–";
var evidence = score.EvidenceCount == 1 ? "1 Aufgabenwert" : $"{score.EvidenceCount} Aufgabenwerte";
var evidenceParts = new List<string>();
if (score.ExamEvidenceCount > 0)
evidenceParts.Add(score.ExamEvidenceCount == 1
? "1 Aufgabenwert"
: $"{score.ExamEvidenceCount} Aufgabenwerte");
if (score.ParticipationEvidenceCount > 0)
evidenceParts.Add(score.ParticipationEvidenceCount == 1
? "1 Mitarbeitswert"
: $"{score.ParticipationEvidenceCount} Mitarbeitswerte");
var evidence = evidenceParts.Count > 0
? string.Join(" · ", evidenceParts)
: score.EvidenceCount == 1 ? "1 Wert" : $"{score.EvidenceCount} Werte";
return includeParticipants
? $"{score.Percent:0.#} % · {score.ParticipantCount} Schüler · {evidence}"
: $"{score.Percent:0.#} % · {evidence}";
@@ -767,26 +767,14 @@ public class AspectColumnDef
/// Tagesform als die fachliche Kompetenz beschreibt.</summary>
public static class ParticipationCompetencyEstimate
{
private const double DayHighlightAdjustment = 1.0;
public static int? Estimate(List<AspectRating> ratings, IReadOnlyList<AspectColumnDef> aspects, DayHighlightKind? dayHighlight)
{
var normalized = ratings
.Select(r => (Rating: r, Def: aspects.FirstOrDefault(a => a.Key == r.Key)))
.Where(x => x.Def is not null)
.Select(x => ParticipationRatingScale.Normalize(x.Def!.ValueType, x.Rating.Value, x.Def.MaxPoints))
.ToList();
if (normalized.Count == 0) return null;
var adjustment = dayHighlight switch
{
DayHighlightKind.Standout => DayHighlightAdjustment,
DayHighlightKind.Sleepy or DayHighlightKind.Rough => -DayHighlightAdjustment,
_ => 0,
};
var estimate = (int)Math.Round(normalized.Average() + adjustment, MidpointRounding.AwayFromZero);
return Math.Clamp(estimate, -2, 2);
}
=> LehrerApp.Core.Services.ParticipationCompetencyEstimate.Estimate(ratings,
aspects.Select(x => new ParticipationAspect
{
Key = x.Key,
ValueType = x.ValueType,
MaxPoints = x.MaxPoints,
}).ToList(), dayHighlight);
}
public class ParticipationSessionItem
@@ -33,6 +33,20 @@ public partial class SettingsViewModel
[ObservableProperty] private string _untisPassword = "";
[ObservableProperty] private string _untisApiStatus = "";
[ObservableProperty] private bool _untisApiBusy;
public bool UntisScheduleSyncIsConfigured => UntisApiIsConfigured || UntisIsConfigured;
public string UntisScheduleSyncSource => UntisApiIsConfigured
? "Direkter WebUntis-REST-Abgleich aktiv; iCal wird nur ohne API-Zugang verwendet."
: "iCal-Fallback aktiv.";
partial void OnUntisApiIsConfiguredChanged(bool value)
{
OnPropertyChanged(nameof(UntisScheduleSyncIsConfigured));
OnPropertyChanged(nameof(UntisScheduleSyncSource));
}
partial void OnUntisIsConfiguredChanged(bool value)
{
OnPropertyChanged(nameof(UntisScheduleSyncIsConfigured));
OnPropertyChanged(nameof(UntisScheduleSyncSource));
}
[ObservableProperty] private string? _untisHomeroomClassName;
public string UntisHomeroomClassDisplay => UntisHomeroomClassName is { Length: > 0 } name
? $"Ausgewählt: {name}"
@@ -44,8 +58,8 @@ public partial class SettingsViewModel
// ── WebUntis-iCal-Abgleich: Laden / Speichern / Entfernen / Jetzt abrufen ────
//
// Wie bei Sync deckt ein Neustart das Registrieren von UntisSyncService ab
// (AppBootstrapper registriert es nur einmalig beim Start, wenn URL+Enabled vorliegen).
// UntisSyncService ist immer registriert und entscheidet je Abruf: REST bei vorhandenem
// API-Zugang, sonst der weiterhin unterstützte iCal-Fallback.
private void LoadUntisSettings()
{
@@ -37,7 +37,7 @@
</StackPanel>
</Grid>
<TextBlock Text="Prozentwerte beruhen auf bewerteten Klausuraufgaben. Eine Aufgabe mit mehreren Kompetenzcodes zählt für jede dieser Kompetenzen."
<TextBlock Text="Prozentwerte beruhen auf bewerteten Klausuraufgaben und Kompetenzwerten aus der Mitarbeit. Automatische Mitarbeitsschätzungen werden dabei wie in der Mitarbeitstabelle berücksichtigt."
TextWrapping="Wrap" FontSize="11" Opacity="0.55" IsVisible="{Binding HasRows}"/>
</StackPanel>
@@ -56,6 +56,7 @@
<DataGridTextColumn Header="Kompetenz" Binding="{Binding Description}" Width="2*"/>
<DataGridTextColumn Header="Unterricht" Binding="{Binding InstructionCoverage}" Width="145"/>
<DataGridTextColumn Header="Klausuren" Binding="{Binding ExamCoverage}" Width="135"/>
<DataGridTextColumn Header="Mitarbeit" Binding="{Binding ParticipationCoverage}" Width="120"/>
<DataGridTextColumn Header="Gruppenmittel" Binding="{Binding GroupResult}" Width="210"/>
<DataGridTextColumn Header="Ausgewählter Schüler" Binding="{Binding StudentResult}" Width="160"/>
<DataGridTemplateColumn Header="Hinweis" Width="155">
@@ -1145,9 +1145,9 @@
<TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Ruft periodisch den persönlichen iCal-Export von WebUntis ab, um Vertretungen, Ausfälle und Raumänderungen automatisch zu erkennen und in den Stundenplan zu übernehmen. Der Link enthält ein Zugangs-Token und wird verschlüsselt gespeichert."/>
Text="Verwendet bei eingerichtetem API-Zugang den direkten WebUntis-REST-Stundenplan für Vertretungen, Ausfälle, Aufsichten und Arbeiten. Ohne API-Zugang bleibt der persönliche iCal-Export als Fallback verfügbar."/>
<StackPanel Spacing="8" IsVisible="{Binding !UntisIsConfigured}">
<StackPanel Spacing="8" IsVisible="{Binding !UntisScheduleSyncIsConfigured}">
<StackPanel Spacing="4">
<TextBlock Text="iCal-URL" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding UntisIcalUrlInput}" PasswordChar="●"
@@ -1158,14 +1158,16 @@
<Button Content="Speichern und aktivieren" Command="{Binding UntisSaveUrlCommand}" HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding UntisIsConfigured}">
<TextBlock Text="iCal-URL hinterlegt (verschlüsselt gespeichert)." FontSize="13" FontWeight="SemiBold"/>
<StackPanel Spacing="8" IsVisible="{Binding UntisScheduleSyncIsConfigured}">
<TextBlock Text="{Binding UntisScheduleSyncSource}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding UntisStatusDisplay}" FontSize="12" Opacity="0.7"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Jetzt abrufen" Command="{Binding UntisFetchNowCommand}"
IsEnabled="{Binding !UntisFetchBusy}"/>
<Button Content="Zuordnung prüfen…" Command="{Binding UntisReviewMappingCommand}"/>
<Button Content="Entfernen" Command="{Binding UntisRemoveCommand}"/>
<Button Content="Zuordnung prüfen…" Command="{Binding UntisReviewMappingCommand}"
IsVisible="{Binding UntisIsConfigured}"/>
<Button Content="iCal entfernen" Command="{Binding UntisRemoveCommand}"
IsVisible="{Binding UntisIsConfigured}"/>
</StackPanel>
</StackPanel>
@@ -108,6 +108,50 @@ public sealed class CompetencyAnalysisServiceTests
Assert.Empty(item.StudentScores);
}
[Fact]
public void Analyze_MitarbeitMitAutomatischerSchaetzung_FuelltKompetenzprofil()
{
var studentId = Guid.NewGuid();
var session = new ParticipationSession { Competencies = [Ref(K1)] };
var entry = new ParticipationEntry
{
SessionId = session.Id,
StudentId = studentId,
Ratings =
[
new AspectRating { Key = "quality", Value = 1 },
new AspectRating { Key = "quantity", Value = 2 },
],
};
var item = Assert.Single(_service.Analyze(Catalog(), [], [], [], [session], [entry],
[.. DefaultParticipationAspects.All]));
Assert.Equal(1, item.ParticipationSessionCount);
Assert.Equal(100, item.GroupScore!.Percent, 6); // Mittel 1,5 wird wie in der UI auf +2 gerundet.
Assert.Equal(1, item.GroupScore.ParticipationEvidenceCount);
Assert.Equal(100, item.StudentScores[studentId].Percent, 6);
}
[Fact]
public void Analyze_ManuelleKompetenzbewertung_UeberschreibtAutomatischeSchaetzung()
{
var studentId = Guid.NewGuid();
var session = new ParticipationSession { Competencies = [Ref(K1)] };
var entry = new ParticipationEntry
{
SessionId = session.Id,
StudentId = studentId,
Ratings = [new AspectRating { Key = "quality", Value = 2 }],
CompetencyRatings = [new CompetencyRating { ItemId = K1.Id, Value = -1 }],
};
var item = Assert.Single(_service.Analyze(Catalog(), [], [], [], [session], [entry],
[.. DefaultParticipationAspects.All]));
Assert.Equal(25, item.StudentScores[studentId].Percent, 6);
}
[Fact]
public void Analyze_GeloeschteKompetenz_BleibtMitGespeicherterMomentaufnahmeSichtbar()
{
+24
View File
@@ -60,6 +60,18 @@ public sealed class UntisDiffServiceTests
Assert.Contains("Fach weicht ab", entry.Description);
}
[Fact]
public void Diff_AbweichenderDoppelblock_ErzeugtVertretungFuerBeideStunden()
{
var mapping = BuildMapping(Guid.NewGuid());
mapping.CoveredPeriods = [1, 2];
var result = new UntisDiffService().Diff([BuildEvent("block", summary: "NAT")], [], [mapping], Monday);
Assert.Equal([1, 2], result.SubstitutionsToSave.Select(entry => entry.PeriodNumber).ToArray());
Assert.Equal(2, result.SubstitutionsToSave.Select(entry => entry.ExternalId).Distinct().Count());
}
[Fact]
public void Diff_AbweichendeKlasse_ErzeugtVertretungsstunde()
{
@@ -202,6 +214,18 @@ public sealed class UntisDiffServiceTests
Assert.Equal(1, entry.PeriodNumber);
}
[Fact]
public void Diff_StatusCancelledBeiDoppelblock_ErzeugtAusfallFuerBeideStunden()
{
var mapping = BuildMapping(Guid.NewGuid());
mapping.CoveredPeriods = [1, 2];
var result = new UntisDiffService().Diff([BuildEvent("block", status: "CANCELLED")], [], [mapping], Monday);
Assert.Equal([1, 2], result.SubstitutionsToSave.Select(entry => entry.PeriodNumber).ToArray());
Assert.All(result.SubstitutionsToSave, entry => Assert.Equal(SubstitutionKind.Cancelled, entry.Kind));
}
[Fact]
public void Diff_VerschwundenerTerminInnerhalbLookahead_ErzeugtAusfall()
{
@@ -72,6 +72,48 @@ public sealed class WebUntisClientTests
await client.DisposeAsync();
}
[Fact]
public async Task GetTimetableGridAsync_ErzeugtBearerUndLiestExamMitDoppelblock()
{
var login = Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}");
login.Headers.TryAddWithoutValidation("Set-Cookie", "Tenant-Id=\"4026000\"; Path=/");
var jwtPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"exp\":4102444800}"))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
var handler = new QueueHandler(
login,
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent($"x.{jwtPayload}.signature", Encoding.UTF8, "application/json"),
},
Json("{\"format\":1,\"errors\":[],\"days\":[{\"gridEntries\":[{" +
"\"ids\":[2002979,2002982],\"duration\":{\"start\":\"2026-09-29T09:40\"," +
"\"end\":\"2026-09-29T11:10\"},\"type\":\"EXAM\",\"status\":\"REGULAR\"," +
"\"lessonInfo\":\"Arbeit 1\",\"position1\":[{\"current\":{\"type\":\"CLASS\"," +
"\"shortName\":\"10c\"},\"removed\":null}],\"position2\":[{\"current\":{" +
"\"type\":\"SUBJECT\",\"shortName\":\"Mat_E\"},\"removed\":null}]}]}]}"),
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var entries = await client.GetTimetableGridAsync(89, 22,
new DateOnly(2026, 9, 28), new DateOnly(2026, 10, 2), CancellationToken.None);
var exam = Assert.Single(entries);
Assert.Equal("EXAM", exam.Type);
Assert.Equal(new DateTime(2026, 9, 29, 9, 40, 0), exam.Start);
Assert.Equal(new DateTime(2026, 9, 29, 11, 10, 0), exam.End);
Assert.Equal([2002979, 2002982], exam.Ids);
Assert.Equal("10c", exam.CurrentResources.Single(resource => resource.Type == "CLASS").ShortName);
Assert.Equal("Mat_E", exam.CurrentResources.Single(resource => resource.Type == "SUBJECT").ShortName);
Assert.EndsWith("/WebUntis/api/token/new", handler.Requests[1].Uri);
Assert.Contains("/api/rest/view/v1/timetable/entries", handler.Requests[2].Uri);
Assert.Contains("start=2026-09-28&end=2026-10-02", handler.Requests[2].Uri);
Assert.Equal("Bearer x." + jwtPayload + ".signature", handler.Requests[2].Authorization);
Assert.Equal("22", handler.Requests[2].SchoolYearId);
Assert.Contains("Tenant-Id=\"4026000\"", handler.Requests[2].Cookie);
await client.DisposeAsync();
}
[Fact]
public async Task FehlendeKonfiguration_BrichtVorHttpRequestAb()
{
@@ -318,7 +360,9 @@ public sealed class WebUntisClientTests
Requests.Add(new CapturedRequest(
request.RequestUri?.ToString() ?? "",
request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken),
string.Join("; ", request.Headers.TryGetValues("Cookie", out var cookies) ? cookies : [])));
string.Join("; ", request.Headers.TryGetValues("Cookie", out var cookies) ? cookies : []),
request.Headers.Authorization?.ToString() ?? "",
string.Join("", request.Headers.TryGetValues("x-webuntis-api-school-year-id", out var years) ? years : [])));
return _responses.Count > 0
? _responses.Dequeue()
: throw new InvalidOperationException("Keine Testantwort mehr vorhanden.");
@@ -353,5 +397,6 @@ public sealed class WebUntisClientTests
}
}
private sealed record CapturedRequest(string Uri, string Body, string Cookie);
private sealed record CapturedRequest(string Uri, string Body, string Cookie, string Authorization,
string SchoolYearId);
}
+178
View File
@@ -2,6 +2,7 @@ using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Globalization;
namespace LehrerApp.WebUntis;
@@ -30,6 +31,8 @@ public sealed partial class WebUntisClient : IAsyncDisposable
// (siehe _sessionId), deshalb hier ausgeklammert.
private string? _serverCookies;
private string? _tenantId;
private string? _restBearerToken;
private DateTimeOffset _restBearerExpiresAt;
private DateTimeOffset _sessionExpiresAt;
private int _activeRequests;
private bool _disposed;
@@ -270,6 +273,59 @@ public sealed partial class WebUntisClient : IAsyncDisposable
Entities(entry, "ro"), entry.Clone())).ToList();
}, cancellationToken);
/// <summary>Ruft den neuen, von der WebUntis-Weboberfläche verwendeten Stundenplan ab. Der
/// kurzlebige Bearer wird aus der vorhandenen JSON-RPC-Sitzung erzeugt und nie persistiert.</summary>
public Task<IReadOnlyList<UntisTimetableGridEntry>> GetTimetableGridAsync(int teacherId, int schoolYearId,
DateOnly start, DateOnly end, CancellationToken cancellationToken) => WithSessionAsync(sessionId =>
GetTimetableGridCoreAsync(sessionId, teacherId, schoolYearId, start, end, cancellationToken),
cancellationToken);
public Task<IReadOnlyList<UntisTimetableGridEntry>> GetOwnTimetableGridAsync(int schoolYearId,
DateOnly start, DateOnly end, CancellationToken cancellationToken) => WithSessionAsync(sessionId =>
{
var teacherId = _myTeacherUntisId
?? throw new WebUntisException("WebUntis hat keine Lehrer-Kennung (personId) für diese Sitzung geliefert.");
return GetTimetableGridCoreAsync(sessionId, teacherId, schoolYearId, start, end, cancellationToken);
}, cancellationToken);
private async Task<IReadOnlyList<UntisTimetableGridEntry>> GetTimetableGridCoreAsync(string sessionId,
int teacherId, int schoolYearId, DateOnly start, DateOnly end, CancellationToken cancellationToken)
{
var bearer = await GetRestBearerTokenAsync(sessionId, cancellationToken);
var configuration = GetConfiguration();
var uri = $"https://{configuration.Host}/WebUntis/api/rest/view/v1/timetable/entries" +
$"?start={start:yyyy-MM-dd}&end={end:yyyy-MM-dd}&format=1" +
$"&resourceType=TEACHER&resources={teacherId}&periodTypes=" +
"&timetableType=MY_TIMETABLE&layout=START_TIME";
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
ApplyBrowserHeaders(request, configuration.Host);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
request.Headers.Add("Cookie", SessionCookie(sessionId));
if (_tenantId is not null) request.Headers.Add("tenant-id", _tenantId);
request.Headers.Add("x-webuntis-api-school-year-id", schoolYearId.ToString(CultureInfo.InvariantCulture));
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
var payload = await ReadJsonAsync(response, cancellationToken);
if (!response.IsSuccessStatusCode)
throw new WebUntisException($"WebUntis-REST-Stundenplan fehlgeschlagen: {ErrorMessage(payload, response)}");
if (!TryProperty(payload, "days", out var days) || days.ValueKind != JsonValueKind.Array)
throw new WebUntisException("WebUntis hat keinen gültigen REST-Stundenplan geliefert.");
if (TryProperty(payload, "errors", out var errors) && errors.ValueKind == JsonValueKind.Array &&
errors.GetArrayLength() > 0)
throw new WebUntisException("WebUntis hat Fehler im REST-Stundenplan gemeldet.");
var entries = new List<UntisTimetableGridEntry>();
foreach (var day in days.EnumerateArray())
{
if (!TryProperty(day, "gridEntries", out var gridEntries) || gridEntries.ValueKind != JsonValueKind.Array)
continue;
foreach (var entry in gridEntries.EnumerateArray())
entries.Add(ParseGridEntry(entry));
}
return (IReadOnlyList<UntisTimetableGridEntry>)entries;
}
public Task<IReadOnlyList<UntisClassRegisterEntry>> GetClassRegisterEntriesAsync(int studentId,
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
@@ -351,6 +407,8 @@ public sealed partial class WebUntisClient : IAsyncDisposable
$"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " +
"Schulkennung mit der WebUntis-Anmeldeseite vergleichen.");
_myTeacherUntisId = OptionalInt(result, "personId");
_restBearerToken = null;
_restBearerExpiresAt = default;
await Task.Delay(PostLoginDelay, cancellationToken);
}
@@ -397,6 +455,8 @@ public sealed partial class WebUntisClient : IAsyncDisposable
sessionId = _sessionId;
_sessionId = null;
_restBearerToken = null;
_restBearerExpiresAt = default;
}
finally
{
@@ -438,6 +498,124 @@ public sealed partial class WebUntisClient : IAsyncDisposable
private TimeSpan SessionIdleTimeout =>
TimeSpan.FromMinutes(Math.Clamp(_options.SessionIdleTimeoutMinutes, 1, 30));
private async Task<string> GetRestBearerTokenAsync(string sessionId, CancellationToken cancellationToken)
{
if (_restBearerToken is not null && _restBearerExpiresAt > DateTimeOffset.UtcNow.AddMinutes(1))
return _restBearerToken;
var configuration = GetConfiguration();
using var request = new HttpRequestMessage(HttpMethod.Get,
$"https://{configuration.Host}/WebUntis/api/token/new");
ApplyBrowserHeaders(request, configuration.Host);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Add("Cookie", SessionCookie(sessionId));
if (_tenantId is not null) request.Headers.Add("tenant-id", _tenantId);
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
var payload = ParseJson(bytes);
if (!response.IsSuccessStatusCode)
throw new WebUntisException($"WebUntis-REST-Token fehlgeschlagen: {ErrorMessage(payload, response)}");
var token = payload.ValueKind == JsonValueKind.String ? payload.GetString()
: OptionalString(payload, "token") ?? OptionalString(payload, "accessToken") ??
OptionalString(payload, "access_token") ?? FindJwt(payload);
if (string.IsNullOrWhiteSpace(token))
{
try { token = StrictUtf8.GetString(bytes).Trim().Trim('"'); }
catch (DecoderFallbackException) { token = null; }
}
if (string.IsNullOrWhiteSpace(token) || token.Count(character => character == '.') != 2)
throw new WebUntisException($"WebUntis hat keinen gültigen REST-Token geliefert ({TokenShape(payload)})." );
_restBearerToken = token;
_restBearerExpiresAt = ReadJwtExpiry(token) ?? DateTimeOffset.UtcNow.AddMinutes(10);
return token;
}
private static string? FindJwt(JsonElement value)
{
if (value.ValueKind == JsonValueKind.String)
{
var candidate = value.GetString();
return candidate is not null && candidate.Count(character => character == '.') == 2
? candidate
: null;
}
if (value.ValueKind == JsonValueKind.Object)
foreach (var property in value.EnumerateObject())
if (FindJwt(property.Value) is { } token) return token;
if (value.ValueKind == JsonValueKind.Array)
foreach (var item in value.EnumerateArray())
if (FindJwt(item) is { } token) return token;
return null;
}
private static string TokenShape(JsonElement value) => value.ValueKind switch
{
JsonValueKind.Object => "Objekt: " + string.Join(", ", value.EnumerateObject()
.Select(property => $"{property.Name}={TokenShape(property.Value)}")),
JsonValueKind.Array => $"Array[{value.GetArrayLength()}]",
JsonValueKind.String => $"Text[{value.GetString()?.Length ?? 0}]",
_ => value.ValueKind.ToString(),
};
private static DateTimeOffset? ReadJwtExpiry(string token)
{
try
{
var parts = token.Split('.');
if (parts.Length < 2) return null;
var payload = parts[1].Replace('-', '+').Replace('_', '/');
payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '=');
using var json = JsonDocument.Parse(Convert.FromBase64String(payload));
return json.RootElement.TryGetProperty("exp", out var exp) && exp.TryGetInt64(out var seconds)
? DateTimeOffset.FromUnixTimeSeconds(seconds)
: null;
}
catch (Exception exception) when (exception is FormatException or JsonException or ArgumentOutOfRangeException)
{
return null;
}
}
private static UntisTimetableGridEntry ParseGridEntry(JsonElement entry)
{
if (!TryProperty(entry, "duration", out var duration) || duration.ValueKind != JsonValueKind.Object ||
!TryGridDateTime(duration, "start", out var start) || !TryGridDateTime(duration, "end", out var end))
throw new WebUntisException("WebUntis-REST-GridEntry hat keine gültige Zeitspanne.");
var type = OptionalString(entry, "type")
?? throw new WebUntisException("WebUntis-REST-GridEntry hat keinen Typ.");
var resources = new List<UntisGridResourceChange>();
for (var position = 1; position <= 7; position++)
{
if (!TryProperty(entry, $"position{position}", out var values) || values.ValueKind != JsonValueKind.Array)
continue;
foreach (var value in values.EnumerateArray())
resources.Add(new UntisGridResourceChange(ParseGridResource(value, "current"),
ParseGridResource(value, "removed")));
}
return new UntisTimetableGridEntry(IntArray(entry, "ids"), start, end, type,
OptionalString(entry, "status"), OptionalString(entry, "statusDetail"), OptionalString(entry, "name"),
OptionalString(entry, "lessonText"), OptionalString(entry, "lessonInfo"),
OptionalString(entry, "substitutionText"), resources, entry.Clone());
}
private static UntisGridResource? ParseGridResource(JsonElement parent, string property)
{
if (!TryProperty(parent, property, out var value) || value.ValueKind != JsonValueKind.Object) return null;
var type = OptionalString(value, "type");
return type is null ? null : new UntisGridResource(type, OptionalString(value, "status"),
OptionalString(value, "shortName"), OptionalString(value, "longName"),
OptionalString(value, "displayName"));
}
private static bool TryGridDateTime(JsonElement parent, string property, out DateTime value)
{
value = default;
return OptionalString(parent, property) is { } text && DateTime.TryParseExact(text,
["yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm:ss"], CultureInfo.InvariantCulture,
DateTimeStyles.None, out value);
}
private async Task<JsonElement> RpcAsync(string method, object parameters, string? sessionId,
CancellationToken cancellationToken, Action<HttpResponseMessage>? onResponse = null)
{
+37
View File
@@ -135,6 +135,43 @@ public sealed record UntisTimetablePeriod(
IReadOnlyList<UntisEntity> Rooms,
JsonElement Raw);
/// <summary>Eine Ressource innerhalb eines Eintrags des neuen WebUntis-Stundenplans. Bei
/// Vertretungen kann <see cref="UntisGridResourceChange.Removed"/> die ursprüngliche und
/// <see cref="UntisGridResourceChange.Current"/> die tatsächliche Belegung enthalten.</summary>
public sealed record UntisGridResource(
string Type,
string? Status,
string? ShortName,
string? LongName,
string? DisplayName);
public sealed record UntisGridResourceChange(
UntisGridResource? Current,
UntisGridResource? Removed);
/// <summary>Typisierter GridEntry aus der REST-Schnittstelle, mit der auch die WebUntis-Webseite
/// ihren Stundenplan aufbaut. Anders als der iCal-Export enthält sie explizite Typen wie
/// NORMAL_TEACHING_PERIOD, ADDITIONAL_PERIOD, BREAK_SUPERVISION und EXAM.</summary>
public sealed record UntisTimetableGridEntry(
IReadOnlyList<int> Ids,
DateTime Start,
DateTime End,
string Type,
string? Status,
string? StatusDetail,
string? Name,
string? LessonText,
string? LessonInfo,
string? SubstitutionText,
IReadOnlyList<UntisGridResourceChange> Resources,
JsonElement Raw)
{
public IReadOnlyList<UntisGridResource> CurrentResources => Resources
.Where(change => change.Current is not null).Select(change => change.Current!).ToList();
public IReadOnlyList<UntisGridResource> RemovedResources => Resources
.Where(change => change.Removed is not null).Select(change => change.Removed!).ToList();
}
public sealed record UntisLessonAbsence(
string StudentName,
int Date,
+5
View File
@@ -439,6 +439,11 @@ Mitarbeit-Note für denselben Zeitraum statt sie zu duplizieren (erkannt über d
zeigt sie bis zum nächsten Neuladen leer, danach erscheint wieder die (neu berechnete)
Schätzung — es gibt keinen persistierten "bewusst leer"-Zustand, da das eine eigene
Tombstone-Markierung bräuchte.
**Nachtrag:** Die Kompetenzübersicht der Lerngruppe berücksichtigt diese automatischen
Schätzungen jetzt ebenfalls. Dazu lädt sie die zugeordneten Mitarbeitssitzungen samt
Einträgen und Aspektdefinitionen; explizite `CompetencyRating`-Werte haben weiterhin Vorrang.
Klausur- und Mitarbeitswerte werden im gemeinsamen Prozentprofil zusammengeführt und die
Herkunft der Evidenz wird in der Anzeige getrennt ausgewiesen.
### Abschnittsnoten & Mitarbeits-Assistent (nicht aus dieser Liste, eigener Workflow-Bedarf)