WIP (unstable): Klassenlehrer-Tab (fremde Klassenbucheinträge + Fehlzeiten der Klasse)

Neuer Tab in der Gruppendetailansicht (LearningGroup.IsClassTeacher-Flag), zeigt
Klassenbucheinträge anderer Lehrkräfte sowie Fehlzeiten der ganzen Klasse über alle
Fächer, pro Schüler*in und Tag zusammengefasst. Beides rein lesend, kein Übernehmen
in lokale Daten. Fehlzeiten-Bericht (AbsencePerStudent) adressiert die Klasse über
"KL"+getKlassen-ID, automatisch aufgelöst - noch nicht mit echtem WebUntis-Zugang
gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 21:19:39 +02:00
co-authored by Claude Sonnet 5
parent f9f5f75aa8
commit 7245196689
17 changed files with 649 additions and 7 deletions
+7
View File
@@ -14,6 +14,13 @@ public class LearningGroup
public bool IsOwnClass { get; set; }
public bool IsDifferentiated { get; set; }
/// <summary>
/// Ich bin Klassenlehrer/-in dieser Klasse. Anders als <see cref="IsOwnClass"/> (rein
/// kalenderfarbliche Markierung, mehrfach setzbar für Klasse + zugehörige Kurse) schaltet
/// dieses Flag Zugriff auf Daten anderer Lehrkräfte zu dieser Klasse frei (Klassenlehrer-Tab)
/// und sollte deshalb nur an genau einer Klasse-Gruppe gesetzt werden.
/// </summary>
public bool IsClassTeacher { get; set; }
/// <summary>
/// Default true (alte Datensätze ohne dieses Feld verhalten sich damit unverändert wie bisher
/// - LiteDB liefert den C#-Default für fehlende Felder). Für Gruppen wie Klassenrat oder
/// Willkommenskreis, die keinen inhaltlichen Verlaufsplan brauchen, in den Stammdaten
@@ -0,0 +1,65 @@
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class ClassTeacherViewModelsTests
{
[Fact]
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
{
var entries = new[]
{
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", "Absent",
"Krank", 1, "24.08.26", true, null, 3, "nicht entsch.", false),
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "PAN", "Kunst", null,
"Krank", 2, "24.08.26", true, null, 5, "nicht entsch.", true),
new UntisClassAbsenceEntryDto("Andere Schuelerin", 2, "6a", 20260824, 1, 20, "BEN", "Deu",
"Verspätung", null, 3, "24.08.26", false, "Zug verpasst", 1, "entsch.", false),
};
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
Assert.Equal(2, rows.Count);
var max = rows[1];
Assert.Equal("Muster Max", max.StudentName);
Assert.Equal(2, max.TotalAbsentPeriods);
Assert.Equal(90, max.TotalAbsentMinutes);
Assert.Equal(["Che", "Kunst"], max.Subjects);
Assert.Equal([3, 5], max.PeriodNumbers.Order());
Assert.Equal(["nicht entsch."], max.Statuses);
Assert.Equal(["Absent"], max.AbsenceReasons);
Assert.Equal("Krank", max.Note);
Assert.Null(max.ExcuseNote);
Assert.True(max.CountsAsFullDay);
var andere = rows[0];
Assert.Equal("Andere Schuelerin", andere.StudentName);
Assert.Equal(1, andere.TotalAbsentPeriods);
Assert.Equal(20, andere.TotalAbsentMinutes);
Assert.Equal(["Deu"], andere.Subjects);
Assert.Null(andere.Note);
Assert.Equal("Zug verpasst", andere.ExcuseNote);
Assert.False(andere.CountsAsFullDay);
}
[Fact]
public void GroupByStudentAndDay_TrenntGleichenNamenAnUnterschiedlichenTagen()
{
var entries = new[]
{
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", null,
null, 1, "24.08.26", true, null, 3, "nicht entsch.", false),
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260825, 1, 45, "HED", "Che", null,
null, 2, "25.08.26", true, null, 3, "nicht entsch.", false),
};
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
Assert.Equal(2, rows.Count);
Assert.Equal(new DateOnly(2026, 8, 25), rows[0].Date);
Assert.Equal(new DateOnly(2026, 8, 24), rows[1].Date);
}
}
@@ -1,5 +1,6 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
@@ -29,7 +30,9 @@ public sealed class GroupDetailViewModelTests
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects()),
new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups));
new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups),
new ClassTeacherTabViewModel(
new WebUntisIntegrationService(new HttpClient(), TestSupport.BuildWebUntisSettingsService()), groups));
vm.LoadGroup(group.Id);
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
@@ -30,6 +30,41 @@ public sealed class WebUntisIntegrationServiceTests
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"authenticate\""));
}
[Fact]
public async Task GetClassAbsencesAsync_LoestKlasseNamensbasiertAufUndAdressiertSieAlsKLPlusId()
{
var csv = Encoding.UTF8.GetBytes(
"Schüler*innen\tExterne Id\tText\tKlasse\tDatum\tWochentag\tFehlstd.\tFehlmin.\tLehrkraft\tFach\t" +
"Abwesenheitsgrund\tText\tENr\tErledigt\tAbwesenheit zählt\tEntschuldigungstext\tStundennr.\tStatus\tFehltage\r\n" +
"Muster Max\t12345\t\t10c\t24.08.26\tMo.\t1\t45\tHED\tChe\tAbsent\tKrank gemeldet\t555\t24.08.26\ttrue\t\t3\tnicht entsch.\t1\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"desktop-session\"}}"), // authenticate (ConnectAsync)
Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ConnectAsync-Validierung)
Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ResolveClassIdAsync)
Json("{\"result\":[{\"id\":874,\"name\":\"10c\"}]}"), // getKlassen
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt.tmp&name=AbsencePerStudent&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
Json("{\"result\":{}}")); // logout (Dispose)
var settings = TestSupport.BuildWebUntisSettingsService();
await using var service = new WebUntisIntegrationService(new HttpClient(handler), settings);
await service.ConnectAsync(new WebUntisCredentials(
"bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
var entries = await service.GetClassAbsencesAsync("10c",
new DateOnly(2026, 8, 24), new DateOnly(2026, 8, 28));
var entry = Assert.Single(entries);
Assert.Equal("Muster Max", entry.StudentName);
Assert.Equal("nicht entsch.", entry.Status);
// Zweimal getSchoolyears: einmal als ConnectAsync-Validierung, einmal in ResolveClassIdAsync.
Assert.Equal(2, handler.Requests.Count(request => request.Body.Contains("\"method\":\"getSchoolyears\"")));
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"getKlassen\""));
Assert.Single(handler.Requests, request =>
request.Uri.Contains("reports.do?name=AbsencePerStudent") &&
request.Uri.Contains("klasseOrStudentgroupId=KL874"));
}
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
+1
View File
@@ -308,6 +308,7 @@ public static class AppBootstrapper
services.AddTransient<CompetencyOverviewTabViewModel>();
services.AddTransient<SeatingPlanTabViewModel>();
services.AddTransient<GroupDocumentationTabViewModel>();
services.AddTransient<ClassTeacherTabViewModel>();
services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<TrashViewModel>();
services.AddTransient<SettingsViewModel>();
@@ -32,6 +32,12 @@ public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int Abs
string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts);
public sealed record UntisClassRegisterEventDto(string ClassName, int Date, string? Subject,
string StudentName, string? CategoryName, string? CategoryGroup, string? Text);
public sealed record UntisForeignClassRegisterEventDto(string ClassName, int Date, string? Subject,
string StudentName, string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text);
public sealed record UntisClassAbsenceEntryDto(string StudentName, int? ExternKey, string ClassName, int Date,
int AbsentPeriods, int AbsentMinutes, string? TeacherUsernames, string? Subject, string? AbsenceReason,
string? Note, int? EntryId, string? HandledOn, bool Counts, string? ExcuseNote, int? PeriodNumber,
string? Status, bool CountsAsFullDay);
/// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
@@ -135,6 +141,65 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
.ToList();
}, token);
// Spiegelbild von GetOwnClassRegisterEventsAsync für das "Klassenlehrer"-Feature (siehe TODO.md):
// dieselben "-alle-"-Rohdaten, aber Einträge anderer Lehrkräfte statt der eigenen, zusätzlich auf
// die übergebene Klasse eingeschränkt (der "-alle-"-Bericht liefert sonst auch andere Klassen mit,
// sobald eine Lehrkraft mehrere Klassen unterrichtet).
public Task<IReadOnlyList<UntisForeignClassRegisterEventDto>> GetForeignClassRegisterEventsAsync(
string className, DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
{
var ownUsername = settings.GetApiCredentials()?.Username;
var entries = await client.GetClassRegisterEventsReportAsync(Date(start), Date(end), token);
return (IReadOnlyList<UntisForeignClassRegisterEventDto>)entries
.Where(x => string.Equals(x.ClassName, className, StringComparison.OrdinalIgnoreCase)
// Nicht einfach "!=" gegen ownUsername: wäre ownUsername unbekannt (null), würde
// ein ebenfalls fehlender TeacherUsername sonst fälschlich als "eigen" erkannt
// (Equals(null,null)==true) und rausgefiltert statt als fremd angezeigt zu werden.
&& !(ownUsername is not null
&& string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase)))
.Select(x => new UntisForeignClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName,
x.TeacherUsername, x.CategoryName, x.CategoryGroup, x.Text))
.ToList();
}, token);
// Fehlzeiten der ganzen Klasse über alle Fächer, Ergänzung zu GetForeignClassRegisterEventsAsync
// für das "Klassenlehrer"-Feature. Der Bericht adressiert die Klasse über "KL"+getKlassen-ID statt
// eines Namens, siehe ResolveClassIdAsync.
public Task<IReadOnlyList<UntisClassAbsenceEntryDto>> GetClassAbsencesAsync(string className,
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
{
var classId = await ResolveClassIdAsync(client, className, token);
var entries = await client.GetClassAbsencesAsync(classId, Date(start), Date(end), token);
return (IReadOnlyList<UntisClassAbsenceEntryDto>)entries.Select(x => new UntisClassAbsenceEntryDto(
x.StudentName, x.ExternKey, x.ClassName, x.Date, x.AbsentPeriods, x.AbsentMinutes,
x.TeacherUsernames, x.Subject, x.AbsenceReason, x.Note, x.EntryId, x.HandledOn, x.Counts,
x.ExcuseNote, x.PeriodNumber, x.Status, x.CountsAsFullDay))
.ToList();
}, token);
// "KL" + getKlassen-ID, dasselbe Element-Kürzel-Schema wie kl/te/su/ro bei anderen Berichten
// (siehe Entities(...) unten). Unbestätigt (kein offizieller Report-Parameter dokumentiert), aber
// aus einem echten Browser-Request rückgeschlossen - schlägt die Zuordnung fehl, liefert der
// Bericht erkennbar leere/falsche Daten statt eines stillen Fehlers.
private static async Task<string> ResolveClassIdAsync(WebUntisClient client, string className,
CancellationToken token)
{
var years = await client.GetSchoolYearsAsync(token);
var today = Date(DateOnly.FromDateTime(DateTime.Today));
var year = years.FirstOrDefault(y => y.StartDate <= today && y.EndDate >= today)
?? years.OrderByDescending(y => y.StartDate).FirstOrDefault()
?? throw new WebUntisIntegrationException("WebUntis hat kein Schuljahr geliefert.");
var classes = await client.GetClassesAsync(year.UntisId, token);
var matches = classes.Where(c => string.Equals(c.Name, className, StringComparison.OrdinalIgnoreCase)).ToList();
return matches.Count switch
{
1 => $"KL{matches[0].UntisId}",
0 => throw new WebUntisIntegrationException($"WebUntis kennt keine Klasse namens \"{className}\"."),
_ => throw new WebUntisIntegrationException(
$"Mehrere WebUntis-Klassen heißen \"{className}\" - bitte manuell prüfen."),
};
}
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
{
try { return await operation(await GetClientAsync(token)); }
@@ -0,0 +1,133 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// <summary>Eine Zeile aus dem "-alle-"-Klassenbuchbericht, die eine andere Lehrkraft angelegt hat.
/// Rein zur Ansicht - anders als beim Dokumentations-Abgleich (eigene Einträge) gibt es hier kein
/// "Übernehmen", da es keine eigenen Einträge zum lokalen Synchronisieren sind.</summary>
public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject, string StudentName,
string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text)
{
public string DateLabel => Date.ToString("dd.MM.yyyy");
}
/// <summary>Fehlzeiten eines/einer Schüler*in an einem Tag, über alle Fächer zusammengefasst -
/// der WebUntis-Bericht liefert eine Zeile pro Fehlstunde, was bei einer ganzen Klasse über mehrere
/// Wochen zu unübersichtlich wäre (Nutzer-Feedback: kompakte "auf einen Blick"-Übersicht statt
/// Rohdaten). Die Rohzeilen (<see cref="UntisClassAbsenceEntryDto"/>) bleiben trotzdem erreichbar,
/// falls später ein Detail-Drill-down pro Schüler*in dazukommt (siehe TODO.md).</summary>
public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName, int? ExternKey,
int TotalAbsentPeriods, int TotalAbsentMinutes, IReadOnlyList<string> Subjects,
IReadOnlyList<int> PeriodNumbers, IReadOnlyList<string> Statuses, IReadOnlyList<string> AbsenceReasons,
string? Note, string? ExcuseNote, bool CountsAsFullDay)
{
public string DateLabel => Date.ToString("dd.MM.yyyy");
public string SubjectsLabel => string.Join(", ", Subjects);
public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order());
public string StatusLabel => string.Join(", ", Statuses);
public string ReasonLabel => string.Join("; ", AbsenceReasons);
public static IReadOnlyList<ClassAbsenceDaySummaryRow> GroupByStudentAndDay(
IEnumerable<UntisClassAbsenceEntryDto> entries) => entries
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
.Where(x => x.Date is not null)
.GroupBy(x => (Date: x.Date!.Value, x.Entry.StudentName, x.Entry.ExternKey))
.Select(g => new ClassAbsenceDaySummaryRow(
g.Key.Date, g.Key.StudentName, g.Key.ExternKey,
g.Sum(x => x.Entry.AbsentPeriods), g.Sum(x => x.Entry.AbsentMinutes),
Distinct(g.Select(x => x.Entry.Subject)),
g.Select(x => x.Entry.PeriodNumber).Where(p => p is not null).Select(p => p!.Value).Distinct().ToList(),
Distinct(g.Select(x => x.Entry.Status)),
Distinct(g.Select(x => x.Entry.AbsenceReason)),
g.Select(x => x.Entry.Note).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
g.Select(x => x.Entry.ExcuseNote).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
g.Any(x => x.Entry.CountsAsFullDay)))
.OrderByDescending(r => r.Date).ThenBy(r => r.StudentName)
.ToList();
private static List<string> Distinct(IEnumerable<string?> values) =>
values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).Distinct().ToList();
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
}
/// <summary>
/// Klassenlehrer-Tab: zeigt Klassenbucheinträge, die andere Lehrkräfte zu Schülern dieser Klasse
/// angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der eigenen
/// Benutzerkennung wie beim bestehenden Dokumentations-Abgleich, siehe
/// <see cref="WebUntisIntegrationService.GetForeignClassRegisterEventsAsync"/>), sowie die
/// Fehlzeiten der Klasse über alle Fächer (<see cref="WebUntisIntegrationService.GetClassAbsencesAsync"/>).
/// Beides rein zur Ansicht, kein Übernehmen in lokale Daten. Nur für Gruppen mit
/// <see cref="Core.Models.LearningGroup.IsClassTeacher"/> sichtbar (siehe
/// GroupDetailViewModel/GroupDetailView).
/// </summary>
public partial class ClassTeacherTabViewModel : ObservableObject
{
private readonly WebUntisIntegrationService _untis;
private readonly IGroupRepository _groups;
private string _className = "";
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-7);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und laden.";
[ObservableProperty] private bool _busy;
public ClassTeacherTabViewModel(WebUntisIntegrationService untis, IGroupRepository groups)
{
_untis = untis; _groups = groups;
}
public void Initialize(Guid groupId)
{
_className = _groups.GetById(groupId)?.Name ?? "";
Entries.Clear();
AbsenceEntries.Clear();
Status = "Zeitraum wählen und laden.";
}
[RelayCommand]
private async Task Load()
{
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
if (string.IsNullOrWhiteSpace(_className)) { Status = "Dieser Gruppe fehlt ein Klassenname."; return; }
Busy = true; Entries.Clear(); AbsenceEntries.Clear();
try
{
var classRegisterTask = _untis.GetForeignClassRegisterEventsAsync(_className, start, end);
var absencesTask = _untis.GetClassAbsencesAsync(_className, start, end);
await Task.WhenAll(classRegisterTask, absencesTask);
var ordered = classRegisterTask.Result
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
.Where(x => x.Date is not null)
.OrderByDescending(x => x.Date).ThenBy(x => x.Entry.StudentName);
foreach (var (entry, date) in ordered)
{
Entries.Add(new ClassTeacherClassRegisterRow(date!.Value, entry.Subject, entry.StudentName,
entry.TeacherUsername, entry.CategoryName, entry.CategoryGroup, entry.Text));
}
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result))
AbsenceEntries.Add(row);
Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
$"{AbsenceEntries.Count} Fehlzeiten-Tage im Zeitraum.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
}
@@ -251,6 +251,7 @@ public partial class GroupDetailViewModel : ObservableObject
// bevor LoadGroup() läuft (siehe MainWindowViewModel.NavigateToGroupDetail), Group ist dann
// kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal einen Binding-Fehler loggen.
public bool IsDifferentiated => Group?.IsDifferentiated ?? false;
public bool IsClassTeacher => Group?.IsClassTeacher ?? false;
public bool IsReadOnly => Group is { IsActive: false };
public bool IsEditable => Group is { IsActive: true };
public string SubjectName { get; private set; } = "";
@@ -258,6 +259,7 @@ public partial class GroupDetailViewModel : ObservableObject
partial void OnGroupChanged(LearningGroup? value)
{
OnPropertyChanged(nameof(IsDifferentiated));
OnPropertyChanged(nameof(IsClassTeacher));
OnPropertyChanged(nameof(IsReadOnly));
OnPropertyChanged(nameof(IsEditable));
NotifyWriteCommands();
@@ -279,6 +281,7 @@ public partial class GroupDetailViewModel : ObservableObject
public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; }
public SeatingPlanTabViewModel SeatingPlanTab { get; }
public GroupDocumentationTabViewModel GroupDocumentationTab { get; }
public ClassTeacherTabViewModel ClassTeacherTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; }
public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; }
public Func<Guid, Task<bool>>? OnAddExam { get; set; }
@@ -295,7 +298,8 @@ public partial class GroupDetailViewModel : ObservableObject
GroupOverviewViewModel overviewTab, ParticipationTabViewModel participationTab,
GradeOverviewTabViewModel gradeOverviewTab,
PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab,
SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab)
SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab,
ClassTeacherTabViewModel classTeacherTab)
{
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades; _tasks = tasks;
@@ -306,6 +310,7 @@ public partial class GroupDetailViewModel : ObservableObject
CompetencyOverviewTab = competencyOverviewTab;
SeatingPlanTab = seatingPlanTab;
GroupDocumentationTab = groupDocumentationTab;
ClassTeacherTab = classTeacherTab;
SeatingPlanTab.OnAssessmentChanged = () =>
{
ParticipationTab.LoadSessions();
@@ -335,6 +340,7 @@ public partial class GroupDetailViewModel : ObservableObject
CompetencyOverviewTab.Initialize(Group);
SeatingPlanTab.Initialize(Group.Id, IsReadOnly);
GroupDocumentationTab.Initialize(Group.Id);
ClassTeacherTab.Initialize(Group.Id);
}
private void ReloadExams()
@@ -877,6 +883,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private int? _hoursPerWeek;
[ObservableProperty] private bool _isOwnClass;
[ObservableProperty] private bool _isDifferentiated;
[ObservableProperty] private bool _isClassTeacher;
[ObservableProperty] private bool _requiresLessonPlanning = true;
[ObservableProperty] private int? _webUntisLessonId;
[ObservableProperty] private string _nameError = "";
@@ -917,6 +924,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
HoursPerWeek = group.HoursPerWeek;
IsOwnClass = group.IsOwnClass;
IsDifferentiated = group.IsDifferentiated;
IsClassTeacher = group.IsClassTeacher;
RequiresLessonPlanning = group.RequiresLessonPlanning;
WebUntisLessonId = group.WebUntisLessonId;
OnPropertyChanged(nameof(DialogTitle));
@@ -964,6 +972,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result.HoursPerWeek = HoursPerWeek;
Result.IsOwnClass = IsOwnClass;
Result.IsDifferentiated = IsDifferentiated;
Result.IsClassTeacher = IsClassTeacher;
Result.RequiresLessonPlanning = RequiresLessonPlanning;
Result.WebUntisLessonId = WebUntisLessonId;
_groups.Save(Result);
@@ -71,6 +71,9 @@
<CheckBox Content="Gehört zu meiner Klasse" IsChecked="{Binding IsOwnClass}"
ToolTip.Tip="Für die farbliche Hervorhebung im Dashboard-Kalender. Mehrere Gruppen (Klasse + zugehörige Kurse) können markiert werden."/>
<CheckBox Content="Klassenlehrer/-in dieser Klasse" IsChecked="{Binding IsClassTeacher}"
ToolTip.Tip="Blendet den Klassenlehrer-Tab ein (Klassenbucheinträge anderer Lehrkräfte zu dieser Klasse). Sollte nur an genau einer Gruppe gesetzt werden."/>
<CheckBox Content="Niveaudifferenzierung (E/G/Förder)" IsChecked="{Binding IsDifferentiated}"
ToolTip.Tip="Blendet im Schüler-Tab eine Niveau-Zuordnung je Schüler ein und erlaubt in Klausuren die Zuordnung zu einem Niveau."/>
@@ -0,0 +1,84 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.ClassTeacherTabView"
x:DataType="vm:ClassTeacherTabViewModel">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,1*,Auto,Auto,1*,Auto" Margin="16" RowSpacing="6">
<TextBlock Grid.Row="0" TextWrapping="Wrap" FontSize="12" Opacity="0.65"
Text="Klassenbucheinträge anderer Lehrkräfte und Fehlzeiten der Klasse über alle Fächer (nur Ansicht, keine Übernahme in eigene Daten)."/>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<DatePicker SelectedDate="{Binding StartDate}"/>
<TextBlock Text="bis" VerticalAlignment="Center"/>
<DatePicker SelectedDate="{Binding EndDate}"/>
<Button Content="Laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<TextBlock Grid.Row="2" Text="Klassenbucheinträge anderer Lehrkräfte" FontWeight="SemiBold" FontSize="13" Margin="0,4,0,0"/>
<Grid Grid.Row="3" ColumnDefinitions="70,1*,1.1*,0.8*,1*,1.3*" ColumnSpacing="8" Margin="4,0">
<TextBlock Grid.Column="0" Text="Datum" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="1" Text="Schüler*in" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="2" Text="Fach / Lehrkraft" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="3" Text="Kategorie" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="4" Text="Gruppe" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="5" Text="Text" FontSize="11" Opacity="0.6"/>
</Grid>
<ScrollViewer Grid.Row="4">
<ItemsControl ItemsSource="{Binding Entries}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherClassRegisterRow">
<Grid ColumnDefinitions="70,1*,1.1*,0.8*,1*,1.3*" ColumnSpacing="8" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding StudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<StackPanel Grid.Column="2" Spacing="0">
<TextBlock Text="{Binding Subject}"/>
<TextBlock Text="{Binding TeacherUsername}" FontSize="11" Opacity="0.6"/>
</StackPanel>
<TextBlock Grid.Column="3" Text="{Binding CategoryName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<TextBlock Grid.Column="4" Text="{Binding CategoryGroup}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<TextBlock Grid.Column="5" Text="{Binding Text}" VerticalAlignment="Center" TextWrapping="Wrap"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<TextBlock Grid.Row="5" Text="Fehlzeiten der Klasse (alle Fächer, pro Schüler*in und Tag zusammengefasst)"
FontWeight="SemiBold" FontSize="13" Margin="0,8,0,0"/>
<Grid Grid.Row="6" ColumnDefinitions="70,1*,1.3*,0.5*,0.6*,0.6*,0.8*,1.5*" ColumnSpacing="8" Margin="4,0">
<TextBlock Grid.Column="0" Text="Datum" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="1" Text="Schüler*in" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="2" Text="Fächer" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="3" Text="Std." FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="4" Text="Fehlstd." FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="5" Text="Fehlmin." FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="6" Text="Status" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="7" Text="Grund / Notiz" FontSize="11" Opacity="0.6"/>
</Grid>
<ScrollViewer Grid.Row="7">
<ItemsControl ItemsSource="{Binding AbsenceEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassAbsenceDaySummaryRow">
<Grid ColumnDefinitions="70,1*,1.3*,0.5*,0.6*,0.6*,0.8*,1.5*" ColumnSpacing="8" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding StudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<TextBlock Grid.Column="2" Text="{Binding SubjectsLabel}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<TextBlock Grid.Column="3" Text="{Binding PeriodsLabel}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<TextBlock Grid.Column="4" Text="{Binding TotalAbsentPeriods}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="5" Text="{Binding TotalAbsentMinutes}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="6" Text="{Binding StatusLabel}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<StackPanel Grid.Column="7" Spacing="0">
<TextBlock Text="{Binding ReasonLabel}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Note}" FontSize="11" Opacity="0.6" TextWrapping="Wrap"
IsVisible="{Binding Note, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<TextBlock Grid.Row="8" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap"/>
</Grid>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.Groups;
public partial class ClassTeacherTabView : UserControl
{
public ClassTeacherTabView() => InitializeComponent();
}
@@ -205,6 +205,11 @@
<views:GroupDocumentationTabView DataContext="{Binding GroupDocumentationTab}"/>
</ContentPage>
<!-- Tab: Klassenlehrer, nur sichtbar für Gruppen mit IsClassTeacher -->
<ContentPage Header="Klassenlehrer" IsVisible="{Binding IsClassTeacher}">
<views:ClassTeacherTabView DataContext="{Binding ClassTeacherTab}"/>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>
@@ -244,6 +244,53 @@ public sealed class WebUntisClientTests
await client.DisposeAsync();
}
[Fact]
public async Task GetClassAbsencesAsync_BautDenBerichtsAbrufAufUndTypisiertDasErgebnis()
{
// Kopfzeile "Text" kommt zweimal vor (Spalte 3 leer, Spalte 12 die eigentliche Tagesnotiz) -
// deckt ab, dass die spätere Spalte beim Parsen die frühere überschreibt (siehe Parser-Kommentar).
var csv = Encoding.UTF8.GetBytes(
"Schüler*innen\tExterne Id\tText\tKlasse\tDatum\tWochentag\tFehlstd.\tFehlmin.\tLehrkraft\tFach\t" +
"Abwesenheitsgrund\tText\tENr\tErledigt\tAbwesenheit zählt\tEntschuldigungstext\tStundennr.\tStatus\tFehltage\r\n" +
"Muster Max\t12345\t\t6a\t24.08.26\tMo.\t1\t45\tHED\tChe\tAbsent\tKrank gemeldet\t555\t24.08.26\ttrue\t\t3\tnicht entsch.\t1\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}"),
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt3.tmp&name=AbsencePerStudent&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var entries = await client.GetClassAbsencesAsync("KL874", 20260824, 20260828, CancellationToken.None);
var entry = Assert.Single(entries);
Assert.Equal("Muster Max", entry.StudentName);
Assert.Equal(12345, entry.ExternKey);
Assert.Equal("6a", entry.ClassName);
Assert.Equal(20260824, entry.Date);
Assert.Equal(1, entry.AbsentPeriods);
Assert.Equal(45, entry.AbsentMinutes);
Assert.Equal("HED", entry.TeacherUsernames);
Assert.Equal("Che", entry.Subject);
Assert.Equal("Absent", entry.AbsenceReason);
Assert.Equal("Krank gemeldet", entry.Note);
Assert.Equal(555, entry.EntryId);
Assert.Equal("24.08.26", entry.HandledOn);
Assert.True(entry.Counts);
Assert.Null(entry.ExcuseNote);
Assert.Equal(3, entry.PeriodNumber);
Assert.Equal("nicht entsch.", entry.Status);
Assert.True(entry.CountsAsFullDay);
Assert.Contains("reports.do?name=AbsencePerStudent", handler.Requests[1].Uri);
Assert.Contains("klasseOrStudentgroupId=KL874", handler.Requests[1].Uri);
Assert.Contains("studentId=-1", handler.Requests[1].Uri);
Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri);
Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri);
Assert.EndsWith("reports.do?get=rpt3.tmp&name=AbsencePerStudent&format=csv", handler.Requests[2].Uri);
await client.DisposeAsync();
}
private static WebUntisClient CreateClient(HttpMessageHandler handler) => new(
new HttpClient(handler),
new WebUntisOptions
@@ -0,0 +1,79 @@
using System.Globalization;
namespace LehrerApp.WebUntis;
public static class WebUntisClassAbsenceParser
{
public static IReadOnlyList<UntisClassAbsenceEntry> Parse(string content)
{
var rows = TabSeparatedTextReader.ParseRows(content, '\t');
if (rows.Count == 0) return [];
var headers = rows[0]
.Select((header, index) => (index == 0 ? header.TrimStart('\uFEFF') : header).Trim())
.ToArray();
var result = new List<UntisClassAbsenceEntry>();
foreach (var row in rows.Skip(1))
{
if (row.All(string.IsNullOrWhiteSpace)) continue;
// Der Header "Text" kommt zweimal vor (Spalte 3, in echten Daten immer leer; die
// eigentliche freitextliche Tagesnotiz steht in der späteren Spalte). Der spätere Index
// überschreibt beim Befüllen den früheren, was hier zufällig den gewünschten Wert übrig
// lässt - bewusst kein Verlass darauf, dass Spalte 3 auch künftig immer leer bleibt.
var values = new Dictionary<string, string?>(StringComparer.Ordinal);
for (var index = 0; index < headers.Length; index++)
values[headers[index]] = index < row.Count ? row[index].Trim() : null;
result.Add(new UntisClassAbsenceEntry(
Get(values, "Schüler*innen")?.Trim() ?? "",
OptionalInt(Get(values, "Externe Id")),
Get(values, "Klasse")?.Trim() ?? "",
GermanShortDate(Get(values, "Datum")) ?? throw new InvalidDataException(
"Ungültiges Datum in Spalte \"Datum\"."),
RequiredInt(Get(values, "Fehlstd."), "Fehlstd."),
RequiredInt(Get(values, "Fehlmin."), "Fehlmin."),
Optional(Get(values, "Lehrkraft")),
Optional(Get(values, "Fach")),
Optional(Get(values, "Abwesenheitsgrund")),
Optional(Get(values, "Text")),
OptionalInt(Get(values, "ENr")),
Optional(Get(values, "Erledigt")),
ParseBoolean(Get(values, "Abwesenheit zählt")),
Optional(Get(values, "Entschuldigungstext")),
OptionalInt(Get(values, "Stundennr.")),
Optional(Get(values, "Status")),
Get(values, "Fehltage")?.Trim() == "1"));
}
return result;
}
private static bool ParseBoolean(string? value) =>
string.Equals(value?.Trim(), "true", StringComparison.OrdinalIgnoreCase);
private static string? Get(IReadOnlyDictionary<string, string?> values, string key) =>
values.TryGetValue(key, out var value) ? value : null;
private static string? Optional(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static int? OptionalInt(string? value) =>
int.TryParse(value?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
private static int RequiredInt(string? value, string field) =>
int.TryParse(value?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: throw new InvalidDataException($"Ungültige Zahl in Spalte \"{field}\".");
private static int? GermanShortDate(string? value)
{
if (!DateOnly.TryParseExact(value?.Trim(), "dd.MM.yy", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date))
return null;
return date.Year * 10_000 + date.Month * 100 + date.Day;
}
}
+29
View File
@@ -175,6 +175,35 @@ public sealed class WebUntisClient : IAsyncDisposable
return WebUntisClassRegisterEventReportParser.Parse(reportText);
}, cancellationToken);
// Undokumentierter Bericht "AbsencePerStudent" (Unterricht -> Berichte -> Fehlzeiten der ganzen
// Klasse), Gegenstück zu GetLessonAbsencesAsync für das "Klassenlehrer"-Feature: deckt alle
// Fächer der Klasse ab, nicht nur den eigenen Unterricht. Adressiert die Klasse über
// klasseOrStudentgroupId = "KL" + getKlassen-ID (Auflösung siehe
// WebUntisIntegrationService.ResolveClassIdAsync) statt einer lsid. Query-Parameter 1:1 aus
// einem echten Browser-Request übernommen (identisch mit GetClassRegisterEventsReportAsync bis
// auf name/klasseOrStudentgroupId/studentId; _csrf/selectedDateRange dort schon als nicht nötig
// bekannt).
public Task<IReadOnlyList<UntisClassAbsenceEntry>> GetClassAbsencesAsync(string klasseOrStudentgroupId,
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var query = "name=AbsencePerStudent&format=csv" +
$"&klasseOrStudentgroupId={Uri.EscapeDataString(klasseOrStudentgroupId)}&studentId=-1" +
"&calendarChange=0&withAbsences=true&_withAbsences=on&withLateness=true&_withLateness=on" +
"&excludeNotCountingAbsences=true&_excludeNotCountingAbsences=on&_allStudents=on" +
"&absStudGroup=1&studExcuseStatusId=-1&_withoutPageBreaksAbsPerStudent=on&absClassGroup=1" +
"&classExcuseStatusId=-1&_absClassSorted=on&_separateAbsentDays=on&_filterAbsencesKlasse=on" +
"&absSubjectGroup=7&absSubjectId=-1&absSubjectGroupGroup=7&subjectGroupId=-1&_totalDays=on" +
"&absentPeriodsLimit=16&reportWeeks=4&dayLimit=3&excuseGroup=1&_groupPerWeek=on" +
"&_excuseStatusAll=on&studEventReasonId=-1&classEventReasonId=-1&_evntClassSorted=on" +
"&_withoutPageBreaks=on&withStudentAbsences=true&_withStudentAbsences=on" +
"&examinationTypeId=-1&teachingMethodId=-1&examTypeId=-1&_usingMarkNames=on" +
$"&rpt_sd={startDate}&rpt_ed={endDate}&rpt_syid=-1&rpt_drdtype=CUSTOM";
var reportData = await RequestReportAsync(sessionId, query, cancellationToken)
?? await PollReportAsync(sessionId, cancellationToken);
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
return WebUntisClassAbsenceParser.Parse(reportText);
}, cancellationToken);
public Task<IReadOnlyList<UntisSubstitution>> GetSubstitutionsAsync(int startDate, int endDate,
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
+27
View File
@@ -147,6 +147,33 @@ public sealed record UntisLessonAbsence(
string? HandledOn,
bool Counts);
// Undokumentierter Bericht "AbsencePerStudent" (Fehlzeiten der ganzen Klasse über alle Fächer,
// nicht nur den eigenen Unterricht wie AbsencePerLesson) - eigenes Spaltenschema, eine Zeile pro
// Fehlstunde. Anders als bei AbsencePerLesson liefert "Status" hier einen Klartext-Wert direkt
// (im Sample nur "nicht entsch." beobachtet, andere Werte nicht bestätigt - Rohwert durchreichen).
// "Fehltage" ist der auf eine ganze Zahl abgerundete Fehlzeit-Tagesanteil (Nutzer-Angabe, nicht
// durch WebUntis-Dokumentation bestätigt): bleibt 0, bis auch die letzte Unterrichtsstunde des
// Tages als versäumt gilt - erst dann springt er auf 1. Pro Schüler*in/Tag trägt deshalb genau die
// Zeile der letzten Stunde des Tages den Wert 1, alle vorherigen Stunden dieses Tages bleiben 0.
public sealed record UntisClassAbsenceEntry(
string StudentName,
int? ExternKey,
string ClassName,
int Date,
int AbsentPeriods,
int AbsentMinutes,
string? TeacherUsernames,
string? Subject,
string? AbsenceReason,
string? Note,
int? EntryId,
string? HandledOn,
bool Counts,
string? ExcuseNote,
int? PeriodNumber,
string? Status,
bool CountsAsFullDay);
// "-alle-"-Bericht ohne externe Schülerkennung - anders als bei AbsencePerLesson bleibt für den
// Abgleich mit lokalen Schülern nur der Name (siehe Namens-Fallback im Desktop-Abgleich).
public sealed record UntisClassRegisterEventReportEntry(
+47 -5
View File
@@ -1277,11 +1277,53 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
Auch hier keine externe Schülerkennung, nur der Name — Namensabgleich soll deshalb zusätzlich auf
Klasse+Fach aus der Zeile scopen (→ genau eine lokale Lerngruppe), nicht global über alle Schüler.
Abgleichsdialog (Diff gegen `Documentation`, Übernehmen in beide Richtungen) noch nicht gebaut.
- **Idee, noch nicht geplant ("Klassenlehrer"-Feature):** Der "-alle-"-Bericht enthält für die
eigene Klasse auch Einträge, die andere Lehrkräfte in deren Fächern angelegt haben (`Benutzer`
≠ eigener Login) — für den reinen Dokumentations-Abgleich (eigene Einträge synchron halten) werden
die herausgefiltert. Eine Klassenlehrer-Übersicht, die stattdessen genau diese fremden Einträge zur
eigenen Klasse zeigt, wäre ein eigenständiges, größeres Feature und ist bewusst zurückgestellt.
- [x] **"Klassenlehrer"-Feature, Schritt 1 (Klassenbuch fremder Einträge, August 2026):** Der
"-alle-"-Bericht enthält für die eigene Klasse auch Einträge, die andere Lehrkräfte in deren
Fächern angelegt haben (`Benutzer` ≠ eigener Login) — für den reinen Dokumentations-Abgleich
(eigene Einträge synchron halten) werden die herausgefiltert. Neues Flag
`LearningGroup.IsClassTeacher` ("Klassenlehrer/-in dieser Klasse", Stammdaten-Checkbox, keine
Migration nötig) blendet einen neuen Tab "Klassenlehrer" in der Gruppendetailansicht ein —
bewusst ein eigenes Flag statt `IsOwnClass` (das ist rein die Kalenderfarb-Markierung und
mehrfach setzbar für Klasse + zugehörige Kurse, würde den Tab sonst versehentlich auch bei
Kursen zeigen). `WebUntisIntegrationService.GetForeignClassRegisterEventsAsync` ist das
Spiegelbild von `GetOwnClassRegisterEventsAsync`: derselbe "-alle-"-Rohbericht, aber Einträge
anderer Lehrkräfte statt der eigenen, zusätzlich auf die übergebene Klasse eingeschränkt (kein
neuer WebUntis-Bericht nötig). `ClassTeacherTabViewModel`/`ClassTeacherTabView` zeigen die
Liste rein lesend an (Datum, Schüler*in, Fach/Lehrkraft, Kategorie, Text) — kein
"Übernehmen"-Command wie beim Dokumentations-Abgleich, da es fremde Einträge sind, keine
eigenen zum lokalen Synchronisieren.
**Nicht Teil dieses Schritts:** Fehlzeiten der Klasse aus fremdem Unterricht — dafür fehlt noch
ein per Netzwerk-Mitschnitt zu ermittelnder klassenbezogener Fehlzeiten-Bericht ohne `lessonId`
(vermutlich `klasseId`/`teacherId` statt `lsid`); folgt als eigener Schritt, sobald die
Query-Parameter bekannt sind. Ebenfalls zurückgestellt: Dokumentation/Behördenschriftverkehr als
eigenständiger, WebUntis-unabhängiger Baustein.
- [x] **"Klassenlehrer"-Feature, Schritt 2 (Fehlzeiten aus fremdem Unterricht, August 2026):** Der
gesuchte Bericht ist `reports.do?name=AbsencePerStudent&klasseOrStudentgroupId=KL<id>&studentId=-1`
(per Netzwerk-Mitschnitt gefunden) — Query-Parameter sind bis auf `name`/`klasseOrStudentgroupId`/
`studentId` identisch mit `ClassregEventPerStudent`. `klasseOrStudentgroupId` ist `"KL"` +
die `getKlassen`-interne Klassen-ID (dasselbe Element-Kürzel-Schema wie `kl`/`te`/`su`/`ro` bei
anderen Berichten) — **kein manuelles Feld nötig**, `WebUntisIntegrationService.ResolveClassIdAsync`
löst sie pro Aufruf über `getSchoolyears`+`getKlassen` und den Klassennamen auf (analog zum
bestehenden Klassen-Auswahldialog). Das CSV-Format ist **nicht** identisch mit `AbsencePerLesson`
(ursprüngliche Vermutung anhand echter Daten widerlegt) — eigenes Spaltenschema, eine Zeile pro
Fehlstunde, neuer Parser `WebUntisClassAbsenceParser`/Modell `UntisClassAbsenceEntry`. Anders als
`AbsencePerLesson` liefert `Status` hier einen Klartext-Wert direkt (im Sample nur `"nicht
entsch."` beobachtet, andere Werte nicht bestätigt), es braucht also keine Status-Heuristik.
Der Header `Text` kommt zweimal vor (eine immer-leere Spalte, dann die eigentliche Tagesnotiz) —
der bestehende Dictionary-per-Header-Parse-Stil überschreibt das zufällig richtig, im Parser
kommentiert statt stillschweigend vorausgesetzt. `Fehltage` (0/1, `CountsAsFullDay`) ist laut
Nutzer-Angabe der auf eine ganze Zahl abgerundete Fehlzeit-Tagesanteil: bleibt 0, bis auch die
letzte Unterrichtsstunde des Tages als versäumt gilt, erst deren Zeile trägt dann den Wert 1.
Im Klassenlehrer-Tab (Nutzer-Feedback) **pro Schüler*in und Tag gruppiert** dargestellt statt
einer Zeile pro Fehlstunde — `ClassAbsenceDaySummaryRow.GroupByStudentAndDay` fasst Fehlstunden/
-minuten zusammen und sammelt Fächer/Status/Gründe als distinkte Listen. Rein Ansicht, kein
Abgleich/Übernehmen in lokale Anwesenheitsdaten; eigene und fremde Fächer erscheinen bewusst
beide ungefiltert.
**Zurückgestellte Idee:** ein Detail-Drill-down pro Schüler*in (z. B. Heatmap der gefehlten
Einzelstunden, Wochenansicht mit Verspätung/vorzeitigem Verlassen) — noch offen, ob im selben Tab
oder über einen eigenen Tab/Dialog "in die Tiefe" erreichbar; die Rohdaten
(`UntisClassAbsenceEntryDto`) sind dafür schon vollständig vorhanden, nur noch nicht in der UI
genutzt.
### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3