Add actions for scoring habits and rewards in Habitica (#129605)

This commit is contained in:
Manu 2024-11-10 12:26:07 +01:00 committed by GitHub
parent d0ad834d93
commit 0677bba5bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 311 additions and 7 deletions

View File

@ -25,6 +25,7 @@ UNIT_TASKS = "tasks"
ATTR_CONFIG_ENTRY = "config_entry" ATTR_CONFIG_ENTRY = "config_entry"
ATTR_SKILL = "skill" ATTR_SKILL = "skill"
ATTR_TASK = "task" ATTR_TASK = "task"
ATTR_DIRECTION = "direction"
SERVICE_CAST_SKILL = "cast_skill" SERVICE_CAST_SKILL = "cast_skill"
SERVICE_START_QUEST = "start_quest" SERVICE_START_QUEST = "start_quest"
SERVICE_ACCEPT_QUEST = "accept_quest" SERVICE_ACCEPT_QUEST = "accept_quest"
@ -32,6 +33,9 @@ SERVICE_CANCEL_QUEST = "cancel_quest"
SERVICE_ABORT_QUEST = "abort_quest" SERVICE_ABORT_QUEST = "abort_quest"
SERVICE_REJECT_QUEST = "reject_quest" SERVICE_REJECT_QUEST = "reject_quest"
SERVICE_LEAVE_QUEST = "leave_quest" SERVICE_LEAVE_QUEST = "leave_quest"
SERVICE_SCORE_HABIT = "score_habit"
SERVICE_SCORE_REWARD = "score_reward"
WARRIOR = "warrior" WARRIOR = "warrior"
ROGUE = "rogue" ROGUE = "rogue"
HEALER = "healer" HEALER = "healer"

View File

@ -181,6 +181,12 @@
}, },
"start_quest": { "start_quest": {
"service": "mdi:script-text-key" "service": "mdi:script-text-key"
},
"score_habit": {
"service": "mdi:counter"
},
"score_reward": {
"service": "mdi:sack"
} }
} }
} }

View File

