Untis API Integration

This commit is contained in:
2026-08-24 21:52:00 +02:00
parent 5841d96c5b
commit 0f10f754d0
26 changed files with 1237 additions and 58 deletions
+1
View File
@@ -223,6 +223,7 @@ 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(), syncSettings, untisSettings));
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
// stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
@@ -0,0 +1,140 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace LehrerApp.Desktop.Services;
public sealed class WebUntisIntegrationException(string message) : Exception(message);
public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate);
public sealed record UntisClassDto(int UntisId, string Name, string? LongName);
public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title,
bool Active, IReadOnlyList<int> DepartmentUntisIds)
{
public string DisplayName => string.IsNullOrWhiteSpace(LongName)
? Name
: $"{ForeName} {LongName} ({Name})".Trim();
}
public sealed record UntisEntityDto(int Id, string Name, int? OriginalId, string? OriginalName, string? ExternalKey);
public sealed record UntisTimeUnitDto(string Name, int StartTime, int EndTime);
public sealed record UntisTimeGridDayDto(int Day, IReadOnlyList<UntisTimeUnitDto> TimeUnits);
public sealed record UntisTimetablePeriodDto(int Id, int Date, int StartTime, int EndTime, string? Code,
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 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,
string? ForeName, string DisplayName, string? Gender, int? BirthDate, string? BirthDateRaw, int? EntryDate,
string? EntryDateRaw, int? ExitDate, string? ExitDateRaw, string? Text, string? MedicalReportDuty,
string? Schulpflicht, string? Majority, UntisStudentAddressDto Address, string? AttributeIL);
public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, IReadOnlyList<UntisStudentDto> Students);
public sealed record UntisStudentAbsenceDto(int StudentKey, int Date, int StartTime, int EndTime, int AbsentMinutes,
bool Checked, string? AbsenceReason, string? ExcuseStatus, int? SubjectId, IReadOnlyList<int> TeacherIds,
string? StudentGroup);
public sealed record UntisStudentAbsenceReportDto(int StudentKey, int StartDate, int EndDate, int EntryCount,
int AbsentMinutes, IReadOnlyList<UntisStudentAbsenceDto> Absences);
/// <summary>Authentifizierter Desktop-Client für die LehrerApp-API. Falls der Server neu gestartet
/// wurde, baut er die nur im Server-RAM gehaltene WebUntis-Verbindung automatisch erneut auf.</summary>
public sealed class WebUntisIntegrationService(HttpClient http, SyncSettingsService syncSettings,
WebUntisSettingsService settings)
{
public bool IsAvailable => syncSettings.IsLoggedIn && !string.IsNullOrWhiteSpace(syncSettings.ServerUrl)
&& settings.ApiIsConfigured;
public async Task ConnectAsync(WebUntisCredentials credentials, CancellationToken token = default)
{
using var response = await SendAsync(HttpMethod.Post, "/api/webuntis/connection", new
{
credentials.School,
Host = string.IsNullOrWhiteSpace(credentials.Host) ? null : credentials.Host,
credentials.Username,
credentials.Password,
}, token);
await EnsureSuccessAsync(response);
}
public async Task DisconnectAsync(CancellationToken token = default)
{
using var response = await SendAsync(HttpMethod.Delete, "/api/webuntis/connection", null, token);
await EnsureSuccessAsync(response);
}
public Task<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisSchoolYearDto>>("/api/webuntis/schoolyears", token);
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisClassDto>>($"/api/webuntis/classes?schoolyearId={schoolYearId}", token);
public Task<IReadOnlyList<UntisTeacherDto>> GetTeachersAsync(CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisTeacherDto>>("/api/webuntis/teachers", token);
public Task<IReadOnlyList<UntisTimeGridDayDto>> GetTimeGridAsync(CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisTimeGridDayDto>>("/api/webuntis/timegrid", token);
public Task<IReadOnlyList<UntisTimetablePeriodDto>> GetTimetableAsync(int teacherId, DateOnly start,
DateOnly end, CancellationToken token = default) => GetAsync<IReadOnlyList<UntisTimetablePeriodDto>>(
$"/api/webuntis/timetable?elementType=teacher&elementId={teacherId}&startDate={Date(start)}&endDate={Date(end)}", token);
public Task<UntisStudentReportDto> GetStudentsAsync(string className, CancellationToken token = default) =>
GetAsync<UntisStudentReportDto>($"/api/webuntis/student-report?className={Uri.EscapeDataString(className)}", token);
public Task<UntisStudentAbsenceReportDto> GetAbsencesAsync(int studentKey, DateOnly start, DateOnly end,
CancellationToken token = default) => GetAsync<UntisStudentAbsenceReportDto>(
$"/api/webuntis/students/{studentKey}/absences?startDate={Date(start)}&endDate={Date(end)}", token);
private async Task<T> GetAsync<T>(string path, CancellationToken token)
{
await EnsureConnectedAsync(token);
using var response = await SendAsync(HttpMethod.Get, path, null, token);
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<T>(cancellationToken: token)
?? throw new WebUntisIntegrationException("Der Server hat keine WebUntis-Daten zurückgegeben.");
}
private async Task EnsureConnectedAsync(CancellationToken token)
{
var credentials = settings.GetApiCredentials()
?? throw new WebUntisIntegrationException("Bitte zuerst die WebUntis-Anmeldedaten einrichten.");
using var status = await SendAsync(HttpMethod.Get, "/api/webuntis/connection", null, token);
await EnsureSuccessAsync(status);
using var json = JsonDocument.Parse(await status.Content.ReadAsStringAsync(token));
if (!json.RootElement.GetProperty("connected").GetBoolean()) await ConnectAsync(credentials, token);
}
private async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, object? body,
CancellationToken token)
{
if (!syncSettings.IsLoggedIn || string.IsNullOrWhiteSpace(syncSettings.ServerUrl))
throw new WebUntisIntegrationException("Bitte zuerst unter „Synchronisation“ am LehrerApp-Server anmelden.");
using var request = new HttpRequestMessage(method, new Uri(new Uri(syncSettings.ServerUrl), path));
request.Headers.Authorization = new("Bearer", syncSettings.GetToken());
if (body is not null) request.Content = JsonContent.Create(body);
try { return await http.SendAsync(request, token); }
catch (HttpRequestException) { throw new WebUntisIntegrationException("Der LehrerApp-Server ist nicht erreichbar."); }
}
private static async Task EnsureSuccessAsync(HttpResponseMessage response)
{
if (response.IsSuccessStatusCode) return;
if (response.StatusCode == HttpStatusCode.Unauthorized)
throw new WebUntisIntegrationException("Die Server-Anmeldung ist abgelaufen. Bitte erneut anmelden.");
if (response.StatusCode == HttpStatusCode.NotFound &&
response.RequestMessage?.RequestUri?.AbsolutePath.StartsWith("/api/webuntis",
StringComparison.OrdinalIgnoreCase) == true)
throw new WebUntisIntegrationException(
"Der konfigurierte LehrerApp-Server enthält die WebUntis-Integration noch nicht. " +
"Bitte zuerst die aktuelle LehrerApp.Api auf dem Server bereitstellen.");
try
{
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
if (json.RootElement.TryGetProperty("detail", out var detail) && !string.IsNullOrWhiteSpace(detail.GetString()))
throw new WebUntisIntegrationException(detail.GetString()!);
}
catch (JsonException) { }
throw new WebUntisIntegrationException("Der WebUntis-Abruf ist fehlgeschlagen.");
}
private static int Date(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
}
@@ -9,8 +9,12 @@ internal class WebUntisSettingsConfig
public string? EncryptedIcalUrl { get; set; }
public DateTime? LastSyncAt { get; set; }
public string LastSyncStatus { get; set; } = "";
public string? EncryptedApiCredentials { get; set; }
public int? TeacherUntisId { get; set; }
}
public sealed record WebUntisCredentials(string School, string Host, string Username, string Password);
/// <summary>
/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie
/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die
@@ -30,6 +34,8 @@ public class WebUntisSettingsService
public bool Enabled => _config.Enabled;
public bool IsConfigured => _config.EncryptedIcalUrl is not null;
public bool ApiIsConfigured => _config.EncryptedApiCredentials is not null;
public int? TeacherUntisId => _config.TeacherUntisId;
public DateTime? LastSyncAt => _config.LastSyncAt;
public string LastSyncStatus => _config.LastSyncStatus;
@@ -63,6 +69,29 @@ public class WebUntisSettingsService
Save();
}
public void SetApiCredentials(WebUntisCredentials credentials)
{
_config.EncryptedApiCredentials = SyncCrypto.EncryptObject(credentials, _urlKey);
Save();
}
public WebUntisCredentials? GetApiCredentials() => _config.EncryptedApiCredentials is null
? null
: SyncCrypto.DecryptObject<WebUntisCredentials>(_config.EncryptedApiCredentials, _urlKey);
public void ClearApiCredentials()
{
_config.EncryptedApiCredentials = null;
_config.TeacherUntisId = null;
Save();
}
public void SetTeacherUntisId(int? teacherUntisId)
{
_config.TeacherUntisId = teacherUntisId;
Save();
}
public void SetLastSync(DateTime at, string status)
{
_config.LastSyncAt = at;
@@ -0,0 +1,131 @@
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Importing;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class WebUntisAbsenceRow : ObservableObject
{
public required string StudentName { get; init; }
public required DateOnly Date { get; init; }
public required string TimeLabel { get; init; }
public required string UntisStatus { get; init; }
public required string LocalStatus { get; init; }
public required AttendanceStatus TargetStatus { get; init; }
public required Guid StudentId { get; init; }
public required Guid? SessionId { get; init; }
public string? Reason { get; init; }
public string DateLabel => Date.ToString("dd.MM.yyyy");
public bool CanApply => SessionId is not null;
[ObservableProperty] private bool _selected;
}
public partial class WebUntisAbsenceComparisonViewModel : ObservableObject
{
private readonly LearningGroup _group;
private readonly WebUntisIntegrationService _untis;
private readonly IStudentRepository _students;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation;
public ObservableCollection<WebUntisAbsenceRow> Rows { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
[ObservableProperty] private bool _busy;
public WebUntisAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
IStudentRepository students, IParticipationSessionRepository sessions,
IParticipationRepository participation)
{
_group = group; _untis = untis; _students = students; _sessions = sessions;
_participation = participation;
}
[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; }
Busy = true; Rows.Clear();
try
{
var courseStudents = _students.GetByGroup(_group.Id);
var localSessions = _sessions.GetByGroup(_group.Id)
.Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date)
.ToDictionary(x => x.Key, x => x.First());
var loaded = new ConcurrentBag<(Student Student, UntisStudentAbsenceDto Absence)>();
var linked = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
.Where(x => x.Key is not null).ToList();
await Parallel.ForEachAsync(linked, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (item, token) =>
{
var report = await _untis.GetAbsencesAsync(item.Key!.Value, start, end, token);
foreach (var absence in report.Absences) loaded.Add((item.Student, absence));
});
foreach (var item in loaded.OrderBy(x => x.Absence.Date).ThenBy(x => x.Student.FullName))
{
if (!TryDate(item.Absence.Date, out var date)) continue;
localSessions.TryGetValue(date, out var session);
var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, item.Student.Id);
var target = MapStatus(item.Absence.ExcuseStatus);
Rows.Add(new WebUntisAbsenceRow
{
StudentName = item.Student.FullName, StudentId = item.Student.Id, Date = date,
TimeLabel = $"{Time(item.Absence.StartTime)}{Time(item.Absence.EndTime)}",
UntisStatus = DisplayUntisStatus(item.Absence),
LocalStatus = entry?.Attendance?.ToString() ?? (session is null ? "keine lokale Stunde" : "nicht erfasst"),
TargetStatus = target, SessionId = session?.Id, Reason = item.Absence.AbsenceReason,
Selected = session is not null && entry?.Attendance != target,
});
}
var withoutKey = courseStudents.Count - linked.Count;
Status = $"{Rows.Count} Untis-Fehlzeiten gefunden; {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar."
+ (withoutKey > 0 ? $" {withoutKey} Schüler haben noch keine WebUntis-Kennung." : "");
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private void Apply()
{
var selected = Rows.Where(x => x.Selected && x.SessionId is not null).ToList();
foreach (var row in selected)
{
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, row.StudentId)
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = row.StudentId };
entry.Attendance = row.TargetStatus;
entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry);
}
Status = $"{selected.Count} Anwesenheitsstatus übernommen.";
foreach (var row in selected) row.Selected = false;
}
private static int? StudentKey(Student student)
{
student.ExternalIds ??= [];
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
&& int.TryParse(value, out var key) ? key : null;
}
private static AttendanceStatus MapStatus(string? value)
{
var text = value?.Trim().ToLowerInvariant() ?? "";
if (text.Contains("unexcused") || text.Contains("unentschuldigt") || text.Contains("nicht entschuldigt"))
return AttendanceStatus.Unexcused;
if (text.Contains("excused") || text.Contains("entschuldigt")) return AttendanceStatus.Excused;
return AttendanceStatus.ExcusePending;
}
private static string DisplayUntisStatus(UntisStudentAbsenceDto absence) =>
string.Join(" · ", new[] { absence.ExcuseStatus, absence.AbsenceReason }.Where(x => !string.IsNullOrWhiteSpace(x)))
is { Length: > 0 } text ? text : "offen";
private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
private static string Time(int value) => $"{value / 100:00}:{value % 100:00}";
}
@@ -0,0 +1,56 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class WebUntisClassSelectionViewModel(WebUntisIntegrationService untis) : ObservableObject
{
public ObservableCollection<UntisSchoolYearDto> SchoolYears { get; } = [];
public ObservableCollection<UntisClassDto> Classes { get; } = [];
[ObservableProperty] private UntisSchoolYearDto? _selectedSchoolYear;
[ObservableProperty] private UntisClassDto? _selectedClass;
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _busy;
public bool CanConfirm => SelectedClass is not null && !Busy;
partial void OnSelectedClassChanged(UntisClassDto? value) => OnPropertyChanged(nameof(CanConfirm));
partial void OnBusyChanged(bool value) => OnPropertyChanged(nameof(CanConfirm));
public async Task InitializeAsync()
{
Busy = true;
try
{
foreach (var year in (await untis.GetSchoolYearsAsync()).OrderByDescending(x => x.StartDate))
SchoolYears.Add(year);
SelectedSchoolYear = SchoolYears.FirstOrDefault(x => x.StartDate <= Today() && x.EndDate >= Today())
?? SchoolYears.FirstOrDefault();
await LoadClasses();
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task LoadClasses()
{
if (SelectedSchoolYear is null) return;
Busy = true; Classes.Clear(); SelectedClass = null;
try
{
foreach (var entry in (await untis.GetClassesAsync(SelectedSchoolYear.UntisId)).OrderBy(x => x.Name))
Classes.Add(entry);
Status = Classes.Count == 0 ? "Keine Klassen gefunden." : $"{Classes.Count} Klassen gefunden.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
private static int Today()
{
var date = DateOnly.FromDateTime(DateTime.Today);
return date.Year * 10000 + date.Month * 100 + date.Day;
}
}
@@ -103,6 +103,7 @@ public partial class TimetableViewModel : ObservableObject
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; }
public Func<Task>? OnAddSubstitution { get; set; }
public Func<Task>? OnImportWebUntisTimetable { get; set; }
public Action<SettingsTab>? OnNavigateToSettings { get; set; }
public Func<Lesson, Task>? OnOpenLessonViewer { get; set; }
public Func<Lesson, Task>? OnOpenTeachingMode { get; set; }
@@ -143,6 +144,14 @@ public partial class TimetableViewModel : ObservableObject
Load();
}
[RelayCommand]
private async Task ImportWebUntisTimetable()
{
if (OnImportWebUntisTimetable is null) return;
await OnImportWebUntisTimetable();
Load();
}
public void Load()
{
var today = DateOnly.FromDateTime(DateTime.Today);
@@ -0,0 +1,159 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Planning;
public sealed record WebUntisGroupOption(Guid Id, string DisplayName);
public partial class WebUntisTimetableRow : ObservableObject
{
public required DayOfWeek Weekday { get; init; }
public required int PeriodNumber { get; init; }
public required string TimeLabel { get; init; }
public required string UntisLabel { get; init; }
public required string SuggestedGroupName { get; init; }
public string? SubjectName { get; init; }
public string? Room { get; init; }
public ObservableCollection<WebUntisGroupOption> GroupOptions { get; } = [];
[ObservableProperty] private WebUntisGroupOption? _selectedGroup;
public string WeekdayLabel => Weekday switch
{
DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi",
DayOfWeek.Thursday => "Do", DayOfWeek.Friday => "Fr", _ => Weekday.ToString()[..2],
};
}
public partial class WebUntisTimetableImportViewModel : ObservableObject
{
private readonly WebUntisIntegrationService _untis;
private readonly WebUntisSettingsService _settings;
private readonly ITimetableSlotRepository _slots;
private readonly IGroupRepository _groups;
public ObservableCollection<UntisTeacherDto> Teachers { get; } = [];
public ObservableCollection<WebUntisTimetableRow> Rows { get; } = [];
[ObservableProperty] private UntisTeacherDto? _selectedTeacher;
[ObservableProperty] private DateTimeOffset _weekDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Lehrkraft auswählen und Untis-Woche laden.";
[ObservableProperty] private bool _busy;
public bool Saved { get; private set; }
public Func<WebUntisTimetableRow, Task<LearningGroup?>>? OnCreateGroup { get; set; }
public WebUntisTimetableImportViewModel(WebUntisIntegrationService untis, WebUntisSettingsService settings,
ITimetableSlotRepository slots, IGroupRepository groups)
{
_untis = untis; _settings = settings; _slots = slots; _groups = groups;
}
public async Task InitializeAsync()
{
Busy = true;
try
{
foreach (var teacher in (await _untis.GetTeachersAsync()).Where(x => x.Active).OrderBy(x => x.DisplayName))
Teachers.Add(teacher);
SelectedTeacher = Teachers.FirstOrDefault(x => x.UntisId == _settings.TeacherUntisId)
?? Teachers.FirstOrDefault();
Status = Teachers.Count == 0 ? "WebUntis hat keine Lehrkräfte geliefert." : "Bereit zum Laden.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task Load()
{
if (SelectedTeacher is null) { Status = "Bitte eine Lehrkraft auswählen."; return; }
Busy = true; Rows.Clear();
try
{
var selected = DateOnly.FromDateTime(WeekDate.LocalDateTime);
var monday = selected.AddDays(-(((int)selected.DayOfWeek + 6) % 7));
var periods = await _untis.GetTimetableAsync(SelectedTeacher.UntisId, monday, monday.AddDays(6));
var grid = await _untis.GetTimeGridAsync();
var groupOptions = BuildGroupOptions();
var localSlots = _slots.GetAll().ToDictionary(x => (x.Weekday, x.PeriodNumber));
foreach (var period in periods.Where(x => string.IsNullOrWhiteSpace(x.Code) || x.Code != "cancelled")
.GroupBy(x => (x.Date, x.StartTime, x.EndTime, x.StudentGroup,
Class: string.Join("/", x.Classes.Select(c => c.Name)),
Subject: string.Join("/", x.Subjects.Select(s => s.Name)),
Room: string.Join("/", x.Rooms.Select(r => r.Name))))
.Select(x => x.First()).OrderBy(x => x.Date).ThenBy(x => x.StartTime))
{
if (!TryDate(period.Date, out var date) || date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
continue;
var dayGrid = grid.FirstOrDefault(x => x.Day == UntisDay(date.DayOfWeek));
var number = dayGrid?.TimeUnits.ToList().FindIndex(x => x.StartTime == period.StartTime) + 1 ?? 0;
if (number <= 0) continue;
var className = period.Classes.FirstOrDefault()?.Name ?? "";
var subject = period.Subjects.FirstOrDefault()?.Name;
var suggested = !string.IsNullOrWhiteSpace(period.StudentGroup) ? period.StudentGroup! : className;
var row = new WebUntisTimetableRow
{
Weekday = date.DayOfWeek, PeriodNumber = number,
TimeLabel = $"{Time(period.StartTime)}{Time(period.EndTime)}",
UntisLabel = string.Join(" · ", new[] { subject, suggested, period.Rooms.FirstOrDefault()?.Name }
.Where(x => !string.IsNullOrWhiteSpace(x))),
SuggestedGroupName = suggested, SubjectName = subject,
Room = period.Rooms.FirstOrDefault()?.Name,
};
foreach (var option in groupOptions) row.GroupOptions.Add(option);
if (localSlots.TryGetValue((row.Weekday, row.PeriodNumber), out var existing))
row.SelectedGroup = groupOptions.FirstOrDefault(x => x.Id == existing.GroupId);
row.SelectedGroup ??= BestMatch(groupOptions, suggested, className, subject);
Rows.Add(row);
}
_settings.SetTeacherUntisId(SelectedTeacher.UntisId);
Status = Rows.Count == 0 ? "In dieser Woche wurde kein Unterricht gefunden."
: $"{Rows.Count} regelmäßige Termine gefunden. Zuordnung prüfen und übernehmen.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task CreateGroup(WebUntisTimetableRow row)
{
if (OnCreateGroup is null) return;
var group = await OnCreateGroup(row);
if (group is null) return;
var option = new WebUntisGroupOption(group.Id, group.Name);
foreach (var item in Rows.Where(x => x.SuggestedGroupName == row.SuggestedGroupName))
{
item.GroupOptions.Add(option);
item.SelectedGroup = option;
}
}
[RelayCommand]
private void Save()
{
var selected = Rows.Where(x => x.SelectedGroup is not null).ToList();
foreach (var row in selected)
{
var existing = _slots.GetAll().FirstOrDefault(x => x.Weekday == row.Weekday && x.PeriodNumber == row.PeriodNumber);
var slot = existing ?? new TimetableSlot { Weekday = row.Weekday, PeriodNumber = row.PeriodNumber };
slot.GroupId = row.SelectedGroup!.Id;
slot.Room = string.IsNullOrWhiteSpace(row.Room) ? null : row.Room;
_slots.Save(slot);
}
Saved = true;
Status = $"{selected.Count} Stundenplan-Einträge übernommen.";
}
private List<WebUntisGroupOption> BuildGroupOptions() => _groups.GetAll().OrderBy(x => x.Name)
.Select(x => new WebUntisGroupOption(x.Id, x.Name)).ToList();
private static WebUntisGroupOption? BestMatch(IEnumerable<WebUntisGroupOption> options, params string?[] terms) =>
options.FirstOrDefault(x => terms.Any(term => !string.IsNullOrWhiteSpace(term) &&
(x.DisplayName.Equals(term, StringComparison.OrdinalIgnoreCase) ||
x.DisplayName.Contains(term, StringComparison.OrdinalIgnoreCase))));
private static int UntisDay(DayOfWeek day) => day == DayOfWeek.Sunday ? 7 : (int)day;
private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
private static string Time(int value) => $"{value / 100:00}:{value % 100:00}";
}
@@ -210,6 +210,13 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _untisUrlError = "";
[ObservableProperty] private string _untisStatusDisplay = "";
[ObservableProperty] private bool _untisFetchBusy;
[ObservableProperty] private bool _untisApiIsConfigured;
[ObservableProperty] private string _untisSchool = "";
[ObservableProperty] private string _untisHost = "";
[ObservableProperty] private string _untisUsername = "";
[ObservableProperty] private string _untisPassword = "";
[ObservableProperty] private string _untisApiStatus = "";
[ObservableProperty] private bool _untisApiBusy;
public Func<Task>? OnReviewUntisMapping { get; set; }
// ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
@@ -299,6 +306,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly AiSettingsService _aiSettings;
private readonly AiPlanningService _aiPlanning;
private readonly WebUntisSettingsService _untisSettings;
private readonly WebUntisIntegrationService? _untisIntegration;
private readonly UntisSyncService? _untisSync;
private readonly AnnualPlanSettingsService _annualPlanSettings;
private readonly AnnualPlanSyncService? _annualPlanSync;
@@ -330,7 +338,7 @@ public partial class SettingsViewModel : ObservableObject
AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null,
SchoolWeatherService? schoolWeather = null)
SchoolWeatherService? schoolWeather = null, WebUntisIntegrationService? untisIntegration = null)
{
_logger = logger;
_syncKeyRecovery = syncKeyRecovery;
@@ -360,6 +368,7 @@ public partial class SettingsViewModel : ObservableObject
_aiSettings = aiSettings;
_aiPlanning = aiPlanning;
_untisSettings = untisSettings;
_untisIntegration = untisIntegration;
_untisSync = untisSync;
_annualPlanSettings = annualPlanSettings;
_annualPlanSync = annualPlanSync;
@@ -542,6 +551,60 @@ public partial class SettingsViewModel : ObservableObject
UntisStatusDisplay = _untisSettings.LastSyncAt is { } at
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_untisSettings.LastSyncStatus}"
: "Noch kein Abgleich durchgeführt.";
UntisApiIsConfigured = _untisSettings.ApiIsConfigured;
if (_untisSettings.GetApiCredentials() is { } credentials)
{
UntisSchool = credentials.School;
UntisHost = credentials.Host;
UntisUsername = credentials.Username;
UntisApiStatus = $"API-Zugang für {credentials.Username} ist lokal verschlüsselt gespeichert.";
}
}
[RelayCommand]
private async Task UntisSaveApi()
{
UntisApiStatus = "";
if (_untisIntegration is null)
{
UntisApiStatus = "Die WebUntis-Integration ist nicht verfügbar.";
return;
}
if (string.IsNullOrWhiteSpace(UntisSchool) || string.IsNullOrWhiteSpace(UntisUsername) ||
string.IsNullOrWhiteSpace(UntisPassword))
{
UntisApiStatus = "Schule, Benutzername und Passwort sind erforderlich.";
return;
}
UntisApiBusy = true;
try
{
var credentials = new WebUntisCredentials(UntisSchool.Trim(), UntisHost.Trim(),
UntisUsername.Trim(), UntisPassword);
await _untisIntegration.ConnectAsync(credentials);
_untisSettings.SetApiCredentials(credentials);
UntisPassword = "";
UntisApiIsConfigured = true;
UntisApiStatus = "Anmeldung erfolgreich. Die WebUntis-Session bleibt bei Nutzung bis zu 10 Minuten offen.";
}
catch (WebUntisIntegrationException ex) { UntisApiStatus = ex.Message; }
finally { UntisApiBusy = false; }
}
[RelayCommand]
private async Task UntisRemoveApi()
{
UntisApiBusy = true;
try { if (_untisIntegration is not null) await _untisIntegration.DisconnectAsync(); }
catch (WebUntisIntegrationException) { /* lokale Zugangsdaten trotzdem sicher entfernen */ }
finally
{
_untisSettings.ClearApiCredentials();
UntisPassword = "";
UntisApiIsConfigured = false;
UntisApiStatus = "WebUntis-API-Zugang entfernt.";
UntisApiBusy = false;
}
}
[RelayCommand]
@@ -25,6 +25,9 @@
<Button Content=" Schüler" Command="{Binding AddStudentCommand}" IsEnabled="{Binding IsEditable}"/>
<Button Content="⇩ Teilnehmer importieren…" Click="OnImportParticipantsClick"
IsEnabled="{Binding IsEditable}"/>
<Button Content="↻ Aus WebUntis…" Click="OnImportParticipantsFromWebUntisClick"
IsEnabled="{Binding IsEditable}"/>
<Button Content="Fehlzeiten abgleichen…" Click="OnCompareWebUntisAbsencesClick"/>
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
Command="{Binding WithdrawStudentCommand}"
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
@@ -13,6 +13,7 @@ using LehrerApp.Desktop.Views.Shared;
using LehrerApp.Desktop.Views.Students;
using LehrerApp.Desktop.Views.Workload;
using Microsoft.Extensions.DependencyInjection;
using System.Text;
namespace LehrerApp.Desktop.Views.Groups;
@@ -113,18 +114,7 @@ public partial class GroupDetailView : UserControl
throw new InvalidDataException("Die Importdatei ist größer als 20 MB.");
var importFile = new ImportFile(files[0].Name, buffer.ToArray());
var preview = await Task.Run(async () =>
await service.AnalyzeAsync(importFile, vm.Group.Id).ConfigureAwait(false));
var dialogVm = new StudentImportDialogViewModel(service, preview, files[0].Name);
var dialog = new StudentImportDialog { DataContext = dialogVm };
if (!await dialog.ShowDialog<bool>(owner)) return;
vm.LoadStudents();
vm.ParticipationTab.RefreshCurrentGrid();
var result = dialogVm.Result!;
App.Services.GetRequiredService<NotificationService>().ShowSuccess(
$"Teilnehmerimport abgeschlossen: {result.CreatedStudents} neu, "
+ $"{result.UpdatedStudents} ergänzt, {result.CreatedMemberships} zugeordnet.");
await ShowStudentImportPreview(owner, vm, importFile, files[0].Name);
}
catch (Exception ex) when (ex is ImportFormatException
or InvalidDataException
@@ -135,6 +125,85 @@ public partial class GroupDetailView : UserControl
}
}
private async void OnImportParticipantsFromWebUntisClick(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || DataContext is not GroupDetailViewModel vm || vm.Group is null) return;
try
{
var untis = App.Services.GetRequiredService<WebUntisIntegrationService>();
var selectionVm = new WebUntisClassSelectionViewModel(untis);
var selection = new WebUntisClassSelectionDialog { DataContext = selectionVm };
await selectionVm.InitializeAsync();
if (!await selection.ShowDialog<bool>(owner) || selectionVm.SelectedClass is null) return;
var report = await untis.GetStudentsAsync(selectionVm.SelectedClass.Name);
var importFile = BuildWebUntisStudentImport(report);
await ShowStudentImportPreview(owner, vm, importFile,
$"WebUntis · {selectionVm.SelectedClass.Name}");
}
catch (WebUntisIntegrationException ex)
{
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
}
catch (Exception ex) when (ex is ImportFormatException or InvalidDataException)
{
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
}
}
private async void OnCompareWebUntisAbsencesClick(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || DataContext is not GroupDetailViewModel vm || vm.Group is null) return;
var dialogVm = new WebUntisAbsenceComparisonViewModel(vm.Group,
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>());
await new WebUntisAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
vm.ParticipationTab.RefreshCurrentGrid();
}
private static ImportFile BuildWebUntisStudentImport(UntisStudentReportDto report)
{
var builder = new StringBuilder();
builder.AppendLine("longName\tforeName\tgender\tbirthDate\tklasse.name\texternKey\taddress.email\taddress.mobile\taddress.phone\taddress.city\taddress.postCode\taddress.street");
foreach (var student in report.Students)
{
var values = new[]
{
student.LongName ?? student.Name, student.ForeName, student.Gender,
student.BirthDate?.ToString() ?? student.BirthDateRaw, student.ClassName,
student.ExternKey.ToString(), student.Address.Email, student.Address.Mobile,
student.Address.Phone, student.Address.City, student.Address.PostCode, student.Address.Street,
};
builder.AppendLine(string.Join('\t', values.Select(SafeTsv)));
}
return new ImportFile("webuntis-students.csv", Encoding.UTF8.GetBytes(builder.ToString()));
}
private static string SafeTsv(string? value) => (value ?? "").Replace('\t', ' ')
.Replace('\r', ' ').Replace('\n', ' ');
private static async Task ShowStudentImportPreview(Window owner, GroupDetailViewModel vm,
ImportFile importFile, string sourceName)
{
var service = App.Services.GetRequiredService<StudentImportService>();
var preview = await Task.Run(async () =>
await service.AnalyzeAsync(importFile, vm.Group!.Id).ConfigureAwait(false));
var dialogVm = new StudentImportDialogViewModel(service, preview, sourceName);
var dialog = new StudentImportDialog { DataContext = dialogVm };
if (!await dialog.ShowDialog<bool>(owner)) return;
vm.LoadStudents();
vm.ParticipationTab.RefreshCurrentGrid();
var result = dialogVm.Result!;
App.Services.GetRequiredService<NotificationService>().ShowSuccess(
$"Teilnehmerimport abgeschlossen: {result.CreatedStudents} neu, "
+ $"{result.UpdatedStudents} ergänzt, {result.CreatedMemberships} zugeordnet.");
}
private async Task<bool> ShowWithdrawStudentDialog(StudentSummary student)
{
if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false;
@@ -0,0 +1,42 @@
<Window 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.WebUntisAbsenceComparisonDialog"
x:DataType="vm:WebUntisAbsenceComparisonViewModel"
Title="Fehlzeiten mit WebUntis abgleichen" Width="850" Height="620"
MinWidth="700" MinHeight="450" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24" RowSpacing="12">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="Fehlzeiten mit WebUntis abgleichen" Classes="dialogtitle"/>
<TextBlock Text="Nur markierte Zeilen mit einer vorhandenen lokalen Kursstunde werden übernommen."
FontSize="12" Opacity="0.65"/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<DatePicker SelectedDate="{Binding StartDate}"/>
<TextBlock Text="bis" VerticalAlignment="Center"/>
<DatePicker SelectedDate="{Binding EndDate}"/>
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisAbsenceRow">
<Grid ColumnDefinitions="Auto,1.5*,90,90,1.5*,1.5*" ColumnSpacing="8" Margin="0,3">
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
<TextBlock Grid.Column="1" Text="{Binding StudentName}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding TimeLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="4" Text="{Binding UntisStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<TextBlock Grid.Column="5" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Schließen" Click="OnClose"/>
<Button Grid.Column="2" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Groups;
public partial class WebUntisAbsenceComparisonDialog : Window
{
public WebUntisAbsenceComparisonDialog() => InitializeComponent();
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
@@ -0,0 +1,34 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisClassSelectionDialog"
x:DataType="vm:WebUntisClassSelectionViewModel"
Title="Schüler aus WebUntis" Width="480" SizeToContent="Height"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="12">
<TextBlock Text="WebUntis-Klasse auswählen" Classes="dialogtitle"/>
<TextBlock Text="Die Schülerliste wird danach im gewohnten Importdialog geprüft."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
<ComboBox Grid.Column="0" ItemsSource="{Binding SchoolYears}" SelectedItem="{Binding SelectedSchoolYear}">
<ComboBox.ItemTemplate><DataTemplate x:DataType="svc:UntisSchoolYearDto"><TextBlock Text="{Binding Name}"/></DataTemplate></ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="1" Content="Klassen laden" Command="{Binding LoadClassesCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
<ComboBox ItemsSource="{Binding Classes}" SelectedItem="{Binding SelectedClass}" PlaceholderText="Klasse auswählen">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="svc:UntisClassDto">
<TextBlock><Run Text="{Binding Name}"/><Run Text=" — "/><Run Text="{Binding LongName}"/></TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="{Binding Status}" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" Click="OnCancel"/>
<Button Grid.Column="2" Content="Schülerliste laden" IsEnabled="{Binding CanConfirm}" Click="OnConfirm"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,15 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class WebUntisClassSelectionDialog : Window
{
public WebUntisClassSelectionDialog() => InitializeComponent();
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
private void OnConfirm(object? sender, RoutedEventArgs e)
{
if (DataContext is WebUntisClassSelectionViewModel { SelectedClass: not null }) Close(true);
}
}
@@ -308,6 +308,11 @@
<ContentPage Header="Bearbeiten">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,6">
<Button Content="Aus WebUntis laden…" Command="{Binding ImportWebUntisTimetableCommand}"/>
<TextBlock Text="Importiert oder vergleicht eine typische Unterrichtswoche; Vertretungen bleiben im iCal-Abgleich."
FontSize="11" Opacity="0.6" VerticalAlignment="Center"/>
</StackPanel>
<!-- Zeilenweise (GridRows) statt eines flachen UniformGrid, siehe Kommentar im
Wochenraster oben (Heute-Tab) - gleicher Grund (Grid.RowDefinitions lässt sich
nicht per {Binding} setzen). -->
@@ -5,6 +5,7 @@ using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Desktop.Services;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Planning;
@@ -20,11 +21,27 @@ public partial class TimetableView : UserControl
{
vm.OnEditSlot = ShowSlotDialog;
vm.OnAddSubstitution = ShowSubstitutionDialog;
vm.OnImportWebUntisTimetable = ShowWebUntisTimetableDialog;
vm.OnOpenLessonViewer = ShowLessonViewerDialog;
vm.OnOpenTeachingMode = ShowTeachingMode;
}
}
private async Task ShowWebUntisTimetableDialog()
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return;
var vm = new WebUntisTimetableImportViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<WebUntisSettingsService>(),
App.Services.GetRequiredService<ITimetableSlotRepository>(),
App.Services.GetRequiredService<IGroupRepository>());
var dialog = new WebUntisTimetableImportDialog { DataContext = vm };
vm.OnCreateGroup = dialog.CreateGroupAsync;
await vm.InitializeAsync();
await dialog.ShowDialog<bool>(owner);
}
private async Task ShowTeachingMode(Lesson lesson)
{
var owner = TopLevel.GetTopLevel(this) as Window;
@@ -0,0 +1,56 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
x:Class="LehrerApp.Desktop.Views.Planning.WebUntisTimetableImportDialog"
x:DataType="vm:WebUntisTimetableImportViewModel"
Title="Stundenplan aus WebUntis" Width="820" Height="650"
MinWidth="680" MinHeight="480" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24" RowSpacing="14">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="Stundenplan aus WebUntis" Classes="dialogtitle"/>
<TextBlock Text="Wähle eine typische Unterrichtswoche. Vorhandene Einträge werden vorgeschlagen und erst nach Bestätigung ersetzt."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="2*,*,Auto" ColumnSpacing="10">
<ComboBox Grid.Column="0" ItemsSource="{Binding Teachers}" SelectedItem="{Binding SelectedTeacher}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<DatePicker Grid.Column="1" SelectedDate="{Binding WeekDate}"/>
<Button Grid.Column="2" Content="Woche laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisTimetableRow">
<Grid ColumnDefinitions="48,85,2*,2*,Auto" ColumnSpacing="8" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding WeekdayLabel}" VerticalAlignment="Center" FontWeight="SemiBold"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding PeriodNumber, StringFormat={}{0}. Std.}" FontSize="12"/>
<TextBlock Text="{Binding TimeLabel}" FontSize="10" Opacity="0.6"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding UntisLabel}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<ComboBox Grid.Column="3" ItemsSource="{Binding GroupOptions}" SelectedItem="{Binding SelectedGroup}"
PlaceholderText="nicht übernehmen">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisGroupOption"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="4" Content="Neue Gruppe…" FontSize="11"
Command="{Binding $parent[ItemsControl].((vm:WebUntisTimetableImportViewModel)DataContext).CreateGroupCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Abbrechen" Click="OnCancel"/>
<Button Grid.Column="2" Content="Zuordnung übernehmen" Command="{Binding SaveCommand}" Click="OnSave"
IsEnabled="{Binding !Busy}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,36 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.Views.Groups;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Planning;
public partial class WebUntisTimetableImportDialog : Window
{
public WebUntisTimetableImportDialog() => InitializeComponent();
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is WebUntisTimetableImportViewModel { Saved: true }) Close(true);
}
public async Task<LearningGroup?> CreateGroupAsync(WebUntisTimetableRow source)
{
var vm = App.Services.GetRequiredService<AddGroupDialogViewModel>();
vm.Name = source.SuggestedGroupName;
vm.Subject = source.SubjectName ?? "";
vm.GradeLevel = ParseGrade(source.SuggestedGroupName) ?? 10;
var dialog = new AddGroupDialog { DataContext = vm };
return await dialog.ShowDialog<bool>(this) ? vm.Result : null;
}
private static int? ParseGrade(string value)
{
var digits = new string(value.TakeWhile(char.IsDigit).ToArray());
return int.TryParse(digits, out var grade) && grade is >= 1 and <= 13 ? grade : null;
}
}
@@ -967,7 +967,40 @@
<TextBlock Text="Untis-Einbettung" FontSize="18" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Bindet den persönlichen WebUntis-Stundenplan und den schulweiten Jahresplan als zwei unabhängige iCal-Quellen ein."/>
Text="Bindet WebUntis-Daten über deinen persönlichen Zugang ein. Der bestehende iCal-Abgleich für Vertretungen bleibt davon unabhängig."/>
<TextBlock Text="WebUntis-API" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Ermöglicht Stundenplan-, Schüler- und Fehlzeitenabgleich. Die Zugangsdaten werden nur auf diesem Gerät verschlüsselt gespeichert; der LehrerApp-Server hält sie und die WebUntis-Sitzung nur im Arbeitsspeicher."/>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="8">
<StackPanel Grid.Row="0" Grid.Column="0" Spacing="4">
<TextBlock Text="Schule" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding UntisSchool}" PlaceholderText="Schulkennung, nicht Anzeigename"/>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" Spacing="4">
<TextBlock Text="Server (optional)" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding UntisHost}" PlaceholderText="z. B. arche.webuntis.com"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="4">
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding UntisUsername}"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="1" Spacing="4">
<TextBlock Text="Passwort" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding UntisPassword}" PasswordChar="●"/>
</StackPanel>
</Grid>
<TextBlock FontSize="11" Opacity="0.6" TextWrapping="Wrap"
Text="Die Schulkennung steht in der WebUntis-Anmelde-URL hinter ?school=. Als Server kannst du auch die vollständige Anmelde-URL einfügen; Server und Schulkennung sind häufig verschieden."/>
<TextBlock Text="{Binding UntisApiStatus}" FontSize="12" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Anmeldung prüfen und speichern" Command="{Binding UntisSaveApiCommand}"
IsEnabled="{Binding !UntisApiBusy}"/>
<Button Content="API-Zugang entfernen" Command="{Binding UntisRemoveApiCommand}"
IsVisible="{Binding UntisApiIsConfigured}" IsEnabled="{Binding !UntisApiBusy}"/>
</StackPanel>
<Separator Margin="0,8"/>
<TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"