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."/> - + - - + +