Add config flow to pushover (#74500)

* Add config flow to `pushover`

* Add tests for reauth

* add deprecated yaml issue

* address comments

* fix test error, other fixes

* update translations
This commit is contained in:
Rami Mosleh 2022-08-19 09:07:32 +03:00 committed by GitHub
parent 09aaf45f0a
commit 72a4f8af3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 660 additions and 46 deletions

View File

@ -848,6 +848,8 @@ build.json @home-assistant/supervisor
/tests/components/pure_energie/ @klaasnicolaas
/homeassistant/components/push/ @dgomes
/tests/components/push/ @dgomes
/homeassistant/components/pushover/ @engrbm87
/tests/components/pushover/ @engrbm87
/homeassistant/components/pvoutput/ @frenck
/tests/components/pvoutput/ @frenck
/homeassistant/components/pvpc_hourly_pricing/ @azogue

View File

@ -1 +1,55 @@
"""The pushover component."""
from __future__ import annotations
from pushover_complete import BadAPIRequestError, PushoverAPI
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import discovery
from homeassistant.helpers.typing import ConfigType
from .const import CONF_USER_KEY, DATA_HASS_CONFIG, DOMAIN
PLATFORMS = [Platform.NOTIFY]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the pushover component."""
hass.data[DATA_HASS_CONFIG] = config
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up pushover from a config entry."""
pushover_api = PushoverAPI(entry.data[CONF_API_KEY])
try:
await hass.async_add_executor_job(
pushover_api.validate, entry.data[CONF_USER_KEY]
)
except BadAPIRequestError as err:
if "application token is invalid" in str(err):
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryNotReady(err) from err
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = pushover_api
hass.async_create_task(
discovery.async_load_platform(
hass,
Platform.NOTIFY,
DOMAIN,
{
CONF_NAME: entry.data[CONF_NAME],
CONF_USER_KEY: entry.data[CONF_USER_KEY],
"entry_id": entry.entry_id,
},
hass.data[DATA_HASS_CONFIG],
)
)
return True

View File

@ -0,0 +1,106 @@
"""Config flow for pushover integration."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from pushover_complete import BadAPIRequestError, PushoverAPI
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_API_KEY, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from .const import CONF_USER_KEY, DEFAULT_NAME, DOMAIN
USER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_USER_KEY): str,
}
)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
"""Validate user input."""
errors = {}
pushover_api = PushoverAPI(data[CONF_API_KEY])
try:
await hass.async_add_executor_job(pushover_api.validate, data[CONF_USER_KEY])
except BadAPIRequestError as err:
if "application token is invalid" in str(err):
errors[CONF_API_KEY] = "invalid_api_key"
elif "user key is invalid" in str(err):
errors[CONF_USER_KEY] = "invalid_user_key"
else:
errors["base"] = "cannot_connect"
return errors
class PushBulletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for pushover integration."""
_reauth_entry: config_entries.ConfigEntry | None
async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult:
"""Handle import from config."""
return await self.async_step_user(import_config)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth upon an API authentication error."""
self._reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
"""Confirm reauth dialog."""
errors = {}
if user_input is not None and self._reauth_entry:
user_input = {**self._reauth_entry.data, **user_input}
errors = await validate_input(self.hass, user_input)
if not errors:
self.hass.config_entries.async_update_entry(
self._reauth_entry, data=user_input
)
await self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_API_KEY): str,
}
),
errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors = {}
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_USER_KEY])
self._abort_if_unique_id_configured()
self._async_abort_entries_match({CONF_NAME: user_input[CONF_NAME]})
errors = await validate_input(self.hass, user_input)
if not errors:
return self.async_create_entry(
title=user_input[CONF_NAME],
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=USER_SCHEMA,
errors=errors,
)

View File

@ -0,0 +1,20 @@
"""Constants for pushover."""
from typing import Final
DOMAIN: Final = "pushover"
DATA_HASS_CONFIG: Final = "pushover_hass_config"
DEFAULT_NAME: Final = "Pushover"
ATTR_ATTACHMENT: Final = "attachment"
ATTR_URL: Final = "url"
ATTR_URL_TITLE: Final = "url_title"
ATTR_PRIORITY: Final = "priority"
ATTR_RETRY: Final = "retry"
ATTR_SOUND: Final = "sound"
ATTR_HTML: Final = "html"
ATTR_CALLBACK_URL: Final = "callback_url"
ATTR_EXPIRE: Final = "expire"
ATTR_TIMESTAMP: Final = "timestamp"
CONF_USER_KEY: Final = "user_key"

View File

@ -1,9 +1,11 @@
{
"domain": "pushover",
"name": "Pushover",
"dependencies": ["repairs"],
"documentation": "https://www.home-assistant.io/integrations/pushover",
"requirements": ["pushover_complete==1.1.1"],
"codeowners": [],
"codeowners": ["@engrbm87"],
"config_flow": true,
"iot_class": "cloud_push",
"loggers": ["pushover_complete"]
}

View File

@ -1,7 +1,10 @@
"""Pushover platform for notify component."""
import logging
from __future__ import annotations
from pushover_complete import PushoverAPI
import logging
from typing import Any
from pushover_complete import BadAPIRequestError, PushoverAPI
import voluptuous as vol
from homeassistant.components.notify import (
@ -12,47 +15,82 @@ from homeassistant.components.notify import (
PLATFORM_SCHEMA,
BaseNotificationService,
)
from homeassistant.components.repairs.issue_handler import async_create_issue
from homeassistant.components.repairs.models import IssueSeverity
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from ...exceptions import HomeAssistantError
from .const import (
ATTR_ATTACHMENT,
ATTR_CALLBACK_URL,
ATTR_EXPIRE,
ATTR_HTML,
ATTR_PRIORITY,
ATTR_RETRY,
ATTR_SOUND,
ATTR_TIMESTAMP,
ATTR_URL,
ATTR_URL_TITLE,
CONF_USER_KEY,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
ATTR_ATTACHMENT = "attachment"
ATTR_URL = "url"
ATTR_URL_TITLE = "url_title"
ATTR_PRIORITY = "priority"
ATTR_RETRY = "retry"
ATTR_SOUND = "sound"
ATTR_HTML = "html"
ATTR_CALLBACK_URL = "callback_url"
ATTR_EXPIRE = "expire"
ATTR_TIMESTAMP = "timestamp"
CONF_USER_KEY = "user_key"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{vol.Required(CONF_USER_KEY): cv.string, vol.Required(CONF_API_KEY): cv.string}
)
def get_service(hass, config, discovery_info=None):
async def async_get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
) -> PushoverNotificationService | None:
"""Get the Pushover notification service."""
if discovery_info is None:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml",
breaks_in_ha_version="2022.11.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
)
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config,
)
)
return None
pushover_api: PushoverAPI = hass.data[DOMAIN][discovery_info["entry_id"]]
return PushoverNotificationService(
hass, config[CONF_USER_KEY], config[CONF_API_KEY]
hass, pushover_api, discovery_info[CONF_USER_KEY]
)
class PushoverNotificationService(BaseNotificationService):
"""Implement the notification service for Pushover."""
def __init__(self, hass, user_key, api_token):
def __init__(
self, hass: HomeAssistant, pushover: PushoverAPI, user_key: str
) -> None:
"""Initialize the service."""
self._hass = hass
self._user_key = user_key
self._api_token = api_token
self.pushover = PushoverAPI(self._api_token)
self.pushover = pushover
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: dict[str, Any]) -> None:
"""Send a message to a user."""
# Extract params from data dict
@ -87,28 +125,22 @@ class PushoverNotificationService(BaseNotificationService):
# Remove attachment key to send without attachment.
image = None
targets = kwargs.get(ATTR_TARGET)
if not isinstance(targets, list):
targets = [targets]
for target in targets:
try:
self.pushover.send_message(
self._user_key,
message,
target,
title,
url,
url_title,
image,
priority,
retry,
expire,
callback_url,
timestamp,
sound,
html,
)
except ValueError as val_err:
_LOGGER.error(val_err)
try:
self.pushover.send_message(
self._user_key,
message,
kwargs.get(ATTR_TARGET),
title,
url,
url_title,
image,
priority,
retry,
expire,
callback_url,
timestamp,
sound,
html,
)
except BadAPIRequestError as err:
raise HomeAssistantError(str(err)) from err

View File

@ -0,0 +1,34 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"invalid_user_key": "Invalid user key"
},
"step": {
"user": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
"user_key": "User key"
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
}
}
},
"issues": {
"deprecated_yaml": {
"title": "The Pushover YAML configuration is being removed",
"description": "Configuring Pushover using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Pushover YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
}
}
}

View File

@ -0,0 +1,34 @@
{
"config": {
"abort": {
"already_configured": "Service is already configured",
"reauth_successful": "Re-authentication was successful"
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_api_key": "Invalid API key",
"invalid_user_key": "Invalid user key"
},
"step": {
"reauth_confirm": {
"data": {
"api_key": "API Key"
},
"title": "Reauthenticate Integration"
},
"user": {
"data": {
"api_key": "API Key",
"name": "Name",
"user_key": "User key"
}
}
}
},
"issues": {
"deprecated_yaml": {
"description": "Configuring Pushover using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Pushover YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
"title": "The Pushover YAML configuration is being removed"
}
}
}

View File

@ -290,6 +290,7 @@ FLOWS = {
"prosegur",
"ps4",
"pure_energie",
"pushover",
"pvoutput",
"pvpc_hourly_pricing",
"qingping",

View File

@ -918,6 +918,9 @@ pure-python-adb[async]==0.3.0.dev0
# homeassistant.components.pushbullet
pushbullet.py==0.11.0
# homeassistant.components.pushover
pushover_complete==1.1.1
# homeassistant.components.pvoutput
pvo==0.2.2

View File

@ -0,0 +1,10 @@
"""Tests for the pushover component."""
from homeassistant.components.pushover.const import CONF_USER_KEY
from homeassistant.const import CONF_API_KEY, CONF_NAME
MOCK_CONFIG = {
CONF_NAME: "Pushover",
CONF_API_KEY: "MYAPIKEY",
CONF_USER_KEY: "MYUSERKEY",
}

View File

@ -0,0 +1,218 @@
"""Test pushbullet config flow."""
from unittest.mock import MagicMock, patch
from pushover_complete import BadAPIRequestError
import pytest
from homeassistant import config_entries, data_entry_flow
from homeassistant.components.pushover.const import CONF_USER_KEY, DOMAIN
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from . import MOCK_CONFIG
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
def mock_pushover():
"""Mock pushover."""
with patch(
"pushover_complete.PushoverAPI._generic_post", return_value={}
) as mock_generic_post:
yield mock_generic_post
@pytest.fixture(autouse=True)
def pushover_setup_fixture():
"""Patch pushover setup entry."""
with patch(
"homeassistant.components.pushover.async_setup_entry", return_value=True
):
yield
async def test_flow_user(hass: HomeAssistant) -> None:
"""Test user initialized flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "Pushover"
assert result["data"] == MOCK_CONFIG
async def test_flow_user_key_already_configured(hass: HomeAssistant) -> None:
"""Test user initialized flow with duplicate user key."""
entry = MockConfigEntry(
domain=DOMAIN,
data=MOCK_CONFIG,
unique_id="MYUSERKEY",
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_flow_name_already_configured(hass: HomeAssistant) -> None:
"""Test user initialized flow with duplicate server."""
entry = MockConfigEntry(
domain=DOMAIN,
data=MOCK_CONFIG,
unique_id="MYUSERKEY",
)
entry.add_to_hass(hass)
new_config = MOCK_CONFIG.copy()
new_config[CONF_USER_KEY] = "NEUSERWKEY"
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=new_config,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_flow_invalid_user_key(
hass: HomeAssistant, mock_pushover: MagicMock
) -> None:
"""Test user initialized flow with wrong user key."""
mock_pushover.side_effect = BadAPIRequestError("400: user key is invalid")
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=MOCK_CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {CONF_USER_KEY: "invalid_user_key"}
async def test_flow_invalid_api_key(
hass: HomeAssistant, mock_pushover: MagicMock
) -> None:
"""Test user initialized flow with wrong api key."""
mock_pushover.side_effect = BadAPIRequestError("400: application token is invalid")
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=MOCK_CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {CONF_API_KEY: "invalid_api_key"}
async def test_flow_conn_err(hass: HomeAssistant, mock_pushover: MagicMock) -> None:
"""Test user initialized flow with conn error."""
mock_pushover.side_effect = BadAPIRequestError
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=MOCK_CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
async def test_import(hass: HomeAssistant) -> None:
"""Test user initialized flow with unreachable server."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=MOCK_CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "Pushover"
assert result["data"] == MOCK_CONFIG
async def test_reauth_success(hass: HomeAssistant) -> None:
"""Test we can reauth."""
entry = MockConfigEntry(
domain=DOMAIN,
data=MOCK_CONFIG,
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": entry.entry_id,
},
data=MOCK_CONFIG,
)
assert result["type"] == "form"
assert result["step_id"] == "reauth_confirm"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: "NEWAPIKEY",
},
)
assert result2["type"] == "abort"
assert result2["reason"] == "reauth_successful"
async def test_reauth_failed(hass: HomeAssistant, mock_pushover: MagicMock) -> None:
"""Test we can reauth."""
entry = MockConfigEntry(
domain=DOMAIN,
data=MOCK_CONFIG,
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": entry.entry_id,
},
data=MOCK_CONFIG,
)
assert result["type"] == "form"
assert result["step_id"] == "reauth_confirm"
mock_pushover.side_effect = BadAPIRequestError("400: application token is invalid")
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: "WRONGAPIKEY",
},
)
assert result2["type"] == "form"
assert result2["errors"] == {
CONF_API_KEY: "invalid_api_key",
}

View File

@ -0,0 +1,98 @@
"""Test pushbullet integration."""
from collections.abc import Awaitable
from typing import Callable
from unittest.mock import MagicMock, patch
import aiohttp
from pushover_complete import BadAPIRequestError
import pytest
from homeassistant.components.notify.const import DOMAIN as NOTIFY_DOMAIN
from homeassistant.components.pushover.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from . import MOCK_CONFIG
from tests.common import MockConfigEntry
from tests.components.repairs import get_repairs
@pytest.fixture(autouse=True)
def mock_pushover():
"""Mock pushover."""
with patch(
"pushover_complete.PushoverAPI._generic_post", return_value={}
) as mock_generic_post:
yield mock_generic_post
async def test_setup(
hass: HomeAssistant,
hass_ws_client: Callable[
[HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse]
],
) -> None:
"""Test integration failed due to an error."""
assert await async_setup_component(
hass,
NOTIFY_DOMAIN,
{
NOTIFY_DOMAIN: [
{
"name": "Pushover",
"platform": "pushover",
"api_key": "MYAPIKEY",
"user_key": "MYUSERKEY",
}
]
},
)
await hass.async_block_till_done()
assert hass.config_entries.async_entries(DOMAIN)
issues = await get_repairs(hass, hass_ws_client)
assert len(issues) == 1
assert issues[0]["issue_id"] == "deprecated_yaml"
async def test_async_setup_entry_success(hass: HomeAssistant) -> None:
"""Test pushover successful setup."""
entry = MockConfigEntry(
domain=DOMAIN,
data=MOCK_CONFIG,
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state == ConfigEntryState.LOADED
async def test_async_setup_entry_failed_invalid_api_key(
hass: HomeAssistant, mock_pushover: MagicMock
) -> None:
"""Test pushover failed setup due to invalid api key."""
entry = MockConfigEntry(
domain=DOMAIN,
data=MOCK_CONFIG,
)
entry.add_to_hass(hass)
mock_pushover.side_effect = BadAPIRequestError("400: application token is invalid")
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state == ConfigEntryState.SETUP_ERROR
async def test_async_setup_entry_failed_conn_error(
hass: HomeAssistant, mock_pushover: MagicMock
) -> None:
"""Test pushover failed setup due to conn error."""
entry = MockConfigEntry(
domain=DOMAIN,
data=MOCK_CONFIG,
)
entry.add_to_hass(hass)
mock_pushover.side_effect = BadAPIRequestError
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state == ConfigEntryState.SETUP_RETRY