diff --git a/homeassistant/components/homeassistant/repairs.py b/homeassistant/components/homeassistant/repairs.py new file mode 100644 index 00000000000..af8f8f05a35 --- /dev/null +++ b/homeassistant/components/homeassistant/repairs.py @@ -0,0 +1,59 @@ +"""Repairs for Home Assistant.""" + +from __future__ import annotations + +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.core import DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import issue_registry as ir + + +class IntegrationNotFoundFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, data: dict[str, str]) -> None: + """Initialize.""" + self.domain = data["domain"] + self.description_placeholders: dict[str, str] = data + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the first step of a fix flow.""" + return self.async_show_menu( + step_id="init", + menu_options=["confirm", "ignore"], + description_placeholders=self.description_placeholders, + ) + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the confirm step of a fix flow.""" + entries = self.hass.config_entries.async_entries(self.domain) + for entry in entries: + await self.hass.config_entries.async_remove(entry.entry_id) + return self.async_create_entry(data={}) + + async def async_step_ignore( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the ignore step of a fix flow.""" + ir.async_get(self.hass).async_ignore( + DOMAIN, f"integration_not_found.{self.domain}", True + ) + return self.async_abort( + reason="issue_ignored", + description_placeholders=self.description_placeholders, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, issue_id: str, data: dict[str, str] | None +) -> RepairsFlow: + """Create flow.""" + + if issue_id.split(".")[0] == "integration_not_found": + assert data + return IntegrationNotFoundFlow(data) + return ConfirmRepairFlow() diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 2acd772b94e..47e8514a6a5 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -56,6 +56,21 @@ "config_entry_reauth": { "title": "[%key:common::config_flow::title::reauth%]", "description": "Reauthentication is needed" + }, + "integration_not_found": { + "title": "Integration {domain} not found", + "fix_flow": { + "step": { + "remove_entries": { + "title": "[%key:component::homeassistant::issues::integration_not_found::title%]", + "description": "The integration `{domain}` could not be found. This happens when a (custom) integration was removed from Home Assistant, but there are still configurations for this `integration`. Please use the buttons below to either remove the previous configurations for `domain` or ignore this.", + "menu_options": { + "confirm": "Remove previous configurations", + "ignore": "Ignore" + } + } + } + } } }, "system_health": { diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 3b512e92686..12dd17b289c 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -29,7 +29,7 @@ from .core import ( callback, ) from .exceptions import DependencyError, HomeAssistantError -from .helpers import singleton, translation +from .helpers import issue_registry as ir, singleton, translation from .helpers.issue_registry import IssueSeverity, async_create_issue from .helpers.typing import ConfigType from .util.async_ import create_eager_task @@ -281,6 +281,19 @@ async def _async_setup_component( integration = await loader.async_get_integration(hass, domain) except loader.IntegrationNotFound: _log_error_setup_error(hass, domain, None, "Integration not found.") + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"integration_not_found.{domain}", + is_fixable=True, + issue_domain=HOMEASSISTANT_DOMAIN, + severity=IssueSeverity.ERROR, + translation_key="integration_not_found", + translation_placeholders={ + "domain": domain, + }, + data={"domain": domain}, + ) return False log_error = partial(_log_error_setup_error, hass, domain, integration) diff --git a/tests/components/homeassistant/test_repairs.py b/tests/components/homeassistant/test_repairs.py new file mode 100644 index 00000000000..968330de0fc --- /dev/null +++ b/tests/components/homeassistant/test_repairs.py @@ -0,0 +1,156 @@ +"""Test the Homeassistant repairs module.""" + +from http import HTTPStatus + +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.components.repairs.issue_handler import ( + async_process_repairs_platforms, +) +from homeassistant.components.repairs.websocket_api import ( + RepairsFlowIndexView, + RepairsFlowResourceView, +) +from homeassistant.core import DOMAIN, HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +async def test_integration_not_found_confirm_step( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the integration_not_found issue confirm step.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + await hass.async_block_till_done() + assert await async_setup_component(hass, "test1", {}) is False + await hass.async_block_till_done() + entry1 = MockConfigEntry(domain="test1") + entry1.add_to_hass(hass) + entry2 = MockConfigEntry(domain="test1") + entry2.add_to_hass(hass) + issue_id = "integration_not_found.test1" + + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + http_client = await hass_client() + + # Assert the issue is present + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert issue["issue_id"] == issue_id + assert issue["translation_placeholders"] == {"domain": "test1"} + + url = RepairsFlowIndexView.url + resp = await http_client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "init" + assert data["description_placeholders"] == {"domain": "test1"} + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + + # Show menu + resp = await http_client.post(url) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "menu" + + # Apply fix + resp = await http_client.post(url, json={"next_step_id": "confirm"}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + + await hass.async_block_till_done() + + assert hass.config_entries.async_get_entry(entry1.entry_id) is None + assert hass.config_entries.async_get_entry(entry2.entry_id) is None + + # Assert the issue is resolved + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 0 + + +async def test_integration_not_found_ignore_step( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the integration_not_found issue ignore step.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + await hass.async_block_till_done() + assert await async_setup_component(hass, "test1", {}) is False + await hass.async_block_till_done() + entry1 = MockConfigEntry(domain="test1") + entry1.add_to_hass(hass) + issue_id = "integration_not_found.test1" + + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + http_client = await hass_client() + + # Assert the issue is present + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert issue["issue_id"] == issue_id + assert issue["translation_placeholders"] == {"domain": "test1"} + + url = RepairsFlowIndexView.url + resp = await http_client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "init" + assert data["description_placeholders"] == {"domain": "test1"} + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + + # Show menu + resp = await http_client.post(url) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "menu" + + # Apply fix + resp = await http_client.post(url, json={"next_step_id": "ignore"}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "abort" + assert data["reason"] == "issue_ignored" + + await hass.async_block_till_done() + + assert hass.config_entries.async_get_entry(entry1.entry_id) + + # Assert the issue is resolved + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert msg["result"]["issues"][0].get("dismissed_version") is not None diff --git a/tests/test_setup.py b/tests/test_setup.py index 1e19f1a7b76..e28506adc59 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -10,13 +10,14 @@ import voluptuous as vol from homeassistant import config_entries, loader, setup from homeassistant.const import EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START -from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.core import DOMAIN, CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, discovery, translation from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.issue_registry import IssueRegistry from .common import ( MockConfigEntry, @@ -236,9 +237,15 @@ async def test_validate_platform_config_4(hass: HomeAssistant) -> None: hass.config.components.remove("platform_conf") -async def test_component_not_found(hass: HomeAssistant) -> None: +async def test_component_not_found( + hass: HomeAssistant, issue_registry: IssueRegistry +) -> None: """setup_component should not crash if component doesn't exist.""" assert await setup.async_setup_component(hass, "non_existing", {}) is False + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, "integration_not_found.non_existing") + assert issue + assert issue.translation_key == "integration_not_found" async def test_component_not_double_initialized(hass: HomeAssistant) -> None: