From 362c772d678802a15bba70b16f6daca51de1a5aa Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 19 Jul 2024 10:08:14 +0200 Subject: [PATCH] Add config flow to worldclock (#121775) --- .../components/worldclock/__init__.py | 24 ++++ .../components/worldclock/config_flow.py | 107 ++++++++++++++++++ homeassistant/components/worldclock/const.py | 11 ++ .../components/worldclock/manifest.json | 1 + homeassistant/components/worldclock/sensor.py | 51 +++++++-- .../components/worldclock/strings.json | 35 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/worldclock/conftest.py | 66 +++++++++++ .../components/worldclock/test_config_flow.py | 104 +++++++++++++++++ tests/components/worldclock/test_init.py | 17 +++ tests/components/worldclock/test_sensor.py | 73 ++++++++---- 12 files changed, 460 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/worldclock/config_flow.py create mode 100644 homeassistant/components/worldclock/const.py create mode 100644 homeassistant/components/worldclock/strings.json create mode 100644 tests/components/worldclock/conftest.py create mode 100644 tests/components/worldclock/test_config_flow.py create mode 100644 tests/components/worldclock/test_init.py diff --git a/homeassistant/components/worldclock/__init__.py b/homeassistant/components/worldclock/__init__.py index 978eaac8968..ad01c45917a 100644 --- a/homeassistant/components/worldclock/__init__.py +++ b/homeassistant/components/worldclock/__init__.py @@ -1 +1,25 @@ """The worldclock component.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Worldclock from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload World clock config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/worldclock/config_flow.py b/homeassistant/components/worldclock/config_flow.py new file mode 100644 index 00000000000..a9598c049aa --- /dev/null +++ b/homeassistant/components/worldclock/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for World clock.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast +import zoneinfo + +import voluptuous as vol + +from homeassistant.const import CONF_NAME, CONF_TIME_ZONE +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, +) +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) + +from .const import CONF_TIME_FORMAT, DEFAULT_NAME, DEFAULT_TIME_STR_FORMAT, DOMAIN + +TIME_STR_OPTIONS = [ + SelectOptionDict( + value=DEFAULT_TIME_STR_FORMAT, label=f"14:05 ({DEFAULT_TIME_STR_FORMAT})" + ), + SelectOptionDict(value="%I:%M %p", label="11:05 am (%I:%M %p)"), + SelectOptionDict(value="%Y-%m-%d %H:%M", label="2024-01-01 14:05 (%Y-%m-%d %H:%M)"), + SelectOptionDict( + value="%a, %b %d, %Y %I:%M %p", + label="Monday, Jan 01, 2024 11:05 am (%a, %b %d, %Y %I:%M %p)", + ), +] + + +async def validate_duplicate( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate already existing entry.""" + handler.parent_handler._async_abort_entries_match({**handler.options, **user_input}) # noqa: SLF001 + + return user_input + + +async def get_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + """Get available timezones.""" + get_timezones: list[str] = list( + await handler.parent_handler.hass.async_add_executor_job( + zoneinfo.available_timezones + ) + ) + return vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Required(CONF_TIME_ZONE): SelectSelector( + SelectSelectorConfig( + options=get_timezones, mode=SelectSelectorMode.DROPDOWN, sort=True + ) + ), + } + ).extend(DATA_SCHEMA_OPTIONS.schema) + + +DATA_SCHEMA_OPTIONS = vol.Schema( + { + vol.Optional(CONF_TIME_FORMAT, default=DEFAULT_TIME_STR_FORMAT): SelectSelector( + SelectSelectorConfig( + options=TIME_STR_OPTIONS, + custom_value=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } +) + + +CONFIG_FLOW = { + "user": SchemaFlowFormStep( + schema=get_schema, + validate_user_input=validate_duplicate, + ), + "import": SchemaFlowFormStep( + schema=get_schema, + validate_user_input=validate_duplicate, + ), +} +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + DATA_SCHEMA_OPTIONS, + validate_user_input=validate_duplicate, + ) +} + + +class WorldclockConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Worldclock.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/worldclock/const.py b/homeassistant/components/worldclock/const.py new file mode 100644 index 00000000000..fafa3dbc52f --- /dev/null +++ b/homeassistant/components/worldclock/const.py @@ -0,0 +1,11 @@ +"""Constants for world clock component.""" + +from homeassistant.const import Platform + +DOMAIN = "worldclock" +PLATFORMS = [Platform.SENSOR] + +CONF_TIME_FORMAT = "time_format" + +DEFAULT_NAME = "Worldclock Sensor" +DEFAULT_TIME_STR_FORMAT = "%H:%M" diff --git a/homeassistant/components/worldclock/manifest.json b/homeassistant/components/worldclock/manifest.json index 61600e4f924..bc7ee3cd939 100644 --- a/homeassistant/components/worldclock/manifest.json +++ b/homeassistant/components/worldclock/manifest.json @@ -2,6 +2,7 @@ "domain": "worldclock", "name": "Worldclock", "codeowners": ["@fabaff"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/worldclock", "iot_class": "local_push", "quality_scale": "internal" diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py index d9b4aa90f07..7ca2b252beb 100644 --- a/homeassistant/components/worldclock/sensor.py +++ b/homeassistant/components/worldclock/sensor.py @@ -10,17 +10,16 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME, CONF_TIME_ZONE -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -CONF_TIME_FORMAT = "time_format" - -DEFAULT_NAME = "Worldclock Sensor" -DEFAULT_TIME_STR_FORMAT = "%H:%M" +from .const import CONF_TIME_FORMAT, DEFAULT_NAME, DEFAULT_TIME_STR_FORMAT, DOMAIN PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( { @@ -38,13 +37,44 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the World clock sensor.""" - time_zone = dt_util.get_time_zone(config[CONF_TIME_ZONE]) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2025.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Worldclock", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the World clock sensor entry.""" + time_zone = await dt_util.async_get_time_zone(entry.options[CONF_TIME_ZONE]) async_add_entities( [ WorldClockSensor( time_zone, - config[CONF_NAME], - config[CONF_TIME_FORMAT], + entry.options[CONF_NAME], + entry.options[CONF_TIME_FORMAT], + entry.entry_id, ) ], True, @@ -56,11 +86,14 @@ class WorldClockSensor(SensorEntity): _attr_icon = "mdi:clock" - def __init__(self, time_zone: tzinfo | None, name: str, time_format: str) -> None: + def __init__( + self, time_zone: tzinfo | None, name: str, time_format: str, unique_id: str + ) -> None: """Initialize the sensor.""" self._attr_name = name self._time_zone = time_zone self._time_format = time_format + self._attr_unique_id = unique_id async def async_update(self) -> None: """Get the time and updates the states.""" diff --git a/homeassistant/components/worldclock/strings.json b/homeassistant/components/worldclock/strings.json new file mode 100644 index 00000000000..2f6b8d67a7c --- /dev/null +++ b/homeassistant/components/worldclock/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "time_zone": "Timezone", + "time_format": "Time format" + }, + "data_description": { + "time_zone": "Select timezone from list", + "time_format": "Select a pre-defined format from the list or define your own format." + } + } + } + }, + "options": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "step": { + "init": { + "data": { + "time_format": "[%key:component::worldclock::config::step::user::data::time_format%]" + }, + "data_description": { + "time_format": "[%key:component::worldclock::config::step::user::data_description::time_format%]" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 62980e3179d..48a98ee9c08 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -643,6 +643,7 @@ FLOWS = { "wled", "wolflink", "workday", + "worldclock", "ws66i", "wyoming", "xbox", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2d6cb4d243f..6c5066a840f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6870,7 +6870,7 @@ "worldclock": { "name": "Worldclock", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "worldtidesinfo": { diff --git a/tests/components/worldclock/conftest.py b/tests/components/worldclock/conftest.py new file mode 100644 index 00000000000..74ed82f099a --- /dev/null +++ b/tests/components/worldclock/conftest.py @@ -0,0 +1,66 @@ +"""Fixtures for the Worldclock integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.worldclock.const import ( + CONF_TIME_FORMAT, + DEFAULT_NAME, + DEFAULT_TIME_STR_FORMAT, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_NAME, CONF_TIME_ZONE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Automatically patch setup.""" + with patch( + "homeassistant.components.worldclock.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="get_config") +async def get_config_to_integration_load() -> dict[str, Any]: + """Return configuration. + + To override the config, tests can be marked with: + @pytest.mark.parametrize("get_config", [{...}]) + """ + return { + CONF_NAME: DEFAULT_NAME, + CONF_TIME_ZONE: "America/New_York", + CONF_TIME_FORMAT: DEFAULT_TIME_STR_FORMAT, + } + + +@pytest.fixture(name="loaded_entry") +async def load_integration( + hass: HomeAssistant, get_config: dict[str, Any] +) -> MockConfigEntry: + """Set up the Worldclock integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + source=SOURCE_USER, + options=get_config, + entry_id="1", + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/worldclock/test_config_flow.py b/tests/components/worldclock/test_config_flow.py new file mode 100644 index 00000000000..dfdb8159b9c --- /dev/null +++ b/tests/components/worldclock/test_config_flow.py @@ -0,0 +1,104 @@ +"""Test the Worldclock config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant import config_entries +from homeassistant.components.worldclock.const import ( + CONF_TIME_FORMAT, + DEFAULT_NAME, + DEFAULT_TIME_STR_FORMAT, + DOMAIN, +) +from homeassistant.const import CONF_NAME, CONF_TIME_ZONE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_TIME_ZONE: "America/New_York", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["version"] == 1 + assert result["options"] == { + CONF_NAME: DEFAULT_NAME, + CONF_TIME_ZONE: "America/New_York", + CONF_TIME_FORMAT: DEFAULT_TIME_STR_FORMAT, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options_flow(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: + """Test options flow.""" + + result = await hass.config_entries.options.async_init(loaded_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_TIME_FORMAT: "%a, %b %d, %Y %I:%M %p", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NAME: DEFAULT_NAME, + CONF_TIME_ZONE: "America/New_York", + CONF_TIME_FORMAT: "%a, %b %d, %Y %I:%M %p", + } + + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + assert len(hass.states.async_all()) == 1 + + state = hass.states.get("sensor.worldclock_sensor") + assert state is not None + + +async def test_entry_already_exist( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test abort when entry already exist.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_TIME_ZONE: "America/New_York", + CONF_TIME_FORMAT: DEFAULT_TIME_STR_FORMAT, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/worldclock/test_init.py b/tests/components/worldclock/test_init.py new file mode 100644 index 00000000000..5683836c166 --- /dev/null +++ b/tests/components/worldclock/test_init.py @@ -0,0 +1,17 @@ +"""Test Worldclock component setup process.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: + """Test unload an entry.""" + + assert loaded_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(loaded_entry.entry_id) + await hass.async_block_till_done() + assert loaded_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/worldclock/test_sensor.py b/tests/components/worldclock/test_sensor.py index 00195a49827..a8e3e41e649 100644 --- a/tests/components/worldclock/test_sensor.py +++ b/tests/components/worldclock/test_sensor.py @@ -1,19 +1,32 @@ """The test for the World clock sensor platform.""" +from datetime import tzinfo + import pytest -from homeassistant.core import HomeAssistant +from homeassistant.components.worldclock.const import ( + CONF_TIME_FORMAT, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.const import CONF_NAME, CONF_TIME_ZONE +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from tests.common import MockConfigEntry + @pytest.fixture -def time_zone(): +async def time_zone() -> tzinfo | None: """Fixture for time zone.""" - return dt_util.get_time_zone("America/New_York") + return await dt_util.async_get_time_zone("America/New_York") -async def test_time(hass: HomeAssistant, time_zone) -> None: +async def test_time_imported_from_yaml( + hass: HomeAssistant, time_zone: tzinfo | None, issue_registry: ir.IssueRegistry +) -> None: """Test the time at a different location.""" config = {"sensor": {"platform": "worldclock", "time_zone": "America/New_York"}} @@ -29,26 +42,42 @@ async def test_time(hass: HomeAssistant, time_zone) -> None: assert state.state == dt_util.now(time_zone=time_zone).strftime("%H:%M") - -async def test_time_format(hass: HomeAssistant, time_zone) -> None: - """Test time_format setting.""" - time_format = "%a, %b %d, %Y %I:%M %p" - config = { - "sensor": { - "platform": "worldclock", - "time_zone": "America/New_York", - "time_format": time_format, - } - } - - assert await async_setup_component( - hass, - "sensor", - config, + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" ) - await hass.async_block_till_done() + assert issue + assert issue.issue_domain == DOMAIN + + +async def test_time_from_config_entry( + hass: HomeAssistant, time_zone: tzinfo | None, loaded_entry: MockConfigEntry +) -> None: + """Test the time at a different location.""" state = hass.states.get("sensor.worldclock_sensor") assert state is not None - assert state.state == dt_util.now(time_zone=time_zone).strftime(time_format) + assert state.state == dt_util.now(time_zone=time_zone).strftime("%H:%M") + + +@pytest.mark.parametrize( + "get_config", + [ + { + CONF_NAME: DEFAULT_NAME, + CONF_TIME_ZONE: "America/New_York", + CONF_TIME_FORMAT: "%a, %b %d, %Y %I:%M %p", + } + ], +) +async def test_time_format( + hass: HomeAssistant, time_zone: tzinfo | None, loaded_entry: MockConfigEntry +) -> None: + """Test time_format setting.""" + + state = hass.states.get("sensor.worldclock_sensor") + assert state is not None + + assert state.state == dt_util.now(time_zone=time_zone).strftime( + "%a, %b %d, %Y %I:%M %p" + )