mirror of
https://github.com/jdejaegh/irm-kmi-api.git
synced 2025-06-27 04:05:56 +02:00
Add docstring for API
This commit is contained in:
parent
2f1c64b1c8
commit
760a13b19f
2 changed files with 131 additions and 53 deletions
|
@ -8,7 +8,7 @@ import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from statistics import mean
|
from statistics import mean
|
||||||
from typing import List, Tuple
|
from typing import List, Dict
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
@ -37,43 +37,61 @@ class IrmKmiApiParametersError(IrmKmiApiError):
|
||||||
"""Exception to indicate a parameter error."""
|
"""Exception to indicate a parameter error."""
|
||||||
|
|
||||||
|
|
||||||
def _api_key(method_name: str) -> str:
|
|
||||||
"""Get API key."""
|
|
||||||
return hashlib.md5(f"r9EnW374jkJ9acc;{method_name};{datetime.now().strftime('%d/%m/%Y')}".encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
class IrmKmiApiClient:
|
class IrmKmiApiClient:
|
||||||
"""API client for IRM KMI weather data"""
|
"""API client for IRM KMI weather data"""
|
||||||
COORD_DECIMALS = 6
|
COORD_DECIMALS = 6
|
||||||
cache_max_age = 60 * 60 * 2 # Remove items from the cache if they have not been hit since 2 hours
|
_cache_max_age = 60 * 60 * 2 # Remove items from the cache if they have not been hit since 2 hours
|
||||||
cache = {}
|
_cache = {}
|
||||||
|
|
||||||
def __init__(self, session: aiohttp.ClientSession, user_agent: str) -> None:
|
def __init__(self, session: aiohttp.ClientSession, user_agent: str) -> None:
|
||||||
self._session = session
|
self._session = session
|
||||||
self._base_url = "https://app.meteo.be/services/appv4/"
|
self._base_url = "https://app.meteo.be/services/appv4/"
|
||||||
self._user_agent = user_agent
|
self._user_agent = user_agent
|
||||||
|
|
||||||
async def get_forecasts_coord(self, coord: dict) -> dict:
|
async def get_forecasts_coord(self, coord: Dict[str, float | int]) -> dict:
|
||||||
"""Get forecasts for given city."""
|
"""
|
||||||
|
Get forecasts for given location.
|
||||||
|
|
||||||
|
:param coord: dict with the following keys: 'lat', 'long' (both float or int)
|
||||||
|
:return: raw forecast as python dict
|
||||||
|
:raise: IrmKmiApiError when communication with the API fails
|
||||||
|
"""
|
||||||
assert 'lat' in coord
|
assert 'lat' in coord
|
||||||
assert 'long' in coord
|
assert 'long' in coord
|
||||||
coord['lat'] = round(coord['lat'], self.COORD_DECIMALS)
|
coord['lat'] = round(coord['lat'], self.COORD_DECIMALS)
|
||||||
coord['long'] = round(coord['long'], self.COORD_DECIMALS)
|
coord['long'] = round(coord['long'], self.COORD_DECIMALS)
|
||||||
|
|
||||||
response: bytes = await self._api_wrapper(params={"s": "getForecasts", "k": _api_key("getForecasts")} | coord)
|
response: bytes = await self._api_wrapper(
|
||||||
|
params={"s": "getForecasts", "k": self._api_key("getForecasts")} | coord
|
||||||
|
)
|
||||||
response: dict = json.loads(response)
|
response: dict = json.loads(response)
|
||||||
|
|
||||||
_LOGGER.debug(f"Observation for {response.get('cityName', '')}: {response.get('obs', '{}')}")
|
_LOGGER.debug(f"Observation for {response.get('cityName', '')}: {response.get('obs', '{}')}")
|
||||||
_LOGGER.debug(f"Full data: {response}")
|
_LOGGER.debug(f"Full data: {response}")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def get_image(self, url, params: dict | None = None) -> bytes:
|
async def get_image(self, url, params: Dict[str, str] | None = None) -> bytes:
|
||||||
"""Get the image at the specified url with the parameters"""
|
"""
|
||||||
|
Get the image at the specified url with the parameters
|
||||||
|
|
||||||
|
:param url: URL to fetch
|
||||||
|
:param params: query parameters to add to the request
|
||||||
|
:return: response body as bytes
|
||||||
|
:raise: IrmKmiApiError when communication with the API fails
|
||||||
|
"""
|
||||||
r: bytes = await self._api_wrapper(base_url=url, params={} if params is None else params)
|
r: bytes = await self._api_wrapper(base_url=url, params={} if params is None else params)
|
||||||
return r
|
return r
|
||||||
|
|
||||||
async def get_svg(self, url, params: dict | None = None) -> str:
|
async def get_svg(self, url, params: Dict[str, str] | None = None) -> str:
|
||||||
"""Get SVG as str at the specified url with the parameters"""
|
"""
|
||||||
|
Get SVG as str at the specified url with the parameters
|
||||||
|
|
||||||
|
:param url: URL to fetch
|
||||||
|
:param params: query parameters to add to the request
|
||||||
|
:return: request body decoded as utf-8 str
|
||||||
|
:raise: IrmKmiApiError when communication with the API fails
|
||||||
|
"""
|
||||||
r: bytes = await self._api_wrapper(base_url=url, params={} if params is None else params)
|
r: bytes = await self._api_wrapper(base_url=url, params={} if params is None else params)
|
||||||
return r.decode()
|
return r.decode()
|
||||||
|
|
||||||
|
@ -94,8 +112,8 @@ class IrmKmiApiClient:
|
||||||
else:
|
else:
|
||||||
headers['User-Agent'] = self._user_agent
|
headers['User-Agent'] = self._user_agent
|
||||||
|
|
||||||
if url in self.cache:
|
if url in self._cache:
|
||||||
headers['If-None-Match'] = self.cache[url]['etag']
|
headers['If-None-Match'] = self._cache[url]['etag']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_timeout.timeout(60):
|
async with async_timeout.timeout(60):
|
||||||
|
@ -110,13 +128,13 @@ class IrmKmiApiClient:
|
||||||
|
|
||||||
if response.status == 304:
|
if response.status == 304:
|
||||||
_LOGGER.debug(f"Cache hit for {url}")
|
_LOGGER.debug(f"Cache hit for {url}")
|
||||||
self.cache[url]['timestamp'] = time.time()
|
self._cache[url]['timestamp'] = time.time()
|
||||||
return self.cache[url]['response']
|
return self._cache[url]['response']
|
||||||
|
|
||||||
if 'ETag' in response.headers:
|
if 'ETag' in response.headers:
|
||||||
_LOGGER.debug(f"Saving in cache {url}")
|
_LOGGER.debug(f"Saving in cache {url}")
|
||||||
r = await response.read()
|
r = await response.read()
|
||||||
self.cache[url] = {'etag': response.headers['ETag'], 'response': r, 'timestamp': time.time()}
|
self._cache[url] = {'etag': response.headers['ETag'], 'response': r, 'timestamp': time.time()}
|
||||||
return r
|
return r
|
||||||
|
|
||||||
return await response.read()
|
return await response.read()
|
||||||
|
@ -128,34 +146,66 @@ class IrmKmiApiClient:
|
||||||
except Exception as exception: # pylint: disable=broad-except
|
except Exception as exception: # pylint: disable=broad-except
|
||||||
raise IrmKmiApiError(f"Something really wrong happened! {exception}") from exception
|
raise IrmKmiApiError(f"Something really wrong happened! {exception}") from exception
|
||||||
|
|
||||||
def expire_cache(self):
|
@staticmethod
|
||||||
|
def _api_key(method_name: str) -> str:
|
||||||
|
"""Get API key."""
|
||||||
|
return hashlib.md5(f"r9EnW374jkJ9acc;{method_name};{datetime.now().strftime('%d/%m/%Y')}".encode()).hexdigest()
|
||||||
|
|
||||||
|
def expire_cache(self) -> None:
|
||||||
|
"""
|
||||||
|
Expire items from the cache which have not been accessed since self._cache_max_age (default 2h).
|
||||||
|
Must be called regularly to clear the cache.
|
||||||
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
keys_to_delete = set()
|
keys_to_delete = set()
|
||||||
for key, value in self.cache.items():
|
for key, value in self._cache.items():
|
||||||
if now - value['timestamp'] > self.cache_max_age:
|
if now - value['timestamp'] > self._cache_max_age:
|
||||||
keys_to_delete.add(key)
|
keys_to_delete.add(key)
|
||||||
for key in keys_to_delete:
|
for key in keys_to_delete:
|
||||||
del self.cache[key]
|
del self._cache[key]
|
||||||
_LOGGER.info(f"Expired {len(keys_to_delete)} elements from API cache")
|
_LOGGER.info(f"Expired {len(keys_to_delete)} elements from API cache")
|
||||||
|
|
||||||
|
|
||||||
class IrmKmiApiClientHa(IrmKmiApiClient):
|
class IrmKmiApiClientHa(IrmKmiApiClient):
|
||||||
|
"""API client for IRM KMI weather data with additional methods to integrate easily with Home Assistant"""
|
||||||
|
|
||||||
def __init__(self, session: aiohttp.ClientSession, user_agent: str, cdt_map: dict) -> None:
|
def __init__(self, session: aiohttp.ClientSession, user_agent: str, cdt_map: dict) -> None:
|
||||||
super().__init__(session, user_agent)
|
super().__init__(session, user_agent)
|
||||||
self._api_data = dict()
|
self._api_data = dict()
|
||||||
self._cdt_map = cdt_map
|
self._cdt_map = cdt_map
|
||||||
|
|
||||||
async def refresh_forecasts_coord(self, coord: dict) -> None:
|
async def refresh_forecasts_coord(self, coord: Dict[str, float | int]) -> None:
|
||||||
|
"""
|
||||||
|
Update the weather data by contacting the remote API. Keep the data in memory for future methods calls.
|
||||||
|
|
||||||
|
:param coord: dict with the following keys: 'lat', 'long' (both float or int)
|
||||||
|
:raise: IrmKmiApiError when communication with the API fails
|
||||||
|
"""
|
||||||
self._api_data = await self.get_forecasts_coord(coord)
|
self._api_data = await self.get_forecasts_coord(coord)
|
||||||
|
|
||||||
def get_city(self) -> str | None:
|
def get_city(self) -> str | None:
|
||||||
|
"""
|
||||||
|
Get the city for which we currently have the forecast
|
||||||
|
|
||||||
|
:return: city name as str or None if unavailable
|
||||||
|
"""
|
||||||
return self._api_data.get('cityName', None)
|
return self._api_data.get('cityName', None)
|
||||||
|
|
||||||
def get_country(self) -> str | None:
|
def get_country(self) -> str | None:
|
||||||
|
"""
|
||||||
|
Get the two-letters country code for which we currently have the forecast
|
||||||
|
|
||||||
|
:return: country code as str or None if unavailable
|
||||||
|
"""
|
||||||
return self._api_data.get('country', None)
|
return self._api_data.get('country', None)
|
||||||
|
|
||||||
def get_current_weather(self, tz: ZoneInfo) -> CurrentWeatherData:
|
def get_current_weather(self, tz: ZoneInfo) -> CurrentWeatherData:
|
||||||
"""Parse the API data to build a CurrentWeatherData."""
|
"""
|
||||||
|
Parse the API data we currently have to build a CurrentWeatherData.
|
||||||
|
|
||||||
|
:param tz: time zone to use to interpret the timestamps in the forecast (generally is Europe/Brussels)
|
||||||
|
:return: current weather
|
||||||
|
"""
|
||||||
|
|
||||||
now_hourly = self._get_now_hourly(tz)
|
now_hourly = self._get_now_hourly(tz)
|
||||||
uv_index = self._get_uv_index()
|
uv_index = self._get_uv_index()
|
||||||
|
@ -246,7 +296,13 @@ class IrmKmiApiClientHa(IrmKmiApiClient):
|
||||||
return now_hourly
|
return now_hourly
|
||||||
|
|
||||||
def get_daily_forecast(self, tz: ZoneInfo, lang: str) -> List[IrmKmiForecast]:
|
def get_daily_forecast(self, tz: ZoneInfo, lang: str) -> List[IrmKmiForecast]:
|
||||||
"""Parse data from the API to create a list of daily forecasts"""
|
"""
|
||||||
|
Parse the API data we currently have to build the daily forecast list.
|
||||||
|
|
||||||
|
:param tz: time zone to use to interpret the timestamps in the forecast (generally is Europe/Brussels)
|
||||||
|
:param lang: langage to get data for (must be 'fr', 'nl', 'de' or 'en')
|
||||||
|
:return: chronologically ordered list of daily forecasts
|
||||||
|
"""
|
||||||
data = self._api_data.get('for', {}).get('daily')
|
data = self._api_data.get('for', {}).get('daily')
|
||||||
if data is None or not isinstance(data, list) or len(data) == 0:
|
if data is None or not isinstance(data, list) or len(data) == 0:
|
||||||
return []
|
return []
|
||||||
|
@ -340,7 +396,12 @@ class IrmKmiApiClientHa(IrmKmiApiClient):
|
||||||
return forecasts
|
return forecasts
|
||||||
|
|
||||||
def get_hourly_forecast(self, tz: ZoneInfo) -> List[Forecast]:
|
def get_hourly_forecast(self, tz: ZoneInfo) -> List[Forecast]:
|
||||||
"""Parse data from the API to create a list of hourly forecasts"""
|
"""
|
||||||
|
Parse data from the API to create a list of hourly forecasts
|
||||||
|
|
||||||
|
:param tz: time zone to use to interpret the timestamps in the forecast (generally is Europe/Brussels)
|
||||||
|
:return: chronologically ordered list of hourly forecasts
|
||||||
|
"""
|
||||||
data = self._api_data.get('for', {}).get('hourly')
|
data = self._api_data.get('for', {}).get('hourly')
|
||||||
|
|
||||||
if data is None or not isinstance(data, list) or len(data) == 0:
|
if data is None or not isinstance(data, list) or len(data) == 0:
|
||||||
|
@ -392,7 +453,11 @@ class IrmKmiApiClientHa(IrmKmiApiClient):
|
||||||
return forecasts
|
return forecasts
|
||||||
|
|
||||||
def get_radar_forecast(self) -> List[IrmKmiRadarForecast]:
|
def get_radar_forecast(self) -> List[IrmKmiRadarForecast]:
|
||||||
"""Create a list of short term forecasts for rain based on the data provided by the rain radar"""
|
"""
|
||||||
|
Create a list of short term forecasts for rain based on the data provided by the rain radar
|
||||||
|
|
||||||
|
:return: chronologically ordered list of 'few'-minutes radar forecasts
|
||||||
|
"""
|
||||||
data = self._api_data.get('animation', {})
|
data = self._api_data.get('animation', {})
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
|
@ -420,14 +485,16 @@ class IrmKmiApiClientHa(IrmKmiApiClient):
|
||||||
)
|
)
|
||||||
return forecast
|
return forecast
|
||||||
|
|
||||||
def get_animation_data(self,
|
def get_animation_data(self, tz: ZoneInfo, lang: str, style: str, dark_mode: bool) -> RadarAnimationData:
|
||||||
tz: ZoneInfo,
|
"""
|
||||||
lang: str,
|
Get all the image URLs and create the radar animation data object.
|
||||||
style: str,
|
|
||||||
dark_mode: bool
|
:param tz: time zone to use to interpret the timestamps in the forecast (generally is Europe/Brussels)
|
||||||
) -> RadarAnimationData:
|
:param lang: langage to get data for (must be 'fr', 'nl', 'de' or 'en')
|
||||||
"""From the API data passed in, call the API to get all the images and create the radar animation data object.
|
:param style: style of the radar (key of STYLE_TO_PARAM_MAP)
|
||||||
Frames from the API are merged with the background map and the location marker to create each frame."""
|
:param dark_mode: true if dark mode
|
||||||
|
:return: animation data that can be used to download the images and build the rain graph animation
|
||||||
|
"""
|
||||||
animation_data = self._api_data.get('animation', {}).get('sequence')
|
animation_data = self._api_data.get('animation', {}).get('sequence')
|
||||||
localisation_layer_url = self._api_data.get('animation', {}).get('localisationLayer')
|
localisation_layer_url = self._api_data.get('animation', {}).get('localisationLayer')
|
||||||
country = self.get_country()
|
country = self.get_country()
|
||||||
|
@ -435,9 +502,9 @@ class IrmKmiApiClientHa(IrmKmiApiClient):
|
||||||
if animation_data is None or localisation_layer_url is None or not isinstance(animation_data, list):
|
if animation_data is None or localisation_layer_url is None or not isinstance(animation_data, list):
|
||||||
raise ValueError("Cannot create animation data")
|
raise ValueError("Cannot create animation data")
|
||||||
|
|
||||||
localisation = self.merge_url_and_params(localisation_layer_url,
|
localisation = self._merge_url_and_params(localisation_layer_url,
|
||||||
{'th': 'd' if country == 'NL' or not dark_mode else 'n'})
|
{'th': 'd' if country == 'NL' or not dark_mode else 'n'})
|
||||||
images_from_api = [self.merge_url_and_params(frame.get('uri'), {'rs': STYLE_TO_PARAM_MAP[style]})
|
images_from_api = [self._merge_url_and_params(frame.get('uri'), {'rs': STYLE_TO_PARAM_MAP[style]})
|
||||||
for frame in animation_data if frame is not None and frame.get('uri') is not None
|
for frame in animation_data if frame is not None and frame.get('uri') is not None
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -472,7 +539,12 @@ class IrmKmiApiClientHa(IrmKmiApiClient):
|
||||||
return radar_animation
|
return radar_animation
|
||||||
|
|
||||||
def get_warnings(self, lang: str) -> List[WarningData]:
|
def get_warnings(self, lang: str) -> List[WarningData]:
|
||||||
"""Create a list of warning data instances based on the api data"""
|
"""
|
||||||
|
Parse the API data we currently have to build the list of warnings.
|
||||||
|
|
||||||
|
:param lang: langage to get data for (must be 'fr', 'nl', 'de' or 'en')
|
||||||
|
:return: unordered list of warnings
|
||||||
|
"""
|
||||||
warning_data = self._api_data.get('for', {}).get('warning')
|
warning_data = self._api_data.get('for', {}).get('warning')
|
||||||
if warning_data is None or not isinstance(warning_data, list) or len(warning_data) == 0:
|
if warning_data is None or not isinstance(warning_data, list) or len(warning_data) == 0:
|
||||||
return []
|
return []
|
||||||
|
@ -506,8 +578,13 @@ class IrmKmiApiClientHa(IrmKmiApiClient):
|
||||||
|
|
||||||
return result if len(result) > 0 else []
|
return result if len(result) > 0 else []
|
||||||
|
|
||||||
async def get_pollen(self) -> dict:
|
async def get_pollen(self) -> Dict[str, str | None]:
|
||||||
"""Get SVG pollen info from the API, return the pollen data dict"""
|
"""
|
||||||
|
Get SVG pollen info from the API, return the pollen data dict
|
||||||
|
|
||||||
|
:return: pollen data as dict mapping from pollen name to pollen level as a color
|
||||||
|
:raise: IrmKmiApiError when communication with the API fails
|
||||||
|
"""
|
||||||
_LOGGER.debug("Getting pollen data from API")
|
_LOGGER.debug("Getting pollen data from API")
|
||||||
svg_url = None
|
svg_url = None
|
||||||
for module in self._api_data.get('module', []):
|
for module in self._api_data.get('module', []):
|
||||||
|
@ -529,7 +606,8 @@ class IrmKmiApiClientHa(IrmKmiApiClient):
|
||||||
return PollenParser(pollen_svg).get_pollen_data()
|
return PollenParser(pollen_svg).get_pollen_data()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def merge_url_and_params(url, params):
|
def _merge_url_and_params(url: str, params: dict) -> str:
|
||||||
|
"""Merge query string params in the URL"""
|
||||||
parsed_url = urllib.parse.urlparse(url)
|
parsed_url = urllib.parse.urlparse(url)
|
||||||
query_params = urllib.parse.parse_qs(parsed_url.query)
|
query_params = urllib.parse.parse_qs(parsed_url.query)
|
||||||
query_params.update(params)
|
query_params.update(params)
|
||||||
|
|
|
@ -69,9 +69,9 @@ async def test_get_image_api_called() -> None:
|
||||||
|
|
||||||
def test_expire_cache_clears_items() -> None:
|
def test_expire_cache_clears_items() -> None:
|
||||||
api = IrmKmiApiClient(session=MagicMock(), user_agent="test-user-agent")
|
api = IrmKmiApiClient(session=MagicMock(), user_agent="test-user-agent")
|
||||||
assert api.cache_max_age == 60 * 60 * 2
|
assert api._cache_max_age == 60 * 60 * 2
|
||||||
|
|
||||||
api.cache = {
|
api._cache = {
|
||||||
'first-url': {
|
'first-url': {
|
||||||
'timestamp': time.time() - timedelta(hours=3).seconds,
|
'timestamp': time.time() - timedelta(hours=3).seconds,
|
||||||
'response': 'wowo',
|
'response': 'wowo',
|
||||||
|
@ -84,12 +84,12 @@ def test_expire_cache_clears_items() -> None:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert len(api.cache) == 2
|
assert len(api._cache) == 2
|
||||||
|
|
||||||
api.expire_cache()
|
api.expire_cache()
|
||||||
|
|
||||||
assert len(api.cache) == 1
|
assert len(api._cache) == 1
|
||||||
assert 'second-url' in api.cache
|
assert 'second-url' in api._cache
|
||||||
|
|
||||||
|
|
||||||
async def test_api_wrapper_puts_response_in_cache() -> None:
|
async def test_api_wrapper_puts_response_in_cache() -> None:
|
||||||
|
@ -107,8 +107,8 @@ async def test_api_wrapper_puts_response_in_cache() -> None:
|
||||||
r = await api._api_wrapper(params={}, base_url='test-url')
|
r = await api._api_wrapper(params={}, base_url='test-url')
|
||||||
|
|
||||||
assert r == b"response value"
|
assert r == b"response value"
|
||||||
assert len(api.cache) == 1
|
assert len(api._cache) == 1
|
||||||
assert 'test-url' in api.cache
|
assert 'test-url' in api._cache
|
||||||
|
|
||||||
session.request.assert_awaited_once_with(
|
session.request.assert_awaited_once_with(
|
||||||
method='get', url='test-url', headers={'User-Agent': 'test-user-agent'}, json=None, params={}
|
method='get', url='test-url', headers={'User-Agent': 'test-user-agent'}, json=None, params={}
|
||||||
|
@ -126,7 +126,7 @@ async def test_api_wrapper_gets_response_from_cache() -> None:
|
||||||
session.request = AsyncMock(return_value=response)
|
session.request = AsyncMock(return_value=response)
|
||||||
|
|
||||||
api = IrmKmiApiClient(session=session, user_agent="test-user-agent")
|
api = IrmKmiApiClient(session=session, user_agent="test-user-agent")
|
||||||
api.cache = {
|
api._cache = {
|
||||||
'test-url': {
|
'test-url': {
|
||||||
'timestamp': time.time(),
|
'timestamp': time.time(),
|
||||||
'response': b"response value",
|
'response': b"response value",
|
||||||
|
@ -137,8 +137,8 @@ async def test_api_wrapper_gets_response_from_cache() -> None:
|
||||||
r = await api._api_wrapper(params={}, base_url='test-url')
|
r = await api._api_wrapper(params={}, base_url='test-url')
|
||||||
|
|
||||||
assert r == b"response value"
|
assert r == b"response value"
|
||||||
assert len(api.cache) == 1
|
assert len(api._cache) == 1
|
||||||
assert 'test-url' in api.cache
|
assert 'test-url' in api._cache
|
||||||
|
|
||||||
session.request.assert_awaited_once_with(
|
session.request.assert_awaited_once_with(
|
||||||
method='get',
|
method='get',
|
||||||
|
|
Loading…
Add table
Reference in a new issue