Add repair for legacy subscription to cloud integration (#82621)
This commit is contained in:
parent
82b2aaaa3e
commit
7c82b78f8c
9 changed files with 442 additions and 14 deletions
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import timedelta
|
||||
from enum import Enum
|
||||
|
||||
from hass_nabucasa import Cloud
|
||||
|
@ -26,6 +27,7 @@ from homeassistant.helpers.dispatcher import (
|
|||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
|
@ -55,6 +57,8 @@ from .const import (
|
|||
MODE_PROD,
|
||||
)
|
||||
from .prefs import CloudPreferences
|
||||
from .repairs import async_manage_legacy_subscription_issue
|
||||
from .subscription import async_subscription_info
|
||||
|
||||
DEFAULT_MODE = MODE_PROD
|
||||
|
||||
|
@ -258,6 +262,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||
|
||||
loaded = False
|
||||
|
||||
async def async_startup_repairs(_=None) -> None:
|
||||
"""Create repair issues after startup."""
|
||||
if not cloud.is_logged_in:
|
||||
return
|
||||
|
||||
if subscription_info := await async_subscription_info(cloud):
|
||||
async_manage_legacy_subscription_issue(hass, subscription_info)
|
||||
|
||||
async def _on_connect():
|
||||
"""Discover RemoteUI binary sensor."""
|
||||
nonlocal loaded
|
||||
|
@ -294,6 +306,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||
|
||||
account_link.async_setup(hass)
|
||||
|
||||
async_call_later(
|
||||
hass=hass,
|
||||
delay=timedelta(hours=1),
|
||||
action=async_startup_repairs,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ from typing import Any
|
|||
import aiohttp
|
||||
import async_timeout
|
||||
import attr
|
||||
from hass_nabucasa import Cloud, auth, cloud_api, thingtalk
|
||||
from hass_nabucasa import Cloud, auth, thingtalk
|
||||
from hass_nabucasa.const import STATE_DISCONNECTED
|
||||
from hass_nabucasa.voice import MAP_VOICE
|
||||
import voluptuous as vol
|
||||
|
@ -38,6 +38,8 @@ from .const import (
|
|||
PREF_TTS_DEFAULT_VOICE,
|
||||
REQUEST_TIMEOUT,
|
||||
)
|
||||
from .repairs import async_manage_legacy_subscription_issue
|
||||
from .subscription import async_subscription_info
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -328,15 +330,14 @@ async def websocket_subscription(
|
|||
) -> None:
|
||||
"""Handle request for account info."""
|
||||
cloud = hass.data[DOMAIN]
|
||||
try:
|
||||
async with async_timeout.timeout(REQUEST_TIMEOUT):
|
||||
data = await cloud_api.async_subscription_info(cloud)
|
||||
except aiohttp.ClientError:
|
||||
if (data := await async_subscription_info(cloud)) is None:
|
||||
connection.send_error(
|
||||
msg["id"], "request_failed", "Failed to request subscription"
|
||||
)
|
||||
else:
|
||||
connection.send_result(msg["id"], data)
|
||||
return
|
||||
|
||||
connection.send_result(msg["id"], data)
|
||||
async_manage_legacy_subscription_issue(hass, data)
|
||||
|
||||
|
||||
@_require_cloud_login
|
||||
|
|
121
homeassistant/components/cloud/repairs.py
Normal file
121
homeassistant/components/cloud/repairs.py
Normal file
|
@ -0,0 +1,121 @@
|
|||
"""Repairs implementation for the cloud integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from hass_nabucasa import Cloud
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow, repairs_flow_manager
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DOMAIN
|
||||
from .subscription import async_subscription_info
|
||||
|
||||
BACKOFF_TIME = 5
|
||||
MAX_RETRIES = 60 # This allows for 10 minutes of retries
|
||||
|
||||
|
||||
@callback
|
||||
def async_manage_legacy_subscription_issue(
|
||||
hass: HomeAssistant,
|
||||
subscription_info: dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Manage the legacy subscription issue.
|
||||
|
||||
If the provider is "legacy" create an issue,
|
||||
in all other cases remove the issue.
|
||||
"""
|
||||
if subscription_info["provider"] == "legacy":
|
||||
ir.async_create_issue(
|
||||
hass=hass,
|
||||
domain=DOMAIN,
|
||||
issue_id="legacy_subscription",
|
||||
is_fixable=True,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="legacy_subscription",
|
||||
)
|
||||
return
|
||||
ir.async_delete_issue(hass=hass, domain=DOMAIN, issue_id="legacy_subscription")
|
||||
|
||||
|
||||
class LegacySubscriptionRepairFlow(RepairsFlow):
|
||||
"""Handler for an issue fixing flow."""
|
||||
|
||||
wait_task: asyncio.Task | None = None
|
||||
_data: dict[str, Any] | None = None
|
||||
|
||||
async def async_step_init(self, _: None = None) -> FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm_change_plan()
|
||||
|
||||
async def async_step_confirm_change_plan(
|
||||
self,
|
||||
user_input: dict[str, str] | None = None,
|
||||
) -> FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_change_plan()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm_change_plan", data_schema=vol.Schema({})
|
||||
)
|
||||
|
||||
async def async_step_change_plan(self, _: None = None) -> FlowResult:
|
||||
"""Wait for the user to authorize the app installation."""
|
||||
|
||||
async def _async_wait_for_plan_change() -> None:
|
||||
flow_manager = repairs_flow_manager(self.hass)
|
||||
# We can not get here without a flow manager
|
||||
assert flow_manager is not None
|
||||
|
||||
cloud: Cloud = self.hass.data[DOMAIN]
|
||||
|
||||
retries = 0
|
||||
while retries < MAX_RETRIES:
|
||||
self._data = await async_subscription_info(cloud)
|
||||
if self._data is not None and self._data["provider"] != "legacy":
|
||||
break
|
||||
|
||||
retries += 1
|
||||
await asyncio.sleep(BACKOFF_TIME)
|
||||
|
||||
self.hass.async_create_task(
|
||||
flow_manager.async_configure(flow_id=self.flow_id)
|
||||
)
|
||||
|
||||
if not self.wait_task:
|
||||
self.wait_task = self.hass.async_create_task(_async_wait_for_plan_change())
|
||||
return self.async_external_step(
|
||||
step_id="change_plan",
|
||||
url="https://account.nabucasa.com/",
|
||||
)
|
||||
|
||||
await self.wait_task
|
||||
|
||||
if self._data is None or self._data["provider"] == "legacy":
|
||||
# If we get here we waited too long.
|
||||
return self.async_external_step_done(next_step_id="timeout")
|
||||
|
||||
return self.async_external_step_done(next_step_id="complete")
|
||||
|
||||
async def async_step_complete(self, _: None = None) -> FlowResult:
|
||||
"""Handle the final step of a fix flow."""
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
async def async_step_timeout(self, _: None = None) -> FlowResult:
|
||||
"""Handle the final step of a fix flow."""
|
||||
return self.async_abort(reason="operation_took_too_long")
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create flow."""
|
||||
return LegacySubscriptionRepairFlow()
|
|
@ -13,5 +13,20 @@
|
|||
"logged_in": "Logged In",
|
||||
"subscription_expiration": "Subscription Expiration"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"legacy_subscription": {
|
||||
"title": "Legacy subscription detected",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm_change_plan": {
|
||||
"description": "We've recently updated our subscription system. To continue using Home Assistant Cloud you need to one-time approve the change in PayPal.\n\nThis takes 1 minute and will not increase the price."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"operation_took_too_long": "The operation took too long. Please try again later."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
24
homeassistant/components/cloud/subscription.py
Normal file
24
homeassistant/components/cloud/subscription.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
"""Subscription information."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
import async_timeout
|
||||
from hass_nabucasa import Cloud, cloud_api
|
||||
|
||||
from .const import REQUEST_TIMEOUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_subscription_info(cloud: Cloud) -> dict[str, Any] | None:
|
||||
"""Fetch the subscription info."""
|
||||
try:
|
||||
async with async_timeout.timeout(REQUEST_TIMEOUT):
|
||||
return await cloud_api.async_subscription_info(cloud)
|
||||
except ClientError:
|
||||
_LOGGER.error("Failed to fetch subscription information")
|
||||
|
||||
return None
|
|
@ -1,4 +1,19 @@
|
|||
{
|
||||
"issues": {
|
||||
"legacy_subscription": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
"operation_took_too_long": "The operation took too long. Please try again later."
|
||||
},
|
||||
"step": {
|
||||
"confirm_change_plan": {
|
||||
"description": "We've recently updated our subscription system. To continue using Home Assistant Cloud you need to one-time approve the change in PayPal.\n\nThis takes 1 minute and will not increase the price."
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Legacy subscription detected"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"alexa_enabled": "Alexa Enabled",
|
||||
|
|
|
@ -52,6 +52,15 @@ def mock_cloud_login(hass, mock_cloud_setup):
|
|||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_auth")
|
||||
def mock_auth_fixture():
|
||||
"""Mock check token."""
|
||||
with patch("hass_nabucasa.auth.CognitoAuth.async_check_token"), patch(
|
||||
"hass_nabucasa.auth.CognitoAuth.async_renew_access_token"
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_expired_cloud_login(hass, mock_cloud_setup):
|
||||
"""Mock cloud is logged in."""
|
||||
|
|
|
@ -24,13 +24,6 @@ from tests.components.google_assistant import MockConfig
|
|||
SUBSCRIPTION_INFO_URL = "https://api-test.hass.io/subscription_info"
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_auth")
|
||||
def mock_auth_fixture():
|
||||
"""Mock check token."""
|
||||
with patch("hass_nabucasa.auth.CognitoAuth.async_check_token"):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_cloud_login")
|
||||
def mock_cloud_login_fixture(hass, setup_api):
|
||||
"""Mock cloud is logged in."""
|
||||
|
|
232
tests/components/cloud/test_repairs.py
Normal file
232
tests/components/cloud/test_repairs.py
Normal file
|
@ -0,0 +1,232 @@
|
|||
"""Test cloud repairs."""
|
||||
from collections.abc import Awaitable, Callable, Generator
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from homeassistant.components.cloud import DOMAIN
|
||||
import homeassistant.components.cloud.repairs as cloud_repairs
|
||||
from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt
|
||||
|
||||
from . import mock_cloud
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
|
||||
|
||||
async def test_do_not_create_repair_issues_at_startup_if_not_logged_in(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Test that we create repair issue at startup if we are logged in."""
|
||||
issue_registry: ir.IssueRegistry = ir.async_get(hass)
|
||||
|
||||
with patch("homeassistant.components.cloud.Cloud.is_logged_in", False):
|
||||
await mock_cloud(hass)
|
||||
|
||||
async_fire_time_changed(hass, dt.utcnow() + timedelta(hours=1))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert not issue_registry.async_get_issue(
|
||||
domain="cloud", issue_id="legacy_subscription"
|
||||
)
|
||||
|
||||
|
||||
async def test_create_repair_issues_at_startup_if_logged_in(
|
||||
hass: HomeAssistant,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
mock_auth: Generator[None, AsyncMock, None],
|
||||
):
|
||||
"""Test that we create repair issue at startup if we are logged in."""
|
||||
issue_registry: ir.IssueRegistry = ir.async_get(hass)
|
||||
aioclient_mock.get(
|
||||
"https://accounts.nabucasa.com/payments/subscription_info",
|
||||
json={"provider": "legacy"},
|
||||
)
|
||||
|
||||
with patch("homeassistant.components.cloud.Cloud.is_logged_in", True):
|
||||
await mock_cloud(hass)
|
||||
|
||||
async_fire_time_changed(hass, dt.utcnow() + timedelta(hours=1))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert issue_registry.async_get_issue(
|
||||
domain="cloud", issue_id="legacy_subscription"
|
||||
)
|
||||
|
||||
|
||||
async def test_legacy_subscription_delete_issue_if_no_longer_legacy(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Test that we delete the legacy subscription issue if no longer legacy."""
|
||||
issue_registry: ir.IssueRegistry = ir.async_get(hass)
|
||||
cloud_repairs.async_manage_legacy_subscription_issue(hass, {"provider": "legacy"})
|
||||
assert issue_registry.async_get_issue(
|
||||
domain="cloud", issue_id="legacy_subscription"
|
||||
)
|
||||
|
||||
cloud_repairs.async_manage_legacy_subscription_issue(hass, {"provider": None})
|
||||
assert not issue_registry.async_get_issue(
|
||||
domain="cloud", issue_id="legacy_subscription"
|
||||
)
|
||||
|
||||
|
||||
async def test_legacy_subscription_repair_flow(
|
||||
hass: HomeAssistant,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
mock_auth: Generator[None, AsyncMock, None],
|
||||
hass_client: Callable[..., Awaitable[ClientSession]],
|
||||
):
|
||||
"""Test desired flow of the fix flow for legacy subscription."""
|
||||
issue_registry: ir.IssueRegistry = ir.async_get(hass)
|
||||
aioclient_mock.get(
|
||||
"https://accounts.nabucasa.com/payments/subscription_info",
|
||||
json={"provider": None},
|
||||
)
|
||||
|
||||
cloud_repairs.async_manage_legacy_subscription_issue(hass, {"provider": "legacy"})
|
||||
repair_issue = issue_registry.async_get_issue(
|
||||
domain="cloud", issue_id="legacy_subscription"
|
||||
)
|
||||
assert repair_issue
|
||||
|
||||
assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}})
|
||||
await mock_cloud(hass)
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_start()
|
||||
|
||||
client = await hass_client()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/repairs/issues/fix",
|
||||
json={"handler": DOMAIN, "issue_id": repair_issue.issue_id},
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
flow_id = data["flow_id"]
|
||||
assert data == {
|
||||
"type": "form",
|
||||
"flow_id": flow_id,
|
||||
"handler": DOMAIN,
|
||||
"step_id": "confirm_change_plan",
|
||||
"data_schema": [],
|
||||
"errors": None,
|
||||
"description_placeholders": None,
|
||||
"last_step": None,
|
||||
}
|
||||
|
||||
resp = await client.post(f"/api/repairs/issues/fix/{flow_id}")
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
flow_id = data["flow_id"]
|
||||
assert data == {
|
||||
"type": "external",
|
||||
"flow_id": flow_id,
|
||||
"handler": DOMAIN,
|
||||
"step_id": "change_plan",
|
||||
"url": "https://account.nabucasa.com/",
|
||||
"description_placeholders": None,
|
||||
}
|
||||
|
||||
resp = await client.post(f"/api/repairs/issues/fix/{flow_id}")
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
flow_id = data["flow_id"]
|
||||
assert data == {
|
||||
"version": 1,
|
||||
"type": "create_entry",
|
||||
"flow_id": flow_id,
|
||||
"handler": DOMAIN,
|
||||
"title": "",
|
||||
"description": None,
|
||||
"description_placeholders": None,
|
||||
}
|
||||
|
||||
assert not issue_registry.async_get_issue(
|
||||
domain="cloud", issue_id="legacy_subscription"
|
||||
)
|
||||
|
||||
|
||||
async def test_legacy_subscription_repair_flow_timeout(
|
||||
hass: HomeAssistant,
|
||||
hass_client: Callable[..., Awaitable[ClientSession]],
|
||||
):
|
||||
"""Test timeout flow of the fix flow for legacy subscription."""
|
||||
issue_registry: ir.IssueRegistry = ir.async_get(hass)
|
||||
|
||||
cloud_repairs.async_manage_legacy_subscription_issue(hass, {"provider": "legacy"})
|
||||
repair_issue = issue_registry.async_get_issue(
|
||||
domain="cloud", issue_id="legacy_subscription"
|
||||
)
|
||||
assert repair_issue
|
||||
|
||||
assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}})
|
||||
await mock_cloud(hass)
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_start()
|
||||
|
||||
client = await hass_client()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/repairs/issues/fix",
|
||||
json={"handler": DOMAIN, "issue_id": repair_issue.issue_id},
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
flow_id = data["flow_id"]
|
||||
assert data == {
|
||||
"type": "form",
|
||||
"flow_id": flow_id,
|
||||
"handler": DOMAIN,
|
||||
"step_id": "confirm_change_plan",
|
||||
"data_schema": [],
|
||||
"errors": None,
|
||||
"description_placeholders": None,
|
||||
"last_step": None,
|
||||
}
|
||||
|
||||
with patch("homeassistant.components.cloud.repairs.MAX_RETRIES", new=0):
|
||||
resp = await client.post(f"/api/repairs/issues/fix/{flow_id}")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
flow_id = data["flow_id"]
|
||||
assert data == {
|
||||
"type": "external",
|
||||
"flow_id": flow_id,
|
||||
"handler": DOMAIN,
|
||||
"step_id": "change_plan",
|
||||
"url": "https://account.nabucasa.com/",
|
||||
"description_placeholders": None,
|
||||
}
|
||||
|
||||
resp = await client.post(f"/api/repairs/issues/fix/{flow_id}")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
flow_id = data["flow_id"]
|
||||
assert data == {
|
||||
"type": "abort",
|
||||
"flow_id": flow_id,
|
||||
"handler": "cloud",
|
||||
"reason": "operation_took_too_long",
|
||||
"description_placeholders": None,
|
||||
"result": None,
|
||||
}
|
||||
|
||||
assert issue_registry.async_get_issue(
|
||||
domain="cloud", issue_id="legacy_subscription"
|
||||
)
|
Loading…
Add table
Reference in a new issue