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_PRIORITY = "priority"
ATTR_COST = "cost" ATTR_COST = "cost"
ATTR_NOTES = "notes" 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_CAST_SKILL = "cast_skill"
SERVICE_START_QUEST = "start_quest" SERVICE_START_QUEST = "start_quest"
@ -57,6 +61,7 @@ SERVICE_TRANSFORMATION = "transformation"
SERVICE_UPDATE_REWARD = "update_reward" SERVICE_UPDATE_REWARD = "update_reward"
SERVICE_CREATE_REWARD = "create_reward" SERVICE_CREATE_REWARD = "create_reward"
SERVICE_UPDATE_HABIT = "update_habit"
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

@ -230,6 +230,13 @@
"sections": { "sections": {
"developer_options": "mdi:test-tube" "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 aiohttp import ClientError
from habiticalib import ( from habiticalib import (
Direction, Direction,
Frequency,
HabiticaException, HabiticaException,
NotAuthorizedError, NotAuthorizedError,
NotFoundError, NotFoundError,
@ -41,8 +42,11 @@ from .const import (
ATTR_ARGS, ATTR_ARGS,
ATTR_CONFIG_ENTRY, ATTR_CONFIG_ENTRY,
ATTR_COST, ATTR_COST,
ATTR_COUNTER_DOWN,
ATTR_COUNTER_UP,
ATTR_DATA, ATTR_DATA,
ATTR_DIRECTION, ATTR_DIRECTION,
ATTR_FREQUENCY,
ATTR_ITEM, ATTR_ITEM,
ATTR_KEYWORD, ATTR_KEYWORD,
ATTR_NOTES, ATTR_NOTES,
@ -54,6 +58,7 @@ from .const import (
ATTR_TARGET, ATTR_TARGET,
ATTR_TASK, ATTR_TASK,
ATTR_TYPE, ATTR_TYPE,
ATTR_UP_DOWN,
DOMAIN, DOMAIN,
EVENT_API_CALL_SUCCESS, EVENT_API_CALL_SUCCESS,
SERVICE_ABORT_QUEST, SERVICE_ABORT_QUEST,
@ -69,6 +74,7 @@ from .const import (
SERVICE_SCORE_REWARD, SERVICE_SCORE_REWARD,
SERVICE_START_QUEST, SERVICE_START_QUEST,
SERVICE_TRANSFORMATION, SERVICE_TRANSFORMATION,
SERVICE_UPDATE_HABIT,
SERVICE_UPDATE_REWARD, SERVICE_UPDATE_REWARD,
) )
from .coordinator import HabiticaConfigEntry from .coordinator import HabiticaConfigEntry
@ -123,6 +129,13 @@ BASE_TASK_SCHEMA = vol.Schema(
cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$") cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$")
), ),
vol.Optional(ATTR_COST): vol.All(vol.Coerce(float), vol.Range(0)), 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, "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: def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
"""Return config entry or raise if not found or not loaded.""" """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 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.""" """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 is_update = call.service in (SERVICE_UPDATE_REWARD, SERVICE_UPDATE_HABIT)
current_task = None current_task = None
if is_update: if is_update:
@ -565,7 +584,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
task task
for task in coordinator.data.tasks for task in coordinator.data.tasks
if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) 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: except StopIteration as e:
raise ServiceValidationError( 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: if (cost := call.data.get(ATTR_COST)) is not None:
data["value"] = cost 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: try:
if is_update: if is_update:
if TYPE_CHECKING: if TYPE_CHECKING:
@ -684,6 +719,13 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
schema=SERVICE_UPDATE_TASK_SCHEMA, schema=SERVICE_UPDATE_TASK_SCHEMA,
supports_response=SupportsResponse.ONLY, 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( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_CREATE_REWARD, SERVICE_CREATE_REWARD,

View File

@ -144,7 +144,7 @@ update_reward:
fields: fields:
config_entry: *config_entry config_entry: *config_entry
task: *task task: *task
rename: rename: &rename
selector: selector:
text: text:
notes: &notes notes: &notes
@ -160,7 +160,7 @@ update_reward:
step: 0.01 step: 0.01
unit_of_measurement: "🪙" unit_of_measurement: "🪙"
mode: box mode: box
tag_options: tag_options: &tag_options
collapsed: true collapsed: true
fields: fields:
tag: &tag tag: &tag
@ -176,7 +176,7 @@ update_reward:
developer_options: &developer_options developer_options: &developer_options
collapsed: true collapsed: true
fields: fields:
alias: alias: &alias
required: false required: false
selector: selector:
text: text:
@ -193,3 +193,62 @@ create_reward:
selector: *cost_selector selector: *cost_selector
tag: *tag tag: *tag
developer_options: *developer_options 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%]" "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": { "selector": {
@ -793,6 +857,14 @@
"medium": "Medium", "medium": "Medium",
"hard": "Hard" "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 uuid import UUID
from aiohttp import ClientError from aiohttp import ClientError
from habiticalib import Direction, HabiticaTaskResponse, Skill, Task, TaskType from habiticalib import (
Direction,
Frequency,
HabiticaTaskResponse,
Skill,
Task,
TaskPriority,
TaskType,
)
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
@ -14,7 +22,10 @@ from homeassistant.components.habitica.const import (
ATTR_ALIAS, ATTR_ALIAS,
ATTR_CONFIG_ENTRY, ATTR_CONFIG_ENTRY,
ATTR_COST, ATTR_COST,
ATTR_COUNTER_DOWN,
ATTR_COUNTER_UP,
ATTR_DIRECTION, ATTR_DIRECTION,
ATTR_FREQUENCY,
ATTR_ITEM, ATTR_ITEM,
ATTR_KEYWORD, ATTR_KEYWORD,
ATTR_NOTES, ATTR_NOTES,
@ -25,6 +36,7 @@ from homeassistant.components.habitica.const import (
ATTR_TARGET, ATTR_TARGET,
ATTR_TASK, ATTR_TASK,
ATTR_TYPE, ATTR_TYPE,
ATTR_UP_DOWN,
DOMAIN, DOMAIN,
SERVICE_ABORT_QUEST, SERVICE_ABORT_QUEST,
SERVICE_ACCEPT_QUEST, SERVICE_ACCEPT_QUEST,
@ -38,6 +50,7 @@ from homeassistant.components.habitica.const import (
SERVICE_SCORE_REWARD, SERVICE_SCORE_REWARD,
SERVICE_START_QUEST, SERVICE_START_QUEST,
SERVICE_TRANSFORMATION, SERVICE_TRANSFORMATION,
SERVICE_UPDATE_HABIT,
SERVICE_UPDATE_REWARD, SERVICE_UPDATE_REWARD,
) )
from homeassistant.components.todo import ATTR_RENAME 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") @pytest.mark.usefixtures("habitica")
async def test_update_task_exceptions( async def test_update_task_exceptions(
hass: HomeAssistant, hass: HomeAssistant,
@ -927,15 +947,16 @@ async def test_update_task_exceptions(
exception: Exception, exception: Exception,
expected_exception: Exception, expected_exception: Exception,
exception_msg: str, exception_msg: str,
service: str,
task_id: str,
) -> None: ) -> None:
"""Test Habitica task action exceptions.""" """Test Habitica task action exceptions."""
task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"
habitica.update_task.side_effect = exception habitica.update_task.side_effect = exception
with pytest.raises(expected_exception, match=exception_msg): with pytest.raises(expected_exception, match=exception_msg):
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
SERVICE_UPDATE_REWARD, service,
service_data={ service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id, ATTR_CONFIG_ENTRY: config_entry.entry_id,
ATTR_TASK: task_id, ATTR_TASK: task_id,
@ -1125,6 +1146,90 @@ async def test_create_reward(
habitica.create_task.assert_awaited_with(call_args) 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( async def test_tags(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,