Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b32166ce0 | ||
|
|
cc973418f3 |
@@ -4,3 +4,6 @@
|
|||||||
# Mindestens 32 zufällige Zeichen – z.B. generiert mit:
|
# Mindestens 32 zufällige Zeichen – z.B. generiert mit:
|
||||||
# openssl rand -base64 32
|
# openssl rand -base64 32
|
||||||
JWT_SECRET=hier-einen-langen-zufaelligen-wert-eintragen
|
JWT_SECRET=hier-einen-langen-zufaelligen-wert-eintragen
|
||||||
|
|
||||||
|
# Identifiziert die Installation gegenüber Nominatim/DWD. Bei eigener Domain bitte anpassen.
|
||||||
|
GEOCODING_USER_AGENT=LehrerApp-Server/1.0 (+https://example.org)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 & 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user