diff --git a/homeassistant/components/iqvia/.translations/en.json b/homeassistant/components/iqvia/.translations/en.json new file mode 100644 index 00000000000..c3cc412d792 --- /dev/null +++ b/homeassistant/components/iqvia/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "ZIP code already registered", + "invalid_zip_code": "ZIP code is invalid" + }, + "step": { + "user": { + "data": { + "zip_code": "ZIP Code" + }, + "description": "Fill out your U.S. or Canadian ZIP code.", + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index 23803d7f17d..cf8f92c1bd2 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -8,28 +8,27 @@ from pyiqvia.errors import IQVIAError, InvalidZipError import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv -from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.decorator import Registry +from .config_flow import configured_instances from .const import ( - DATA_CLIENT, DATA_LISTENER, DOMAIN, SENSORS, TOPIC_DATA_UPDATE, - TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_INDEX, TYPE_ALLERGY_OUTLOOK, - TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW, TYPE_ASTHMA_FORECAST, - TYPE_ASTHMA_INDEX, TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW, - TYPE_DISEASE_FORECAST, TYPE_DISEASE_INDEX, TYPE_DISEASE_TODAY) + CONF_ZIP_CODE, DATA_CLIENT, DATA_LISTENER, DOMAIN, SENSORS, + TOPIC_DATA_UPDATE, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_INDEX, + TYPE_ALLERGY_OUTLOOK, TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW, + TYPE_ASTHMA_FORECAST, TYPE_ASTHMA_INDEX, TYPE_ASTHMA_TODAY, + TYPE_ASTHMA_TOMORROW, TYPE_DISEASE_FORECAST, TYPE_DISEASE_INDEX, + TYPE_DISEASE_TODAY) _LOGGER = logging.getLogger(__name__) - -CONF_ZIP_CODE = 'zip_code' - DATA_CONFIG = 'config' DEFAULT_ATTRIBUTION = 'Data provided by IQVIA™' @@ -59,23 +58,39 @@ async def async_setup(hass, config): hass.data[DOMAIN][DATA_CLIENT] = {} hass.data[DOMAIN][DATA_LISTENER] = {} + if DOMAIN not in config: + return True + conf = config[DOMAIN] + if conf[CONF_ZIP_CODE] in configured_instances(hass): + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={'source': SOURCE_IMPORT}, data=conf)) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up IQVIA as config entry.""" websession = aiohttp_client.async_get_clientsession(hass) try: iqvia = IQVIAData( - Client(conf[CONF_ZIP_CODE], websession), - conf[CONF_MONITORED_CONDITIONS]) + Client(config_entry.data[CONF_ZIP_CODE], websession), + config_entry.data.get(CONF_MONITORED_CONDITIONS, list(SENSORS))) await iqvia.async_update() except IQVIAError as err: _LOGGER.error('Unable to set up IQVIA: %s', err) return False - hass.data[DOMAIN][DATA_CLIENT] = iqvia + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = iqvia hass.async_create_task( - async_load_platform(hass, 'sensor', DOMAIN, {}, config)) + hass.config_entries.async_forward_entry_setup( + config_entry, 'sensor')) async def refresh(event_time): """Refresh IQVIA data.""" @@ -83,8 +98,23 @@ async def async_setup(hass, config): await iqvia.async_update() async_dispatcher_send(hass, TOPIC_DATA_UPDATE) - hass.data[DOMAIN][DATA_LISTENER] = async_track_time_interval( - hass, refresh, DEFAULT_SCAN_INTERVAL) + hass.data[DOMAIN][DATA_LISTENER][ + config_entry.entry_id] = async_track_time_interval( + hass, refresh, DEFAULT_SCAN_INTERVAL) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an OpenUV config entry.""" + hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) + + remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop( + config_entry.entry_id) + remove_listener() + + await hass.config_entries.async_forward_entry_unload( + config_entry, 'sensor') return True diff --git a/homeassistant/components/iqvia/config_flow.py b/homeassistant/components/iqvia/config_flow.py new file mode 100644 index 00000000000..d9a8c693670 --- /dev/null +++ b/homeassistant/components/iqvia/config_flow.py @@ -0,0 +1,65 @@ +"""Config flow to configure the IQVIA component.""" + +from collections import OrderedDict +import voluptuous as vol + +from pyiqvia import Client +from pyiqvia.errors import IQVIAError + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client + +from .const import CONF_ZIP_CODE, DOMAIN + + +@callback +def configured_instances(hass): + """Return a set of configured IQVIA instances.""" + return set( + entry.data[CONF_ZIP_CODE] + for entry in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class IQVIAFlowHandler(config_entries.ConfigFlow): + """Handle an IQVIA config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize the config flow.""" + self.data_schema = OrderedDict() + self.data_schema[vol.Required(CONF_ZIP_CODE)] = str + + async def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id='user', + data_schema=vol.Schema(self.data_schema), + errors=errors if errors else {}, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + if not user_input: + return await self._show_form() + + if user_input[CONF_ZIP_CODE] in configured_instances(self.hass): + return await self._show_form({CONF_ZIP_CODE: 'identifier_exists'}) + + websession = aiohttp_client.async_get_clientsession(self.hass) + client = Client(user_input[CONF_ZIP_CODE], websession) + + try: + await client.allergens.current() + except IQVIAError: + return await self._show_form({CONF_ZIP_CODE: 'invalid_zip_code'}) + + return self.async_create_entry( + title=user_input[CONF_ZIP_CODE], data=user_input) diff --git a/homeassistant/components/iqvia/const.py b/homeassistant/components/iqvia/const.py index 025fa8a9505..e9bffabcc43 100644 --- a/homeassistant/components/iqvia/const.py +++ b/homeassistant/components/iqvia/const.py @@ -1,6 +1,8 @@ """Define IQVIA constants.""" DOMAIN = 'iqvia' +CONF_ZIP_CODE = 'zip_code' + DATA_CLIENT = 'client' DATA_LISTENER = 'listener' diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index b0b09c3f977..5128b997b35 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -54,8 +54,13 @@ TREND_SUBSIDING = 'Subsiding' async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Configure the platform and add the sensors.""" - iqvia = hass.data[DOMAIN][DATA_CLIENT] + """Set up IQVIA sensors based on the old way.""" + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up IQVIA sensors based on a config entry.""" + iqvia = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] sensor_class_mapping = { TYPE_ALLERGY_FORECAST: ForecastSensor, diff --git a/homeassistant/components/iqvia/strings.json b/homeassistant/components/iqvia/strings.json new file mode 100644 index 00000000000..00f383be502 --- /dev/null +++ b/homeassistant/components/iqvia/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "IQVIA", + "step": { + "user": { + "title": "IQVIA", + "description": "Fill out your U.S. or Canadian ZIP code.", + "data": { + "zip_code": "ZIP Code" + } + } + }, + "error": { + "identifier_exists": "ZIP code already registered", + "invalid_zip_code": "ZIP code is invalid" + } + } +} diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a2b34a00efd..593b402a3fd 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -160,6 +160,7 @@ FLOWS = [ 'ifttt', 'ios', 'ipma', + 'iqvia', 'lifx', 'locative', 'logi_circle', diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fbf5a701072..5e14fa57221 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -234,6 +234,9 @@ pyheos==0.5.2 # homeassistant.components.homematic pyhomematic==0.1.58 +# homeassistant.components.iqvia +pyiqvia==0.2.0 + # homeassistant.components.litejet pylitejet==0.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 14303bd6d65..057f5c9fd24 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -103,6 +103,7 @@ TEST_REQUIREMENTS = ( 'pydispatcher', 'pyheos', 'pyhomematic', + 'pyiqvia', 'pylitejet', 'pymonoprice', 'pynx584', diff --git a/tests/components/iqvia/__init__.py b/tests/components/iqvia/__init__.py new file mode 100644 index 00000000000..a4a57b8aafa --- /dev/null +++ b/tests/components/iqvia/__init__.py @@ -0,0 +1 @@ +"""Define tests for IQVIA.""" diff --git a/tests/components/iqvia/test_config_flow.py b/tests/components/iqvia/test_config_flow.py new file mode 100644 index 00000000000..97ab4014291 --- /dev/null +++ b/tests/components/iqvia/test_config_flow.py @@ -0,0 +1,97 @@ +"""Define tests for the IQVIA config flow.""" +from pyiqvia.errors import IQVIAError +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.iqvia import CONF_ZIP_CODE, DOMAIN, config_flow + +from tests.common import MockConfigEntry, MockDependency, mock_coro + + +@pytest.fixture +def allergens_current_response(): + """Define a fixture for a successful allergens.current response.""" + return mock_coro() + + +@pytest.fixture +def mock_pyiqvia(allergens_current_response): + """Mock the pyiqvia library.""" + with MockDependency('pyiqvia') as mock_pyiqvia_: + mock_pyiqvia_.Client().allergens.current.return_value = ( + allergens_current_response) + yield mock_pyiqvia_ + + +async def test_duplicate_error(hass): + """Test that errors are shown when duplicates are added.""" + conf = { + CONF_ZIP_CODE: '12345', + } + + MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) + flow = config_flow.IQVIAFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_ZIP_CODE: 'identifier_exists'} + + +@pytest.mark.parametrize( + 'allergens_current_response', [mock_coro(exception=IQVIAError)]) +async def test_invalid_zip_code(hass, mock_pyiqvia): + """Test that an invalid ZIP code key throws an error.""" + conf = { + CONF_ZIP_CODE: 'abcde', + } + + flow = config_flow.IQVIAFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_ZIP_CODE: 'invalid_zip_code'} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + flow = config_flow.IQVIAFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=None) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_step_import(hass, mock_pyiqvia): + """Test that the import step works.""" + conf = { + CONF_ZIP_CODE: '12345', + } + + flow = config_flow.IQVIAFlowHandler() + flow.hass = hass + + result = await flow.async_step_import(import_config=conf) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '12345' + assert result['data'] == { + CONF_ZIP_CODE: '12345', + } + + +async def test_step_user(hass, mock_pyiqvia): + """Test that the user step works.""" + conf = { + CONF_ZIP_CODE: '12345', + } + + flow = config_flow.IQVIAFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '12345' + assert result['data'] == { + CONF_ZIP_CODE: '12345', + }