Add re-auth flow for OpenUV (#79691)

This commit is contained in:
Aaron Bach 2022-11-08 07:41:09 -07:00 committed by GitHub
parent 014c2d487d
commit 45be2a260e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 228 additions and 27 deletions

View file

@ -31,7 +31,7 @@ from .const import (
DOMAIN, DOMAIN,
LOGGER, LOGGER,
) )
from .coordinator import OpenUvCoordinator from .coordinator import InvalidApiKeyMonitor, OpenUvCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
@ -53,6 +53,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
high = entry.options.get(CONF_TO_WINDOW, DEFAULT_TO_WINDOW) high = entry.options.get(CONF_TO_WINDOW, DEFAULT_TO_WINDOW)
return await client.uv_protection_window(low=low, high=high) return await client.uv_protection_window(low=low, high=high)
invalid_api_key_monitor = InvalidApiKeyMonitor(hass, entry)
coordinators: dict[str, OpenUvCoordinator] = { coordinators: dict[str, OpenUvCoordinator] = {
coordinator_name: OpenUvCoordinator( coordinator_name: OpenUvCoordinator(
hass, hass,
@ -60,6 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
latitude=client.latitude, latitude=client.latitude,
longitude=client.longitude, longitude=client.longitude,
update_method=update_method, update_method=update_method,
invalid_api_key_monitor=invalid_api_key_monitor,
) )
for coordinator_name, update_method in ( for coordinator_name, update_method in (
(DATA_UV, client.uv_index), (DATA_UV, client.uv_index),

View file

@ -1,6 +1,8 @@
"""Config flow to configure the OpenUV component.""" """Config flow to configure the OpenUV component."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any from typing import Any
from pyopenuv import Client from pyopenuv import Client
@ -27,14 +29,39 @@ from .const import (
DOMAIN, DOMAIN,
) )
STEP_REAUTH_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
}
)
@dataclass
class OpenUvData:
"""Define structured OpenUV data needed to create/re-auth an entry."""
api_key: str
latitude: float
longitude: float
elevation: float
@property
def unique_id(self) -> str:
"""Return the unique for this data."""
return f"{self.latitude}, {self.longitude}"
class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle an OpenUV config flow.""" """Handle an OpenUV config flow."""
VERSION = 2 VERSION = 2
def __init__(self) -> None:
"""Initialize."""
self._reauth_data: Mapping[str, Any] = {}
@property @property
def config_schema(self) -> vol.Schema: def step_user_schema(self) -> vol.Schema:
"""Return the config schema.""" """Return the config schema."""
return vol.Schema( return vol.Schema(
{ {
@ -51,13 +78,41 @@ class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
} }
) )
async def _show_form(self, errors: dict[str, Any] | None = None) -> FlowResult: async def _async_verify(
"""Show the form to the user.""" self, data: OpenUvData, error_step_id: str, error_schema: vol.Schema
return self.async_show_form( ) -> FlowResult:
step_id="user", """Verify the credentials and create/re-auth the entry."""
data_schema=self.config_schema, websession = aiohttp_client.async_get_clientsession(self.hass)
errors=errors if errors else {}, client = Client(data.api_key, 0, 0, session=websession)
) client.disable_request_retries()
try:
await client.uv_index()
except OpenUvError:
return self.async_show_form(
step_id=error_step_id,
data_schema=error_schema,
errors={CONF_API_KEY: "invalid_api_key"},
description_placeholders={
CONF_LATITUDE: str(data.latitude),
CONF_LONGITUDE: str(data.longitude),
},
)
entry_data = {
CONF_API_KEY: data.api_key,
CONF_LATITUDE: data.latitude,
CONF_LONGITUDE: data.longitude,
CONF_ELEVATION: data.elevation,
}
if existing_entry := await self.async_set_unique_id(data.unique_id):
self.hass.config_entries.async_update_entry(existing_entry, data=entry_data)
self.hass.async_create_task(
self.hass.config_entries.async_reload(existing_entry.entry_id)
)
return self.async_abort(reason="reauth_successful")
return self.async_create_entry(title=data.unique_id, data=entry_data)
@staticmethod @staticmethod
@callback @callback
@ -65,26 +120,54 @@ class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Define the config flow to handle options.""" """Define the config flow to handle options."""
return OpenUvOptionsFlowHandler(config_entry) return OpenUvOptionsFlowHandler(config_entry)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Handle configuration by re-auth."""
self._reauth_data = entry_data
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle re-auth completion."""
if not user_input:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_SCHEMA,
description_placeholders={
CONF_LATITUDE: self._reauth_data[CONF_LATITUDE],
CONF_LONGITUDE: self._reauth_data[CONF_LONGITUDE],
},
)
data = OpenUvData(
user_input[CONF_API_KEY],
self._reauth_data[CONF_LATITUDE],
self._reauth_data[CONF_LONGITUDE],
self._reauth_data[CONF_ELEVATION],
)
return await self._async_verify(data, "reauth_confirm", STEP_REAUTH_SCHEMA)
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
"""Handle the start of the config flow.""" """Handle the start of the config flow."""
if not user_input: if not user_input:
return await self._show_form() return self.async_show_form(
step_id="user", data_schema=self.step_user_schema
)
identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" data = OpenUvData(
await self.async_set_unique_id(identifier) user_input[CONF_API_KEY],
user_input[CONF_LATITUDE],
user_input[CONF_LONGITUDE],
user_input[CONF_ELEVATION],
)
await self.async_set_unique_id(data.unique_id)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
websession = aiohttp_client.async_get_clientsession(self.hass) return await self._async_verify(data, "user", self.step_user_schema)
client = Client(user_input[CONF_API_KEY], 0, 0, session=websession)
try:
await client.uv_index()
except OpenUvError:
return await self._show_form({CONF_API_KEY: "invalid_api_key"})
return self.async_create_entry(title=identifier, data=user_input)
class OpenUvOptionsFlowHandler(config_entries.OptionsFlow): class OpenUvOptionsFlowHandler(config_entries.OptionsFlow):

View file

@ -1,23 +1,94 @@
"""Define an update coordinator for OpenUV.""" """Define an update coordinator for OpenUV."""
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any, cast from typing import Any, cast
from pyopenuv.errors import OpenUvError from pyopenuv.errors import InvalidApiKeyError, OpenUvError
from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import LOGGER from .const import DOMAIN, LOGGER
DEFAULT_DEBOUNCER_COOLDOWN_SECONDS = 15 * 60 DEFAULT_DEBOUNCER_COOLDOWN_SECONDS = 15 * 60
class InvalidApiKeyMonitor:
"""Define a monitor for failed API calls (due to bad keys) across coordinators."""
DEFAULT_FAILED_API_CALL_THRESHOLD = 5
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize."""
self._count = 1
self._lock = asyncio.Lock()
self._reauth_flow_manager = ReauthFlowManager(hass, entry)
self.entry = entry
async def async_increment(self) -> None:
"""Increment the counter."""
LOGGER.debug("Invalid API key response detected (number %s)", self._count)
async with self._lock:
self._count += 1
if self._count > self.DEFAULT_FAILED_API_CALL_THRESHOLD:
self._reauth_flow_manager.start_reauth()
async def async_reset(self) -> None:
"""Reset the counter."""
async with self._lock:
self._count = 0
self._reauth_flow_manager.cancel_reauth()
class ReauthFlowManager:
"""Define an OpenUV reauth flow manager."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize."""
self.entry = entry
self.hass = hass
@callback
def _get_active_reauth_flow(self) -> FlowResult | None:
"""Get an active reauth flow (if it exists)."""
try:
[reauth_flow] = [
flow
for flow in self.hass.config_entries.flow.async_progress_by_handler(
DOMAIN
)
if flow["context"]["source"] == "reauth"
and flow["context"]["entry_id"] == self.entry.entry_id
]
except ValueError:
return None
return reauth_flow
@callback
def cancel_reauth(self) -> None:
"""Cancel a reauth flow (if appropriate)."""
if reauth_flow := self._get_active_reauth_flow():
LOGGER.debug("API seems to have recovered; canceling reauth flow")
self.hass.config_entries.flow.async_abort(reauth_flow["flow_id"])
@callback
def start_reauth(self) -> None:
"""Start a reauth flow (if appropriate)."""
if not self._get_active_reauth_flow():
LOGGER.debug("Multiple API failures in a row; starting reauth flow")
self.entry.async_start_reauth(self.hass)
class OpenUvCoordinator(DataUpdateCoordinator): class OpenUvCoordinator(DataUpdateCoordinator):
"""Define an OpenUV data coordinator.""" """Define an OpenUV data coordinator."""
config_entry: ConfigEntry
update_method: Callable[[], Awaitable[dict[str, Any]]] update_method: Callable[[], Awaitable[dict[str, Any]]]
def __init__( def __init__(
@ -28,6 +99,7 @@ class OpenUvCoordinator(DataUpdateCoordinator):
latitude: str, latitude: str,
longitude: str, longitude: str,
update_method: Callable[[], Awaitable[dict[str, Any]]], update_method: Callable[[], Awaitable[dict[str, Any]]],
invalid_api_key_monitor: InvalidApiKeyMonitor,
) -> None: ) -> None:
"""Initialize.""" """Initialize."""
super().__init__( super().__init__(
@ -43,6 +115,7 @@ class OpenUvCoordinator(DataUpdateCoordinator):
), ),
) )
self._invalid_api_key_monitor = invalid_api_key_monitor
self.latitude = latitude self.latitude = latitude
self.longitude = longitude self.longitude = longitude
@ -50,6 +123,10 @@ class OpenUvCoordinator(DataUpdateCoordinator):
"""Fetch data from OpenUV.""" """Fetch data from OpenUV."""
try: try:
data = await self.update_method() data = await self.update_method()
except InvalidApiKeyError:
await self._invalid_api_key_monitor.async_increment()
except OpenUvError as err: except OpenUvError as err:
raise UpdateFailed(f"Error during protection data update: {err}") from err raise UpdateFailed(f"Error during protection data update: {err}") from err
await self._invalid_api_key_monitor.async_reset()
return cast(dict[str, Any], data["result"]) return cast(dict[str, Any], data["result"])

View file

@ -1,6 +1,13 @@
{ {
"config": { "config": {
"step": { "step": {
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "Please re-enter the API key for {latitude}, {longitude}.",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
},
"user": { "user": {
"title": "Fill in your information", "title": "Fill in your information",
"data": { "data": {
@ -15,7 +22,8 @@
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]" "already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
} }
}, },
"options": { "options": {

View file

@ -1,12 +1,20 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "Location is already configured" "already_configured": "Location is already configured",
"reauth_successful": "Re-authentication was successful"
}, },
"error": { "error": {
"invalid_api_key": "Invalid API key" "invalid_api_key": "Invalid API key"
}, },
"step": { "step": {
"reauth_confirm": {
"data": {
"api_key": "API Key"
},
"description": "Please re-enter the API key for {latitude}, {longitude}.",
"title": "Reauthenticate Integration"
},
"user": { "user": {
"data": { "data": {
"api_key": "API Key", "api_key": "API Key",

View file

@ -5,7 +5,7 @@ from pyopenuv.errors import InvalidApiKeyError
from homeassistant import data_entry_flow from homeassistant import data_entry_flow
from homeassistant.components.openuv import CONF_FROM_WINDOW, CONF_TO_WINDOW, DOMAIN from homeassistant.components.openuv import CONF_FROM_WINDOW, CONF_TO_WINDOW, DOMAIN
from homeassistant.config_entries import SOURCE_USER from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER
from homeassistant.const import ( from homeassistant.const import (
CONF_API_KEY, CONF_API_KEY,
CONF_ELEVATION, CONF_ELEVATION,
@ -51,6 +51,28 @@ async def test_options_flow(hass, config_entry):
assert config_entry.options == {CONF_FROM_WINDOW: 3.5, CONF_TO_WINDOW: 2.0} assert config_entry.options == {CONF_FROM_WINDOW: 3.5, CONF_TO_WINDOW: 2.0}
async def test_step_reauth(hass, config, config_entry, setup_openuv):
"""Test that the reauth step works."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_REAUTH}, data=config
)
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
with patch("homeassistant.components.openuv.async_setup_entry", return_value=True):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_API_KEY: "new_api_key"}
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert len(hass.config_entries.async_entries()) == 1
async def test_step_user(hass, config, setup_openuv): async def test_step_user(hass, config, setup_openuv):
"""Test that the user step works.""" """Test that the user step works."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(