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