From dcec6c3dc85ebb9a37670379e35ae9850f609cfc Mon Sep 17 00:00:00 2001 From: Paul Annekov Date: Tue, 11 Nov 2025 15:05:14 +0200 Subject: [PATCH] Forbid to choose state in Ukraine Alarm integration (#156183) --- .../components/ukraine_alarm/__init__.py | 63 ++++++++++++ .../components/ukraine_alarm/config_flow.py | 4 +- .../components/ukraine_alarm/strings.json | 12 ++- tests/components/ukraine_alarm/__init__.py | 26 +++++ .../ukraine_alarm/test_config_flow.py | 97 +------------------ tests/components/ukraine_alarm/test_init.py | 70 +++++++++++++ 6 files changed, 172 insertions(+), 100 deletions(-) create mode 100644 tests/components/ukraine_alarm/test_init.py diff --git a/homeassistant/components/ukraine_alarm/__init__.py b/homeassistant/components/ukraine_alarm/__init__.py index 3658b821625..c5cdd3bfb3e 100644 --- a/homeassistant/components/ukraine_alarm/__init__.py +++ b/homeassistant/components/ukraine_alarm/__init__.py @@ -2,13 +2,23 @@ from __future__ import annotations +import logging +from typing import TYPE_CHECKING + +import aiohttp +from uasiren.client import Client + from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_REGION from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, PLATFORMS from .coordinator import UkraineAlarmDataUpdateCoordinator +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ukraine Alarm as config entry.""" @@ -30,3 +40,56 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + # Version 1 had states as first-class selections + # Version 2 only allows states w/o districts, districts and communities + region_id = config_entry.data[CONF_REGION] + + websession = async_get_clientsession(hass) + try: + regions_data = await Client(websession).get_regions() + except (aiohttp.ClientError, TimeoutError) as err: + _LOGGER.warning( + "Could not migrate config entry %s: failed to fetch current regions: %s", + config_entry.entry_id, + err, + ) + return False + + if TYPE_CHECKING: + assert isinstance(regions_data, dict) + + state_with_districts = None + for state in regions_data["states"]: + if state["regionId"] == region_id and state.get("regionChildIds"): + state_with_districts = state + break + + if state_with_districts: + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_state_region_{config_entry.entry_id}", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_state_region", + translation_placeholders={ + "region_name": config_entry.data.get(CONF_NAME, region_id), + }, + ) + + return False + + hass.config_entries.async_update_entry(config_entry, version=2) + _LOGGER.info("Migration to version %s successful", 2) + return True + + _LOGGER.error("Unknown version %s", config_entry.version) + return False diff --git a/homeassistant/components/ukraine_alarm/config_flow.py b/homeassistant/components/ukraine_alarm/config_flow.py index 12059124fa2..c65b1a3713f 100644 --- a/homeassistant/components/ukraine_alarm/config_flow.py +++ b/homeassistant/components/ukraine_alarm/config_flow.py @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow for Ukraine Alarm.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: """Initialize a new UkraineAlarmConfigFlow.""" @@ -112,7 +112,7 @@ class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN): return await self._async_finish_flow() regions = {} - if self.selected_region: + if self.selected_region and step_id != "district": regions[self.selected_region["regionId"]] = self.selected_region[ "regionName" ] diff --git a/homeassistant/components/ukraine_alarm/strings.json b/homeassistant/components/ukraine_alarm/strings.json index a8c54ba934b..5a586c19fb8 100644 --- a/homeassistant/components/ukraine_alarm/strings.json +++ b/homeassistant/components/ukraine_alarm/strings.json @@ -13,19 +13,19 @@ "data": { "region": "[%key:component::ukraine_alarm::config::step::user::data::region%]" }, - "description": "If you want to monitor not only state and district, choose its specific community" + "description": "Choose the district you selected above or select a specific community within that district" }, "district": { "data": { "region": "[%key:component::ukraine_alarm::config::step::user::data::region%]" }, - "description": "If you want to monitor not only state, choose its specific district" + "description": "Choose a district to monitor within the selected state" }, "user": { "data": { "region": "Region" }, - "description": "Choose state to monitor" + "description": "Choose a state" } } }, @@ -50,5 +50,11 @@ "name": "Urban fights" } } + }, + "issues": { + "deprecated_state_region": { + "description": "The region `{region_name}` is a state-level region, which is no longer supported. Please remove this integration entry and add it again, selecting a district or community instead of the entire state.", + "title": "State-level region monitoring is no longer supported" + } } } diff --git a/tests/components/ukraine_alarm/__init__.py b/tests/components/ukraine_alarm/__init__.py index 228594b3d0c..7a539cc0a6e 100644 --- a/tests/components/ukraine_alarm/__init__.py +++ b/tests/components/ukraine_alarm/__init__.py @@ -1 +1,27 @@ """Tests for the Ukraine Alarm integration.""" + + +def _region(rid, recurse=0, depth=0): + """Create a test region with optional nested structure.""" + if depth == 0: + name_prefix = "State" + elif depth == 1: + name_prefix = "District" + else: + name_prefix = "Community" + + name = f"{name_prefix} {rid}" + region = {"regionId": rid, "regionName": name, "regionChildIds": []} + + if not recurse: + return region + + for i in range(1, 4): + region["regionChildIds"].append(_region(f"{rid}.{i}", recurse - 1, depth + 1)) + + return region + + +REGIONS = { + "states": [_region(f"{i}", i - 1) for i in range(1, 4)], +} diff --git a/tests/components/ukraine_alarm/test_config_flow.py b/tests/components/ukraine_alarm/test_config_flow.py index de9bdd618de..3350a95fd71 100644 --- a/tests/components/ukraine_alarm/test_config_flow.py +++ b/tests/components/ukraine_alarm/test_config_flow.py @@ -12,34 +12,11 @@ from homeassistant.components.ukraine_alarm.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import REGIONS + from tests.common import MockConfigEntry -def _region(rid, recurse=0, depth=0): - if depth == 0: - name_prefix = "State" - elif depth == 1: - name_prefix = "District" - else: - name_prefix = "Community" - - name = f"{name_prefix} {rid}" - region = {"regionId": rid, "regionName": name, "regionChildIds": []} - - if not recurse: - return region - - for i in range(1, 4): - region["regionChildIds"].append(_region(f"{rid}.{i}", recurse - 1, depth + 1)) - - return region - - -REGIONS = { - "states": [_region(f"{i}", i - 1) for i in range(1, 4)], -} - - @pytest.fixture(autouse=True) def mock_get_regions() -> Generator[AsyncMock]: """Mock the get_regions method.""" @@ -51,37 +28,6 @@ def mock_get_regions() -> Generator[AsyncMock]: yield mock_get -async def test_state(hass: HomeAssistant) -> None: - """Test we can create entry for state.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - - result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2["type"] is FlowResultType.FORM - - with patch( - "homeassistant.components.ukraine_alarm.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "region": "1", - }, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "State 1" - assert result3["data"] == { - "region": "1", - "name": result3["title"], - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_state_district(hass: HomeAssistant) -> None: """Test we can create entry for state + district.""" result = await hass.config_entries.flow.async_init( @@ -121,45 +67,6 @@ async def test_state_district(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_state_district_pick_region(hass: HomeAssistant) -> None: - """Test we can create entry for region which has districts.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - - result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2["type"] is FlowResultType.FORM - - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "region": "2", - }, - ) - assert result3["type"] is FlowResultType.FORM - - with patch( - "homeassistant.components.ukraine_alarm.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result4 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "region": "2", - }, - ) - await hass.async_block_till_done() - - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == "State 2" - assert result4["data"] == { - "region": "2", - "name": result4["title"], - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_state_district_community(hass: HomeAssistant) -> None: """Test we can create entry for state + district + community.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/ukraine_alarm/test_init.py b/tests/components/ukraine_alarm/test_init.py new file mode 100644 index 00000000000..f1b762339f3 --- /dev/null +++ b/tests/components/ukraine_alarm/test_init.py @@ -0,0 +1,70 @@ +"""Test the Ukraine Alarm integration initialization.""" + +from unittest.mock import patch + +from homeassistant.components.ukraine_alarm.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from . import REGIONS + +from tests.common import MockConfigEntry + + +async def test_migration_v1_to_v2_state_without_districts( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test migration allows states without districts.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + data={"region": "1", "name": "State 1"}, + unique_id="1", + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.ukraine_alarm.Client.get_regions", + return_value=REGIONS, + ), + patch( + "homeassistant.components.ukraine_alarm.Client.get_alerts", + return_value=[{"activeAlerts": []}], + ), + ): + result = await hass.config_entries.async_setup(entry.entry_id) + assert result is True + assert entry.version == 2 + + assert ( + DOMAIN, + f"deprecated_state_region_{entry.entry_id}", + ) not in issue_registry.issues + + +async def test_migration_v1_to_v2_state_with_districts_fails( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test migration rejects states with districts.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + data={"region": "2", "name": "State 2"}, + unique_id="2", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.ukraine_alarm.Client.get_regions", + return_value=REGIONS, + ): + result = await hass.config_entries.async_setup(entry.entry_id) + assert result is False + + assert ( + DOMAIN, + f"deprecated_state_region_{entry.entry_id}", + ) in issue_registry.issues