Add update_habit action to Habitica integration (#139311)

* Add update_habit action

* icons
This commit is contained in:
Manu 2025-03-02 14:04:13 +01:00 committed by GitHub
parent e6c946b3f4
commit b0b5567316
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 299 additions and 9 deletions

View File

@ -40,6 +40,10 @@ ATTR_ALIAS = "alias"
ATTR_PRIORITY = "priority"
ATTR_COST = "cost"
ATTR_NOTES = "notes"
ATTR_UP_DOWN = "up_down"
ATTR_FREQUENCY = "frequency"
ATTR_COUNTER_UP = "counter_up"
ATTR_COUNTER_DOWN = "counter_down"
SERVICE_CAST_SKILL = "cast_skill"
SERVICE_START_QUEST = "start_quest"
@ -57,6 +61,7 @@ SERVICE_TRANSFORMATION = "transformation"
SERVICE_UPDATE_REWARD = "update_reward"
SERVICE_CREATE_REWARD = "create_reward"
SERVICE_UPDATE_HABIT = "update_habit"
DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf"
X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"

View File

@ -230,6 +230,13 @@
"sections": {
"developer_options": "mdi:test-tube"
}
},
"update_habit": {
"service": "mdi:contrast-box",
"sections": {
"tag_options": "mdi:tag",
"developer_options": "mdi:test-tube"
}
}
}
}

View File

