From 074eb966ddaa61cc7e3b226d3c27678b364f7815 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 18:05:23 +0200 Subject: [PATCH] Add config flow to AfterShip (#100872) * Add config flow to Aftership * Add config flow to Aftership * Fix schema * Update homeassistant/components/aftership/strings.json Co-authored-by: Robert Resch * Fix feedback --------- Co-authored-by: Robert Resch --- .coveragerc | 3 +- .../components/aftership/__init__.py | 43 ++++++- .../components/aftership/config_flow.py | 90 ++++++++++++++ .../components/aftership/manifest.json | 1 + homeassistant/components/aftership/sensor.py | 46 ++++++-- .../components/aftership/strings.json | 25 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/aftership/__init__.py | 1 + tests/components/aftership/conftest.py | 14 +++ .../components/aftership/test_config_flow.py | 110 ++++++++++++++++++ 12 files changed, 326 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/aftership/config_flow.py create mode 100644 tests/components/aftership/__init__.py create mode 100644 tests/components/aftership/conftest.py create mode 100644 tests/components/aftership/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 7a016dac370..21932b67437 100644 --- a/.coveragerc +++ b/.coveragerc @@ -29,7 +29,8 @@ omit = homeassistant/components/adguard/switch.py homeassistant/components/ads/* homeassistant/components/aemet/weather_update_coordinator.py - homeassistant/components/aftership/* + homeassistant/components/aftership/__init__.py + homeassistant/components/aftership/sensor.py homeassistant/components/agent_dvr/alarm_control_panel.py homeassistant/components/agent_dvr/camera.py homeassistant/components/agent_dvr/helpers.py diff --git a/homeassistant/components/aftership/__init__.py b/homeassistant/components/aftership/__init__.py index b063c919f18..66610e6e01b 100644 --- a/homeassistant/components/aftership/__init__.py +++ b/homeassistant/components/aftership/__init__.py @@ -1 +1,42 @@ -"""The aftership component.""" +"""The AfterShip integration.""" +from __future__ import annotations + +from pyaftership import AfterShip, AfterShipException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up AfterShip from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + + session = async_get_clientsession(hass) + aftership = AfterShip(api_key=entry.data[CONF_API_KEY], session=session) + + try: + await aftership.trackings.list() + except AfterShipException as err: + raise ConfigEntryNotReady from err + + hass.data[DOMAIN][entry.entry_id] = aftership + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/aftership/config_flow.py b/homeassistant/components/aftership/config_flow.py new file mode 100644 index 00000000000..3da6ac9e3d5 --- /dev/null +++ b/homeassistant/components/aftership/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for AfterShip integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pyaftership import AfterShip, AfterShipException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AfterShipConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for AfterShip.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match({CONF_API_KEY: user_input[CONF_API_KEY]}) + try: + aftership = AfterShip( + api_key=user_input[CONF_API_KEY], + session=async_get_clientsession(self.hass), + ) + await aftership.trackings.list() + except AfterShipException: + _LOGGER.exception("Aftership raised exception") + errors["base"] = "cannot_connect" + else: + return self.async_create_entry(title="AfterShip", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import configuration from yaml.""" + try: + self._async_abort_entries_match({CONF_API_KEY: config[CONF_API_KEY]}) + except AbortFlow as err: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_import_issue_already_configured", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_already_configured", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "AfterShip", + }, + ) + raise err + + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "AfterShip", + }, + ) + return self.async_create_entry( + title=config.get(CONF_NAME, "AfterShip"), + data={CONF_API_KEY: config[CONF_API_KEY]}, + ) diff --git a/homeassistant/components/aftership/manifest.json b/homeassistant/components/aftership/manifest.json index 1cfc88a6f9d..eb4fffa57bc 100644 --- a/homeassistant/components/aftership/manifest.json +++ b/homeassistant/components/aftership/manifest.json @@ -2,6 +2,7 @@ "domain": "aftership", "name": "AfterShip", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aftership", "iot_class": "cloud_polling", "requirements": ["pyaftership==21.11.0"] diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index d816afa3b17..a3b85f2188d 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -20,6 +21,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle @@ -58,19 +60,43 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the AfterShip sensor platform.""" - apikey = config[CONF_API_KEY] - name = config[CONF_NAME] - - session = async_get_clientsession(hass) - aftership = AfterShip(api_key=apikey, session=session) - + aftership = AfterShip( + api_key=config[CONF_API_KEY], session=async_get_clientsession(hass) + ) try: await aftership.trackings.list() - except AfterShipException as err: - _LOGGER.error("No tracking data found. Check API key is correct: %s", err) - return + except AfterShipException: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_cannot_connect", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_cannot_connect", + translation_placeholders={ + "integration_title": "AfterShip", + "url": "/config/integrations/dashboard/add?domain=aftership", + }, + ) - async_add_entities([AfterShipSensor(aftership, name)], True) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AfterShip sensor entities based on a config entry.""" + aftership: AfterShip = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities([AfterShipSensor(aftership, config_entry.title)], True) async def handle_add_tracking(call: ServiceCall) -> None: """Call when a user adds a new Aftership tracking from Home Assistant.""" diff --git a/homeassistant/components/aftership/strings.json b/homeassistant/components/aftership/strings.json index a7ccdd48202..b49c19976a6 100644 --- a/homeassistant/components/aftership/strings.json +++ b/homeassistant/components/aftership/strings.json @@ -1,4 +1,19 @@ { + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, "services": { "add_tracking": { "name": "Add tracking", @@ -32,5 +47,15 @@ } } } + }, + "issues": { + "deprecated_yaml_import_issue_already_configured": { + "title": "The {integration_title} YAML configuration import failed", + "description": "Configuring {integration_title} using YAML is being removed but the YAML configuration was already imported.\n\nRemove the YAML configuration and restart Home Assistant." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The {integration_title} YAML configuration import failed", + "description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f229d753fec..d240f868b3e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -23,6 +23,7 @@ FLOWS = { "adguard", "advantage_air", "aemet", + "aftership", "agent_dvr", "airly", "airnow", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ef79e680ea2..9ebe29c8a48 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -68,7 +68,7 @@ "aftership": { "name": "AfterShip", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "agent_dvr": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b70a81ec68..8aea760dd3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1188,6 +1188,9 @@ pyW215==0.7.0 # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 +# homeassistant.components.aftership +pyaftership==21.11.0 + # homeassistant.components.airnow pyairnow==1.2.1 diff --git a/tests/components/aftership/__init__.py b/tests/components/aftership/__init__.py new file mode 100644 index 00000000000..cdc39e5edfc --- /dev/null +++ b/tests/components/aftership/__init__.py @@ -0,0 +1 @@ +"""Tests for the AfterShip integration.""" diff --git a/tests/components/aftership/conftest.py b/tests/components/aftership/conftest.py new file mode 100644 index 00000000000..e3fdc00bc30 --- /dev/null +++ b/tests/components/aftership/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the AfterShip tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aftership.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/aftership/test_config_flow.py b/tests/components/aftership/test_config_flow.py new file mode 100644 index 00000000000..2ac5919a555 --- /dev/null +++ b/tests/components/aftership/test_config_flow.py @@ -0,0 +1,110 @@ +"""Test AfterShip config flow.""" +from unittest.mock import AsyncMock, patch + +from pyaftership import AfterShipException + +from homeassistant.components.aftership.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_full_user_flow(hass: HomeAssistant, mock_setup_entry) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.return_value.trackings.return_value.list.return_value = {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "mock-api-key", + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "AfterShip" + assert result["data"] == { + CONF_API_KEY: "mock-api-key", + } + + +async def test_flow_cannot_connect(hass: HomeAssistant, mock_setup_entry) -> None: + """Test handling invalid connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.side_effect = AfterShipException + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "mock-api-key", + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.return_value.trackings.return_value.list.return_value = {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "mock-api-key", + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "AfterShip" + assert result["data"] == { + CONF_API_KEY: "mock-api-key", + } + + +async def test_import_flow(hass: HomeAssistant, mock_setup_entry) -> None: + """Test importing yaml config.""" + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.return_value.trackings.return_value.list.return_value = {} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: "yaml-api-key"}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "AfterShip" + assert result["data"] == { + CONF_API_KEY: "yaml-api-key", + } + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_import_flow_already_exists(hass: HomeAssistant) -> None: + """Test importing yaml config where entry already exists.""" + entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "yaml-api-key"}) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_API_KEY: "yaml-api-key"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured"