Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 80 additions & 21 deletions homeassistant/components/google_sheets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,22 @@
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import ConfigEntrySelector

from .const import DEFAULT_ACCESS, DOMAIN

DATA = "data"
DATA_CONFIG_ENTRY = "config_entry"
WORKSHEET = "worksheet"

SERVICE_APPEND_SHEET = "append_sheet"
from .const import (
CONF_SHEETS_ACCESS,
DATA,
DATA_CONFIG_ENTRY,
DOCUMENT_ID,
DOMAIN,
SERVICE_APPEND_SHEET,
SERVICE_EDIT_SHEET,
WORKSHEET,
FeatureAccess,
)

SHEET_SERVICE_SCHEMA = vol.All(
{
vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector(),
vol.Optional(DOCUMENT_ID): cv.string,
vol.Optional(WORKSHEET): cv.string,
vol.Required(DATA): dict,
},
Expand All @@ -45,6 +50,11 @@

async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Google Sheets from a config entry."""
if not entry.options:
hass.config_entries.async_update_entry(
entry, options={CONF_SHEETS_ACCESS: FeatureAccess.read_only.name}
)

implementation = await async_get_config_entry_implementation(hass, entry)
session = OAuth2Session(hass, entry, implementation)
try:
Expand All @@ -58,18 +68,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err

if not async_entry_has_scopes(hass, entry):
if not async_entry_has_scopes(entry):
raise ConfigEntryAuthFailed("Required scopes are not present, reauth required")
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = session

await async_setup_service(hass)

entry.async_on_unload(entry.add_update_listener(async_reload_entry))

return True


def async_entry_has_scopes(hass: HomeAssistant, entry: ConfigEntry) -> bool:
def get_feature_access(config_entry: ConfigEntry) -> list[FeatureAccess]:
"""Return the desired sheets feature access."""
return [
FeatureAccess.file,
FeatureAccess[config_entry.options[CONF_SHEETS_ACCESS]],
]


def async_entry_has_scopes(entry: ConfigEntry) -> bool:
"""Verify that the config entry desired scope is present in the oauth token."""
return DEFAULT_ACCESS in entry.data.get(CONF_TOKEN, {}).get("scope", "").split(" ")
return all(
feature in entry.data[CONF_TOKEN]["scope"].split(" ")
for feature in get_feature_access(entry)
)


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Expand All @@ -87,14 +110,33 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True


async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload config entry if the access options change."""
if not async_entry_has_scopes(entry):
await hass.config_entries.async_reload(entry.entry_id)


async def async_setup_service(hass: HomeAssistant) -> None:
"""Add the services for Google Sheets."""

def _append_to_sheet(call: ServiceCall, entry: ConfigEntry) -> None:
async def _async_verify(call: ServiceCall) -> tuple[ConfigEntry, Client]:
"""Validate service call and return requested worksheet."""
entry: ConfigEntry | None = hass.config_entries.async_get_entry(
call.data[DATA_CONFIG_ENTRY]
)
if not entry:
raise ValueError(f"Invalid config entry: {call.data[DATA_CONFIG_ENTRY]}")
if not (session := hass.data[DOMAIN].get(entry.entry_id)):
raise ValueError(f"Config entry not loaded: {call.data[DATA_CONFIG_ENTRY]}")
await session.async_ensure_token_valid()
return entry, Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN]))

def _append_to_sheet(
call: ServiceCall, entry: ConfigEntry, service: Client
) -> None:
"""Run append in the executor."""
service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN]))
try:
sheet = service.open_by_key(entry.unique_id)
sheet = service.open_by_key(call.data.get(DOCUMENT_ID, entry.unique_id))
except RefreshError as ex:
entry.async_start_reauth(hass)
raise ex
Expand All @@ -114,19 +156,36 @@ def _append_to_sheet(call: ServiceCall, entry: ConfigEntry) -> None:

async def append_to_sheet(call: ServiceCall) -> None:
"""Append new line of data to a Google Sheets document."""
entry: ConfigEntry | None = hass.config_entries.async_get_entry(
call.data[DATA_CONFIG_ENTRY]
await hass.async_add_executor_job(
_append_to_sheet, call, *(await _async_verify(call))
)

def _edit_sheet(call: ServiceCall, entry: ConfigEntry, service: Client) -> None:
"""Run edit in the executor."""
try:
sheet = service.open_by_key(call.data.get(DOCUMENT_ID, entry.unique_id))
except RefreshError as ex:
entry.async_start_reauth(hass)
raise ex
worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title))
data = [{"range": k, "values": [[v]]} for k, v in call.data[DATA].items()]
worksheet.batch_update(data)

async def edit_sheet(call: ServiceCall) -> None:
"""Edit Google Sheets document."""
await hass.async_add_executor_job(
_edit_sheet, call, *(await _async_verify(call))
)
if not entry:
raise ValueError(f"Invalid config entry: {call.data[DATA_CONFIG_ENTRY]}")
if not (session := hass.data[DOMAIN].get(entry.entry_id)):
raise ValueError(f"Config entry not loaded: {call.data[DATA_CONFIG_ENTRY]}")
await session.async_ensure_token_valid()
await hass.async_add_executor_job(_append_to_sheet, call, entry)

