diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 353bcbbd39d..bd1363ca979 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -56,6 +56,7 @@ SERVICE_SCORE_REWARD = "score_reward" SERVICE_TRANSFORMATION = "transformation" SERVICE_UPDATE_REWARD = "update_reward" +SERVICE_CREATE_REWARD = "create_reward" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index e119b063aa5..83df86f3945 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -224,6 +224,12 @@ "tag_options": "mdi:tag", "developer_options": "mdi:test-tube" } + }, + "create_reward": { + "service": "mdi:treasure-chest-outline", + "sections": { + "developer_options": "mdi:test-tube" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 57005cf2b72..1abe977681f 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -61,6 +61,7 @@ from .const import ( SERVICE_API_CALL, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_REWARD, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, @@ -112,18 +113,29 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( } ) -SERVICE_UPDATE_TASK_SCHEMA = vol.Schema( +BASE_TASK_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), - vol.Required(ATTR_TASK): cv.string, vol.Optional(ATTR_RENAME): cv.string, vol.Optional(ATTR_NOTES): cv.string, vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]), - vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_ALIAS): vol.All( cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$") ), - vol.Optional(ATTR_COST): vol.Coerce(float), + vol.Optional(ATTR_COST): vol.All(vol.Coerce(float), vol.Range(0)), + } +) + +SERVICE_UPDATE_TASK_SCHEMA = BASE_TASK_SCHEMA.extend( + { + vol.Required(ATTR_TASK): cv.string, + vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]), + } +) + +SERVICE_CREATE_TASK_SCHEMA = BASE_TASK_SCHEMA.extend( + { + vol.Required(ATTR_NAME): cv.string, } ) @@ -539,33 +551,36 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 return result - async def update_task(call: ServiceCall) -> ServiceResponse: - """Update task action.""" + async def create_or_update_task(call: ServiceCall) -> ServiceResponse: + """Create or update task action.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data await coordinator.async_refresh() + is_update = call.service == SERVICE_UPDATE_REWARD + current_task = None - try: - current_task = next( - task - for task in coordinator.data.tasks - if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) - and task.Type is TaskType.REWARD - ) - except StopIteration as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="task_not_found", - translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, - ) from e + if is_update: + try: + current_task = next( + task + for task in coordinator.data.tasks + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + and task.Type is TaskType.REWARD + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_not_found", + translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, + ) from e - task_id = current_task.id - if TYPE_CHECKING: - assert task_id data = Task() - if rename := call.data.get(ATTR_RENAME): - data["text"] = rename + if not is_update: + data["type"] = TaskType.REWARD + + if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)): + data["text"] = text if (notes := call.data.get(ATTR_NOTES)) is not None: data["notes"] = notes @@ -574,7 +589,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) if tags or remove_tags: - update_tags = set(current_task.tags) + update_tags = set(current_task.tags) if current_task else set() user_tags = { tag.name.lower(): tag.id for tag in coordinator.data.user.tags @@ -634,7 +649,13 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 data["value"] = cost try: - response = await coordinator.habitica.update_task(task_id, data) + if is_update: + if TYPE_CHECKING: + assert current_task + assert current_task.id + response = await coordinator.habitica.update_task(current_task.id, data) + else: + response = await coordinator.habitica.create_task(data) except TooManyRequestsError as e: raise HomeAssistantError( translation_domain=DOMAIN, @@ -659,10 +680,17 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, SERVICE_UPDATE_REWARD, - update_task, + create_or_update_task, schema=SERVICE_UPDATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_REWARD, + create_or_update_task, + schema=SERVICE_CREATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_API_CALL, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index 7b486690ef5..b92b765e18c 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -147,14 +147,14 @@ update_reward: rename: selector: text: - notes: + notes: ¬es required: false selector: text: multiline: true cost: required: false - selector: + selector: &cost_selector number: min: 0 step: 0.01 @@ -163,7 +163,7 @@ update_reward: tag_options: collapsed: true fields: - tag: + tag: &tag required: false selector: text: @@ -173,10 +173,23 @@ update_reward: selector: text: multiple: true - developer_options: + developer_options: &developer_options collapsed: true fields: alias: required: false selector: text: +create_reward: + fields: + config_entry: *config_entry + name: + required: true + selector: + text: + notes: *notes + cost: + required: true + selector: *cost_selector + tag: *tag + developer_options: *developer_options diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 1bb2fcbd9d7..0658e594d07 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -23,7 +23,9 @@ "developer_options_name": "Advanced settings", "developer_options_description": "Additional features available in developer mode.", "tag_options_name": "Tags", - "tag_options_description": "Add or remove tags from a task." + "tag_options_description": "Add or remove tags from a task.", + "name_description": "The title for the Habitica task.", + "cost_name": "Cost" }, "config": { "abort": { @@ -707,7 +709,7 @@ "description": "[%key:component::habitica::common::alias_description%]" }, "cost": { - "name": "Cost", + "name": "[%key:component::habitica::common::cost_name%]", "description": "Update the cost of a reward." } }, @@ -721,6 +723,42 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "create_reward": { + "name": "Create reward", + "description": "Adds a new custom reward.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to create a reward." + }, + "name": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::name_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "cost": { + "name": "[%key:component::habitica::common::cost_name%]", + "description": "The cost of the reward." + } + }, + "sections": { + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } } }, "selector": { diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 45c33a9ebb6..efb4f7300bf 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -151,6 +151,9 @@ async def mock_habiticalib() -> Generator[AsyncMock]: client.create_tag.return_value = HabiticaTagResponse.from_json( load_fixture("create_tag.json", DOMAIN) ) + client.create_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) client.habitipy.return_value = { "tasks": { "user": { diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index a4442016784..0b25dc4385e 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError -from habiticalib import Direction, HabiticaTaskResponse, Skill, Task +from habiticalib import Direction, HabiticaTaskResponse, Skill, Task, TaskType import pytest from syrupy.assertion import SnapshotAssertion @@ -30,6 +30,7 @@ from homeassistant.components.habitica.const import ( SERVICE_ACCEPT_QUEST, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_REWARD, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, @@ -41,6 +42,7 @@ from homeassistant.components.habitica.const import ( ) from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -943,6 +945,51 @@ async def test_update_task_exceptions( ) +@pytest.mark.parametrize( + ("exception", "expected_exception", "exception_msg"), + [ + ( + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + ERROR_BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ], +) +@pytest.mark.usefixtures("habitica") +async def test_create_task_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, + expected_exception: Exception, + exception_msg: str, +) -> None: + """Test Habitica task create action exceptions.""" + + habitica.create_task.side_effect = exception + with pytest.raises(expected_exception, match=exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_NAME: "TITLE", + }, + return_response=True, + blocking=True, + ) + + @pytest.mark.usefixtures("habitica") async def test_task_not_found( hass: HomeAssistant, @@ -1024,6 +1071,60 @@ async def test_update_reward( habitica.update_task.assert_awaited_with(UUID(task_id), call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_NAME: "TITLE", + ATTR_COST: 100, + }, + Task(type=TaskType.REWARD, text="TITLE", value=100), + ), + ( + { + ATTR_NAME: "TITLE", + }, + Task(type=TaskType.REWARD, text="TITLE"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_NOTES: "NOTES", + }, + Task(type=TaskType.REWARD, text="TITLE", notes="NOTES"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ALIAS: "ALIAS", + }, + Task(type=TaskType.REWARD, text="TITLE", alias="ALIAS"), + ), + ], +) +async def test_create_reward( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica create_reward action.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.create_task.assert_awaited_with(call_args) + + async def test_tags( hass: HomeAssistant, config_entry: MockConfigEntry,