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
pull/118147/head^2
Mr. Bubbles 2024-07-07 17:50:27 +02:00 committed by GitHub
parent 866cdcc993
commit 751935539a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 462 additions and 3 deletions

View File

@ -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(
{

View File

@ -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"}

View File

@ -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

View File

@ -1,5 +1,13 @@
{
"entity": {
"todo": {
"todos": {
"default": "mdi:checkbox-outline"
},
"dailys": {
"default": "mdi:calendar-month"
}
},
"sensor": {
"display_name": {
"default": "mdi:account-circle"

View File

@ -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()

View File

@ -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",

View File

@ -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
)
]

View File

@ -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

View File

@ -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",