hass.services.async_register(
DOMAIN,
SERVICE_APPEND_SHEET,
append_to_sheet,
schema=SHEET_SERVICE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_EDIT_SHEET,
edit_sheet,
schema=SHEET_SERVICE_SCHEMA,
)
65 changes: 62 additions & 3 deletions homeassistant/components/google_sheets/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,23 @@

from google.oauth2.credentials import Credentials
from gspread import Client, GSpreadException
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_entry_oauth2_flow

from .const import DEFAULT_ACCESS, DEFAULT_NAME, DOMAIN
from . import get_feature_access
from .const import (
CONF_SHEETS_ACCESS,
DEFAULT_ACCESS,
DEFAULT_NAME,
DOMAIN,
FeatureAccess,
)

_LOGGER = logging.getLogger(__name__)

Expand All @@ -35,8 +45,12 @@ def logger(self) -> logging.Logger:
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url."""
if self.reauth_entry:
scopes = get_feature_access(self.reauth_entry)
else:
scopes = DEFAULT_ACCESS
return {
"scope": DEFAULT_ACCESS,
"scope": " ".join(scopes),
# Add params to ensure we get back a refresh token
"access_type": "offline",
"prompt": "consent",
Expand Down Expand Up @@ -91,5 +105,50 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
await self.async_set_unique_id(doc.id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=DEFAULT_NAME, data=data, description_placeholders={"url": doc.url}
title=DEFAULT_NAME,
data=data,
description_placeholders={"url": doc.url},
options={
CONF_SHEETS_ACCESS: FeatureAccess.read_only.name,
},
)

@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Create an options flow."""
return OptionsFlowHandler(config_entry)


class OptionsFlowHandler(config_entries.OptionsFlow):
"""Google Sheets options flow."""

def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry

async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)

return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
CONF_SHEETS_ACCESS,
default=self.config_entry.options.get(CONF_SHEETS_ACCESS),
): vol.In(
{
FeatureAccess.read_write.name: "Read/Write access",
FeatureAccess.read_only.name: "Read-only access",
}
)
}
),
)
24 changes: 23 additions & 1 deletion homeassistant/components/google_sheets/const.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
"""Constants for Google Sheets integration."""
from __future__ import annotations

from typing import Final

from homeassistant.backports.enum import StrEnum

DOMAIN = "google_sheets"

CONF_SHEETS_ACCESS = "sheets_access"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please make this a Final constant?

DATA = "data"
DATA_CONFIG_ENTRY: Final = "config_entry"
DEFAULT_NAME = "Google Sheets"
DEFAULT_ACCESS = "https://www.googleapis.com/auth/drive.file"
DOCUMENT_ID = "document_id"
WORKSHEET = "worksheet"

SERVICE_APPEND_SHEET = "append_sheet"
SERVICE_EDIT_SHEET = "edit_sheet"


class FeatureAccess(StrEnum):
"""Class to represent different access scopes."""

read_write = "https://www.googleapis.com/auth/spreadsheets"
read_only = "https://www.googleapis.com/auth/spreadsheets.readonly"
file = "https://www.googleapis.com/auth/drive.file"


DEFAULT_ACCESS = [FeatureAccess.file, FeatureAccess.read_only]
38 changes: 37 additions & 1 deletion homeassistant/components/google_sheets/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ append_sheet:
selector:
config_entry:
integration: google_sheets
document_id:
name: Document ID
description: ID of the document to edit.
example: "1_4qUcssiORL6VaC11HQ--pas0wW20zt5B694LRdGe3g"
selector:
text:
worksheet:
name: Worksheet
description: Name of the worksheet. Defaults to the first one in the document.
Expand All @@ -19,6 +25,36 @@ append_sheet:
name: Data
description: Data to be appended to the worksheet. This puts the values on a new row underneath the matching column (key). Any new key is placed on the top of a new column.
required: true
example: '{"hello": world, "cool": True, "count": 5}'
example: '{"temperature": "{{states(''sensor.example.state'')}}"}'
selector:
object:
edit_sheet:
name: Edit Sheet
description: Edit cells within a sheet.
fields:
config_entry:
name: Sheet
description: The sheet to modify.
required: true
selector:
config_entry:
integration: google_sheets
document_id:
name: Document ID
description: ID of the document to edit.
example: "1_4qUcssiORL6VaC11HQ--pas0wW20zt5B694LRdGe3g"
selector:
text:
worksheet:
name: Worksheet
description: Name of the worksheet. Defaults to the first one in the document.
example: "Sheet1"
selector:
text:
data:
name: Data
description: Data locations to be edited on the worksheet. This puts the values in the cells matching the provided keys.
required: true
example: '{"A2": "{{states(''sensor.example.state'')}}"}'
selector:
object:
9 changes: 9 additions & 0 deletions homeassistant/components/google_sheets/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@
"default": "Successfully authenticated and spreadsheet created at: {url}"
}
},
"options": {
"step": {
"init": {
"data": {
"sheets_access": "Allow write access to edit other sheets in your drive"
}
}
}
},
"application_credentials": {
"description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Sheets. 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"
}
Expand Down
Loading