diff --git a/LehrerApp.Core/Models/Exam.cs b/LehrerApp.Core/Models/Exam.cs
index d095530..813862e 100644
--- a/LehrerApp.Core/Models/Exam.cs
+++ b/LehrerApp.Core/Models/Exam.cs
@@ -15,6 +15,9 @@ public class Exam
public string? Notes { get; set; }
public DateOnly? ApprovalGrantedAt { get; set; }
public DateOnly? AnnouncedAt { get; set; }
+ /// Stabile Kennung einer automatisch importierten Arbeit. Bleibt bei manuell
+ /// angelegten Klausuren null; aktuell wird der Präfix "webuntis-rest-exam:" verwendet.
+ 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).
diff --git a/LehrerApp.Core/Services/CompetencyAnalysisService.cs b/LehrerApp.Core/Services/CompetencyAnalysisService.cs
index 78131f0..dd2d72d 100644
--- a/LehrerApp.Core/Services/CompetencyAnalysisService.cs
+++ b/LehrerApp.Core/Services/CompetencyAnalysisService.cs
@@ -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 StudentScores { get; init; } =
new Dictionary();
@@ -29,7 +31,10 @@ public sealed class CompetencyAnalysisService
IReadOnlyList catalog,
IReadOnlyList units,
IReadOnlyList exams,
- IReadOnlyList results)
+ IReadOnlyList results,
+ IReadOnlyList? participationSessions = null,
+ IReadOnlyList? participationEntries = null,
+ IReadOnlyList? participationAspects = null)
{
var builders = new List();
var byId = new Dictionary();
@@ -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);
}
}
diff --git a/LehrerApp.Core/Services/ParticipationCompetencyEstimate.cs b/LehrerApp.Core/Services/ParticipationCompetencyEstimate.cs
new file mode 100644
index 0000000..c66b9df
--- /dev/null
+++ b/LehrerApp.Core/Services/ParticipationCompetencyEstimate.cs
@@ -0,0 +1,36 @@
+using LehrerApp.Core.Models;
+
+namespace LehrerApp.Core.Services;
+
+///
+/// Leitet aus den allgemeinen Mitarbeitsaspekten einen Kompetenzwert auf der gemeinsamen
+/// Skala von -2 bis +2 ab. Explizite Kompetenzbewertungen werden vom Aufrufer bevorzugt.
+///
+public static class ParticipationCompetencyEstimate
+{
+ public static int? Estimate(IEnumerable ratings,
+ IReadOnlyCollection 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);
+ }
+}
diff --git a/LehrerApp.Core/Services/UntisDiffService.cs b/LehrerApp.Core/Services/UntisDiffService.cs
index d05fbb6..388dedc 100644
--- a/LehrerApp.Core/Services/UntisDiffService.cs
+++ b/LehrerApp.Core/Services/UntisDiffService.cs
@@ -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 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 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 Periods(UntisSlotMapping mapping) => mapping.CoveredPeriods.Count > 0
+ ? mapping.CoveredPeriods.Distinct().Order().ToList()
+ : mapping.PeriodNumber is { } period ? [period] : [];
+
+ private static IEnumerable 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.
diff --git a/LehrerApp.Desktop.Tests/CompetencyOverviewViewModelTests.cs b/LehrerApp.Desktop.Tests/CompetencyOverviewViewModelTests.cs
index ef53e2d..93f05fa 100644
--- a/LehrerApp.Desktop.Tests/CompetencyOverviewViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/CompetencyOverviewViewModelTests.cs
@@ -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);
+ }
}
diff --git a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs
index 8b747ec..994a7eb 100644
--- a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs
@@ -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));
diff --git a/LehrerApp.Desktop.Tests/WebUntisRestSyncProcessorTests.cs b/LehrerApp.Desktop.Tests/WebUntisRestSyncProcessorTests.cs
new file mode 100644
index 0000000..41e8bf1
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/WebUntisRestSyncProcessorTests.cs
@@ -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 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 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? initial = null) : IExamRepository
+ {
+ private readonly List _all = initial ?? [];
+ public Exam? GetById(Guid id) => _all.FirstOrDefault(exam => exam.Id == id);
+ public List GetAll() => _all.ToList();
+ public List 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);
+ }
+}
diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs
index a7df39d..1f7cbd9 100644
--- a/LehrerApp.Desktop/AppBootstrapper.cs
+++ b/LehrerApp.Desktop/AppBootstrapper.cs
@@ -238,23 +238,23 @@ public static class AppBootstrapper
services.AddSingleton();
services.AddSingleton();
- // ── 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();
services.AddSingleton();
- if (untisSettings.Enabled && !string.IsNullOrEmpty(untisSettings.GetIcalUrl()))
- {
- services.AddSingleton(sp => new UntisSyncService(
- new HttpClient(), untisSettings,
- sp.GetRequiredService(), sp.GetRequiredService(),
- sp.GetRequiredService(), sp.GetRequiredService(),
- sp.GetRequiredService(), sp.GetRequiredService(),
- sp.GetRequiredService(), sp.GetRequiredService(),
- sp.GetRequiredService(), sp.GetRequiredService(),
- sp.GetRequiredService(), sp.GetRequiredService(),
- sp.GetRequiredService()));
- }
+ services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
+ services.AddSingleton();
+ services.AddSingleton(sp => new UntisSyncService(
+ new HttpClient(), untisSettings,
+ sp.GetRequiredService(), sp.GetRequiredService(),
+ sp.GetRequiredService(), sp.GetRequiredService(),
+ sp.GetRequiredService(), sp.GetRequiredService(),
+ sp.GetRequiredService(), sp.GetRequiredService(),
+ sp.GetRequiredService(), sp.GetRequiredService(),
+ sp.GetRequiredService(), sp.GetRequiredService(),
+ sp.GetRequiredService(), sp.GetRequiredService(),
+ sp.GetRequiredService()));
// ── 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();
services.AddSingleton();
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
diff --git a/LehrerApp.Desktop/Services/UntisSyncService.cs b/LehrerApp.Desktop/Services/UntisSyncService.cs
index f170a70..6e08fc5 100644
--- a/LehrerApp.Desktop/Services/UntisSyncService.cs
+++ b/LehrerApp.Desktop/Services/UntisSyncService.cs
@@ -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();
+ 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)
diff --git a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs
index 1ed0f5f..d6c28bd 100644
--- a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs
+++ b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs
@@ -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 Classes, IReadOnlyList Teachers,
IReadOnlyList Subjects, IReadOnlyList 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 Ids, DateTime Start, DateTime End, string Type,
+ string? Status, string? StatusDetail, string? Name, string? LessonText, string? LessonInfo,
+ string? SubstitutionText, IReadOnlyList 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> 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> GetOwnTimetableGridAsync(int schoolYearId,
+ DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(
+ client => GridEntries(client, schoolYearId, start, end, token), token);
+
+ private static async Task> GridEntries(WebUntisClient client,
+ int schoolYearId, DateOnly start, DateOnly end, CancellationToken token)
+ {
+ var entries = await client.GetOwnTimetableGridAsync(schoolYearId, start, end, token);
+ return (IReadOnlyList)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 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 Entities(IReadOnlyList 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) =>
diff --git a/LehrerApp.Desktop/Services/WebUntisRestSyncProcessor.cs b/LehrerApp.Desktop/Services/WebUntisRestSyncProcessor.cs
new file mode 100644
index 0000000..709140a
--- /dev/null
+++ b/LehrerApp.Desktop/Services/WebUntisRestSyncProcessor.cs
@@ -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);
+
+/// 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.
+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 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 BuildSubstitutions(UntisTimetableGridEntryDto entry,
+ List 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 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 examEntries,
+ List confirmedMappings, DateOnly from, DateOnly to)
+ {
+ var changes = 0;
+ var desiredIds = new HashSet();
+ 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 candidates, DayOfWeek weekday, TimeOnly start,
+ List 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 classNames, string? subject,
+ int period, DayOfWeek weekday, List 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 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 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;
+}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/CompetencyOverviewViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/CompetencyOverviewViewModels.cs
index 61e8e84..87dc2ad 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/CompetencyOverviewViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/CompetencyOverviewViewModels.cs
@@ -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();
+ 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}";
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs
index 8d227ab..e57d441 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs
@@ -767,26 +767,14 @@ public class AspectColumnDef
/// Tagesform als die fachliche Kompetenz beschreibt.
public static class ParticipationCompetencyEstimate
{
- private const double DayHighlightAdjustment = 1.0;
-
public static int? Estimate(List ratings, IReadOnlyList 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
diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.WebUntis.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.WebUntis.cs
index 9b97daf..e128683 100644
--- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.WebUntis.cs
+++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.WebUntis.cs
@@ -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()
{
diff --git a/LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml b/LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml
index 6f85772..016b6ff 100644
--- a/LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml
+++ b/LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml
@@ -37,7 +37,7 @@
-
@@ -56,6 +56,7 @@
+
diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
index 501bc9c..15f3395 100644
--- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
+++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
@@ -1145,9 +1145,9 @@
+ 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."/>
-
+
-
-
+
+
-
-
+
+
diff --git a/LehrerApp.Tests/CompetencyAnalysisServiceTests.cs b/LehrerApp.Tests/CompetencyAnalysisServiceTests.cs
index df105e1..7f9674a 100644
--- a/LehrerApp.Tests/CompetencyAnalysisServiceTests.cs
+++ b/LehrerApp.Tests/CompetencyAnalysisServiceTests.cs
@@ -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()
{
diff --git a/LehrerApp.Tests/UntisDiffServiceTests.cs b/LehrerApp.Tests/UntisDiffServiceTests.cs
index 59297c0..a8943ae 100644
--- a/LehrerApp.Tests/UntisDiffServiceTests.cs
+++ b/LehrerApp.Tests/UntisDiffServiceTests.cs
@@ -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()
{
diff --git a/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs
index ce81bbd..762843b 100644
--- a/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs
+++ b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs
@@ -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);
}
diff --git a/LehrerApp.WebUntis/WebUntisClient.cs b/LehrerApp.WebUntis/WebUntisClient.cs
index 1e4b3b3..d55ed40 100644
--- a/LehrerApp.WebUntis/WebUntisClient.cs
+++ b/LehrerApp.WebUntis/WebUntisClient.cs
@@ -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);
+ /// 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.
+ public Task> GetTimetableGridAsync(int teacherId, int schoolYearId,
+ DateOnly start, DateOnly end, CancellationToken cancellationToken) => WithSessionAsync(sessionId =>
+ GetTimetableGridCoreAsync(sessionId, teacherId, schoolYearId, start, end, cancellationToken),
+ cancellationToken);
+
+ public Task> 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> 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();
+ 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)entries;
+ }
+
public Task> 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 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();
+ 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 RpcAsync(string method, object parameters, string? sessionId,
CancellationToken cancellationToken, Action? onResponse = null)
{
diff --git a/LehrerApp.WebUntis/WebUntisModels.cs b/LehrerApp.WebUntis/WebUntisModels.cs
index 7077964..0fe6ae5 100644
--- a/LehrerApp.WebUntis/WebUntisModels.cs
+++ b/LehrerApp.WebUntis/WebUntisModels.cs
@@ -135,6 +135,43 @@ public sealed record UntisTimetablePeriod(
IReadOnlyList Rooms,
JsonElement Raw);
+/// Eine Ressource innerhalb eines Eintrags des neuen WebUntis-Stundenplans. Bei
+/// Vertretungen kann die ursprüngliche und
+/// die tatsächliche Belegung enthalten.
+public sealed record UntisGridResource(
+ string Type,
+ string? Status,
+ string? ShortName,
+ string? LongName,
+ string? DisplayName);
+
+public sealed record UntisGridResourceChange(
+ UntisGridResource? Current,
+ UntisGridResource? Removed);
+
+/// 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.
+public sealed record UntisTimetableGridEntry(
+ IReadOnlyList Ids,
+ DateTime Start,
+ DateTime End,
+ string Type,
+ string? Status,
+ string? StatusDetail,
+ string? Name,
+ string? LessonText,
+ string? LessonInfo,
+ string? SubstitutionText,
+ IReadOnlyList Resources,
+ JsonElement Raw)
+{
+ public IReadOnlyList CurrentResources => Resources
+ .Where(change => change.Current is not null).Select(change => change.Current!).ToList();
+ public IReadOnlyList RemovedResources => Resources
+ .Where(change => change.Removed is not null).Select(change => change.Removed!).ToList();
+}
+
public sealed record UntisLessonAbsence(
string StudentName,
int Date,
diff --git a/TODO.md b/TODO.md
index db8768f..b414799 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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)