Wetterdienst

This commit is contained in:
2026-08-23 20:51:19 +02:00
parent cc973418f3
commit 1b32166ce0
21 changed files with 1186 additions and 3 deletions
@@ -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 = $$"""
<?xml version="1.0" encoding="UTF-8"?>
<kml:kml xmlns:kml="http://www.opengis.net/kml/2.2" xmlns:dwd="https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd">
<kml:Document><kml:ExtendedData><dwd:ProductDefinition>
<dwd:IssueTime>2026-08-23T15:00:00Z</dwd:IssueTime>
<dwd:ForecastTimeSteps><dwd:TimeStep>{{now}}</dwd:TimeStep></dwd:ForecastTimeSteps>
</dwd:ProductDefinition></kml:ExtendedData><kml:Placemark><kml:ExtendedData>
<dwd:Forecast dwd:elementName="TTT"><dwd:value>293.15</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="FF"><dwd:value>5</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="FX1"><dwd:value>-</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="RR1c"><dwd:value>1.25</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="Neff"><dwd:value>75</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="ww"><dwd:value>61</dwd:value></dwd:Forecast>
</kml:ExtendedData></kml:Placemark></kml:Document>
</kml:kml>
""";
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 = """
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
<identifier>warnung-1</identifier><status>Actual</status>
<info><language>de-DE</language><event>GEWITTER</event><severity>Severe</severity>
<onset>2026-08-23T18:00:00Z</onset><expires>2099-08-23T20:00:00Z</expires>
<headline>Amtliche Warnung</headline><description>Test</description>
<area><polygon>50.0,8.0 50.0,9.0 51.0,9.0 51.0,8.0 50.0,8.0</polygon></area>
</info>
</alert>
""";
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();
}
}
@@ -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<HttpRequestMessage, HttpResponseMessage> response)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken) => Task.FromResult(response(request));
}
}
@@ -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);
}
}
}
+266
View File
@@ -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;
/// <summary>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.</summary>
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<MosmixStation>? _stations;
private DateTime _stationsLoadedAt;
private readonly Dictionary<string, CachedForecast> _forecastCache = [];
private CachedWarnings? _warningsCache;
public async Task<WeatherSnapshot> 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<List<MosmixStation>> 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<ParsedForecast> 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<List<ParsedWarning>> 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<MosmixStation> 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<MosmixStation>();
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<MosmixStation> 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<WeatherForecastHour>();
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<ParsedWarning> ParseCapArchive(byte[] zip, DateTime nowUtc)
{
var result = new List<ParsedWarning>();
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<double?> 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<GeoPoint> 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<GeoPoint> 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<IReadOnlyList<GeoPoint>> 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<WeatherForecastHour> Hours);
public sealed record GeoPoint(double Latitude, double Longitude);
public sealed record ParsedWarning(WeatherWarning Warning, List<List<GeoPoint>> Polygons);
private sealed record CachedForecast(DateTime LoadedAt, ParsedForecast Forecast);
private sealed record CachedWarnings(DateTime LoadedAt, List<ParsedWarning> Warnings);
}
+117
View File
@@ -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<string, string[]> ValidateLocation(SchoolLocationRequest request)
{
var errors = new Dictionary<string, string[]>();
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 ─────────────────────────────────────────────────────────────────── // ── JWT ───────────────────────────────────────────────────────────────────
private static string Jwt(string userId, string secret) private static string Jwt(string userId, string secret)
+78
View File
@@ -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<GeocodingResult?> GeocodeAsync(SchoolLocationRequest address, CancellationToken cancellationToken);
}
public sealed record GeocodingResult(double Latitude, double Longitude, string DisplayName);
/// <summary>Einmalige, explizit ausgelöste Adressauflösung über den öffentlichen OSM-Nominatim-
/// Dienst. Kein Autocomplete; die dauerhafte Ergebnisspeicherung übernimmt SchoolWeatherStore.</summary>
public sealed class NominatimGeocoder(HttpClient http) : IGeocoder
{
private static readonly SemaphoreSlim RequestGate = new(1, 1);
private static DateTime _lastRequestAt = DateTime.MinValue;
public async Task<GeocodingResult?> GeocodeAsync(SchoolLocationRequest address,
CancellationToken cancellationToken)
{
var structured = Query(new Dictionary<string, string>
{
["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<string, string> { ["q"] = freeForm }), cancellationToken);
}
private async Task<GeocodingResult?> 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<List<NominatimCandidate>>(
$"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<string, string> 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; }
}
}
+17
View File
@@ -26,6 +26,8 @@ if (args.Length > 0 && args[0] == "set-password")
var secret = builder.Configuration["JWT_SECRET"] var secret = builder.Configuration["JWT_SECRET"]
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert."); ?? 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) builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => o.TokenValidationParameters = new() .AddJwtBearer(o => o.TokenValidationParameters = new()
@@ -81,6 +83,20 @@ builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data)); builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
builder.Services.AddSingleton<PlainEventStore>(sp => builder.Services.AddSingleton<PlainEventStore>(sp =>
new PlainEventStore(sp.GetRequiredService<EventStore>())); new PlainEventStore(sp.GetRequiredService<EventStore>()));
builder.Services.AddSingleton<SchoolWeatherStore>(_ => new SchoolWeatherStore(data));
builder.Services.AddHttpClient<IGeocoder, NominatimGeocoder>(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<IHttpClientFactory>().CreateClient("dwd")));
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders(); app.UseForwardedHeaders();
@@ -95,5 +111,6 @@ app.MapAttachmentEndpoints();
app.MapSnapshotEndpoints(); app.MapSnapshotEndpoints();
app.MapReadableSnapshotEndpoints(); app.MapReadableSnapshotEndpoints();
app.MapPlainSyncEndpoints(); app.MapPlainSyncEndpoints();
app.MapSchoolWeatherEndpoints();
app.Run(); app.Run();
return 0; return 0;
+83
View File
@@ -0,0 +1,83 @@
using LehrerApp.Core.Models;
using LiteDB;
namespace LehrerApp.Api;
/// <summary>Unverschlüsselte Serverdaten, die der Server zur Abfrage standortbezogener öffentlicher
/// Wetterdaten benötigt. Bewusst getrennt vom clientseitig verschlüsselten EventStore.</summary>
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<LocationEntry> Locations
{
get
{
var col = _db.GetCollection<LocationEntry>("locations");
col.EnsureIndex(x => x.UserId, unique: true);
return col;
}
}
private ILiteCollection<WeatherEntry> Weather
{
get
{
var col = _db.GetCollection<WeatherEntry>("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();
}
}
+59
View File
@@ -0,0 +1,59 @@
namespace LehrerApp.Core.Models;
/// <summary>Vom Nutzer eingegebene Schuladresse. Die Geocodierung erfolgt ausschließlich
/// serverseitig beim expliziten Speichern, nicht während der Eingabe.</summary>
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;
}
/// <summary>Serverseitig gespeicherter Schulstandort einschließlich des bestätigbaren
/// Geocoding-Ergebnisses. Koordinaten werden nie an den DWD als Straßenadresse übertragen.</summary>
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; }
/// <summary>True, wenn der DWD vorübergehend nicht erreichbar war und der Server den zuletzt
/// erfolgreich gespeicherten Stand zurückgibt.</summary>
public bool IsStale { get; set; }
public List<WeatherForecastHour> Forecast { get; set; } = [];
public List<WeatherWarning> 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; }
}
@@ -523,4 +523,22 @@ public sealed class DashboardViewModelTests
Assert.Empty(tasks.GetAll()); 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);
}
} }
@@ -275,6 +275,34 @@ public sealed class SettingsViewModelTests
Assert.Empty(vm.SchoolHolidayEntries); 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] [Fact]
public void SelectedStateName_Aendern_PersistiertUeberSchoolCalendarSettings() public void SelectedStateName_Aendern_PersistiertUeberSchoolCalendarSettings()
{ {
+1
View File
@@ -222,6 +222,7 @@ public static class AppBootstrapper
var syncSettings = new SyncSettingsService(appData); var syncSettings = new SyncSettingsService(appData);
services.AddSingleton(syncSettings); services.AddSingleton(syncSettings);
services.AddSingleton(_ => new SyncAuthService(new HttpClient())); 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) // 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 // 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. // Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
@@ -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);
/// <summary>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.</summary>
public sealed class SchoolWeatherService(HttpClient http, SyncSettingsService syncSettings)
{
public bool IsAvailable => !string.IsNullOrWhiteSpace(syncSettings.ServerUrl) && syncSettings.IsLoggedIn;
public async Task<SchoolLocationProfile?> 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<SchoolLocationProfile>(cancellationToken);
}
public async Task<SchoolLocationProfile> 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<SchoolLocationProfile>(cancellationToken)
?? throw new SchoolWeatherException("Der Server hat keine Standortdaten zurückgegeben.");
}
public async Task<WeatherSnapshot?> 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<WeatherSnapshot>(cancellationToken);
}
private async Task<HttpResponseMessage> 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.");
}
}
@@ -36,6 +36,7 @@ public partial class DashboardViewModel : ObservableObject
private readonly SchoolCalendarSettingsService _calendarSettings; private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly ISubstitutionEntryRepository _substitutions; private readonly ISubstitutionEntryRepository _substitutions;
private readonly IAnnualPlanEventRepository? _annualPlanEvents; private readonly IAnnualPlanEventRepository? _annualPlanEvents;
private readonly SchoolWeatherService? _schoolWeather;
private const int OpenExcuseMaxAgeDays = 21; private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14; private const int SupportPlanDueWithinDays = 14;
@@ -56,6 +57,11 @@ public partial class DashboardViewModel : ObservableObject
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today); [ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
[ObservableProperty] private string _selectedDayLabel = ""; [ObservableProperty] private string _selectedDayLabel = "";
[ObservableProperty] private bool _isDashboardSettingsOpen; [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); public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy", De);
@@ -72,6 +78,7 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<DashboardAlertItem> Alerts { get; } = []; public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = []; public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = []; public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
public ObservableCollection<DashboardWeatherWarningItem> WeatherWarnings { get; } = [];
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
// Navigation-Callback wird von App.axaml.cs verdrahtet // Navigation-Callback wird von App.axaml.cs verdrahtet
@@ -111,7 +118,7 @@ public partial class DashboardViewModel : ObservableObject
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays, DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings, PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null, ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null,
AnnualPlanSyncService? annualPlanSync = null) AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
{ {
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships; _examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
@@ -122,6 +129,7 @@ public partial class DashboardViewModel : ObservableObject
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings; _schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_substitutions = substitutions; _substitutions = substitutions;
_annualPlanEvents = annualPlanEvents; _annualPlanEvents = annualPlanEvents;
_schoolWeather = schoolWeather;
if (annualPlanSync is not null) if (annualPlanSync is not null)
{ {
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar); 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); CurrentDate = now.ToString("dddd, d. MMMM yyyy", De);
CurrentSchoolYear = _sy.CurrentSchoolYear(); CurrentSchoolYear = _sy.CurrentSchoolYear();
Greeting = now.Hour < 12 ? "Guten Morgen" : now.Hour < 18 ? "Guten Tag" : "Guten Abend"; Greeting = now.Hour < 12 ? "Guten Morgen" : now.Hour < 18 ? "Guten Tag" : "Guten Abend";
_ = LoadWeatherAsync();
TodaysLessons.Clear(); TodaysLessons.Clear();
var groups = _groups.GetBySchoolYear(_sy.CurrentSchoolYear()).ToDictionary(g => g.Id); var groups = _groups.GetBySchoolYear(_sy.CurrentSchoolYear()).ToDictionary(g => g.Id);
@@ -197,6 +206,57 @@ public partial class DashboardViewModel : ObservableObject
LoadAlerts(groups, today); 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<string>();
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) ──────────────────────────────────────────── // ── Fehlzeiten-Warnung (5.2.3) ────────────────────────────────────────────
private void LoadAttendanceWarnings(DateOnly today) 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 class LessonItem
{ {
public Guid LessonId { get; set; } public Guid LessonId { get; set; }
@@ -162,6 +162,16 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _newHolidayEndText = ""; [ObservableProperty] private string _newHolidayEndText = "";
[ObservableProperty] private string _holidayNameError = ""; [ObservableProperty] private string _holidayNameError = "";
[ObservableProperty] private string _holidayDateError = ""; [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<string> StateOptions { get; } = GermanStateDisplay.Options.ToList(); public List<string> StateOptions { get; } = GermanStateDisplay.Options.ToList();
public ObservableCollection<SchoolHolidayItem> SchoolHolidayEntries { get; } = []; public ObservableCollection<SchoolHolidayItem> SchoolHolidayEntries { get; } = [];
@@ -283,6 +293,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly ISchoolHolidayRepository _schoolHolidays; private readonly ISchoolHolidayRepository _schoolHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings; private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly SchoolWeatherService? _schoolWeather;
private readonly PeriodScheduleService _periodSchedule; private readonly PeriodScheduleService _periodSchedule;
private readonly ISupervisionDutyRepository _supervisionDuties; private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly AiSettingsService _aiSettings; private readonly AiSettingsService _aiSettings;
@@ -318,7 +329,8 @@ public partial class SettingsViewModel : ObservableObject
AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery, AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery,
AppearanceSettingsService appearance, TrashViewModel trashTab, AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null, SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null) UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null,
SchoolWeatherService? schoolWeather = null)
{ {
_logger = logger; _logger = logger;
_syncKeyRecovery = syncKeyRecovery; _syncKeyRecovery = syncKeyRecovery;
@@ -341,6 +353,7 @@ public partial class SettingsViewModel : ObservableObject
_shorthandCodes = shorthandCodes; _shorthandCodes = shorthandCodes;
_schoolHolidays = schoolHolidays; _schoolHolidays = schoolHolidays;
_calendarSettings = calendarSettings; _calendarSettings = calendarSettings;
_schoolWeather = schoolWeather;
_periodSchedule = periodSchedule; _periodSchedule = periodSchedule;
_supervisionDuties = supervisionDuties; _supervisionDuties = supervisionDuties;
_letterTemplates = letterTemplates; _letterTemplates = letterTemplates;
@@ -930,6 +943,60 @@ public partial class SettingsViewModel : ObservableObject
partial void OnSelectedStateNameChanged(string value) => partial void OnSelectedStateNameChanged(string value) =>
_calendarSettings.SetState(GermanStateDisplay.FromLabel(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() private void LoadSchoolHolidays()
{ {
SchoolHolidayEntries.Clear(); SchoolHolidayEntries.Clear();
@@ -54,6 +54,46 @@
</ItemsControl> </ItemsControl>
</Border> </Border>
<!-- Serverseitig gecachte DWD-Daten für den in den Einstellungen hinterlegten Schulstandort. -->
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8"
Padding="16" IsVisible="{Binding IsWeatherPanelVisible}">
<StackPanel Spacing="10">
<Grid ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="WETTER AM SCHULSTANDORT" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding WeatherSummary}" FontSize="20" FontWeight="SemiBold" Margin="0,5,0,0"
IsVisible="{Binding WeatherSummary, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding WeatherDetails}" FontSize="11" Opacity="0.65" TextWrapping="Wrap"
IsVisible="{Binding WeatherDetails, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Button Grid.Column="1" Content="Aktualisieren" FontSize="11" Padding="8,3"
Command="{Binding RefreshCommand}" VerticalAlignment="Top"/>
</Grid>
<TextBlock Text="{Binding WeatherStatus}" FontSize="11" Opacity="0.65" TextWrapping="Wrap"
IsVisible="{Binding WeatherStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<ItemsControl ItemsSource="{Binding WeatherWarnings}" IsVisible="{Binding HasWeatherWarnings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DashboardWeatherWarningItem">
<Border BorderBrush="{Binding SeverityColor}" BorderThickness="4,0,0,0"
Background="#14D32F2F" Padding="10" Margin="0,3" CornerRadius="4">
<StackPanel Spacing="3">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Headline}" FontSize="13" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock Grid.Column="1" Text="{Binding PeriodDisplay}" FontSize="10" Opacity="0.65"
Margin="10,0,0,0"/>
</Grid>
<TextBlock Text="{Binding Description}" FontSize="11" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Instruction}" FontSize="11" Opacity="0.75" TextWrapping="Wrap"
IsVisible="{Binding Instruction, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Wetterdaten © Deutscher Wetterdienst" FontSize="10" Opacity="0.5"/>
</StackPanel>
</Border>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto"> <Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto">
<!-- Heutige Stunden --> <!-- Heutige Stunden -->
@@ -415,6 +415,45 @@
<ScrollViewer> <ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480"> <StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Schulstandort &amp; Wetter" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="Die Adresse wird beim Speichern einmalig auf dem LehrerApp-Server geocodiert. Der Server ruft damit Wettervorhersagen und amtliche Warnungen des DWD ab."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<StackPanel Spacing="4">
<TextBox Text="{Binding SchoolName}" PlaceholderText="Name der Schule"/>
<TextBlock Text="{Binding SchoolNameError}" Foreground="Red" FontSize="11"
IsVisible="{Binding SchoolNameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBox Text="{Binding SchoolStreet}" PlaceholderText="Straße und Hausnummer"/>
<TextBlock Text="{Binding SchoolStreetError}" Foreground="Red" FontSize="11"
IsVisible="{Binding SchoolStreetError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="120,8,*">
<TextBox Grid.Column="0" Text="{Binding SchoolPostalCode}" PlaceholderText="PLZ" MaxLength="5"/>
<TextBox Grid.Column="2" Text="{Binding SchoolCity}" PlaceholderText="Ort"/>
</Grid>
<Grid ColumnDefinitions="120,8,*">
<TextBlock Grid.Column="0" Text="{Binding SchoolPostalCodeError}" Foreground="Red" FontSize="11"
TextWrapping="Wrap"
IsVisible="{Binding SchoolPostalCodeError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Grid.Column="2" Text="{Binding SchoolCityError}" Foreground="Red" FontSize="11"
IsVisible="{Binding SchoolCityError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</Grid>
<Button Content="Adresse prüfen und speichern" Command="{Binding SaveSchoolLocationCommand}"
HorizontalAlignment="Left" Margin="0,4,0,0"/>
<TextBlock Text="{Binding SchoolLocationStatus}" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding SchoolLocationStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="5" Padding="10"
IsVisible="{Binding ResolvedSchoolAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<StackPanel Spacing="3">
<TextBlock Text="Gefundener Standort" FontSize="11" FontWeight="SemiBold" Opacity="0.7"/>
<TextBlock Text="{Binding ResolvedSchoolAddress}" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<TextBlock Text="Geocodierung © OpenStreetMap-Mitwirkende · Wetterdaten © Deutscher Wetterdienst"
FontSize="10" Opacity="0.55" TextWrapping="Wrap"/>
</StackPanel>
<Separator Margin="0,4"/>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="Bundesland (für Feiertage)" FontSize="13" FontWeight="SemiBold"/> <TextBlock Text="Bundesland (für Feiertage)" FontSize="13" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding StateOptions}" SelectedItem="{Binding SelectedStateName}" <ComboBox ItemsSource="{Binding StateOptions}" SelectedItem="{Binding SelectedStateName}"
@@ -31,6 +31,7 @@ public partial class SettingsView : UserControl
vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog; vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog;
vm.OnThemeChanged = App.ApplyTheme; vm.OnThemeChanged = App.ApplyTheme;
vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog; vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog;
_ = vm.LoadSchoolLocationCommand.ExecuteAsync(null);
} }
} }
+12
View File
@@ -739,6 +739,18 @@ weiterhin keine automatisierte Testsuite (kein PHP-Testframework im Projekt).
generierten Stunden ist erst mit der (bewusst zurückgestellten) Serienerzeugung 4.2.5 generierten Stunden ist erst mit der (bewusst zurückgestellten) Serienerzeugung 4.2.5
relevant und verwendet dieselben zwei Datenquellen. relevant und verwendet dieselben zwei Datenquellen.
**Nachtrag Schulstandort, Wetter und Warnungen:** Im selben Einstellungs-Tab kann nun eine
Schuladresse hinterlegt werden. Der authentifizierte LehrerApp-Server geocodiert sie nur beim
expliziten Speichern über Nominatim/OpenStreetMap (1 Anfrage/s, identifizierender User-Agent,
persistentes Ergebnis) und speichert das Profil getrennt vom verschlüsselten Sync-Eventstrom.
Aus den Koordinaten bestimmt er die nächstgelegene Station im offiziellen
MOSMIX-Stationskatalog, parst deren kleine MOSMIX-L-KMZ und ordnet amtliche DWD-CAP-Warnungen
per Punkt-in-Polygon-Prüfung zu. Vorhersagen, Warnungsarchive und der letzte erfolgreiche
Nutzer-Snapshot werden gecacht; bei einem DWD-Ausfall liefert `/api/school/weather` den
letzten Stand als `IsStale`. Das Dashboard zeigt aktuelle Temperatur/Wind/Niederschlag und
hebt örtlich zutreffende Warnungen prominent hervor. Parser, Stationswahl, Persistenz,
Standortwechsel und UI-Validierung sind durch API-/Desktop-Tests abgedeckt.
**Nachtrag zu 4.3 (Nutzer-Feedback nach Erstumsetzung):** **Nachtrag zu 4.3 (Nutzer-Feedback nach Erstumsetzung):**
- **Ferien/Feiertage-Pflege verschoben:** Die Bundesland-Auswahl und das Schulferien-CRUD standen - **Ferien/Feiertage-Pflege verschoben:** Die Bundesland-Auswahl und das Schulferien-CRUD standen
ursprünglich in der Seitenleiste des Stundenplans selbst — das wirkte dort deplatziert, da es ursprünglich in der Seitenleiste des Stundenplans selbst — das wirkte dort deplatziert, da es
+12 -1
View File
@@ -7,7 +7,8 @@ cd docker
JWT_SECRET=<zufälliger-langer-string> docker compose up -d JWT_SECRET=<zufälliger-langer-string> docker compose up -d
``` ```
Alle Server-Daten (Ereignis-Logs, Snapshots, Anhänge, Nutzer) liegen im Named Volume `api-data` Alle Server-Daten (Ereignis-Logs, Snapshots, Anhänge, Nutzer, Schulstandorte und Wetter-Cache)
liegen im Named Volume `api-data`
bei Neustarts/Updates bleibt es erhalten. Bewusst **kein** Bind-Mount auf einen Host-Pfad: bei bei Neustarts/Updates bleibt es erhalten. Bewusst **kein** Bind-Mount auf einen Host-Pfad: bei
Git-basierten Deployments (z. B. Dokploy) wird das Checkout-Verzeichnis bei jedem Redeploy neu Git-basierten Deployments (z. B. Dokploy) wird das Checkout-Verzeichnis bei jedem Redeploy neu
geklont, ein dort liegendes `./data` wäre dabei jedes Mal leer (siehe TODO.md 10.2.4 für den geklont, ein dort liegendes `./data` wäre dabei jedes Mal leer (siehe TODO.md 10.2.4 für den
@@ -74,6 +75,16 @@ Endpunkte sind zusätzlich global auf 120 Anfragen pro Minute je IP begrenzt. An
15 MB Anfragegröße gedeckelt (Kestrel `MaxRequestBodySize`), einzelne Dateien clientseitig 15 MB Anfragegröße gedeckelt (Kestrel `MaxRequestBodySize`), einzelne Dateien clientseitig
zusätzlich auf 10 MB (`IAttachmentStorage.MaxSizeBytes`). zusätzlich auf 10 MB (`IAttachmentStorage.MaxSizeBytes`).
## Schulstandort und Wetter
Die authentifizierten Endpunkte unter `/api/school` geocodieren eine Schuladresse einmalig über
Nominatim/OpenStreetMap und laden danach ausschließlich mit den ermittelten Koordinaten
MOSMIX-Vorhersagen und CAP-Warnungen des DWD. Ergebnisse werden gecacht; bei einem vorübergehenden
DWD-Ausfall liefert der Server den letzten erfolgreichen Stand. Für eine eigene Installation sollte
`GEOCODING_USER_AGENT` auf eine Kennung mit eigener Kontakt-Webseite gesetzt werden, beispielsweise
`LehrerApp-Server/1.0 (+https://schule.example)`. Der Container benötigt ausgehenden HTTPS-Zugriff
auf `nominatim.openstreetmap.org`, `www.dwd.de` und `opendata.dwd.de`.
## Deployment über Dokploy ## Deployment über Dokploy
Kein manuelles Bauen/Hochladen nötig Dokploy zieht das Repo direkt per Git und baut das Image Kein manuelles Bauen/Hochladen nötig Dokploy zieht das Repo direkt per Git und baut das Image
+1
View File
@@ -12,6 +12,7 @@ services:
- api-data:/app/data - api-data:/app/data
environment: environment:
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
- Geocoding__UserAgent=${GEOCODING_USER_AGENT:-LehrerApp-Server/1.0 (+https://science-teaching.de)}
- ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_ENVIRONMENT=Production
restart: unless-stopped restart: unless-stopped