@ -25,6 +25,7 @@ from .const import (
ATTR_ARGS, ATTR_ARGS,
ATTR_CONFIG_ENTRY, ATTR_CONFIG_ENTRY,
ATTR_DATA, ATTR_DATA,
ATTR_DIRECTION,
ATTR_PATH, ATTR_PATH,
ATTR_SKILL, ATTR_SKILL,
ATTR_TASK, ATTR_TASK,
@ -37,6 +38,8 @@ from .const import (
SERVICE_CAST_SKILL, SERVICE_CAST_SKILL,
SERVICE_LEAVE_QUEST, SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST, SERVICE_REJECT_QUEST,
SERVICE_SCORE_HABIT,
SERVICE_SCORE_REWARD,
SERVICE_START_QUEST, SERVICE_START_QUEST,
) )
from .types import HabiticaConfigEntry from .types import HabiticaConfigEntry
@ -65,6 +68,13 @@ SERVICE_MANAGE_QUEST_SCHEMA = vol.Schema(
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
} }
) )
SERVICE_SCORE_TASK_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
vol.Required(ATTR_TASK): cv.string,
vol.Optional(ATTR_DIRECTION): cv.string,
}
)
def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
@ -82,7 +92,7 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
return entry return entry
def async_setup_services(hass: HomeAssistant) -> None: def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
"""Set up services for Habitica integration.""" """Set up services for Habitica integration."""
async def handle_api_call(call: ServiceCall) -> None: async def handle_api_call(call: ServiceCall) -> None:
@ -223,6 +233,53 @@ def async_setup_services(hass: HomeAssistant) -> None:
supports_response=SupportsResponse.ONLY, supports_response=SupportsResponse.ONLY,
) )
async def score_task(call: ServiceCall) -> ServiceResponse:
"""Score a task action."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data
try:
task_id, task_value = next(
(task["id"], task.get("value"))
for task in coordinator.data.tasks
if call.data[ATTR_TASK] in (task["id"], task.get("alias"))
or call.data[ATTR_TASK] == task["text"]
)
except StopIteration as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="task_not_found",
translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"},
) from e
try:
response: dict[str, Any] = (
await coordinator.api.tasks[task_id]
.score[call.data.get(ATTR_DIRECTION, "up")]
.post()
)
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
if e.status == HTTPStatus.UNAUTHORIZED and task_value is not None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_enough_gold",
translation_placeholders={
"gold": f"{coordinator.data.user["stats"]["gp"]:.2f} GP",
"cost": f"{task_value} GP",
},
) from e
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
else:
await coordinator.async_request_refresh()
return response
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_API_CALL, SERVICE_API_CALL,
@ -237,3 +294,18 @@ def async_setup_services(hass: HomeAssistant) -> None:
schema=SERVICE_CAST_SKILL_SCHEMA, schema=SERVICE_CAST_SKILL_SCHEMA,
supports_response=SupportsResponse.ONLY, supports_response=SupportsResponse.ONLY,
) )
hass.services.async_register(
DOMAIN,
SERVICE_SCORE_HABIT,
score_task,
schema=SERVICE_SCORE_TASK_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_SCORE_REWARD,
score_task,
schema=SERVICE_SCORE_TASK_SCHEMA,
supports_response=SupportsResponse.ONLY,
)

View File

@ -33,7 +33,7 @@ cast_skill:
- "fireball" - "fireball"
mode: dropdown mode: dropdown
translation_key: "skill_select" translation_key: "skill_select"
task: task: &task
required: true required: true
selector: selector:
text: text:
@ -55,3 +55,20 @@ abort_quest:
leave_quest: leave_quest:
fields: fields:
config_entry: *config_entry config_entry: *config_entry
score_habit:
fields:
config_entry: *config_entry
task: *task
direction:
required: true
selector:
select:
options:
- value: up
label: ""
- value: down
label: ""
score_reward:
fields:
config_entry: *config_entry
task: *task

View File

@ -301,6 +301,9 @@
"not_enough_mana": { "not_enough_mana": {
"message": "Unable to cast skill, not enough mana. Your character has {mana}, but the skill costs {cost}." "message": "Unable to cast skill, not enough mana. Your character has {mana}, but the skill costs {cost}."
}, },
"not_enough_gold": {
"message": "Unable to buy reward, not enough gold. Your character has {gold}, but the reward costs {cost}."
},
"skill_not_found": { "skill_not_found": {
"message": "Unable to cast skill, your character does not have the skill or spell {skill}." "message": "Unable to cast skill, your character does not have the skill or spell {skill}."
}, },
@ -311,7 +314,7 @@
"message": "The selected character is currently not loaded or disabled in Home Assistant." "message": "The selected character is currently not loaded or disabled in Home Assistant."
}, },
"task_not_found": { "task_not_found": {
"message": "Unable to cast skill, could not find the task {task}" "message": "Unable to complete action, could not find the task {task}"
}, },
"quest_action_unallowed": { "quest_action_unallowed": {
"message": "Action not allowed, only quest leader or group leader can perform this action" "message": "Action not allowed, only quest leader or group leader can perform this action"
@ -350,7 +353,7 @@
"description": "Use a skill or spell from your Habitica character on a specific task to affect its progress or status.", "description": "Use a skill or spell from your Habitica character on a specific task to affect its progress or status.",
"fields": { "fields": {
"config_entry": { "config_entry": {
"name": "Select character", "name": "[%key:component::habitica::common::config_entry_name%]",
"description": "Choose the Habitica character to cast the skill." "description": "Choose the Habitica character to cast the skill."
}, },
"skill": { "skill": {
@ -422,6 +425,38 @@
"description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]" "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
} }
} }
},
"score_habit": {
"name": "Track a habit",
"description": "Increase the positive or negative streak of a habit to track its progress.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
"description": "Select the Habitica character tracking your habit."
},
"task": {
"name": "Habit name",
"description": "The name (or task ID) of the Habitica habit."
},
"direction": {
"name": "Reward or loss",
"description": "Is it positive or negative progress you want to track for your habit."
}
}
},
"score_reward": {
"name": "Buy a reward",
"description": "Reward yourself and buy one of your custom rewards with gold earned by fulfilling tasks.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
"description": "Select the Habitica character buying the reward."
},
"task": {
"name": "Reward name",
"description": "The name (or task ID) of the custom reward."
}
}
} }
}, },
"selector": { "selector": {

View File

@ -34,7 +34,7 @@ def mock_called_with(
( (
call call
for call in mock_client.mock_calls for call in mock_client.mock_calls
if call[0] == method.upper() and call[1] == URL(url) if call[0].upper() == method.upper() and call[1] == URL(url)
), ),
None, None,
) )

View File

@ -121,7 +121,8 @@
"createdAt": "2024-07-07T17:51:53.264Z", "createdAt": "2024-07-07T17:51:53.264Z",
"updatedAt": "2024-07-12T09:58:45.438Z", "updatedAt": "2024-07-12T09:58:45.438Z",
"userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c",
"id": "e97659e0-2c42-4599-a7bb-00282adc410d" "id": "e97659e0-2c42-4599-a7bb-00282adc410d",
"alias": "create_a_task"
}, },
{ {
"_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", "_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa",

View File

@ -9,6 +9,7 @@ import pytest
from homeassistant.components.habitica.const import ( from homeassistant.components.habitica.const import (
ATTR_CONFIG_ENTRY, ATTR_CONFIG_ENTRY,
ATTR_DIRECTION,
ATTR_SKILL, ATTR_SKILL,
ATTR_TASK, ATTR_TASK,
DEFAULT_URL, DEFAULT_URL,
@ -19,6 +20,8 @@ from homeassistant.components.habitica.const import (
SERVICE_CAST_SKILL, SERVICE_CAST_SKILL,
SERVICE_LEAVE_QUEST, SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST, SERVICE_REJECT_QUEST,
SERVICE_SCORE_HABIT,
SERVICE_SCORE_REWARD,
SERVICE_START_QUEST, SERVICE_START_QUEST,
) )
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
@ -168,7 +171,7 @@ async def test_cast_skill(
}, },
HTTPStatus.OK, HTTPStatus.OK,
ServiceValidationError, ServiceValidationError,
"Unable to cast skill, could not find the task 'task-not-found", "Unable to complete action, could not find the task 'task-not-found'",
), ),
( (
{ {
@ -377,3 +380,169 @@ async def test_handle_quests_exceptions(
return_response=True, return_response=True,
blocking=True, blocking=True,
) )
@pytest.mark.parametrize(
("service", "service_data", "task_id"),
[
(
SERVICE_SCORE_HABIT,
{
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "up",
},
"e97659e0-2c42-4599-a7bb-00282adc410d",
),
(
SERVICE_SCORE_HABIT,
{
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "down",
},
"e97659e0-2c42-4599-a7bb-00282adc410d",
),
(
SERVICE_SCORE_REWARD,
{
ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b",
},
"5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b",
),
(
SERVICE_SCORE_HABIT,
{
ATTR_TASK: "Füge eine Aufgabe zu Habitica hinzu",
ATTR_DIRECTION: "up",
},
"e97659e0-2c42-4599-a7bb-00282adc410d",
),
(
SERVICE_SCORE_HABIT,
{
ATTR_TASK: "create_a_task",
ATTR_DIRECTION: "up",
},
"e97659e0-2c42-4599-a7bb-00282adc410d",
),
],
ids=[
"habit score up",
"habit score down",
"buy reward",
"match task by name",
"match task by alias",
],
)
async def test_score_task(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
service: str,
service_data: dict[str, Any],
task_id: str,
) -> None:
"""Test Habitica score task action."""
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/tasks/{task_id}/score/{service_data.get(ATTR_DIRECTION, "up")}",
json={"success": True, "data": {}},
)
await hass.services.async_call(
DOMAIN,
service,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
**service_data,
},
return_response=True,
blocking=True,
)
assert mock_called_with(
mock_habitica,
"post",
f"{DEFAULT_URL}/api/v3/tasks/{task_id}/score/{service_data.get(ATTR_DIRECTION, "up")}",
)
@pytest.mark.parametrize(
(
"service_data",
"http_status",
"expected_exception",
"expected_exception_msg",
),
[
(
{
ATTR_TASK: "task does not exist",
ATTR_DIRECTION: "up",
},
HTTPStatus.OK,
ServiceValidationError,
"Unable to complete action, could not find the task 'task does not exist'",
),
(
{
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "up",
},
HTTPStatus.TOO_MANY_REQUESTS,
ServiceValidationError,
RATE_LIMIT_EXCEPTION_MSG,
),
(
{
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "up",
},
HTTPStatus.BAD_REQUEST,
HomeAssistantError,
REQUEST_EXCEPTION_MSG,
),
(
{
ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b",
ATTR_DIRECTION: "up",
},
HTTPStatus.UNAUTHORIZED,
HomeAssistantError,
"Unable to buy reward, not enough gold. Your character has 137.63 GP, but the reward costs 10 GP",
),
],
)
@pytest.mark.usefixtures("mock_habitica")
async def test_score_task_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
service_data: dict[str, Any],
http_status: HTTPStatus,
expected_exception: Exception,
expected_exception_msg: str,
) -> None:
"""Test Habitica score task action exceptions."""
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/tasks/e97659e0-2c42-4599-a7bb-00282adc410d/score/up",
json={"success": True, "data": {}},
status=http_status,
)
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/tasks/5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b/score/up",
json={"success": True, "data": {}},
status=http_status,
)
with pytest.raises(expected_exception, match=expected_exception_msg):
await hass.services.async_call(
DOMAIN,
SERVICE_SCORE_HABIT,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
**service_data,
},
return_response=True,
blocking=True,
)