Add Google tasks integration, with initial read-only To-do list (#102629)

* Add Google Tasks integration

* Update tests and unique id

* Revert devcontainer change

* Increase test coverage

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Remove ternary

* Fix JSON

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2023-10-24 21:30:29 -07:00 committed by GitHub
parent fb13d9ce7c
commit 0cb0e3ceeb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 750 additions and 0 deletions

View file

@ -0,0 +1,46 @@
"""The Google Tasks integration."""
from __future__ import annotations
from aiohttp import ClientError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow
from . import api
from .const import DOMAIN
PLATFORMS: list[Platform] = [Platform.TODO]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Google Tasks from a config entry."""
hass.data.setdefault(DOMAIN, {})
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
auth = api.AsyncConfigEntryAuth(hass, session)
try:
await auth.async_get_access_token()
except ClientError as err:
raise ConfigEntryNotReady from err
hass.data[DOMAIN][entry.entry_id] = auth
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View file

@ -0,0 +1,53 @@
"""API for Google Tasks bound to Home Assistant OAuth."""
from typing import Any
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build
from googleapiclient.http import HttpRequest
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
MAX_TASK_RESULTS = 100
class AsyncConfigEntryAuth:
"""Provide Google Tasks authentication tied to an OAuth2 based config entry."""
def __init__(
self,
hass: HomeAssistant,
oauth2_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize Google Tasks Auth."""
self._hass = hass
self._oauth_session = oauth2_session
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
if not self._oauth_session.valid_token:
await self._oauth_session.async_ensure_token_valid()
return self._oauth_session.token[CONF_ACCESS_TOKEN]
async def _get_service(self) -> Resource:
"""Get current resource."""
token = await self.async_get_access_token()
return build("tasks", "v1", credentials=Credentials(token=token))
async def list_task_lists(self) -> list[dict[str, Any]]:
"""Get all TaskList resources."""
service = await self._get_service()
cmd: HttpRequest = service.tasklists().list()
result = await self._hass.async_add_executor_job(cmd.execute)
return result["items"]
async def list_tasks(self, task_list_id: str) -> list[dict[str, Any]]:
"""Get all Task resources for the task list."""
service = await self._get_service()
cmd: HttpRequest = service.tasks().list(
tasklist=task_list_id, maxResults=MAX_TASK_RESULTS
)
result = await self._hass.async_add_executor_job(cmd.execute)
return result["items"]

View file

@ -0,0 +1,23 @@
"""Application credentials platform for the Google Tasks integration."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server."""
return AuthorizationServer(
authorize_url=OAUTH2_AUTHORIZE,
token_url=OAUTH2_TOKEN,
)
async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
"""Return description placeholders for the credentials dialog."""
return {
"oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
"more_info_url": "https://www.home-assistant.io/integrations/google_tasks/",
"oauth_creds_url": "https://console.cloud.google.com/apis/credentials",
}

View file

@ -0,0 +1,30 @@
"""Config flow for Google Tasks."""
import logging
from typing import Any
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN, OAUTH2_SCOPES
class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle Google Tasks OAuth2 authentication."""
DOMAIN = DOMAIN
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url."""
return {
"scope": " ".join(OAUTH2_SCOPES),
# Add params to ensure we get back a refresh token
"access_type": "offline",
"prompt": "consent",
}

View file

@ -0,0 +1,16 @@
"""Constants for the Google Tasks integration."""
from enum import StrEnum
DOMAIN = "google_tasks"
OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth"
OAUTH2_TOKEN = "https://oauth2.googleapis.com/token"
OAUTH2_SCOPES = ["https://www.googleapis.com/auth/tasks"]
class TaskStatus(StrEnum):
"""Status of a Google Task."""
NEEDS_ACTION = "needsAction"
COMPLETED = "completed"

View file

@ -0,0 +1,38 @@
"""Coordinator for fetching data from Google Tasks API."""
import asyncio
import datetime
import logging
from typing import Any, Final
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .api import AsyncConfigEntryAuth
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL: Final = datetime.timedelta(minutes=30)
TIMEOUT = 10
class TaskUpdateCoordinator(DataUpdateCoordinator):
"""Coordinator for fetching Google Tasks for a Task List form the API."""
def __init__(
self, hass: HomeAssistant, api: AsyncConfigEntryAuth, task_list_id: str
) -> None:
"""Initialize TaskUpdateCoordinator."""
super().__init__(
hass,
_LOGGER,
name=f"Google Tasks {task_list_id}",
update_interval=UPDATE_INTERVAL,
)
self._api = api
self._task_list_id = task_list_id
async def _async_update_data(self) -> list[dict[str, Any]]:
"""Fetch tasks from API endpoint."""
async with asyncio.timeout(TIMEOUT):
return await self._api.list_tasks(self._task_list_id)

View file

@ -0,0 +1,10 @@
{
"domain": "google_tasks",
"name": "Google Tasks",
"codeowners": ["@allenporter"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/google_tasks",
"iot_class": "cloud_polling",
"requirements": ["google-api-python-client==2.71.0"]
}

View file

@ -0,0 +1,24 @@
{
"application_credentials": {
"description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Tasks. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n"
},
"config": {
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
}
}

View file

@ -0,0 +1,75 @@
"""Google Tasks todo platform."""
from __future__ import annotations
from datetime import timedelta
from homeassistant.components.todo import TodoItem, TodoItemStatus, TodoListEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .api import AsyncConfigEntryAuth
from .const import DOMAIN
from .coordinator import TaskUpdateCoordinator
SCAN_INTERVAL = timedelta(minutes=15)
TODO_STATUS_MAP = {
"needsAction": TodoItemStatus.NEEDS_ACTION,
"completed": TodoItemStatus.COMPLETED,
}
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the Google Tasks todo platform."""
api: AsyncConfigEntryAuth = hass.data[DOMAIN][entry.entry_id]
task_lists = await api.list_task_lists()
async_add_entities(
(
GoogleTaskTodoListEntity(
TaskUpdateCoordinator(hass, api, task_list["id"]),
task_list["title"],
entry.entry_id,
task_list["id"],
)
for task_list in task_lists
),
True,
)
class GoogleTaskTodoListEntity(CoordinatorEntity, TodoListEntity):
"""A To-do List representation of the Shopping List."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: TaskUpdateCoordinator,
name: str,
config_entry_id: str,
task_list_id: str,
) -> None:
"""Initialize LocalTodoListEntity."""
super().__init__(coordinator)
self._attr_name = name.capitalize()
self._attr_unique_id = f"{config_entry_id}-{task_list_id}"
@property
def todo_items(self) -> list[TodoItem] | None:
"""Get the current set of To-do items."""
if self.coordinator.data is None:
return None
return [
TodoItem(
summary=item["title"],
uid=item["id"],
status=TODO_STATUS_MAP.get(
item.get("status"), TodoItemStatus.NEEDS_ACTION
),
)
for item in self.coordinator.data
]