Document WeatherAPI and department weather endpoints in OpenAPI

This commit is contained in:
Jeppe B
2026-03-11 13:56:29 +01:00
parent 6713a67eb5
commit 527e6a9ec4
8 changed files with 721 additions and 0 deletions
+169
View File
@@ -6470,6 +6470,99 @@ paths:
'200':
description: Exchange rates retrieved successfully
/modules/weatherapi/current:
get:
tags:
- Modules
summary: Get current weather
description: Get current weather data from WeatherAPI for a location query
operationId: weatherApiCurrent
parameters:
- name: q
in: query
required: true
schema:
type: string
description: Location query (e.g. city, postal code, or latitude,longitude)
responses:
'200':
description: Current weather retrieved successfully
/modules/weatherapi/forecast:
get:
tags:
- Modules
summary: Get weather forecast
description: Get forecast weather data from WeatherAPI
operationId: weatherApiForecast
parameters:
- name: q
in: query
required: true
schema:
type: string
description: Location query (e.g. city, postal code, or latitude,longitude)
- name: days
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 14
description: Number of forecast days
responses:
'200':
description: Forecast weather retrieved successfully
/modules/weatherapi/search:
get:
tags:
- Modules
summary: Search weather locations
description: Search location suggestions from WeatherAPI
operationId: weatherApiSearch
parameters:
- name: q
in: query
required: true
schema:
type: string
description: Search text
responses:
'200':
description: Location search results retrieved successfully
/departments/weather:
get:
tags:
- Departments
summary: Get department weather timeline
description: Returns hourly weather, washes, hours and productivity status for a department
operationId: getDepartmentWeatherTimeline
parameters:
- name: id
in: query
required: true
schema:
type: integer
minimum: 1
description: Department ID
responses:
'200':
description: Department weather timeline retrieved successfully
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/SuccessResponse'
- type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/DepartmentWeatherTimelineEntry'
/modules/entra/users:
get:
tags:
@@ -6923,6 +7016,31 @@ paths:
schema:
$ref: '#/components/schemas/ModuleConfigUpdateResponse'
/weatherapi/config:
get:
tags: [Config]
summary: Get WeatherAPI config
operationId: getWeatherApiConfig
responses:
'200':
description: WeatherAPI configuration retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/WeatherApiConfigListResponse'
post:
tags: [Config]
summary: Update WeatherAPI config
operationId: updateWeatherApiConfig
responses:
'200':
description: WeatherAPI configuration updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/ModuleConfigUpdateResponse'
/gatewayapi/config:
get:
tags: [Config]
@@ -7898,6 +8016,19 @@ components:
- type: string
required: [module, variable, type, value]
WeatherApiConfigEntry:
type: object
properties:
module: { type: string, enum: [weatherapi] }
variable: { type: string, enum: [enabled, secret_key] }
type: { type: string, enum: [bool, string] }
value:
oneOf:
- type: boolean
- type: string
required: [module, variable, type, value]
GatewayApiConfigEntry:
type: object
properties:
@@ -8083,6 +8214,15 @@ components:
data: { type: array, items: { $ref: '#/components/schemas/FxRatesApiConfigEntry' } }
required: [data]
WeatherApiConfigListResponse:
allOf:
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
- type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/WeatherApiConfigEntry' } }
required: [data]
GatewayApiConfigListResponse:
allOf:
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
@@ -8163,6 +8303,35 @@ components:
data: { type: array, items: { $ref: '#/components/schemas/SelfServeConfigEntry' } }
required: [data]
DepartmentWeatherStatus:
type: string
enum: [unknown, healthy, degraded, unhealthy]
DepartmentWeatherCondition:
type: string
enum: [clear, mostly_clear, partly_cloudy, mostly_cloudy, overcast, rain, showers, thunderstorm, snow, fog]
DepartmentWeatherTimelineEntry:
type: object
properties:
time:
type: string
example: '01:00'
weather:
$ref: '#/components/schemas/DepartmentWeatherCondition'
washes:
type: integer
minimum: 0
example: 0
hours:
type: integer
minimum: 0
example: 10
status:
$ref: '#/components/schemas/DepartmentWeatherStatus'
required: [time, weather, washes, hours, status]
ModuleConfigEntry:
type: object
properties:
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace classes;
use Exception;
use interfaces\weatherapi_i;
use weatherapi\weatherapi_c;
require_once WD . '/modules/weatherapi/weatherapi_c.php';
class weatherapi implements weatherapi_i
{
public weatherapi_c $config;
private string $api_url = 'https://api.weatherapi.com/v1/';
public function __construct()
{
$this->config = new weatherapi_c();
}
public function requireModuleEnabled(): void
{
if (!$this->config->enabled->isTrue()) {
throw new Exception('The weatherapi module is not enabled');
}
}
public function requireValidSecretKey(): void
{
if ($this->config->secret_key->getVariableValue() === null || $this->config->secret_key->getVariableValue() === '') {
throw new Exception('Invalid secret key defined in the config (weatherapi_secret_key_c)');
}
}
public function sendRequest(string $endpoint, array $query = []): object
{
$this->requireModuleEnabled();
$this->requireValidSecretKey();
$endpoint = ltrim($endpoint, '/');
$query = array_merge(['key' => $this->config->secret_key->getVariableValue()], $query);
$url = $this->api_url . $endpoint . '?' . http_build_query($query);
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => '',
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => 'GET',
]);
$response = curl_exec($curl);
$status_code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($error) {
throw new Exception('cURL request failed: ' . $error);
}
if ($response === false || $response === '') {
throw new Exception('Empty response from WeatherAPI');
}
$decoded = json_decode($response);
if (json_last_error() !== JSON_ERROR_NONE || !is_object($decoded) && !is_array($decoded)) {
throw new Exception('Invalid response from WeatherAPI');
}
if ($status_code >= 400) {
$message = 'WeatherAPI request failed with HTTP ' . $status_code;
if (is_object($decoded) && isset($decoded->error->message)) {
$message .= ': ' . $decoded->error->message;
}
throw new Exception($message);
}
return (object)$decoded;
}
public function current(string $query, array $options = []): object
{
return $this->sendRequest('current.json', array_merge(['q' => $query], $options));
}
public function forecast(string $query, int $days = 1, array $options = []): object
{
return $this->sendRequest('forecast.json', array_merge(['q' => $query, 'days' => $days], $options));
}
public function history(string $query, string $date, array $options = []): object
{
return $this->sendRequest('history.json', array_merge(['q' => $query, 'dt' => $date], $options));
}
public function astronomy(string $query, string $date, array $options = []): object
{
return $this->sendRequest('astronomy.json', array_merge(['q' => $query, 'dt' => $date], $options));
}
public function timezone(string $query, array $options = []): object
{
return $this->sendRequest('timezone.json', array_merge(['q' => $query], $options));
}
public function sports(string $query, array $options = []): object
{
return $this->sendRequest('sports.json', array_merge(['q' => $query], $options));
}
public function search(string $query, array $options = []): object
{
return $this->sendRequest('search.json', array_merge(['q' => $query], $options));
}
public function marine(string $query, int $days = 1, array $options = []): object
{
return $this->sendRequest('marine.json', array_merge(['q' => $query, 'days' => $days], $options));
}
public function future(string $query, string $date, array $options = []): object
{
return $this->sendRequest('future.json', array_merge(['q' => $query, 'dt' => $date], $options));
}
}
@@ -0,0 +1,30 @@
<?php
namespace interfaces;
interface weatherapi_i
{
public function requireModuleEnabled(): void;
public function requireValidSecretKey(): void;
public function sendRequest(string $endpoint, array $query = []): object;
public function current(string $query, array $options = []): object;
public function forecast(string $query, int $days = 1, array $options = []): object;
public function history(string $query, string $date, array $options = []): object;
public function astronomy(string $query, string $date, array $options = []): object;
public function timezone(string $query, array $options = []): object;
public function sports(string $query, array $options = []): object;
public function search(string $query, array $options = []): object;
public function marine(string $query, int $days = 1, array $options = []): object;
public function future(string $query, string $date, array $options = []): object;
}
@@ -0,0 +1,29 @@
<?php
namespace weatherapi\config;
use Exception;
use traits\module_config_variable;
class weatherapi_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'weatherapi',
'enabled',
'bool',
true,
null,
'Whether the weatherapi module is enabled or not',
'1',
false,
'false'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace weatherapi\config;
use Exception;
use traits\module_config_variable;
class weatherapi_secret_key_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'weatherapi',
'secret_key',
'string',
false,
null,
'The API token for weatherapi.com',
'1',
true,
''
);
}
}
@@ -0,0 +1,30 @@
<?php
namespace weatherapi;
require_once WD . '/modules/weatherapi/config/weatherapi_enabled_c.php';
require_once WD . '/modules/weatherapi/config/weatherapi_secret_key_c.php';
use traits\module_config_t;
use weatherapi\config\weatherapi_enabled_c;
use weatherapi\config\weatherapi_secret_key_c;
class weatherapi_c
{
use module_config_t;
public weatherapi_enabled_c $enabled;
public weatherapi_secret_key_c $secret_key;
public function __construct()
{
$this->setupConfig('weatherapi');
$this->allowUpdate([
weatherapi_enabled_c::class,
weatherapi_secret_key_c::class,
]);
$this->enabled = new weatherapi_enabled_c();
$this->secret_key = new weatherapi_secret_key_c();
}
}
@@ -12,6 +12,7 @@ use classes\recaptcha;
use classes\response;
use classes\router;
use classes\stripe;
use classes\weatherapi;
use objects\logs_o;
use traits\route_t;
@@ -412,6 +413,45 @@ class moduleConfigRoute
]
);
/** WeatherAPI config > GET */
$this->get('/weatherapi/config', function () {
global $response;
$this->requirePermission('weatherapi_config');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('weatherapi_config', 'global', 1, $user->id, 'WEATHERAPI_CONFIG', 'Successfully fetched weatherapi config');
$response->success(
(new weatherapi())->config->getConfigRequest()
);
} else {
(new logs_o())->add('weatherapi_config', 'global', 1, 0, 'WEATHERAPI_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'weatherapi_config' => 'Get weatherapi config'
]
);
/** WeatherAPI config > POST */
$this->post('/weatherapi/config', function () {
global $response;
$this->requirePermission('weatherapi_config');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('weatherapi_config', 'global', 1, $user->id, 'WEATHERAPI_CONFIG', 'Successfully updated weatherapi config');
$response->success(
(new weatherapi())->config->postConfigRequest()
);
} else {
(new logs_o())->add('weatherapi_config', 'global', 1, 0, 'WEATHERAPI_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'weatherapi_config' => 'Update weatherapi config'
]
);
/** XLVask config > GET */
$this->get('/xlvask/config', function () {
global $response;
@@ -0,0 +1,265 @@
<?php
namespace routes;
use classes\authentication;
use classes\response;
use classes\router;
use classes\weatherapi;
use DateInterval;
use DateTime;
use Exception;
use objects\department_time_bookings_opening_hours_o;
use objects\departments_o;
use objects\logs_o;
use objects\orders_o;
use traits\route_t;
class moduleWeatherAPIRoute
{
use route_t;
public function run(): void
{
global /** @var response $response */
/** @var router $router */
$router, $response;
$this->get('/modules/weatherapi/current', function () {
global $response;
self::requirePermission('modules_weatherapi_current');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
self::requireParameters(['q']);
self::requireType('q', self::type_string());
$result = (new weatherapi())->current(self::getParameter('q'));
(new logs_o())->add('modules_weatherapi', 'global', 1, $user->id, 'MODULES_WEATHERAPI', 'Current weather request completed');
$response->success($result, 200);
}, [
'modules_weatherapi_current' => 'Get current weather data from WeatherAPI',
]);
$this->get('/modules/weatherapi/forecast', function () {
global $response;
self::requirePermission('modules_weatherapi_forecast');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
self::requireParameters(['q']);
self::requireType('q', self::type_string());
$days = (int)(self::getParameter('days') ?? 1);
$result = (new weatherapi())->forecast(self::getParameter('q'), $days);
(new logs_o())->add('modules_weatherapi', 'global', 1, $user->id, 'MODULES_WEATHERAPI', 'Forecast weather request completed');
$response->success($result, 200);
}, [
'modules_weatherapi_forecast' => 'Get weather forecast data from WeatherAPI',
]);
$this->get('/modules/weatherapi/search', function () {
global $response;
self::requirePermission('modules_weatherapi_search');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
self::requireParameters(['q']);
self::requireType('q', self::type_string());
$result = (new weatherapi())->search(self::getParameter('q'));
(new logs_o())->add('modules_weatherapi', 'global', 1, $user->id, 'MODULES_WEATHERAPI', 'Weather location search completed');
$response->success($result, 200);
}, [
'modules_weatherapi_search' => 'Search location data from WeatherAPI',
]);
$this->get('/departments/weather', function () {
global $response;
self::requirePermission('departments_weather_get');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
self::requireParameters(['id']);
self::requireType((int)self::getParameter('id'), self::type_int());
self::requireMinValue((int)self::getParameter('id'), 1);
self::requireDepartmentAccess((int)self::getParameter('id'));
$department = (new departments_o())->select((int)self::getParameter('id'));
$lat = (float)$department->latitude->value();
$lon = (float)$department->longitude->value();
if ($lat === 0.0 && $lon === 0.0) {
$response->error('Department does not have GPS coordinates configured', 400);
return;
}
$opening_hours = new department_time_bookings_opening_hours_o();
$opening_hours->selectByDepartment((int)self::getParameter('id'));
$weather = (new weatherapi())->forecast($lat . ',' . $lon, 2);
$timeline = self::buildDepartmentWeatherTimeline((int)self::getParameter('id'), $weather, $opening_hours);
(new logs_o())->add('modules_weatherapi', (int)self::getParameter('id'), 1, $user->id, 'DEPARTMENTS_WEATHER_GET', 'Department weather timeline fetched');
$response->success($timeline, 200);
}, [
'departments_weather_get' => 'Get department weather timeline with washes and productivity status',
'department_access_:id' => 'Access weather timeline for a specific department',
]);
}
/**
* @throws Exception
*/
private function buildDepartmentWeatherTimeline(int $department_id, object $forecast, department_time_bookings_opening_hours_o $opening_hours): array
{
$hourly_weather = [];
foreach (($forecast->forecast->forecastday ?? []) as $day) {
foreach (($day->hour ?? []) as $hour) {
$key = (new DateTime((string)$hour->time))->format('Y-m-d H:00');
$hourly_weather[$key] = self::mapWeatherCondition((int)($hour->condition->code ?? 1000), (string)($hour->condition->text ?? ''));
}
}
$entries = [];
$slot = new DateTime(date('Y-m-d H:00:00'));
$slot->add(new DateInterval('PT1H'));
for ($i = 0; $i < 24; $i++) {
$slot_key = $slot->format('Y-m-d H:00');
$weather = $hourly_weather[$slot_key] ?? 'mostly_clear';
$is_open = self::isDepartmentOpenAtHour($opening_hours, $slot);
$hours = $is_open ? self::getDailyOpenHours($opening_hours, $slot) : 0;
$washes = self::countWashesForHour($department_id, $slot);
$entries[] = [
'time' => $slot->format('H:00'),
'weather' => $weather,
'washes' => $washes,
'hours' => $hours,
'status' => self::calculateStatus($washes, $hours),
];
$slot->add(new DateInterval('PT1H'));
}
return $entries;
}
private function calculateStatus(int $washes, int $hours): string
{
if ($hours <= 0) {
return 'unknown';
}
$ratio = $washes / $hours;
if ($ratio >= 0.8) {
return 'healthy';
}
if ($ratio >= 0.4) {
return 'degraded';
}
return 'unhealthy';
}
/**
* @throws Exception
*/
private function isDepartmentOpenAtHour(department_time_bookings_opening_hours_o $opening_hours, DateTime $date_time): bool
{
$weekday = strtolower($date_time->format('l'));
$start = $opening_hours->{"{$weekday}_start"}->value();
$end = $opening_hours->{"{$weekday}_end"}->value();
if ($start === null || $end === null) {
return false;
}
$current = $date_time->format('H:i');
$start_hour = (new DateTime((string)$start))->format('H:i');
$end_hour = (new DateTime((string)$end))->format('H:i');
return $current >= $start_hour && $current < $end_hour;
}
/**
* @throws Exception
*/
private function getDailyOpenHours(department_time_bookings_opening_hours_o $opening_hours, DateTime $date_time): int
{
$weekday = strtolower($date_time->format('l'));
$start = $opening_hours->{"{$weekday}_start"}->value();
$end = $opening_hours->{"{$weekday}_end"}->value();
if ($start === null || $end === null) {
return 0;
}
$start_dt = new DateTime((string)$start);
$end_dt = new DateTime((string)$end);
$hours = ((int)$end_dt->format('U') - (int)$start_dt->format('U')) / 3600;
return max(0, (int)round($hours));
}
/**
* @throws Exception
*/
private function countWashesForHour(int $department_id, DateTime $hour_start): int
{
$start = clone $hour_start;
$end = clone $hour_start;
$end->add(new DateInterval('PT59M59S'));
return (new orders_o())->countWashesInDateRange(
$start->format('Y-m-d H:i:s'),
$end->format('Y-m-d H:i:s'),
$department_id
);
}
private function mapWeatherCondition(int $code, string $text): string
{
$rain = [1063, 1150, 1153, 1180, 1183, 1186, 1189, 1192, 1195, 1240, 1243, 1246];
$showers = [1072, 1168, 1171, 1198, 1201, 1249, 1252];
$snow = [1066, 1069, 1114, 1117, 1204, 1207, 1210, 1213, 1216, 1219, 1222, 1225, 1237, 1255, 1258, 1261, 1264];
$thunder = [1087, 1273, 1276, 1279, 1282];
if ($code === 1000) {
return 'clear';
}
if ($code === 1003) {
return 'mostly_clear';
}
if ($code === 1006) {
return 'partly_cloudy';
}
if ($code === 1009) {
return 'mostly_cloudy';
}
if ($code === 1030 || str_contains(strtolower($text), 'overcast')) {
return 'overcast';
}
if ($code === 1135 || $code === 1147) {
return 'fog';
}
if (in_array($code, $thunder, true)) {
return 'thunderstorm';
}
if (in_array($code, $snow, true)) {
return 'snow';
}
if (in_array($code, $showers, true)) {
return 'showers';
}
if (in_array($code, $rain, true)) {
return 'rain';
}
return 'mostly_clear';
}
}