From 751935539a77616b88d65e1d924f0912b417758e Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Sun, 7 Jul 2024 17:50:27 +0200 Subject: [PATCH] Migrate Habitica Dailies and To-Do's to the todo platform (#116655) * Add todo platform * update for DataUpdateCoordinator * set lastCron as dailies due date * parse alternative duedate format * fix tests * send notification on item drop * fix drop message * update exception messages * Simplified the update of user_fields by using set union * move userFields to const * Issue deprecation only if entity is acutally used * Resolve issues * user entity registry to get entity_id * raise ServiceValidationError * requested changes * Move next_due_date helper function to util.py module * some changes * Move function to util.py --- homeassistant/components/habitica/__init__.py | 2 +- homeassistant/components/habitica/const.py | 3 + .../components/habitica/coordinator.py | 5 +- homeassistant/components/habitica/icons.json | 8 + homeassistant/components/habitica/sensor.py | 42 +++ .../components/habitica/strings.json | 38 +++ homeassistant/components/habitica/todo.py | 312 ++++++++++++++++++ homeassistant/components/habitica/util.py | 42 +++ tests/components/habitica/test_init.py | 13 + 9 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/habitica/todo.py create mode 100644 homeassistant/components/habitica/util.py diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index b50e5855f00..d54bf9d63fb 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -82,7 +82,7 @@ INSTANCE_LIST_SCHEMA = vol.All( ) CONFIG_SCHEMA = vol.Schema({DOMAIN: INSTANCE_LIST_SCHEMA}, extra=vol.ALLOW_EXTRA) -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.SENSOR, Platform.TODO] SERVICE_API_CALL_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 13babdf458a..43f8906ca64 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -5,6 +5,7 @@ from homeassistant.const import CONF_PATH CONF_API_USER = "api_user" DEFAULT_URL = "https://habitica.com" +ASSETS_URL = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/" DOMAIN = "habitica" # service constants @@ -18,3 +19,5 @@ ATTR_DATA = "data" MANUFACTURER = "HabitRPG, Inc." NAME = "Habitica" + +ADDITIONAL_USER_FIELDS: set[str] = {"lastCron"} diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index d190cd41d4e..0867a8bd550 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import ADDITIONAL_USER_FIELDS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -43,11 +43,12 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): self.api = habitipy async def _async_update_data(self) -> HabiticaData: - user_fields = set(self.async_contexts()) + user_fields = set(self.async_contexts()) | ADDITIONAL_USER_FIELDS try: user_response = await self.api.user.get(userFields=",".join(user_fields)) tasks_response = await self.api.tasks.user.get() + tasks_response.extend(await self.api.tasks.user.get(type="completedTodos")) except ClientResponseError as error: raise UpdateFailed(f"Error communicating with API: {error}") from error diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 5a722ce6f4b..def355678c1 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -1,5 +1,13 @@ { "entity": { + "todo": { + "todos": { + "default": "mdi:checkbox-outline" + }, + "dailys": { + "default": "mdi:calendar-month" + } + }, "sensor": { "display_name": { "default": "mdi:account-circle" diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 5073c31d350..31ecbcfd41c 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -9,6 +9,7 @@ import logging from typing import TYPE_CHECKING, cast from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -16,14 +17,21 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import HabiticaConfigEntry from .const import DOMAIN, MANUFACTURER, NAME from .coordinator import HabiticaDataUpdateCoordinator +from .util import entity_used_in _LOGGER = logging.getLogger(__name__) @@ -255,6 +263,7 @@ class HabitipyTaskSensor( task for task in self.coordinator.data.tasks if task.get("type") in self._task_type.path + and not task.get("completed") ] ) @@ -278,3 +287,36 @@ class HabitipyTaskSensor( def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._task_type.unit + + async def async_added_to_hass(self) -> None: + """Raise issue when entity is registered and was not disabled.""" + if TYPE_CHECKING: + assert self.unique_id + if entity_id := er.async_get(self.hass).async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, self.unique_id + ): + if ( + self.enabled + and self._task_name in ("todos", "dailys") + and entity_used_in(self.hass, entity_id) + ): + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_task_entity_{self._task_name}", + breaks_in_ha_version="2025.2.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_task_entity", + translation_placeholders={ + "task_name": self._task_name, + "entity": entity_id, + }, + ) + else: + async_delete_issue( + self.hass, + DOMAIN, + f"deprecated_task_entity_{self._task_name}", + ) + await super().async_added_to_hass() diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 6023aa2d228..7ff1e7b1a81 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -57,13 +57,51 @@ "rogue": "Rogue" } } + }, + "todo": { + "todos": { + "name": "To-Do's" + }, + "dailys": { + "name": "Dailies" + } } }, "exceptions": { + "delete_todos_failed": { + "message": "Unable to delete {count} Habitica to-do(s), please try again" + }, + "move_todos_item_failed": { + "message": "Unable to move the Habitica to-do to position {pos}, please try again" + }, + "move_dailys_item_failed": { + "message": "Unable to move the Habitica daily to position {pos}, please try again" + }, + "update_todos_item_failed": { + "message": "Unable to update the Habitica to-do `{name}`, please try again" + }, + "update_dailys_item_failed": { + "message": "Unable to update the Habitica daily `{name}`, please try again" + }, + "score_todos_item_failed": { + "message": "Unable to update the score for your Habitica to-do `{name}`, please try again" + }, + "score_dailys_item_failed": { + "message": "Unable to update the score for your Habitica daily `{name}`, please try again" + }, + "create_todos_item_failed": { + "message": "Unable to create new to-do `{name}` for Habitica, please try again" + }, "setup_rate_limit_exception": { "message": "Currently rate limited, try again later" } }, + "issues": { + "deprecated_task_entity": { + "title": "The Habitica `{task_name}` sensor is deprecated", + "description": "The Habitica entity `{entity}` is deprecated and will be removed in `2024.12`.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`." + } + }, "services": { "api_call": { "name": "API name", diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py new file mode 100644 index 00000000000..01a2a9aed55 --- /dev/null +++ b/homeassistant/components/habitica/todo.py @@ -0,0 +1,312 @@ +"""Todo platform for the Habitica integration.""" + +from __future__ import annotations + +import datetime +from enum import StrEnum +from typing import TYPE_CHECKING + +from aiohttp import ClientResponseError + +from homeassistant.components import persistent_notification +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.const import CONF_NAME, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from . import HabiticaConfigEntry +from .const import ASSETS_URL, DOMAIN, MANUFACTURER, NAME +from .coordinator import HabiticaDataUpdateCoordinator +from .util import next_due_date + + +class HabiticaTodoList(StrEnum): + """Habitica Entities.""" + + HABITS = "habits" + DAILIES = "dailys" + TODOS = "todos" + REWARDS = "rewards" + + +class HabiticaTaskType(StrEnum): + """Habitica Entities.""" + + HABIT = "habit" + DAILY = "daily" + TODO = "todo" + REWARD = "reward" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HabiticaConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor from a config entry created in the integrations UI.""" + coordinator = config_entry.runtime_data + + async_add_entities( + [ + HabiticaTodosListEntity(coordinator), + HabiticaDailiesListEntity(coordinator), + ], + ) + + +class BaseHabiticaListEntity( + CoordinatorEntity[HabiticaDataUpdateCoordinator], TodoListEntity +): + """Representation of Habitica task lists.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: HabiticaDataUpdateCoordinator, + key: HabiticaTodoList, + ) -> None: + """Initialize HabiticaTodoListEntity.""" + entry = coordinator.config_entry + if TYPE_CHECKING: + assert entry.unique_id + super().__init__(coordinator) + + self._attr_unique_id = f"{entry.unique_id}_{key}" + self._attr_translation_key = key + self.idx = key + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer=MANUFACTURER, + model=NAME, + name=entry.data[CONF_NAME], + configuration_url=entry.data[CONF_URL], + identifiers={(DOMAIN, entry.unique_id)}, + ) + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete Habitica tasks.""" + for task_id in uids: + try: + await self.coordinator.api.tasks[task_id].delete() + except ClientResponseError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key=f"delete_{self.idx}_failed", + ) from e + + await self.coordinator.async_refresh() + + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: + """Move an item in the To-do list.""" + if TYPE_CHECKING: + assert self.todo_items + + if previous_uid: + pos = ( + self.todo_items.index( + next(item for item in self.todo_items if item.uid == previous_uid) + ) + + 1 + ) + else: + pos = 0 + + try: + await self.coordinator.api.tasks[uid].move.to[str(pos)].post() + + except ClientResponseError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key=f"move_{self.idx}_item_failed", + translation_placeholders={"pos": str(pos)}, + ) from e + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update a Habitica todo.""" + current_item = next( + (task for task in (self.todo_items or []) if task.uid == item.uid), + None, + ) + + if TYPE_CHECKING: + assert item.uid + assert current_item + assert item.due + + if self.idx is HabiticaTodoList.TODOS: # Only todos support a due date. + date = item.due.isoformat() + else: + date = None + + try: + await self.coordinator.api.tasks[item.uid].put( + text=item.summary, + notes=item.description or "", + date=date, + ) + except ClientResponseError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key=f"update_{self.idx}_item_failed", + translation_placeholders={"name": item.summary or ""}, + ) from e + + try: + # Score up or down if item status changed + if ( + current_item.status is TodoItemStatus.NEEDS_ACTION + and item.status is TodoItemStatus.COMPLETED + ): + score_result = ( + await self.coordinator.api.tasks[item.uid].score["up"].post() + ) + elif ( + current_item.status is TodoItemStatus.COMPLETED + and item.status is TodoItemStatus.NEEDS_ACTION + ): + score_result = ( + await self.coordinator.api.tasks[item.uid].score["down"].post() + ) + else: + score_result = None + + except ClientResponseError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key=f"score_{self.idx}_item_failed", + translation_placeholders={"name": item.summary or ""}, + ) from e + + if score_result and (drop := score_result.get("_tmp", {}).get("drop", False)): + msg = ( + f"![{drop["key"]}]({ASSETS_URL}Pet_{drop["type"]}_{drop["key"]}.png)\n" + f"{drop["dialog"]}" + ) + persistent_notification.async_create( + self.hass, message=msg, title="Habitica" + ) + + await self.coordinator.async_refresh() + + +class HabiticaTodosListEntity(BaseHabiticaListEntity): + """List of Habitica todos.""" + + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.MOVE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM + ) + + def __init__(self, coordinator: HabiticaDataUpdateCoordinator) -> None: + """Initialize HabiticaTodosListEntity.""" + super().__init__(coordinator, HabiticaTodoList.TODOS) + + @property + def todo_items(self) -> list[TodoItem]: + """Return the todo items.""" + + return [ + *( + TodoItem( + uid=task["id"], + summary=task["text"], + description=task["notes"], + due=( + dt_util.as_local( + datetime.datetime.fromisoformat(task["date"]) + ).date() + if task.get("date") + else None + ), + status=( + TodoItemStatus.NEEDS_ACTION + if not task["completed"] + else TodoItemStatus.COMPLETED + ), + ) + for task in self.coordinator.data.tasks + if task["type"] == HabiticaTaskType.TODO + ), + ] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Create a Habitica todo.""" + + try: + await self.coordinator.api.tasks.user.post( + text=item.summary, + type=HabiticaTaskType.TODO, + notes=item.description, + date=item.due.isoformat() if item.due else None, + ) + except ClientResponseError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key=f"create_{self.idx}_item_failed", + translation_placeholders={"name": item.summary or ""}, + ) from e + + await self.coordinator.async_refresh() + + +class HabiticaDailiesListEntity(BaseHabiticaListEntity): + """List of Habitica dailies.""" + + _attr_supported_features = ( + TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.MOVE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM + ) + + def __init__(self, coordinator: HabiticaDataUpdateCoordinator) -> None: + """Initialize HabiticaDailiesListEntity.""" + super().__init__(coordinator, HabiticaTodoList.DAILIES) + + @property + def todo_items(self) -> list[TodoItem]: + """Return the dailies. + + dailies don't have a date, but we still can show the next due date, + which is a calculated value based on recurrence of the task. + If a task is a yesterdaily, the due date is the last time + a new day has been started. This allows to check off dailies from yesterday, + that have been completed but forgotten to mark as completed before resetting the dailies. + Changes of the date input field in Home Assistant will be ignored. + """ + + last_cron = self.coordinator.data.user["lastCron"] + + return [ + *( + TodoItem( + uid=task["id"], + summary=task["text"], + description=task["notes"], + due=next_due_date(task, last_cron), + status=( + TodoItemStatus.COMPLETED + if task["completed"] + else TodoItemStatus.NEEDS_ACTION + ), + ) + for task in self.coordinator.data.tasks + if task["type"] == HabiticaTaskType.DAILY + ) + ] diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py new file mode 100644 index 00000000000..b3241aa5787 --- /dev/null +++ b/homeassistant/components/habitica/util.py @@ -0,0 +1,42 @@ +"""Utility functions for Habitica.""" + +from __future__ import annotations + +import datetime +from typing import Any + +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + + +def next_due_date(task: dict[str, Any], last_cron: str) -> datetime.date | None: + """Calculate due date for dailies and yesterdailies.""" + + if task["isDue"] and not task["completed"]: + return dt_util.as_local(datetime.datetime.fromisoformat(last_cron)).date() + try: + return dt_util.as_local( + datetime.datetime.fromisoformat(task["nextDue"][0]) + ).date() + except ValueError: + # sometimes nextDue dates are in this format instead of iso: + # "Mon May 06 2024 00:00:00 GMT+0200" + try: + return dt_util.as_local( + datetime.datetime.strptime( + task["nextDue"][0], "%a %b %d %Y %H:%M:%S %Z%z" + ) + ).date() + except ValueError: + return None + except IndexError: + return None + + +def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: + """Get list of related automations and scripts.""" + used_in = automations_with_entity(hass, entity_id) + used_in += scripts_with_entity(hass, entity_id) + return used_in diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index 5dbff3b71e8..31c3a1fae39 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -88,6 +88,19 @@ def common_requests(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker: ] }, ) + aioclient_mock.get( + "https://habitica.com/api/v3/tasks/user?type=completedTodos", + json={ + "data": [ + { + "text": "this is a mock todo #5", + "id": 5, + "type": "todo", + "completed": True, + } + ] + }, + ) aioclient_mock.post( "https://habitica.com/api/v3/tasks/user",