@ -10,6 +10,7 @@ from uuid import UUID
from aiohttp import ClientError
from habiticalib import (
Direction,
Frequency,
HabiticaException,
NotAuthorizedError,
NotFoundError,
@ -41,8 +42,11 @@ from .const import (
ATTR_ARGS,
ATTR_CONFIG_ENTRY,
ATTR_COST,
ATTR_COUNTER_DOWN,
ATTR_COUNTER_UP,
ATTR_DATA,
ATTR_DIRECTION,
ATTR_FREQUENCY,
ATTR_ITEM,
ATTR_KEYWORD,
ATTR_NOTES,
@ -54,6 +58,7 @@ from .const import (
ATTR_TARGET,
ATTR_TASK,
ATTR_TYPE,
ATTR_UP_DOWN,
DOMAIN,
EVENT_API_CALL_SUCCESS,
SERVICE_ABORT_QUEST,
@ -69,6 +74,7 @@ from .const import (
SERVICE_SCORE_REWARD,
SERVICE_START_QUEST,
SERVICE_TRANSFORMATION,
SERVICE_UPDATE_HABIT,
SERVICE_UPDATE_REWARD,
)
from .coordinator import HabiticaConfigEntry
@ -123,6 +129,13 @@ BASE_TASK_SCHEMA = vol.Schema(
cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$")
),
vol.Optional(ATTR_COST): vol.All(vol.Coerce(float), vol.Range(0)),
vol.Optional(ATTR_PRIORITY): vol.All(
vol.Upper, vol.In(TaskPriority._member_names_)
),
vol.Optional(ATTR_UP_DOWN): vol.All(cv.ensure_list, [str]),
vol.Optional(ATTR_COUNTER_UP): vol.All(int, vol.Range(0)),
vol.Optional(ATTR_COUNTER_DOWN): vol.All(int, vol.Range(0)),
vol.Optional(ATTR_FREQUENCY): vol.Coerce(Frequency),
}
)
@ -173,6 +186,12 @@ ITEMID_MAP = {
"shiny_seed": Skill.SHINY_SEED,
}
SERVICE_TASK_TYPE_MAP = {
SERVICE_UPDATE_REWARD: TaskType.REWARD,
SERVICE_CREATE_REWARD: TaskType.REWARD,
SERVICE_UPDATE_HABIT: TaskType.HABIT,
}
def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
"""Return config entry or raise if not found or not loaded."""
@ -551,12 +570,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
return result
async def create_or_update_task(call: ServiceCall) -> ServiceResponse:
async def create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: C901
"""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
is_update = call.service in (SERVICE_UPDATE_REWARD, SERVICE_UPDATE_HABIT)
current_task = None
if is_update:
@ -565,7 +584,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
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
and task.Type is SERVICE_TASK_TYPE_MAP[call.service]
)
except StopIteration as e:
raise ServiceValidationError(
@ -648,6 +667,22 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
if (cost := call.data.get(ATTR_COST)) is not None:
data["value"] = cost
if priority := call.data.get(ATTR_PRIORITY):
data["priority"] = TaskPriority[priority]
if frequency := call.data.get(ATTR_FREQUENCY):
data["frequency"] = frequency
if up_down := call.data.get(ATTR_UP_DOWN):
data["up"] = "up" in up_down
data["down"] = "down" in up_down
if counter_up := call.data.get(ATTR_COUNTER_UP):
data["counterUp"] = counter_up
if counter_down := call.data.get(ATTR_COUNTER_DOWN):
data["counterDown"] = counter_down
try:
if is_update:
if TYPE_CHECKING:
@ -684,6 +719,13 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
schema=SERVICE_UPDATE_TASK_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_UPDATE_HABIT,
create_or_update_task,
schema=SERVICE_UPDATE_TASK_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_CREATE_REWARD,

View File

@ -144,7 +144,7 @@ update_reward:
fields:
config_entry: *config_entry
task: *task
rename:
rename: &rename
selector:
text:
notes: &notes
@ -160,7 +160,7 @@ update_reward:
step: 0.01
unit_of_measurement: "🪙"
mode: box
tag_options:
tag_options: &tag_options
collapsed: true
fields:
tag: &tag
@ -176,7 +176,7 @@ update_reward:
developer_options: &developer_options
collapsed: true
fields:
alias:
alias: &alias
required: false
selector:
text:
@ -193,3 +193,62 @@ create_reward:
selector: *cost_selector
tag: *tag
developer_options: *developer_options
update_habit:
fields:
config_entry: *config_entry
task: *task
rename: *rename
notes: *notes
up_down:
required: false
selector:
select:
options:
- value: up
label: ""
- value: down
label: ""
multiple: true
mode: list
priority:
required: false
selector:
select:
options:
- "trivial"
- "easy"
- "medium"
- "hard"
mode: dropdown
translation_key: "priority"
frequency:
required: false
selector:
select:
options:
- "daily"
- "weekly"
- "monthly"
translation_key: "frequency"
mode: dropdown
tag_options: *tag_options
developer_options:
collapsed: true
fields:
counter_up:
required: false
selector:
number:
min: 0
step: 1
unit_of_measurement: ""
mode: box
counter_down:
required: false
selector:
number:
min: 0
step: 1
unit_of_measurement: ""
mode: box
alias: *alias

View File

@ -759,6 +759,70 @@
"description": "[%key:component::habitica::common::developer_options_description%]"
}
}
},
"update_habit": {
"name": "Update a habit",
"description": "Updates a specific habit for the selected Habitica character",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
"description": "Select the Habitica account to update a habit."
},
"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%]"
},
"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%]"
},
"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%]"
},
"priority": {
"name": "Difficulty",
"description": "Update the difficulty of a task."
},
"frequency": {
"name": "Counter reset",
"description": "Update when a habit's counter resets: daily resets at the start of a new day, weekly after Sunday night, and monthly at the beginning of a new month."
},
"up_down": {
"name": "Rewards or losses",
"description": "Update if the habit is good and rewarding (positive), bad and penalizing (negative), or both."
},
"counter_up": {
"name": "Adjust positive counter",
"description": "Update the up counter of a positive habit."
},
"counter_down": {
"name": "Adjust negative counter",
"description": "Update the down counter of a negative habit."
}
},
"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": {
@ -793,6 +857,14 @@
"medium": "Medium",
"hard": "Hard"
}
},
"frequency": {
"options": {
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"yearly": "Yearly"
}
}
}
}

View File

@ -6,7 +6,15 @@ from unittest.mock import AsyncMock, patch
from uuid import UUID
from aiohttp import ClientError
from habiticalib import Direction, HabiticaTaskResponse, Skill, Task, TaskType
from habiticalib import (
Direction,
Frequency,
HabiticaTaskResponse,
Skill,
Task,
TaskPriority,
TaskType,
)
import pytest
from syrupy.assertion import SnapshotAssertion
@ -14,7 +22,10 @@ from homeassistant.components.habitica.const import (
ATTR_ALIAS,
ATTR_CONFIG_ENTRY,
ATTR_COST,
ATTR_COUNTER_DOWN,
ATTR_COUNTER_UP,
ATTR_DIRECTION,
ATTR_FREQUENCY,
ATTR_ITEM,
ATTR_KEYWORD,
ATTR_NOTES,
@ -25,6 +36,7 @@ from homeassistant.components.habitica.const import (
ATTR_TARGET,
ATTR_TASK,
ATTR_TYPE,
ATTR_UP_DOWN,
DOMAIN,
SERVICE_ABORT_QUEST,
SERVICE_ACCEPT_QUEST,
@ -38,6 +50,7 @@ from homeassistant.components.habitica.const import (
SERVICE_SCORE_REWARD,
SERVICE_START_QUEST,
SERVICE_TRANSFORMATION,
SERVICE_UPDATE_HABIT,
SERVICE_UPDATE_REWARD,
)
from homeassistant.components.todo import ATTR_RENAME
@ -919,6 +932,13 @@ async def test_get_tasks(
),
],
)
@pytest.mark.parametrize(
("service", "task_id"),
[
(SERVICE_UPDATE_REWARD, "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"),
(SERVICE_UPDATE_HABIT, "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a"),
],
)
@pytest.mark.usefixtures("habitica")
async def test_update_task_exceptions(
hass: HomeAssistant,
@ -927,15 +947,16 @@ async def test_update_task_exceptions(
exception: Exception,
expected_exception: Exception,
exception_msg: str,
service: str,
task_id: 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,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
ATTR_TASK: task_id,
@ -1125,6 +1146,90 @@ async def test_create_reward(
habitica.create_task.assert_awaited_with(call_args)
@pytest.mark.parametrize(
("service_data", "call_args"),
[
(
{
ATTR_RENAME: "RENAME",
},
Task(text="RENAME"),
),
(
{
ATTR_NOTES: "NOTES",
},
Task(notes="NOTES"),
),
(
{
ATTR_UP_DOWN: [""],
},
Task(up=False, down=False),
),
(
{
ATTR_UP_DOWN: ["up"],
},
Task(up=True, down=False),
),
(
{
ATTR_UP_DOWN: ["down"],
},
Task(up=False, down=True),
),
(
{
ATTR_PRIORITY: "trivial",
},
Task(priority=TaskPriority.TRIVIAL),
),
(
{
ATTR_FREQUENCY: "daily",
},
Task(frequency=Frequency.DAILY),
),
(
{
ATTR_COUNTER_UP: 1,
ATTR_COUNTER_DOWN: 2,
},
Task(counterUp=1, counterDown=2),
),
(
{
ATTR_ALIAS: "ALIAS",
},
Task(alias="ALIAS"),
),
],
)
async def test_update_habit(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
service_data: dict[str, Any],
call_args: Task,
) -> None:
"""Test Habitica habit action."""
task_id = "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a"
await hass.services.async_call(
DOMAIN,
SERVICE_UPDATE_HABIT,
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,