Add update reward action to Habitica integration (#139157)

This commit is contained in:
Manu 2025-02-25 13:40:21 +01:00 committed by GitHub
parent 694a77fe3c
commit 2509353221
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 593 additions and 5 deletions

View File

@ -35,6 +35,10 @@ ATTR_TYPE = "type"
ATTR_PRIORITY = "priority"
ATTR_TAG = "tag"
ATTR_KEYWORD = "keyword"
ATTR_REMOVE_TAG = "remove_tag"
ATTR_ALIAS = "alias"
ATTR_PRIORITY = "priority"
ATTR_COST = "cost"
SERVICE_CAST_SKILL = "cast_skill"
SERVICE_START_QUEST = "start_quest"
@ -50,6 +54,7 @@ SERVICE_SCORE_REWARD = "score_reward"
SERVICE_TRANSFORMATION = "transformation"
SERVICE_UPDATE_REWARD = "update_reward"
DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf"
X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"

View File

@ -217,6 +217,13 @@
"sections": {
"filter": "mdi:calendar-filter"
}
},
"update_reward": {
"service": "mdi:treasure-chest",
"sections": {
"tag_options": "mdi:tag",
"developer_options": "mdi:test-tube"
}
}
}
}

View File

@ -4,7 +4,8 @@ from __future__ import annotations
from dataclasses import asdict
import logging
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from aiohttp import ClientError
from habiticalib import (
@ -13,6 +14,7 @@ from habiticalib import (
NotAuthorizedError,
NotFoundError,
Skill,
Task,
TaskData,
TaskPriority,
TaskType,
@ -20,6 +22,7 @@ from habiticalib import (
)
import voluptuous as vol
from homeassistant.components.todo import ATTR_DESCRIPTION, ATTR_RENAME
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_NAME, CONF_NAME
from homeassistant.core import (
@ -34,14 +37,17 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss
from homeassistant.helpers.selector import ConfigEntrySelector
from .const import (
ATTR_ALIAS,
ATTR_ARGS,
ATTR_CONFIG_ENTRY,
ATTR_COST,
ATTR_DATA,
ATTR_DIRECTION,
ATTR_ITEM,
ATTR_KEYWORD,
ATTR_PATH,
ATTR_PRIORITY,
ATTR_REMOVE_TAG,
ATTR_SKILL,
ATTR_TAG,
ATTR_TARGET,
@ -61,6 +67,7 @@ from .const import (
SERVICE_SCORE_REWARD,
SERVICE_START_QUEST,
SERVICE_TRANSFORMATION,
SERVICE_UPDATE_REWARD,
)
from .coordinator import HabiticaConfigEntry
@ -104,6 +111,21 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
}
)
SERVICE_UPDATE_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_DESCRIPTION): 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),
}
)
SERVICE_GET_TASKS_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
@ -516,6 +538,130 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
return result
async def update_task(call: ServiceCall) -> ServiceResponse:
"""Update task action."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data
await coordinator.async_refresh()
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 (description := call.data.get(ATTR_DESCRIPTION)) is not None:
data["notes"] = description
tags = cast(list[str], call.data.get(ATTR_TAG))
remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG))
if tags or remove_tags:
update_tags = set(current_task.tags)
user_tags = {
tag.name.lower(): tag.id
for tag in coordinator.data.user.tags
if tag.id and tag.name
}
if tags:
# Creates new tag if it doesn't exist
async def create_tag(tag_name: str) -> UUID:
tag_id = (await coordinator.habitica.create_tag(tag_name)).data.id
if TYPE_CHECKING:
assert tag_id
return tag_id
try:
update_tags.update(
{
user_tags.get(tag_name.lower())
or (await create_tag(tag_name))
for tag_name in tags
}
)
except TooManyRequestsError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
translation_placeholders={"retry_after": str(e.retry_after)},
) from e
except HabiticaException as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
translation_placeholders={"reason": str(e.error.message)},
) from e
except ClientError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
translation_placeholders={"reason": str(e)},
) from e
if remove_tags:
update_tags.difference_update(
{
user_tags[tag_name.lower()]
for tag_name in remove_tags
if tag_name.lower() in user_tags
}
)
data["tags"] = list(update_tags)
if (alias := call.data.get(ATTR_ALIAS)) is not None:
data["alias"] = alias
if (cost := call.data.get(ATTR_COST)) is not None:
data["value"] = cost
try:
response = await coordinator.habitica.update_task(task_id, data)
except TooManyRequestsError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
translation_placeholders={"retry_after": str(e.retry_after)},
) from e
except HabiticaException as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
translation_placeholders={"reason": str(e.error.message)},
) from e
except ClientError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
translation_placeholders={"reason": str(e)},
) from e
else:
return response.data.to_dict(omit_none=True)
hass.services.async_register(
DOMAIN,
SERVICE_UPDATE_REWARD,
update_task,
schema=SERVICE_UPDATE_TASK_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_API_CALL,

View File

@ -140,3 +140,43 @@ get_tasks:
required: false
selector:
text:
update_reward:
fields:
config_entry: *config_entry
task: *task
rename:
selector:
text:
description:
required: false
selector:
text:
multiline: true
cost:
required: false
selector:
number:
min: 0
step: 0.01
unit_of_measurement: "🪙"
mode: box
tag_options:
collapsed: true
fields:
tag:
required: false
selector:
text:
multiple: true
remove_tag:
required: false
selector:
text:
multiple: true
developer_options:
collapsed: true
fields:
alias:
required: false
selector:
text:

View File

@ -7,7 +7,23 @@
"unit_tasks": "tasks",
"unit_health_points": "HP",
"unit_mana_points": "MP",
"unit_experience_points": "XP"
"unit_experience_points": "XP",
"config_entry_description": "Select the Habitica account to update a task.",
"task_description": "The name (or task ID) of the task you want to update.",
"rename_name": "Rename",
"rename_description": "The new title for the Habitica task.",
"description_name": "Update description",
"description_description": "The new description for the Habitica task.",
"tag_name": "Add tags",
"tag_description": "Add tags to the Habitica task. If a tag does not already exist, a new one will be created.",
"remove_tag_name": "Remove tags",
"remove_tag_description": "Remove tags from the Habitica task.",
"alias_name": "Task alias",
"alias_description": "A task alias can be used instead of the name or task ID. Only dashes, underscores, and alphanumeric characters are supported. The task alias must be unique among all your tasks.",
"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."
},
"config": {
"abort": {
@ -457,6 +473,12 @@
},
"authentication_failed": {
"message": "Authentication failed. It looks like your API token has been reset. Please re-authenticate using your new token"
},
"frequency_not_weekly": {
"message": "Unable to update task, weekly repeat settings apply only to weekly recurring dailies."
},
"frequency_not_monthly": {
"message": "Unable to update task, monthly repeat settings apply only to monthly recurring dailies."
}
},
"issues": {
@ -651,6 +673,54 @@
"description": "Use the optional filters to narrow the returned tasks."
}
}
},
"update_reward": {
"name": "Update a reward",
"description": "Updates a specific reward for the selected Habitica character",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
"description": "Select the Habitica account to update a reward."
},
"task": {
"name": "[%key:component::habitica::common::task_name%]",
"description": "[%key:component::habitica::common::task_description%]"
},
"rename": {
"name": "[%key:component::habitica::common::rename_name%]",
"description": "[%key:component::habitica::common::rename_description%]"
},
"description": {
"name": "[%key:component::habitica::common::description_name%]",
"description": "[%key:component::habitica::common::description_description%]"
},
"tag": {
"name": "[%key:component::habitica::common::tag_name%]",
"description": "[%key:component::habitica::common::tag_description%]"
},
"remove_tag": {
"name": "[%key:component::habitica::common::remove_tag_name%]",
"description": "[%key:component::habitica::common::remove_tag_description%]"
},
"alias": {
"name": "[%key:component::habitica::common::alias_name%]",
"description": "[%key:component::habitica::common::alias_description%]"
},
"cost": {
"name": "Cost",
"description": "Update the cost of a reward."
}
},
"sections": {
"tag_options": {
"name": "[%key:component::habitica::common::tag_options_name%]",
"description": "[%key:component::habitica::common::tag_options_description%]"
},
"developer_options": {
"name": "[%key:component::habitica::common::developer_options_name%]",
"description": "[%key:component::habitica::common::developer_options_description%]"
}
}
}
},
"selector": {

View File

@ -14,6 +14,7 @@ from habiticalib import (
HabiticaResponse,
HabiticaScoreResponse,
HabiticaSleepResponse,
HabiticaTagResponse,
HabiticaTaskOrderResponse,
HabiticaTaskResponse,
HabiticaTasksResponse,
@ -144,6 +145,12 @@ async def mock_habiticalib() -> Generator[AsyncMock]:
load_fixture("anonymized.json", DOMAIN)
)
)
client.update_task.return_value = HabiticaTaskResponse.from_json(
load_fixture("task.json", DOMAIN)
)
client.create_tag.return_value = HabiticaTagResponse.from_json(
load_fixture("create_tag.json", DOMAIN)
)
client.habitipy.return_value = {
"tasks": {
"user": {

View File

@ -0,0 +1,8 @@
{
"success": true,
"data": {
"name": "Home Assistant",
"id": "8bc0afbf-ab8e-49a4-982d-67a40557ed1a"
},
"notifications": []
}

View File

@ -0,0 +1,27 @@
{
"success": true,
"data": {
"_id": "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b",
"type": "reward",
"text": "Belohne Dich selbst",
"notes": "Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!",
"tags": [],
"value": 10,
"priority": 1,
"attribute": "str",
"challenge": {},
"group": {
"completedBy": {},
"assignedUsers": []
},
"byHabitica": false,
"reminders": [],
"createdAt": "2024-07-07T17:51:53.266Z",
"updatedAt": "2024-07-07T17:51:53.266Z",
"userId": "5f359083-ef78-4af0-985a-0b2c6d05797c",
"id": "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"
},
"notifications": [],
"userV": 589,
"appVersion": "5.28.6"
}

View File

@ -533,7 +533,10 @@
"type": "reward",
"text": "Belohne Dich selbst",
"notes": "Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!",
"tags": [],
"tags": [
"3450351f-1323-4c7e-9fd2-0cdff25b3ce0",
"b2780f82-b3b5-49a3-a677-48f2c8c7e3bb"
],
"value": 10,
"priority": 1,
"attribute": "str",

View File

@ -1271,6 +1271,10 @@
'th': False,
'w': True,
}),
'tags': list([
'3450351f-1323-4c7e-9fd2-0cdff25b3ce0',
'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb',
]),
'text': 'Belohne Dich selbst',
'type': 'reward',
'value': 10.0,

View File

@ -1081,6 +1081,8 @@
'startDate': None,
'streak': None,
'tags': list([
'3450351f-1323-4c7e-9fd2-0cdff25b3ce0',
'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb',
]),
'text': 'Belohne Dich selbst',
'type': 'reward',
@ -3321,6 +3323,8 @@
'startDate': None,
'streak': None,
'tags': list([
'3450351f-1323-4c7e-9fd2-0cdff25b3ce0',
'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb',
]),
'text': 'Belohne Dich selbst',
'type': 'reward',
@ -5580,6 +5584,8 @@
'startDate': None,
'streak': None,
'tags': list([
'3450351f-1323-4c7e-9fd2-0cdff25b3ce0',
'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb',
]),
'text': 'Belohne Dich selbst',
'type': 'reward',
@ -5954,6 +5960,8 @@
'startDate': None,
'streak': None,
'tags': list([
'3450351f-1323-4c7e-9fd2-0cdff25b3ce0',
'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb',
]),
'text': 'Belohne Dich selbst',
'type': 'reward',

View File

@ -6,16 +6,19 @@ from unittest.mock import AsyncMock, patch
from uuid import UUID
from aiohttp import ClientError
from habiticalib import Direction, Skill
from habiticalib import Direction, HabiticaTaskResponse, Skill, Task
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.habitica.const import (
ATTR_ALIAS,
ATTR_CONFIG_ENTRY,
ATTR_COST,
ATTR_DIRECTION,
ATTR_ITEM,
ATTR_KEYWORD,
ATTR_PRIORITY,
ATTR_REMOVE_TAG,
ATTR_SKILL,
ATTR_TAG,
ATTR_TARGET,
@ -33,7 +36,9 @@ from homeassistant.components.habitica.const import (
SERVICE_SCORE_REWARD,
SERVICE_START_QUEST,
SERVICE_TRANSFORMATION,
SERVICE_UPDATE_REWARD,
)
from homeassistant.components.todo import ATTR_DESCRIPTION, ATTR_RENAME
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
@ -45,7 +50,7 @@ from .conftest import (
ERROR_TOO_MANY_REQUESTS,
)
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, load_fixture
REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica: reason"
RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again in 5 seconds"
@ -889,3 +894,261 @@ async def test_get_tasks(
)
assert response == snapshot
@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_update_task_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
exception: Exception,
expected_exception: Exception,
exception_msg: str,
) -> None:
"""Test Habitica task action exceptions."""
task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"
habitica.update_task.side_effect = exception
with pytest.raises(expected_exception, match=exception_msg):
await hass.services.async_call(
DOMAIN,
SERVICE_UPDATE_REWARD,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
ATTR_TASK: task_id,
},
return_response=True,
blocking=True,
)
@pytest.mark.usefixtures("habitica")
async def test_task_not_found(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
) -> None:
"""Test Habitica task not found exceptions."""
task_id = "7f902bbc-eb3d-4a8f-82cf-4e2025d69af1"
with pytest.raises(
ServiceValidationError,
match="Unable to complete action, could not find the task '7f902bbc-eb3d-4a8f-82cf-4e2025d69af1'",
):
await hass.services.async_call(
DOMAIN,
SERVICE_UPDATE_REWARD,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
ATTR_TASK: task_id,
},
return_response=True,
blocking=True,
)
@pytest.mark.parametrize(
("service_data", "call_args"),
[
(
{
ATTR_COST: 100,
},
Task(value=100),
),
(
{
ATTR_RENAME: "RENAME",
},
Task(text="RENAME"),
),
(
{
ATTR_DESCRIPTION: "DESCRIPTION",
},
Task(notes="DESCRIPTION"),
),
(
{
ATTR_ALIAS: "ALIAS",
},
Task(alias="ALIAS"),
),
],
)
async def test_update_reward(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
service_data: dict[str, Any],
call_args: Task,
) -> None:
"""Test Habitica update_reward action."""
task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"
habitica.update_task.return_value = HabiticaTaskResponse.from_json(
load_fixture("task.json", DOMAIN)
)
await hass.services.async_call(
DOMAIN,
SERVICE_UPDATE_REWARD,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
ATTR_TASK: task_id,
**service_data,
},
return_response=True,
blocking=True,
)
habitica.update_task.assert_awaited_with(UUID(task_id), call_args)
async def test_tags(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
) -> None:
"""Test adding tags to a task."""
task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"
await hass.services.async_call(
DOMAIN,
SERVICE_UPDATE_REWARD,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
ATTR_TASK: task_id,
ATTR_TAG: ["Schule"],
},
return_response=True,
blocking=True,
)
call_args = habitica.update_task.call_args[0]
assert call_args[0] == UUID(task_id)
assert set(call_args[1]["tags"]) == {
UUID("2ac458af-0833-4f3f-bf04-98a0c33ef60b"),
UUID("3450351f-1323-4c7e-9fd2-0cdff25b3ce0"),
UUID("b2780f82-b3b5-49a3-a677-48f2c8c7e3bb"),
}
async def test_create_new_tag(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
) -> None:
"""Test adding a non-existent tag and create it as new."""
task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"
await hass.services.async_call(
DOMAIN,
SERVICE_UPDATE_REWARD,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
ATTR_TASK: task_id,
ATTR_TAG: ["Home Assistant"],
},
return_response=True,
blocking=True,
)
habitica.create_tag.assert_awaited_with("Home Assistant")
call_args = habitica.update_task.call_args[0]
assert call_args[0] == UUID(task_id)
assert set(call_args[1]["tags"]) == {
UUID("8bc0afbf-ab8e-49a4-982d-67a40557ed1a"),
UUID("3450351f-1323-4c7e-9fd2-0cdff25b3ce0"),
UUID("b2780f82-b3b5-49a3-a677-48f2c8c7e3bb"),
}
@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: ",
),
],
)
async def test_create_new_tag_exception(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
exception: Exception,
expected_exception: Exception,
exception_msg: str,
) -> None:
"""Test create new tag exception."""
task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"
habitica.create_tag.side_effect = exception
with pytest.raises(expected_exception, match=exception_msg):
await hass.services.async_call(
DOMAIN,
SERVICE_UPDATE_REWARD,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
ATTR_TASK: task_id,
ATTR_TAG: ["Home Assistant"],
},
return_response=True,
blocking=True,
)
async def test_remove_tags(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
) -> None:
"""Test removing tags from a task."""
task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"
await hass.services.async_call(
DOMAIN,
SERVICE_UPDATE_REWARD,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
ATTR_TASK: task_id,
ATTR_REMOVE_TAG: ["Kreativität"],
},
return_response=True,
blocking=True,
)
call_args = habitica.update_task.call_args[0]
assert call_args[0] == UUID(task_id)
assert set(call_args[1]["tags"]) == {UUID("b2780f82-b3b5-49a3-a677-48f2c8c7e3bb")}