Add create_reward action to Habitica integration (#139304)

Add create_reward action to Habitica
This commit is contained in:
Manu 2025-03-01 22:27:31 +01:00 committed by GitHub
parent 2cce1b024e
commit 3588784f1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 224 additions and 34 deletions

View File

@ -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__}"

View File

@ -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"
}
}
}
}

View File

@ -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,

View File

@ -147,14 +147,14 @@ update_reward:
rename:
selector:
text:
notes:
notes: &notes
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

View File

@ -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": {

View File

@ -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": {

View File

@ -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,