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.pypull/118147/head^2
parent
866cdcc993
commit
751935539a
|
@ -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(
|
||||
{
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
{
|
||||
"entity": {
|
||||
"todo": {
|
||||
"todos": {
|
||||
"default": "mdi:checkbox-outline"
|
||||
},
|
||||
"dailys": {
|
||||
"default": "mdi:calendar-month"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"display_name": {
|
||||
"default": "mdi:account-circle"
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
)
|
||||
]
|
|
@ -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
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue