diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 7c0dc8ff0a6af1..4ea4da602e37c8 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -2,17 +2,25 @@ from __future__ import annotations from datetime import date, timedelta +from typing import Any import holidays from holidays import DateLike, HolidayBase +import voluptuous as vol -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.binary_sensor import ( + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, + BinarySensorEntity, +) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from .const import ( @@ -24,11 +32,81 @@ CONF_PROVINCE, CONF_REMOVE_HOLIDAYS, CONF_WORKDAYS, + DEFAULT_EXCLUDES, + DEFAULT_NAME, + DEFAULT_OFFSET, + DEFAULT_WORKDAYS, DOMAIN, LOGGER, ) +def valid_country(value: Any) -> str: + """Validate that the given country is supported.""" + value = cv.string(value) + all_supported_countries = holidays.list_supported_countries() + + try: + raw_value = value.encode("utf-8") + except UnicodeError as err: + raise vol.Invalid( + "The country name or the abbreviation must be a valid UTF-8 string." + ) from err + if not raw_value: + raise vol.Invalid("Country name or the abbreviation must not be empty.") + if value not in all_supported_countries: + raise vol.Invalid("Country is not supported.") + return value + + +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_COUNTRY): valid_country, + vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES): vol.All( + cv.ensure_list, [vol.In(ALLOWED_DAYS)] + ), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): vol.Coerce(int), + vol.Optional(CONF_PROVINCE): cv.string, + vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): vol.All( + cv.ensure_list, [vol.In(ALLOWED_DAYS)] + ), + vol.Optional(CONF_ADD_HOLIDAYS, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_REMOVE_HOLIDAYS, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Workday sensor.""" + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.11.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index bfa6c299b57b76..7153dac1bcba73 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -155,6 +155,33 @@ def async_get_options_flow( """Get the options flow for this handler.""" return WorkdayOptionsFlowHandler(config_entry) + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a configuration from config.yaml.""" + + abort_match = { + CONF_COUNTRY: config[CONF_COUNTRY], + CONF_EXCLUDES: config[CONF_EXCLUDES], + CONF_OFFSET: config[CONF_OFFSET], + CONF_WORKDAYS: config[CONF_WORKDAYS], + CONF_ADD_HOLIDAYS: config[CONF_ADD_HOLIDAYS], + CONF_REMOVE_HOLIDAYS: config[CONF_REMOVE_HOLIDAYS], + CONF_PROVINCE: config.get(CONF_PROVINCE), + } + new_config = config.copy() + new_config[CONF_PROVINCE] = config.get(CONF_PROVINCE) + LOGGER.debug("Importing with %s", new_config) + + self._async_abort_entries_match(abort_match) + + self.data[CONF_NAME] = config.get(CONF_NAME, DEFAULT_NAME) + self.data[CONF_COUNTRY] = config[CONF_COUNTRY] + LOGGER.debug( + "No duplicate, next step with name %s for country %s", + self.data[CONF_NAME], + self.data[CONF_COUNTRY], + ) + return await self.async_step_options(user_input=new_config) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index 5af69e29a8baa9..e6753b39dcefaf 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -64,6 +64,12 @@ "already_configured": "Service with this configuration already exist" } }, + "issues": { + "deprecated_yaml": { + "title": "The Workday YAML configuration is being removed", + "description": "Configuring Workday using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Workday YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + }, "selector": { "province": { "options": { diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index d2ae9895544fe8..71dd23c19a31ee 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -4,7 +4,9 @@ from freezegun.api import FrozenDateTimeFactory import pytest +import voluptuous as vol +from homeassistant.components.workday import binary_sensor from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.dt import UTC @@ -28,6 +30,21 @@ ) +async def test_valid_country_yaml() -> None: + """Test valid country from yaml.""" + # Invalid UTF-8, must not contain U+D800 to U+DFFF + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("\ud800") + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("\udfff") + # Country MUST NOT be empty + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("") + # Country must be supported by holidays + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("HomeAssistantLand") + + @pytest.mark.parametrize( ("config", "expected_state"), [ @@ -62,6 +79,34 @@ async def test_setup( } +async def test_setup_from_import( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2022, 4, 15, 12, tzinfo=UTC)) # Monday + await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "workday", + "country": "DE", + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "off" + assert state.attributes == { + "friendly_name": "Workday Sensor", + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + } + + async def test_setup_with_invalid_province_from_yaml(hass: HomeAssistant) -> None: """Test setup invalid province with import.""" diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index ce4dd1277783c5..7e28471c78cfac 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -13,6 +13,7 @@ CONF_REMOVE_HOLIDAYS, CONF_WORKDAYS, DEFAULT_EXCLUDES, + DEFAULT_NAME, DEFAULT_OFFSET, DEFAULT_WORKDAYS, DOMAIN, @@ -23,6 +24,8 @@ from . import init_integration +from tests.common import MockConfigEntry + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -111,6 +114,142 @@ async def test_form_no_subdivision(hass: HomeAssistant) -> None: } +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test a successful import of yaml.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_NAME: DEFAULT_NAME, + CONF_COUNTRY: "DE", + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Workday Sensor" + assert result["options"] == { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": None, + } + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_NAME: "Workday Sensor 2", + CONF_COUNTRY: "DE", + CONF_PROVINCE: "BW", + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Workday Sensor 2" + assert result2["options"] == { + "name": "Workday Sensor 2", + "country": "DE", + "province": "BW", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + } + + +async def test_import_flow_already_exist(hass: HomeAssistant) -> None: + """Test import of yaml already exist.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": None, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_NAME: "Workday sensor 2", + CONF_COUNTRY: "DE", + CONF_EXCLUDES: ["sat", "sun", "holiday"], + CONF_OFFSET: 0, + CONF_WORKDAYS: ["mon", "tue", "wed", "thu", "fri"], + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_flow_province_no_conflict(hass: HomeAssistant) -> None: + """Test import of yaml with province.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_NAME: "Workday sensor 2", + CONF_COUNTRY: "DE", + CONF_PROVINCE: "BW", + CONF_EXCLUDES: ["sat", "sun", "holiday"], + CONF_OFFSET: 0, + CONF_WORKDAYS: ["mon", "tue", "wed", "thu", "fri"], + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + + async def test_options_form(hass: HomeAssistant) -> None: """Test we get the form in options."""