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_TRANSFORMATION = "transformation"
SERVICE_UPDATE_REWARD = "update_reward" SERVICE_UPDATE_REWARD = "update_reward"
SERVICE_CREATE_REWARD = "create_reward"
DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf"
X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"

View File

@ -224,6 +224,12 @@
"tag_options": "mdi:tag", "tag_options": "mdi:tag",
"developer_options": "mdi:test-tube" "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_API_CALL,
SERVICE_CANCEL_QUEST, SERVICE_CANCEL_QUEST,
SERVICE_CAST_SKILL, SERVICE_CAST_SKILL,
SERVICE_CREATE_REWARD,
SERVICE_GET_TASKS, SERVICE_GET_TASKS,
SERVICE_LEAVE_QUEST, SERVICE_LEAVE_QUEST,
SERVICE_REJECT_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_CONFIG_ENTRY): ConfigEntrySelector(),
vol.Required(ATTR_TASK): cv.string,
vol.Optional(ATTR_RENAME): cv.string, vol.Optional(ATTR_RENAME): cv.string,
vol.Optional(ATTR_NOTES): cv.string, vol.Optional(ATTR_NOTES): cv.string,
vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]), 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( vol.Optional(ATTR_ALIAS): vol.All(
cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$") 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 return result
async def update_task(call: ServiceCall) -> ServiceResponse: async def create_or_update_task(call: ServiceCall) -> ServiceResponse:
"""Update task action.""" """Create or update task action."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data coordinator = entry.runtime_data
await coordinator.async_refresh() await coordinator.async_refresh()
is_update = call.service == SERVICE_UPDATE_REWARD
current_task = None
try: if is_update:
current_task = next( try:
task current_task = next(
for task in coordinator.data.tasks task
if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) for task in coordinator.data.tasks
and task.Type is TaskType.REWARD 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( except StopIteration as e:
translation_domain=DOMAIN, raise ServiceValidationError(
translation_key="task_not_found", translation_domain=DOMAIN,
translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, translation_key="task_not_found",
) from e translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"},
) from e
task_id = current_task.id
if TYPE_CHECKING:
assert task_id
data = Task() data = Task()
if rename := call.data.get(ATTR_RENAME): if not is_update:
data["text"] = rename 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: if (notes := call.data.get(ATTR_NOTES)) is not None:
data["notes"] = notes 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)) remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG))
if tags or remove_tags: if tags or remove_tags:
update_tags = set(current_task.tags) update_tags = set(current_task.tags) if current_task else set()
user_tags = { user_tags = {
tag.name.lower(): tag.id tag.name.lower(): tag.id
for tag in coordinator.data.user.tags for tag in coordinator.data.user.tags
@ -634,7 +649,13 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
data["value"] = cost data["value"] = cost
try: 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: except TooManyRequestsError as e:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
@ -659,10 +680,17 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_UPDATE_REWARD, SERVICE_UPDATE_REWARD,
update_task, create_or_update_task,
schema=SERVICE_UPDATE_TASK_SCHEMA, schema=SERVICE_UPDATE_TASK_SCHEMA,
supports_response=SupportsResponse.ONLY, 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( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_API_CALL, SERVICE_API_CALL,

View File

@ -147,14 +147,14 @@ update_reward:
rename: rename:
selector: selector:
text: text:
notes: notes: &notes
required: false required: false
selector: selector:
text: text:
multiline: true multiline: true
cost: cost:
required: false required: false
selector: selector: &cost_selector
number: number:
min: 0 min: 0
step: 0.01 step: 0.01
@ -163,7 +163,7 @@ update_reward:
tag_options: tag_options:
collapsed: true collapsed: true
fields: fields:
tag: tag: &tag
required: false required: false
selector: selector:
text: text:
@ -173,10 +173,23 @@ update_reward:
selector: selector:
text: text:
multiple: true multiple: true
developer_options: developer_options: &developer_options
collapsed: true collapsed: true
fields: fields:
alias: alias:
required: false required: false
selector: selector:
text: 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_name": "Advanced settings",
"developer_options_description": "Additional features available in developer mode.", "developer_options_description": "Additional features available in developer mode.",
"tag_options_name": "Tags", "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": { "config": {
"abort": { "abort": {
@ -707,7 +709,7 @@
"description": "[%key:component::habitica::common::alias_description%]" "description": "[%key:component::habitica::common::alias_description%]"
}, },
"cost": { "cost": {
"name": "Cost", "name": "[%key:component::habitica::common::cost_name%]",
"description": "Update the cost of a reward." "description": "Update the cost of a reward."
} }
}, },
@ -721,6 +723,42 @@
"description": "[%key:component::habitica::common::developer_options_description%]" "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": { "selector": {

View File

@ -151,6 +151,9 @@ async def mock_habiticalib() -> Generator[AsyncMock]:
client.create_tag.return_value = HabiticaTagResponse.from_json( client.create_tag.return_value = HabiticaTagResponse.from_json(
load_fixture("create_tag.json", DOMAIN) load_fixture("create_tag.json", DOMAIN)
) )
client.create_task.return_value = HabiticaTaskResponse.from_json(
load_fixture("task.json", DOMAIN)
)
client.habitipy.return_value = { client.habitipy.return_value = {
"tasks": { "tasks": {
"user": { "user": {

View File

@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch
from uuid import UUID from uuid import UUID
from aiohttp import ClientError from aiohttp import ClientError
from habiticalib import Direction, HabiticaTaskResponse, Skill, Task from habiticalib import Direction, HabiticaTaskResponse, Skill, Task, TaskType
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
@ -30,6 +30,7 @@ from homeassistant.components.habitica.const import (
SERVICE_ACCEPT_QUEST, SERVICE_ACCEPT_QUEST,
SERVICE_CANCEL_QUEST, SERVICE_CANCEL_QUEST,
SERVICE_CAST_SKILL, SERVICE_CAST_SKILL,
SERVICE_CREATE_REWARD,
SERVICE_GET_TASKS, SERVICE_GET_TASKS,
SERVICE_LEAVE_QUEST, SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST, SERVICE_REJECT_QUEST,
@ -41,6 +42,7 @@ from homeassistant.components.habitica.const import (
) )
from homeassistant.components.todo import ATTR_RENAME from homeassistant.components.todo import ATTR_RENAME
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError 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") @pytest.mark.usefixtures("habitica")
async def test_task_not_found( async def test_task_not_found(
hass: HomeAssistant, hass: HomeAssistant,
@ -1024,6 +1071,60 @@ async def test_update_reward(
habitica.update_task.assert_awaited_with(UUID(task_id), call_args) 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( async def test_tags(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,