From 0cde518a89a514ce7bf7c4da89dafb51f07d483e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 19 Jul 2024 17:49:27 +0200 Subject: [PATCH] Allow to add optional holiday categories in workday (#121396) * Allow to add optional holiday categories in workday * Add tests * Fix coverage --- .../components/workday/binary_sensor.py | 19 +++++- .../components/workday/config_flow.py | 52 ++++++++++++---- homeassistant/components/workday/const.py | 1 + homeassistant/components/workday/strings.json | 30 ++++++++-- tests/components/workday/__init__.py | 25 ++++++++ .../components/workday/test_binary_sensor.py | 22 +++++++ tests/components/workday/test_config_flow.py | 59 +++++++++++++++++-- 7 files changed, 185 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 5df8e6c3d75..4635b2209a6 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -6,6 +6,7 @@ from datetime import date, datetime, timedelta from typing import Final from holidays import ( + PUBLIC, HolidayBase, __version__ as python_holidays_version, country_holidays, @@ -35,6 +36,7 @@ from homeassistant.util import dt as dt_util, slugify from .const import ( ALLOWED_DAYS, CONF_ADD_HOLIDAYS, + CONF_CATEGORY, CONF_EXCLUDES, CONF_OFFSET, CONF_PROVINCE, @@ -69,17 +71,28 @@ def validate_dates(holiday_list: list[str]) -> list[str]: def _get_obj_holidays( - country: str | None, province: str | None, year: int, language: str | None + country: str | None, + province: str | None, + year: int, + language: str | None, + categories: list[str] | None, ) -> HolidayBase: """Get the object for the requested country and year.""" if not country: return HolidayBase() + set_categories = None + if categories: + category_list = [PUBLIC] + category_list.extend(categories) + set_categories = tuple(category_list) + obj_holidays: HolidayBase = country_holidays( country, subdiv=province, years=year, language=language, + categories=set_categories, # type: ignore[arg-type] ) if (supported_languages := obj_holidays.supported_languages) and language == "en": for lang in supported_languages: @@ -89,6 +102,7 @@ def _get_obj_holidays( subdiv=province, years=year, language=lang, + categories=set_categories, # type: ignore[arg-type] ) LOGGER.debug("Changing language from %s to %s", language, lang) return obj_holidays @@ -107,10 +121,11 @@ async def async_setup_entry( sensor_name: str = entry.options[CONF_NAME] workdays: list[str] = entry.options[CONF_WORKDAYS] language: str | None = entry.options.get(CONF_LANGUAGE) + categories: list[str] | None = entry.options.get(CONF_CATEGORY) year: int = (dt_util.now() + timedelta(days=days_offset)).year obj_holidays: HolidayBase = await hass.async_add_executor_job( - _get_obj_holidays, country, province, year, language + _get_obj_holidays, country, province, year, language, categories ) calc_add_holidays: list[str] = validate_dates(add_holidays) calc_remove_holidays: list[str] = validate_dates(remove_holidays) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index a66a9c51588..ebbc8fb0b99 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations from functools import partial from typing import Any -from holidays import HolidayBase, country_holidays, list_supported_countries +from holidays import PUBLIC, HolidayBase, country_holidays, list_supported_countries import voluptuous as vol from homeassistant.config_entries import ( @@ -36,6 +36,7 @@ from homeassistant.util import dt as dt_util from .const import ( ALLOWED_DAYS, CONF_ADD_HOLIDAYS, + CONF_CATEGORY, CONF_EXCLUDES, CONF_OFFSET, CONF_PROVINCE, @@ -86,7 +87,29 @@ def add_province_and_language_to_schema( ), } - return vol.Schema({**DATA_SCHEMA_OPT.schema, **language_schema, **province_schema}) + category_schema = {} + # PUBLIC will always be included and can therefore not be set/removed + _categories = [x for x in _country.supported_categories if x != PUBLIC] + if _categories: + category_schema = { + vol.Optional(CONF_CATEGORY): SelectSelector( + SelectSelectorConfig( + options=_categories, + mode=SelectSelectorMode.DROPDOWN, + multiple=True, + translation_key=CONF_CATEGORY, + ) + ), + } + + return vol.Schema( + { + **DATA_SCHEMA_OPT.schema, + **language_schema, + **province_schema, + **category_schema, + } + ) def _is_valid_date_range(check_date: str, error: type[HomeAssistantError]) -> bool: @@ -256,6 +279,8 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): CONF_REMOVE_HOLIDAYS: combined_input[CONF_REMOVE_HOLIDAYS], CONF_PROVINCE: combined_input.get(CONF_PROVINCE), } + if CONF_CATEGORY in combined_input: + abort_match[CONF_CATEGORY] = combined_input[CONF_CATEGORY] LOGGER.debug("abort_check in options with %s", combined_input) self._async_abort_entries_match(abort_match) @@ -314,18 +339,19 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): errors["remove_holidays"] = "remove_holiday_range_error" else: LOGGER.debug("abort_check in options with %s", combined_input) + abort_match = { + CONF_COUNTRY: self._config_entry.options.get(CONF_COUNTRY), + CONF_EXCLUDES: combined_input[CONF_EXCLUDES], + CONF_OFFSET: combined_input[CONF_OFFSET], + CONF_WORKDAYS: combined_input[CONF_WORKDAYS], + CONF_ADD_HOLIDAYS: combined_input[CONF_ADD_HOLIDAYS], + CONF_REMOVE_HOLIDAYS: combined_input[CONF_REMOVE_HOLIDAYS], + CONF_PROVINCE: combined_input.get(CONF_PROVINCE), + } + if CONF_CATEGORY in combined_input: + abort_match[CONF_CATEGORY] = combined_input[CONF_CATEGORY] try: - self._async_abort_entries_match( - { - CONF_COUNTRY: self._config_entry.options.get(CONF_COUNTRY), - CONF_EXCLUDES: combined_input[CONF_EXCLUDES], - CONF_OFFSET: combined_input[CONF_OFFSET], - CONF_WORKDAYS: combined_input[CONF_WORKDAYS], - CONF_ADD_HOLIDAYS: combined_input[CONF_ADD_HOLIDAYS], - CONF_REMOVE_HOLIDAYS: combined_input[CONF_REMOVE_HOLIDAYS], - CONF_PROVINCE: combined_input.get(CONF_PROVINCE), - } - ) + self._async_abort_entries_match(abort_match) except AbortFlow as err: errors = {"base": err.reason} else: diff --git a/homeassistant/components/workday/const.py b/homeassistant/components/workday/const.py index 6a46f1e824b..76580ae642f 100644 --- a/homeassistant/components/workday/const.py +++ b/homeassistant/components/workday/const.py @@ -19,6 +19,7 @@ CONF_EXCLUDES = "excludes" CONF_OFFSET = "days_offset" CONF_ADD_HOLIDAYS = "add_holidays" CONF_REMOVE_HOLIDAYS = "remove_holidays" +CONF_CATEGORY = "category" # By default, Monday - Friday are workdays DEFAULT_WORKDAYS = ["mon", "tue", "wed", "thu", "fri"] diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index 0e618beaf82..f3b966e28ea 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -20,7 +20,8 @@ "add_holidays": "Add holidays", "remove_holidays": "Remove Holidays", "province": "Subdivision of country", - "language": "Language for named holidays" + "language": "Language for named holidays", + "category": "Additional category as holiday" }, "data_description": { "excludes": "List of workdays to exclude, notice the keyword `holiday` and read the documentation on how to use it correctly", @@ -29,7 +30,8 @@ "add_holidays": "Add custom holidays as YYYY-MM-DD or as range using `,` as separator", "remove_holidays": "Remove holidays as YYYY-MM-DD, as range using `,` as separator or by using partial of name", "province": "State, territory, province or region of country", - "language": "Language to use when configuring named holiday exclusions" + "language": "Language to use when configuring named holiday exclusions", + "category": "Select additional categories to include as holidays" } } }, @@ -51,7 +53,8 @@ "add_holidays": "[%key:component::workday::config::step::options::data::add_holidays%]", "remove_holidays": "[%key:component::workday::config::step::options::data::remove_holidays%]", "province": "[%key:component::workday::config::step::options::data::province%]", - "language": "[%key:component::workday::config::step::options::data::language%]" + "language": "[%key:component::workday::config::step::options::data::language%]", + "category": "[%key:component::workday::config::step::options::data::category%]" }, "data_description": { "excludes": "[%key:component::workday::config::step::options::data_description::excludes%]", @@ -60,7 +63,8 @@ "add_holidays": "[%key:component::workday::config::step::options::data_description::add_holidays%]", "remove_holidays": "[%key:component::workday::config::step::options::data_description::remove_holidays%]", "province": "[%key:component::workday::config::step::options::data_description::province%]", - "language": "[%key:component::workday::config::step::options::data_description::language%]" + "language": "[%key:component::workday::config::step::options::data_description::language%]", + "category": "[%key:component::workday::config::step::options::data_description::category%]" } } }, @@ -78,6 +82,24 @@ "none": "No subdivision" } }, + "category": { + "options": { + "armed_forces": "Armed forces", + "bank": "Bank", + "government": "Government", + "half_day": "Half day", + "optional": "Optional", + "public": "Public", + "school": "School", + "unofficial": "Unofficial", + "workday": "Workday", + "chinese": "Chinese", + "christian": "Christian", + "hebrew": "Hebrew", + "hindu": "Hindu", + "islamic": "Islamic" + } + }, "days": { "options": { "mon": "[%key:common::time::monday%]", diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py index a7e26765643..17449af8bd1 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +from holidays import OPTIONAL + from homeassistant.components.workday.const import ( DEFAULT_EXCLUDES, DEFAULT_NAME, @@ -310,3 +312,26 @@ TEST_LANGUAGE_NO_CHANGE = { "remove_holidays": ["2022-12-04", "2022-12-24,2022-12-26"], "language": "de", } +TEST_NO_OPTIONAL_CATEGORY = { + "name": DEFAULT_NAME, + "country": "CH", + "province": "FR", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], + "language": "de", +} +TEST_OPTIONAL_CATEGORY = { + "name": DEFAULT_NAME, + "country": "CH", + "province": "FR", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], + "language": "de", + "category": [OPTIONAL], +} diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index e973a9f9c28..a2718c00824 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -39,6 +39,8 @@ from . import ( TEST_CONFIG_YESTERDAY, TEST_LANGUAGE_CHANGE, TEST_LANGUAGE_NO_CHANGE, + TEST_NO_OPTIONAL_CATEGORY, + TEST_OPTIONAL_CATEGORY, init_integration, ) @@ -400,3 +402,23 @@ async def test_language_difference_no_change_other_language( """Test skipping if no difference in language naming.""" await init_integration(hass, TEST_LANGUAGE_NO_CHANGE) assert "Changing language from en to en_US" not in caplog.text + + +@pytest.mark.parametrize( + ("config", "end_state"), + [(TEST_OPTIONAL_CATEGORY, "off"), (TEST_NO_OPTIONAL_CATEGORY, "on")], +) +async def test_optional_category( + hass: HomeAssistant, + config: dict[str, Any], + end_state: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + # CH, subdiv FR has optional holiday Jan 2nd + freezer.move_to(datetime(2024, 1, 2, 12, tzinfo=UTC)) # Tuesday + await init_integration(hass, config) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None + assert state.state == end_state diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 7eb3065e576..cc83cee93a2 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -5,11 +5,13 @@ from __future__ import annotations from datetime import datetime from freezegun.api import FrozenDateTimeFactory +from holidays import HALF_DAY, OPTIONAL import pytest from homeassistant import config_entries from homeassistant.components.workday.const import ( CONF_ADD_HOLIDAYS, + CONF_CATEGORY, CONF_EXCLUDES, CONF_OFFSET, CONF_REMOVE_HOLIDAYS, @@ -354,13 +356,14 @@ async def test_options_form_abort_duplicate(hass: HomeAssistant) -> None: hass, { "name": "Workday Sensor", - "country": "DE", + "country": "CH", "excludes": ["sat", "sun", "holiday"], "days_offset": 0, "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "province": None, + "province": "FR", + "category": [OPTIONAL], }, entry_id="1", ) @@ -368,13 +371,14 @@ async def test_options_form_abort_duplicate(hass: HomeAssistant) -> None: hass, { "name": "Workday Sensor2", - "country": "DE", + "country": "CH", "excludes": ["sat", "sun", "holiday"], "days_offset": 0, "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": ["2023-03-28"], "remove_holidays": [], - "province": None, + "province": "FR", + "category": [OPTIONAL], }, entry_id="2", ) @@ -389,6 +393,8 @@ async def test_options_form_abort_duplicate(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], + "province": "FR", + "category": [OPTIONAL], }, ) @@ -602,3 +608,48 @@ async def test_language( state = hass.states.get("binary_sensor.workday_sensor") assert state is not None assert state.state == "on" + + +async def test_form_with_categories(hass: HomeAssistant) -> None: + """Test optional categories.""" + + 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"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "CH", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + CONF_LANGUAGE: "de", + CONF_CATEGORY: [HALF_DAY], + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "CH", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "language": "de", + "category": ["half_day"], + }