diff --git a/LehrerApp.Api.Tests/DwdWeatherServiceTests.cs b/LehrerApp.Api.Tests/DwdWeatherServiceTests.cs new file mode 100644 index 0000000..80ab3b0 --- /dev/null +++ b/LehrerApp.Api.Tests/DwdWeatherServiceTests.cs @@ -0,0 +1,91 @@ +using System.IO.Compression; +using System.Text; +using Xunit; + +namespace LehrerApp.Api.Tests; + +public sealed class DwdWeatherServiceTests +{ + [Fact] + public void ParseStationCatalog_LiestFestbreitenformatUndFindetNaechsteStation() + { + const string catalog = """ + ID ICAO NAME LAT LON ELEV + ----- ---- -------------------- ----- ------- ----- + 10641 EDDF OFFENBACH-WETTERPARK 50.08 8.78 119 + 10382 EDDT BERLIN-TEGEL 52.56 13.31 37 + """; + + var stations = DwdWeatherService.ParseStationCatalog(catalog); + var nearest = DwdWeatherService.FindNearestStation(stations, 50.1, 8.7); + + Assert.Equal(2, stations.Count); + Assert.Equal("10641", nearest!.Id); + Assert.Equal("OFFENBACH-WETTERPARK", nearest.Name); + } + + [Fact] + public void ParseMosmix_KonvertiertEinheitenUndBehaeltFehlwerteAlsNull() + { + var now = DateTime.UtcNow.AddHours(1).ToString("yyyy-MM-dd'T'HH:00:00.000'Z'"); + var kml = $$""" + + + + 2026-08-23T15:00:00Z + {{now}} + + 293.15 + 5 + - + 1.25 + 75 + 61 + + + """; + + var parsed = DwdWeatherService.ParseMosmix(Zip("forecast.kml", kml)); + var hour = Assert.Single(parsed.Hours); + + Assert.Equal(20, hour.TemperatureC); + Assert.Equal(18, hour.WindSpeedKmh); + Assert.Null(hour.WindGustKmh); + Assert.Equal(1.3, hour.PrecipitationMm); + Assert.Equal(61, hour.WeatherCode); + } + + [Fact] + public void ParseCapArchive_OrdnetWarnpolygonGeografischZu() + { + var xml = """ + + warnung-1Actual + de-DEGEWITTERSevere + 2026-08-23T18:00:00Z2099-08-23T20:00:00Z + Amtliche WarnungTest + 50.0,8.0 50.0,9.0 51.0,9.0 51.0,8.0 50.0,8.0 + + + """; + + var warning = Assert.Single(DwdWeatherService.ParseCapArchive( + Zip("warning.xml", xml), DateTime.UtcNow)); + + Assert.True(DwdWeatherService.PointInPolygon(warning.Polygons[0], 50.5, 8.5)); + Assert.False(DwdWeatherService.PointInPolygon(warning.Polygons[0], 52.0, 8.5)); + Assert.Equal("GEWITTER", warning.Warning.Event); + } + + private static byte[] Zip(string fileName, string content) + { + using var memory = new MemoryStream(); + using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry(fileName); + using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false)); + writer.Write(content); + } + return memory.ToArray(); + } +} diff --git a/LehrerApp.Api.Tests/NominatimGeocoderTests.cs b/LehrerApp.Api.Tests/NominatimGeocoderTests.cs new file mode 100644 index 0000000..e260808 --- /dev/null +++ b/LehrerApp.Api.Tests/NominatimGeocoderTests.cs @@ -0,0 +1,46 @@ +using System.Net; +using System.Text; +using LehrerApp.Core.Models; +using Xunit; + +namespace LehrerApp.Api.Tests; + +public sealed class NominatimGeocoderTests +{ + [Fact] + public async Task GeocodeAsync_NutztStrukturierteDeutschlandSucheUndLiestDisplayName() + { + Uri? requestedUri = null; + var handler = new RecordingHandler(request => + { + requestedUri = request.RequestUri; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """[{"lat":"50.105","lon":"8.761","display_name":"Testschule, Offenbach, Deutschland"}]""", + Encoding.UTF8, "application/json"), + }; + }); + var http = new HttpClient(handler) { BaseAddress = new Uri("https://nominatim.openstreetmap.org/") }; + var geocoder = new NominatimGeocoder(http); + + var result = await geocoder.GeocodeAsync(new SchoolLocationRequest + { + SchoolName = "Testschule", Street = "Schulweg 1", PostalCode = "63000", City = "Offenbach", + }, CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(50.105, result!.Latitude); + Assert.Equal("Testschule, Offenbach, Deutschland", result.DisplayName); + Assert.Contains("countrycodes=de", requestedUri!.Query); + Assert.Contains("street=Schulweg%201", requestedUri.Query); + Assert.Contains("postalcode=63000", requestedUri.Query); + } + + private sealed class RecordingHandler(Func response) + : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) => Task.FromResult(response(request)); + } +} diff --git a/LehrerApp.Api.Tests/SchoolWeatherStoreTests.cs b/LehrerApp.Api.Tests/SchoolWeatherStoreTests.cs new file mode 100644 index 0000000..489e6ef --- /dev/null +++ b/LehrerApp.Api.Tests/SchoolWeatherStoreTests.cs @@ -0,0 +1,49 @@ +using LehrerApp.Core.Models; +using Xunit; + +namespace LehrerApp.Api.Tests; + +public sealed class SchoolWeatherStoreTests +{ + [Fact] + public void SaveLocation_TrenntNutzerUndErsetztVorhandenenStandort() + { + using var temp = new TempStore(); + temp.Store.SaveLocation("eins", Location("Köln", 50.9, 6.9)); + temp.Store.SaveLocation("zwei", Location("Berlin", 52.5, 13.4)); + temp.Store.SaveLocation("eins", Location("Bonn", 50.7, 7.1)); + + Assert.Equal("Bonn", temp.Store.GetLocation("eins")!.City); + Assert.Equal("Berlin", temp.Store.GetLocation("zwei")!.City); + } + + [Fact] + public void SaveLocation_AenderungEntferntWettercacheDesAltenOrts() + { + using var temp = new TempStore(); + temp.Store.SaveLocation("eins", Location("Köln", 50.9, 6.9)); + temp.Store.SaveWeather("eins", new WeatherSnapshot { StationId = "alt", RetrievedAt = DateTime.UtcNow }); + + temp.Store.SaveLocation("eins", Location("Bonn", 50.7, 7.1)); + + Assert.Null(temp.Store.GetWeather("eins")); + } + + private static SchoolLocationProfile Location(string city, double lat, double lon) => new() + { + SchoolName = "Testschule", Street = "Schulweg 1", PostalCode = "12345", City = city, + Latitude = lat, Longitude = lon, ResolvedAddress = city, UpdatedAt = DateTime.UtcNow, + }; + + private sealed class TempStore : IDisposable + { + private readonly string _path = Path.Combine(Path.GetTempPath(), $"lehrerapp-weather-tests-{Guid.NewGuid():N}"); + public SchoolWeatherStore Store { get; } + public TempStore() { Directory.CreateDirectory(_path); Store = new(_path); } + public void Dispose() + { + Store.Dispose(); + if (Directory.Exists(_path)) Directory.Delete(_path, recursive: true); + } + } +} diff --git a/LehrerApp.Api/DwdWeatherService.cs b/LehrerApp.Api/DwdWeatherService.cs new file mode 100644 index 0000000..83911d5 --- /dev/null +++ b/LehrerApp.Api/DwdWeatherService.cs @@ -0,0 +1,266 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using LehrerApp.Core.Models; + +namespace LehrerApp.Api; + +/// Lädt DWD-MOSMIX-Punktvorhersagen und CAP-Warnungen. Alle externen Antworten werden +/// pro Serverinstanz gecacht; pro Benutzer wird zusätzlich der letzte erfolgreiche Snapshot im +/// SchoolWeatherStore persistiert. +public sealed class DwdWeatherService(HttpClient http) +{ + private const string StationCatalogUrl = + "https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.cfg?view=nasPublication"; + private const string ForecastUrl = + "https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/{0}/kml/MOSMIX_L_LATEST_{0}.kmz"; + private const string WarningsUrl = + "https://opendata.dwd.de/weather/alerts/cap/COMMUNEUNION_DWD_STAT/Z_CAP_C_EDZW_LATEST_PVW_STATUS_PREMIUMDWD_COMMUNEUNION_DE.zip"; + + private readonly SemaphoreSlim _gate = new(1, 1); + private List? _stations; + private DateTime _stationsLoadedAt; + private readonly Dictionary _forecastCache = []; + private CachedWarnings? _warningsCache; + + public async Task GetAsync(SchoolLocationProfile location, + CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + var stations = await GetStationsAsync(cancellationToken); + var station = FindNearestStation(stations, location.Latitude, location.Longitude) + ?? throw new InvalidOperationException("Der DWD-Stationskatalog enthält keine Stationen."); + var forecast = await GetForecastAsync(station, cancellationToken); + var warnings = await GetWarningsAsync(cancellationToken); + + return new WeatherSnapshot + { + StationId = station.Id, + StationName = station.Name, + StationDistanceKm = Math.Round(DistanceKm( + location.Latitude, location.Longitude, station.Latitude, station.Longitude), 1), + ForecastIssuedAt = forecast.IssuedAt, + RetrievedAt = DateTime.UtcNow, + Forecast = forecast.Hours, + Warnings = warnings.Where(w => Contains(w.Polygons, location.Latitude, location.Longitude)) + .Select(w => w.Warning) + .OrderByDescending(w => SeverityRank(w.Severity)) + .ThenBy(w => w.Onset) + .ToList(), + }; + } + finally { _gate.Release(); } + } + + private async Task> GetStationsAsync(CancellationToken cancellationToken) + { + if (_stations is not null && DateTime.UtcNow - _stationsLoadedAt < TimeSpan.FromDays(7)) + return _stations; + var text = await http.GetStringAsync(StationCatalogUrl, cancellationToken); + _stations = ParseStationCatalog(text); + _stationsLoadedAt = DateTime.UtcNow; + return _stations; + } + + private async Task GetForecastAsync(MosmixStation station, + CancellationToken cancellationToken) + { + if (_forecastCache.TryGetValue(station.Id, out var cached) && + DateTime.UtcNow - cached.LoadedAt < TimeSpan.FromMinutes(45)) + return cached.Forecast; + + var bytes = await http.GetByteArrayAsync(string.Format(CultureInfo.InvariantCulture, + ForecastUrl, station.Id), cancellationToken); + var parsed = ParseMosmix(bytes); + _forecastCache[station.Id] = new(DateTime.UtcNow, parsed); + return parsed; + } + + private async Task> GetWarningsAsync(CancellationToken cancellationToken) + { + if (_warningsCache is not null && + DateTime.UtcNow - _warningsCache.LoadedAt < TimeSpan.FromMinutes(5)) + return _warningsCache.Warnings; + var bytes = await http.GetByteArrayAsync(WarningsUrl, cancellationToken); + var warnings = ParseCapArchive(bytes, DateTime.UtcNow); + _warningsCache = new(DateTime.UtcNow, warnings); + return warnings; + } + + public static List ParseStationCatalog(string text) + { + var regex = new Regex(@"^(\S+)\s+\S+\s+(.{20})\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+", RegexOptions.Compiled); + var result = new List(); + foreach (var line in text.Split('\n')) + { + var match = regex.Match(line.TrimEnd('\r')); + if (!match.Success || + !double.TryParse(match.Groups[3].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lat) || + !double.TryParse(match.Groups[4].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lon)) + continue; + result.Add(new(match.Groups[1].Value, match.Groups[2].Value.Trim(), lat, lon)); + } + return result; + } + + public static MosmixStation? FindNearestStation(IEnumerable stations, + double latitude, double longitude) => stations.MinBy(s => + DistanceKm(latitude, longitude, s.Latitude, s.Longitude)); + + public static ParsedForecast ParseMosmix(byte[] kmz) + { + using var memory = new MemoryStream(kmz); + using var archive = new ZipArchive(memory, ZipArchiveMode.Read); + var entry = archive.Entries.FirstOrDefault(x => x.Name.EndsWith(".kml", StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidDataException("Die MOSMIX-Datei enthält kein KML-Dokument."); + using var stream = entry.Open(); + var document = XDocument.Load(stream); + XNamespace dwd = "https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd"; + + var times = document.Descendants(dwd + "TimeStep") + .Select(x => ParseDate(x.Value)) + .Where(x => x.HasValue) + .Select(x => x!.Value) + .ToList(); + if (times.Count == 0) throw new InvalidDataException("MOSMIX enthält keine Zeitschritte."); + + var values = document.Descendants(dwd + "Forecast") + .Where(x => x.Attribute(dwd + "elementName") is not null) + .ToDictionary( + x => x.Attribute(dwd + "elementName")!.Value, + x => ParseValues(x.Element(dwd + "value")?.Value ?? ""), + StringComparer.Ordinal); + + double? Value(string key, int i) => values.TryGetValue(key, out var series) && i < series.Count + ? series[i] : null; + var hours = new List(); + var earliest = DateTime.UtcNow.AddHours(-1); + for (var i = 0; i < times.Count && hours.Count < 72; i++) + { + if (times[i] < earliest) continue; + hours.Add(new WeatherForecastHour + { + ValidAt = times[i], + TemperatureC = Round(Value("TTT", i) - 273.15, 1), + WindSpeedKmh = Round(Value("FF", i) * 3.6, 1), + WindGustKmh = Round(Value("FX1", i) * 3.6, 1), + PrecipitationMm = Round(Value("RR1c", i), 1), + CloudCoverPercent = Round(Value("Neff", i), 0), + WeatherCode = Value("ww", i) is { } code ? (int)Math.Round(code) : null, + }); + } + + return new(ParseDate(document.Descendants(dwd + "IssueTime").FirstOrDefault()?.Value), hours); + } + + public static List ParseCapArchive(byte[] zip, DateTime nowUtc) + { + var result = new List(); + using var memory = new MemoryStream(zip); + using var archive = new ZipArchive(memory, ZipArchiveMode.Read); + foreach (var entry in archive.Entries.Where(x => x.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))) + { + using var stream = entry.Open(); + var document = XDocument.Load(stream); + XNamespace cap = "urn:oasis:names:tc:emergency:cap:1.2"; + var root = document.Root; + if (root is null || root.Element(cap + "status")?.Value != "Actual") continue; + var info = root.Elements(cap + "info").FirstOrDefault(x => + x.Element(cap + "language")?.Value.StartsWith("de", StringComparison.OrdinalIgnoreCase) == true) + ?? root.Elements(cap + "info").FirstOrDefault(); + if (info is null) continue; + var expires = ParseDate(info.Element(cap + "expires")?.Value); + if (expires is { } until && until < nowUtc) continue; + + var polygons = info.Elements(cap + "area") + .SelectMany(x => x.Elements(cap + "polygon")) + .Select(x => ParsePolygon(x.Value)) + .Where(x => x.Count >= 3) + .ToList(); + // Warngebiete ohne Polygon (z.B. Seegebiete) können einer Schulkoordinate nicht + // zuverlässig zugeordnet werden und werden daher bewusst nicht global angezeigt. + if (polygons.Count == 0) continue; + result.Add(new ParsedWarning(new WeatherWarning + { + Identifier = root.Element(cap + "identifier")?.Value ?? "", + Event = info.Element(cap + "event")?.Value ?? "", + Headline = info.Element(cap + "headline")?.Value ?? "", + Description = info.Element(cap + "description")?.Value ?? "", + Instruction = info.Element(cap + "instruction")?.Value ?? "", + Severity = info.Element(cap + "severity")?.Value ?? "Unknown", + Onset = ParseDate(info.Element(cap + "onset")?.Value), + Expires = expires, + }, polygons)); + } + return result; + } + + private static List ParseValues(string raw) => raw + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) + .Select(x => double.TryParse(x, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) + ? (double?)value : null) + .ToList(); + + private static List ParsePolygon(string raw) => raw + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Split(',')) + .Where(x => x.Length >= 2 && + double.TryParse(x[0], NumberStyles.Float, CultureInfo.InvariantCulture, out _) && + double.TryParse(x[1], NumberStyles.Float, CultureInfo.InvariantCulture, out _)) + .Select(x => new GeoPoint( + double.Parse(x[0], CultureInfo.InvariantCulture), + double.Parse(x[1], CultureInfo.InvariantCulture))) + .ToList(); + + public static bool PointInPolygon(IReadOnlyList polygon, double latitude, double longitude) + { + var inside = false; + for (int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++) + { + var a = polygon[i]; + var b = polygon[j]; + if ((a.Latitude > latitude) != (b.Latitude > latitude) && + longitude < (b.Longitude - a.Longitude) * (latitude - a.Latitude) / + (b.Latitude - a.Latitude) + a.Longitude) + inside = !inside; + } + return inside; + } + + private static bool Contains(IEnumerable> polygons, + double latitude, double longitude) => polygons.Any(p => PointInPolygon(p, latitude, longitude)); + + private static double DistanceKm(double lat1, double lon1, double lat2, double lon2) + { + const double earthRadiusKm = 6371.0; + static double Rad(double degrees) => degrees * Math.PI / 180.0; + var dLat = Rad(lat2 - lat1); + var dLon = Rad(lon2 - lon1); + var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + + Math.Cos(Rad(lat1)) * Math.Cos(Rad(lat2)) * + Math.Sin(dLon / 2) * Math.Sin(dLon / 2); + return earthRadiusKm * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); + } + + private static DateTime? ParseDate(string? value) => + DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, + out var parsed) ? parsed.UtcDateTime : null; + + private static double? Round(double? value, int digits) => value is null + ? null : Math.Round(value.Value, digits, MidpointRounding.AwayFromZero); + + private static int SeverityRank(string severity) => severity switch + { + "Extreme" => 4, "Severe" => 3, "Moderate" => 2, "Minor" => 1, _ => 0, + }; + + public sealed record MosmixStation(string Id, string Name, double Latitude, double Longitude); + public sealed record ParsedForecast(DateTime? IssuedAt, List Hours); + public sealed record GeoPoint(double Latitude, double Longitude); + public sealed record ParsedWarning(WeatherWarning Warning, List> Polygons); + private sealed record CachedForecast(DateTime LoadedAt, ParsedForecast Forecast); + private sealed record CachedWarnings(DateTime LoadedAt, List Warnings); +} diff --git a/LehrerApp.Api/Endpoints/Endpoints.cs b/LehrerApp.Api/Endpoints/Endpoints.cs index f4f8303..e7c696d 100644 --- a/LehrerApp.Api/Endpoints/Endpoints.cs +++ b/LehrerApp.Api/Endpoints/Endpoints.cs @@ -149,6 +149,123 @@ public static class Endpoints }); } + // ── Schulstandort und DWD-Wetter ───────────────────────────────────────── + + public static void MapSchoolWeatherEndpoints(this WebApplication app) + { + var g = app.MapGroup("/api/school").RequireAuthorization(); + + g.MapGet("/location", (ClaimsPrincipal user, SchoolWeatherStore store) => + { + var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (uid is null) return Results.Unauthorized(); + var location = store.GetLocation(uid); + return location is null + ? Results.NotFound("Noch kein Schulstandort hinterlegt.") + : Results.Ok(location); + }); + + g.MapPut("/location", async ([FromBody] SchoolLocationRequest request, + ClaimsPrincipal user, SchoolWeatherStore store, IGeocoder geocoder, + CancellationToken cancellationToken) => + { + var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (uid is null) return Results.Unauthorized(); + + var errors = ValidateLocation(request); + if (errors.Count > 0) return Results.ValidationProblem(errors); + + var existing = store.GetLocation(uid); + if (existing is not null && SameLocation(existing, request)) + return Results.Ok(existing); + + GeocodingResult? result; + try { result = await geocoder.GeocodeAsync(request, cancellationToken); } + catch (HttpRequestException) + { + return Results.Problem("Der Geocoding-Dienst ist momentan nicht erreichbar.", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return Results.Problem("Die Adressprüfung hat zu lange gedauert.", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + if (result is null) + return Results.UnprocessableEntity(new + { + detail = "Die Schuladresse konnte nicht eindeutig gefunden werden. Bitte Schreibweise, PLZ und Ort prüfen.", + }); + + var profile = new SchoolLocationProfile + { + SchoolName = request.SchoolName.Trim(), + Street = request.Street.Trim(), + PostalCode = request.PostalCode.Trim(), + City = request.City.Trim(), + State = request.State, + Latitude = result.Latitude, + Longitude = result.Longitude, + ResolvedAddress = result.DisplayName, + UpdatedAt = DateTime.UtcNow, + }; + store.SaveLocation(uid, profile); + return Results.Ok(profile); + }); + + g.MapGet("/weather", async (ClaimsPrincipal user, SchoolWeatherStore store, + DwdWeatherService weather, CancellationToken cancellationToken) => + { + var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (uid is null) return Results.Unauthorized(); + var location = store.GetLocation(uid); + if (location is null) return Results.NotFound("Noch kein Schulstandort hinterlegt."); + + var cached = store.GetWeather(uid); + if (cached is not null && DateTime.UtcNow - cached.RetrievedAt.ToUniversalTime() < TimeSpan.FromMinutes(5)) + return Results.Ok(cached); + try + { + var current = await weather.GetAsync(location, cancellationToken); + store.SaveWeather(uid, current); + return Results.Ok(current); + } + catch (Exception ex) when (ex is HttpRequestException or InvalidDataException or InvalidOperationException) + { + if (cached is not null) + { + cached.IsStale = true; + return Results.Ok(cached); + } + return Results.Problem("Der DWD ist momentan nicht erreichbar und es liegt noch kein Wetter-Cache vor.", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + }); + } + + private static Dictionary ValidateLocation(SchoolLocationRequest request) + { + var errors = new Dictionary(); + if (string.IsNullOrWhiteSpace(request.SchoolName)) + errors[nameof(request.SchoolName)] = ["Bitte den Namen der Schule angeben."]; + if (string.IsNullOrWhiteSpace(request.Street)) + errors[nameof(request.Street)] = ["Bitte Straße und Hausnummer angeben."]; + if (!System.Text.RegularExpressions.Regex.IsMatch(request.PostalCode?.Trim() ?? "", @"^\d{5}$")) + errors[nameof(request.PostalCode)] = ["Bitte eine fünfstellige deutsche PLZ angeben."]; + if (string.IsNullOrWhiteSpace(request.City)) + errors[nameof(request.City)] = ["Bitte den Ort angeben."]; + if (!Enum.IsDefined(request.State)) + errors[nameof(request.State)] = ["Das Bundesland ist ungültig."]; + return errors; + } + + private static bool SameLocation(SchoolLocationProfile existing, SchoolLocationRequest request) => + string.Equals(existing.SchoolName, request.SchoolName.Trim(), StringComparison.OrdinalIgnoreCase) && + string.Equals(existing.Street, request.Street.Trim(), StringComparison.OrdinalIgnoreCase) && + string.Equals(existing.PostalCode, request.PostalCode.Trim(), StringComparison.Ordinal) && + string.Equals(existing.City, request.City.Trim(), StringComparison.OrdinalIgnoreCase) && + existing.State == request.State; + // ── JWT ─────────────────────────────────────────────────────────────────── private static string Jwt(string userId, string secret) diff --git a/LehrerApp.Api/NominatimGeocoder.cs b/LehrerApp.Api/NominatimGeocoder.cs new file mode 100644 index 0000000..db81d30 --- /dev/null +++ b/LehrerApp.Api/NominatimGeocoder.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using LehrerApp.Core.Models; + +namespace LehrerApp.Api; + +public interface IGeocoder +{ + Task GeocodeAsync(SchoolLocationRequest address, CancellationToken cancellationToken); +} + +public sealed record GeocodingResult(double Latitude, double Longitude, string DisplayName); + +/// Einmalige, explizit ausgelöste Adressauflösung über den öffentlichen OSM-Nominatim- +/// Dienst. Kein Autocomplete; die dauerhafte Ergebnisspeicherung übernimmt SchoolWeatherStore. +public sealed class NominatimGeocoder(HttpClient http) : IGeocoder +{ + private static readonly SemaphoreSlim RequestGate = new(1, 1); + private static DateTime _lastRequestAt = DateTime.MinValue; + + public async Task GeocodeAsync(SchoolLocationRequest address, + CancellationToken cancellationToken) + { + var structured = Query(new Dictionary + { + ["street"] = address.Street, + ["postalcode"] = address.PostalCode, + ["city"] = address.City, + }); + var result = await SearchRespectingRateLimitAsync(structured, cancellationToken); + if (result is not null) return result; + + // Manche Schulen/Ortsteile sind bei OSM anders adressiert. Ein kontrollierter zweiter + // Versuch mit der gesamten Suchzeile ist robuster, bleibt aber weit weg vom 1-rps-Limit. + var freeForm = string.Join(", ", new[] + { + address.SchoolName, address.Street, $"{address.PostalCode} {address.City}", "Deutschland", + }.Where(x => !string.IsNullOrWhiteSpace(x))); + return await SearchRespectingRateLimitAsync( + Query(new Dictionary { ["q"] = freeForm }), cancellationToken); + } + + private async Task SearchRespectingRateLimitAsync(string query, + CancellationToken cancellationToken) + { + await RequestGate.WaitAsync(cancellationToken); + try + { + var wait = TimeSpan.FromSeconds(1) - (DateTime.UtcNow - _lastRequestAt); + if (wait > TimeSpan.Zero) await Task.Delay(wait, cancellationToken); + _lastRequestAt = DateTime.UtcNow; + var candidates = await http.GetFromJsonAsync>( + $"search?format=jsonv2&limit=1&countrycodes=de{query}", cancellationToken); + var candidate = candidates?.FirstOrDefault(); + if (candidate is null || + !double.TryParse(candidate.Lat, NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude) || + !double.TryParse(candidate.Lon, NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude)) + return null; + return new(latitude, longitude, candidate.DisplayName ?? ""); + } + finally { RequestGate.Release(); } + } + + private static string Query(IReadOnlyDictionary values) => string.Concat( + values.Where(x => !string.IsNullOrWhiteSpace(x.Value)) + .Select(x => $"&{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value.Trim())}")); + + private sealed class NominatimCandidate + { + [JsonPropertyName("lat")] + public string? Lat { get; set; } + [JsonPropertyName("lon")] + public string? Lon { get; set; } + [JsonPropertyName("display_name")] + public string? DisplayName { get; set; } + } +} diff --git a/LehrerApp.Api/Program.cs b/LehrerApp.Api/Program.cs index 34dca9d..fb4c224 100644 --- a/LehrerApp.Api/Program.cs +++ b/LehrerApp.Api/Program.cs @@ -26,6 +26,8 @@ if (args.Length > 0 && args[0] == "set-password") var secret = builder.Configuration["JWT_SECRET"] ?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert."); +var outboundUserAgent = builder.Configuration["Geocoding:UserAgent"] + ?? "LehrerApp-Server/1.0 (+https://science-teaching.de)"; builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(o => o.TokenValidationParameters = new() @@ -81,6 +83,20 @@ builder.Services.AddSingleton(_ => new SnapshotStore(data)); builder.Services.AddSingleton(_ => new ReadableSnapshotStore(data)); builder.Services.AddSingleton(sp => new PlainEventStore(sp.GetRequiredService())); +builder.Services.AddSingleton(_ => new SchoolWeatherStore(data)); +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://nominatim.openstreetmap.org/"); + client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent); + client.Timeout = TimeSpan.FromSeconds(15); +}); +builder.Services.AddHttpClient("dwd", client => +{ + client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent); + client.Timeout = TimeSpan.FromSeconds(30); +}); +builder.Services.AddSingleton(sp => new DwdWeatherService( + sp.GetRequiredService().CreateClient("dwd"))); var app = builder.Build(); app.UseForwardedHeaders(); @@ -95,5 +111,6 @@ app.MapAttachmentEndpoints(); app.MapSnapshotEndpoints(); app.MapReadableSnapshotEndpoints(); app.MapPlainSyncEndpoints(); +app.MapSchoolWeatherEndpoints(); app.Run(); return 0; diff --git a/LehrerApp.Api/SchoolWeatherStore.cs b/LehrerApp.Api/SchoolWeatherStore.cs new file mode 100644 index 0000000..629833c --- /dev/null +++ b/LehrerApp.Api/SchoolWeatherStore.cs @@ -0,0 +1,83 @@ +using LehrerApp.Core.Models; +using LiteDB; + +namespace LehrerApp.Api; + +/// Unverschlüsselte Serverdaten, die der Server zur Abfrage standortbezogener öffentlicher +/// Wetterdaten benötigt. Bewusst getrennt vom clientseitig verschlüsselten EventStore. +public sealed class SchoolWeatherStore : IDisposable +{ + private readonly LiteDatabase _db; + + public SchoolWeatherStore(string dataPath) + { + Directory.CreateDirectory(dataPath); + _db = new LiteDatabase(Path.Combine(dataPath, "school-weather.db")); + } + + private ILiteCollection Locations + { + get + { + var col = _db.GetCollection("locations"); + col.EnsureIndex(x => x.UserId, unique: true); + return col; + } + } + + private ILiteCollection Weather + { + get + { + var col = _db.GetCollection("weather"); + col.EnsureIndex(x => x.UserId, unique: true); + return col; + } + } + + public SchoolLocationProfile? GetLocation(string userId) => + Locations.FindOne(x => x.UserId == userId)?.Profile; + + public void SaveLocation(string userId, SchoolLocationProfile profile) + { + var existing = Locations.FindOne(x => x.UserId == userId); + Locations.Upsert(new LocationEntry + { + Id = existing?.Id ?? ObjectId.NewObjectId(), + UserId = userId, + Profile = profile, + }); + // Ein geänderter Standort darf niemals den Wetterstand des alten Orts liefern. + Weather.DeleteMany(x => x.UserId == userId); + } + + public WeatherSnapshot? GetWeather(string userId) => + Weather.FindOne(x => x.UserId == userId)?.Snapshot; + + public void SaveWeather(string userId, WeatherSnapshot snapshot) + { + var existing = Weather.FindOne(x => x.UserId == userId); + Weather.Upsert(new WeatherEntry + { + Id = existing?.Id ?? ObjectId.NewObjectId(), + UserId = userId, + Snapshot = snapshot, + }); + } + + public void Dispose() => _db.Dispose(); + + private sealed class LocationEntry + { + public ObjectId Id { get; set; } = ObjectId.NewObjectId(); + public string UserId { get; set; } = ""; + public SchoolLocationProfile Profile { get; set; } = new(); + } + + private sealed class WeatherEntry + { + public ObjectId Id { get; set; } = ObjectId.NewObjectId(); + public string UserId { get; set; } = ""; + public WeatherSnapshot Snapshot { get; set; } = new(); + } +} diff --git a/LehrerApp.Core/Models/Weather.cs b/LehrerApp.Core/Models/Weather.cs new file mode 100644 index 0000000..93927f3 --- /dev/null +++ b/LehrerApp.Core/Models/Weather.cs @@ -0,0 +1,59 @@ +namespace LehrerApp.Core.Models; + +/// Vom Nutzer eingegebene Schuladresse. Die Geocodierung erfolgt ausschließlich +/// serverseitig beim expliziten Speichern, nicht während der Eingabe. +public class SchoolLocationRequest +{ + public string SchoolName { get; set; } = ""; + public string Street { get; set; } = ""; + public string PostalCode { get; set; } = ""; + public string City { get; set; } = ""; + public GermanState State { get; set; } = GermanState.NW; +} + +/// Serverseitig gespeicherter Schulstandort einschließlich des bestätigbaren +/// Geocoding-Ergebnisses. Koordinaten werden nie an den DWD als Straßenadresse übertragen. +public sealed class SchoolLocationProfile : SchoolLocationRequest +{ + public double Latitude { get; set; } + public double Longitude { get; set; } + public string ResolvedAddress { get; set; } = ""; + public DateTime UpdatedAt { get; set; } +} + +public sealed class WeatherSnapshot +{ + public string StationId { get; set; } = ""; + public string StationName { get; set; } = ""; + public double StationDistanceKm { get; set; } + public DateTime? ForecastIssuedAt { get; set; } + public DateTime RetrievedAt { get; set; } + /// True, wenn der DWD vorübergehend nicht erreichbar war und der Server den zuletzt + /// erfolgreich gespeicherten Stand zurückgibt. + public bool IsStale { get; set; } + public List Forecast { get; set; } = []; + public List Warnings { get; set; } = []; +} + +public sealed class WeatherForecastHour +{ + public DateTime ValidAt { get; set; } + public double? TemperatureC { get; set; } + public double? WindSpeedKmh { get; set; } + public double? WindGustKmh { get; set; } + public double? PrecipitationMm { get; set; } + public double? CloudCoverPercent { get; set; } + public int? WeatherCode { get; set; } +} + +public sealed class WeatherWarning +{ + public string Identifier { get; set; } = ""; + public string Event { get; set; } = ""; + public string Headline { get; set; } = ""; + public string Description { get; set; } = ""; + public string Instruction { get; set; } = ""; + public string Severity { get; set; } = ""; + public DateTime? Onset { get; set; } + public DateTime? Expires { get; set; } +} diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs index 098d707..c9d089c 100644 --- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs @@ -523,4 +523,22 @@ public sealed class DashboardViewModelTests Assert.Empty(tasks.GetAll()); } + + [Fact] + public void WeatherWarningItem_BereitetAmtlicheWarnungFuerDashboardAuf() + { + var warning = new WeatherWarning + { + Event = "GEWITTER", Headline = "Amtliche Warnung vor Gewitter", + Description = "Es treten Gewitter auf.", Instruction = "Gebäude aufsuchen.", + Severity = "Severe", Onset = new DateTime(2026, 8, 23, 16, 0, 0, DateTimeKind.Utc), + Expires = new DateTime(2026, 8, 23, 18, 0, 0, DateTimeKind.Utc), + }; + + var item = new DashboardWeatherWarningItem(warning); + + Assert.Equal("Amtliche Warnung vor Gewitter", item.Headline); + Assert.Equal("#D32F2F", item.SeverityColor); + Assert.Contains("Uhr", item.PeriodDisplay); + } } diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index 7006aed..9d99a8c 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -275,6 +275,34 @@ public sealed class SettingsViewModelTests Assert.Empty(vm.SchoolHolidayEntries); } + [Fact] + public async Task SaveSchoolLocation_FehlendeFelder_ZeigtAlleFeldfehler() + { + var vm = BuildViewModel(); + + await vm.SaveSchoolLocationCommand.ExecuteAsync(null); + + Assert.NotEmpty(vm.SchoolNameError); + Assert.NotEmpty(vm.SchoolStreetError); + Assert.NotEmpty(vm.SchoolPostalCodeError); + Assert.NotEmpty(vm.SchoolCityError); + } + + [Fact] + public async Task SaveSchoolLocation_UngueltigePlz_RuftKeinenServerAuf() + { + var vm = BuildViewModel(); + vm.SchoolName = "Testschule"; + vm.SchoolStreet = "Schulweg 1"; + vm.SchoolPostalCode = "1234"; + vm.SchoolCity = "Teststadt"; + + await vm.SaveSchoolLocationCommand.ExecuteAsync(null); + + Assert.Contains("fünfstellige", vm.SchoolPostalCodeError); + Assert.Empty(vm.SchoolLocationStatus); + } + [Fact] public void SelectedStateName_Aendern_PersistiertUeberSchoolCalendarSettings() { diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 90a2359..4938975 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -222,6 +222,7 @@ public static class AppBootstrapper var syncSettings = new SyncSettingsService(appData); services.AddSingleton(syncSettings); services.AddSingleton(_ => new SyncAuthService(new HttpClient())); + services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings)); // 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. diff --git a/LehrerApp.Desktop/Services/SchoolWeatherService.cs b/LehrerApp.Desktop/Services/SchoolWeatherService.cs new file mode 100644 index 0000000..a095e44 --- /dev/null +++ b/LehrerApp.Desktop/Services/SchoolWeatherService.cs @@ -0,0 +1,73 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using LehrerApp.Core.Models; + +namespace LehrerApp.Desktop.Services; + +public sealed class SchoolWeatherException(string message) : Exception(message); + +/// Client für das serverseitige Schulstandort-/Wetterprofil. Liest URL und Token je +/// Aufruf, damit ein Login oder Serverwechsel in den Einstellungen sofort berücksichtigt wird. +public sealed class SchoolWeatherService(HttpClient http, SyncSettingsService syncSettings) +{ + public bool IsAvailable => !string.IsNullOrWhiteSpace(syncSettings.ServerUrl) && syncSettings.IsLoggedIn; + + public async Task GetLocationAsync(CancellationToken cancellationToken = default) + { + using var response = await SendAsync(HttpMethod.Get, "/api/school/location", null, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) return null; + await EnsureSuccessAsync(response); + return await response.Content.ReadFromJsonAsync(cancellationToken); + } + + public async Task SaveLocationAsync(SchoolLocationRequest location, + CancellationToken cancellationToken = default) + { + using var response = await SendAsync(HttpMethod.Put, "/api/school/location", location, cancellationToken); + await EnsureSuccessAsync(response); + return await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new SchoolWeatherException("Der Server hat keine Standortdaten zurückgegeben."); + } + + public async Task GetWeatherAsync(CancellationToken cancellationToken = default) + { + using var response = await SendAsync(HttpMethod.Get, "/api/school/weather", null, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) return null; + await EnsureSuccessAsync(response); + return await response.Content.ReadFromJsonAsync(cancellationToken); + } + + private async Task SendAsync(HttpMethod method, string path, object? body, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(syncSettings.ServerUrl) || !syncSettings.IsLoggedIn) + throw new SchoolWeatherException("Bitte zuerst im Reiter „Synchronisation“ am Server anmelden."); + using var request = new HttpRequestMessage(method, new Uri(new Uri(syncSettings.ServerUrl), path)); + var token = syncSettings.GetToken(); + if (token is not null) + request.Headers.Authorization = new("Bearer", token); + if (body is not null) request.Content = JsonContent.Create(body); + try { return await http.SendAsync(request, cancellationToken); } + catch (HttpRequestException) + { + throw new SchoolWeatherException("Der LehrerApp-Server ist nicht erreichbar."); + } + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response) + { + if (response.IsSuccessStatusCode) return; + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new SchoolWeatherException("Die Server-Anmeldung ist abgelaufen. Bitte erneut anmelden."); + try + { + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + if (document.RootElement.TryGetProperty("detail", out var detail) && + !string.IsNullOrWhiteSpace(detail.GetString())) + throw new SchoolWeatherException(detail.GetString()!); + } + catch (JsonException) { } + throw new SchoolWeatherException("Der Schulstandort konnte nicht gespeichert werden."); + } +} diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index 0349001..5ac6cae 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -36,6 +36,7 @@ public partial class DashboardViewModel : ObservableObject private readonly SchoolCalendarSettingsService _calendarSettings; private readonly ISubstitutionEntryRepository _substitutions; private readonly IAnnualPlanEventRepository? _annualPlanEvents; + private readonly SchoolWeatherService? _schoolWeather; private const int OpenExcuseMaxAgeDays = 21; private const int SupportPlanDueWithinDays = 14; @@ -56,6 +57,11 @@ public partial class DashboardViewModel : ObservableObject [ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today); [ObservableProperty] private string _selectedDayLabel = ""; [ObservableProperty] private bool _isDashboardSettingsOpen; + [ObservableProperty] private bool _isWeatherPanelVisible; + [ObservableProperty] private string _weatherSummary = ""; + [ObservableProperty] private string _weatherDetails = ""; + [ObservableProperty] private string _weatherStatus = ""; + [ObservableProperty] private bool _hasWeatherWarnings; public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy", De); @@ -72,6 +78,7 @@ public partial class DashboardViewModel : ObservableObject public ObservableCollection Alerts { get; } = []; public ObservableCollection SelectedDayEvents { get; } = []; public ObservableCollection DashboardCards { get; } = []; + public ObservableCollection WeatherWarnings { get; } = []; public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; // Navigation-Callback – wird von App.axaml.cs verdrahtet @@ -111,7 +118,7 @@ public partial class DashboardViewModel : ObservableObject DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays, PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings, ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null, - AnnualPlanSyncService? annualPlanSync = null) + AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null) { _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships; @@ -122,6 +129,7 @@ public partial class DashboardViewModel : ObservableObject _schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings; _substitutions = substitutions; _annualPlanEvents = annualPlanEvents; + _schoolWeather = schoolWeather; if (annualPlanSync is not null) { annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar); @@ -142,6 +150,7 @@ public partial class DashboardViewModel : ObservableObject CurrentDate = now.ToString("dddd, d. MMMM yyyy", De); CurrentSchoolYear = _sy.CurrentSchoolYear(); Greeting = now.Hour < 12 ? "Guten Morgen" : now.Hour < 18 ? "Guten Tag" : "Guten Abend"; + _ = LoadWeatherAsync(); TodaysLessons.Clear(); var groups = _groups.GetBySchoolYear(_sy.CurrentSchoolYear()).ToDictionary(g => g.Id); @@ -197,6 +206,57 @@ public partial class DashboardViewModel : ObservableObject LoadAlerts(groups, today); } + private async Task LoadWeatherAsync() + { + if (_schoolWeather?.IsAvailable != true) return; + try + { + var snapshot = await _schoolWeather.GetWeatherAsync(); + if (snapshot is null) return; + var current = snapshot.Forecast + .Where(x => x.ValidAt.ToUniversalTime() >= DateTime.UtcNow.AddHours(-1)) + .MinBy(x => Math.Abs((x.ValidAt.ToUniversalTime() - DateTime.UtcNow).TotalMinutes)); + WeatherWarnings.Clear(); + foreach (var warning in snapshot.Warnings) + WeatherWarnings.Add(new DashboardWeatherWarningItem(warning)); + HasWeatherWarnings = WeatherWarnings.Count > 0; + + if (current is not null) + { + var temperature = current.TemperatureC is { } t ? $"{t:0.#} °C" : "Temperatur unbekannt"; + WeatherSummary = $"{temperature} · {WeatherDescription(current.WeatherCode)}"; + var details = new List(); + if (current.WindSpeedKmh is { } wind) details.Add($"Wind {wind:0.#} km/h"); + if (current.WindGustKmh is { } gust) details.Add($"Böen {gust:0.#} km/h"); + if (current.PrecipitationMm is > 0) details.Add($"Niederschlag {current.PrecipitationMm:0.#} mm"); + details.Add($"DWD-Station {snapshot.StationName} ({snapshot.StationDistanceKm:0.#} km)"); + WeatherDetails = string.Join(" · ", details); + } + WeatherStatus = snapshot.IsStale + ? $"Letzter verfügbarer Stand vom {snapshot.RetrievedAt.ToLocalTime():dd.MM., HH:mm} Uhr" + : ""; + IsWeatherPanelVisible = current is not null || HasWeatherWarnings; + } + catch (SchoolWeatherException ex) + { + WeatherStatus = ex.Message; + IsWeatherPanelVisible = true; + } + } + + private static string WeatherDescription(int? code) => code switch + { + 0 => "klar", + >= 1 and <= 3 => "bewölkt", + 45 or 48 => "Nebel", + >= 51 and <= 67 => "Regen", + >= 71 and <= 77 => "Schnee", + >= 80 and <= 82 => "Regenschauer", + 85 or 86 => "Schneeschauer", + >= 95 and <= 99 => "Gewitter", + _ => "Wettervorhersage", + }; + // ── Fehlzeiten-Warnung (5.2.3) ──────────────────────────────────────────── private void LoadAttendanceWarnings(DateOnly today) @@ -761,6 +821,32 @@ public partial class DashboardViewModel : ObservableObject } } +public sealed class DashboardWeatherWarningItem +{ + public string Headline { get; } + public string Description { get; } + public string Instruction { get; } + public string PeriodDisplay { get; } + public string SeverityColor { get; } + + public DashboardWeatherWarningItem(WeatherWarning warning) + { + Headline = string.IsNullOrWhiteSpace(warning.Headline) ? warning.Event : warning.Headline; + Description = warning.Description; + Instruction = warning.Instruction; + PeriodDisplay = (warning.Onset, warning.Expires) switch + { + ({ } onset, { } expires) => $"{onset.ToLocalTime():dd.MM., HH:mm}–{expires.ToLocalTime():HH:mm} Uhr", + ({ } onset, null) => $"ab {onset.ToLocalTime():dd.MM., HH:mm} Uhr", + _ => "", + }; + SeverityColor = warning.Severity switch + { + "Extreme" => "#7E0023", "Severe" => "#D32F2F", "Moderate" => "#F59E0B", _ => "#FDD835", + }; + } +} + public class LessonItem { public Guid LessonId { get; set; } diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index 0645af9..c05da80 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -162,6 +162,16 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private string _newHolidayEndText = ""; [ObservableProperty] private string _holidayNameError = ""; [ObservableProperty] private string _holidayDateError = ""; + [ObservableProperty] private string _schoolName = ""; + [ObservableProperty] private string _schoolStreet = ""; + [ObservableProperty] private string _schoolPostalCode = ""; + [ObservableProperty] private string _schoolCity = ""; + [ObservableProperty] private string _schoolNameError = ""; + [ObservableProperty] private string _schoolStreetError = ""; + [ObservableProperty] private string _schoolPostalCodeError = ""; + [ObservableProperty] private string _schoolCityError = ""; + [ObservableProperty] private string _schoolLocationStatus = ""; + [ObservableProperty] private string _resolvedSchoolAddress = ""; public List StateOptions { get; } = GermanStateDisplay.Options.ToList(); public ObservableCollection SchoolHolidayEntries { get; } = []; @@ -283,6 +293,7 @@ public partial class SettingsViewModel : ObservableObject private readonly ISchoolHolidayRepository _schoolHolidays; private readonly SchoolCalendarSettingsService _calendarSettings; + private readonly SchoolWeatherService? _schoolWeather; private readonly PeriodScheduleService _periodSchedule; private readonly ISupervisionDutyRepository _supervisionDuties; private readonly AiSettingsService _aiSettings; @@ -318,7 +329,8 @@ public partial class SettingsViewModel : ObservableObject AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery, AppearanceSettingsService appearance, TrashViewModel trashTab, SnapshotService? snapshotService = null, SyncEngine? syncEngine = null, - UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null) + UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null, + SchoolWeatherService? schoolWeather = null) { _logger = logger; _syncKeyRecovery = syncKeyRecovery; @@ -341,6 +353,7 @@ public partial class SettingsViewModel : ObservableObject _shorthandCodes = shorthandCodes; _schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings; + _schoolWeather = schoolWeather; _periodSchedule = periodSchedule; _supervisionDuties = supervisionDuties; _letterTemplates = letterTemplates; @@ -930,6 +943,60 @@ public partial class SettingsViewModel : ObservableObject partial void OnSelectedStateNameChanged(string value) => _calendarSettings.SetState(GermanStateDisplay.FromLabel(value)); + [RelayCommand] + private async Task LoadSchoolLocation() + { + if (_schoolWeather is null) return; + try + { + var location = await _schoolWeather.GetLocationAsync(); + if (location is null) return; + SchoolName = location.SchoolName; + SchoolStreet = location.Street; + SchoolPostalCode = location.PostalCode; + SchoolCity = location.City; + SelectedStateName = GermanStateDisplay.Label(location.State); + ResolvedSchoolAddress = location.ResolvedAddress; + } + catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; } + } + + [RelayCommand] + private async Task SaveSchoolLocation() + { + SchoolNameError = ""; SchoolStreetError = ""; SchoolPostalCodeError = ""; + SchoolCityError = ""; SchoolLocationStatus = ""; + var valid = true; + if (string.IsNullOrWhiteSpace(SchoolName)) + { SchoolNameError = "Name der Schule erforderlich."; valid = false; } + if (string.IsNullOrWhiteSpace(SchoolStreet)) + { SchoolStreetError = "Straße und Hausnummer erforderlich."; valid = false; } + if (!System.Text.RegularExpressions.Regex.IsMatch(SchoolPostalCode.Trim(), @"^\d{5}$")) + { SchoolPostalCodeError = "Bitte eine fünfstellige PLZ angeben."; valid = false; } + if (string.IsNullOrWhiteSpace(SchoolCity)) + { SchoolCityError = "Ort erforderlich."; valid = false; } + if (!valid) return; + if (_schoolWeather is null) + { + SchoolLocationStatus = "Der Wetterdienst ist nicht verfügbar."; + return; + } + + SchoolLocationStatus = "Adresse wird geprüft …"; + try + { + var profile = await _schoolWeather.SaveLocationAsync(new SchoolLocationRequest + { + SchoolName = SchoolName.Trim(), Street = SchoolStreet.Trim(), + PostalCode = SchoolPostalCode.Trim(), City = SchoolCity.Trim(), + State = GermanStateDisplay.FromLabel(SelectedStateName), + }); + ResolvedSchoolAddress = profile.ResolvedAddress; + SchoolLocationStatus = "Schulstandort gespeichert. Wetterdaten werden serverseitig abgerufen."; + } + catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; } + } + private void LoadSchoolHolidays() { SchoolHolidayEntries.Clear(); diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index c56c025..673a171 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -54,6 +54,46 @@ + + + + + + + + + +