Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
27 changes: 23 additions & 4 deletions homeassistant/components/google_sheets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import ConfigEntrySelector

from .const import DATA_CONFIG_ENTRY, DEFAULT_ACCESS, DOMAIN
from .const import CONF_SHEETS_ACCESS, DATA_CONFIG_ENTRY, DOMAIN, FeatureAccess

DATA = "data"
WORKSHEET = "worksheet"
Expand Down Expand Up @@ -52,18 +52,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[str]:
"""Return the desired sheets feature access."""
return [
FeatureAccess.file.value,
FeatureAccess[config_entry.options[CONF_SHEETS_ACCESS]].value,

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 help me understand in more detail how the read and write scopes will be used? I get the general idea of read vs write scopes, but I don't quite get how these will be used compare to the file scope.

The way it appears right now is that I see is the user will create a single sheet (using the file scope) -- but it's also given a read-only scope for everything in the account. I don't see a way to access the other sheets or why giving broad read/write access can even be used.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The read scope gives read access to all spreadsheets. The file scope ensures users still have write access to the document that the integration created. I plan on extending the append service to allow the user to add a document id so they can specify a different document other than the integration made one.

The plan is to add an edit service which makes more sense for any other documents that people have access to that they want to modify. It should be the same schema for both services.

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.

Yeah I understand the scope differences, just wanting to see how they connect to the feature to ensure they all make sense together.

Ok, I'd say let's do the scope changes with the features it will enable? I don't mind viewing the larger PR. I just want to make sure it all connects.

]


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 @@ -72,6 +85,12 @@ 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."""

Expand Down
73 changes: 70 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 SOURCE_REAUTH, 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 @@ -25,6 +35,11 @@ class OAuth2FlowHandler(

DOMAIN = DOMAIN

def __init__(self) -> None:
"""Set up instance."""
super().__init__()
self._reauth_entry: config_entries.ConfigEntry | None = None

@property
def logger(self) -> logging.Logger:
"""Return logger."""
Expand All @@ -33,15 +48,22 @@ 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",
}

async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth upon an API authentication error."""
self._reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()

async def async_step_reauth_confirm(
Expand Down Expand Up @@ -98,5 +120,50 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:

await self.async_set_unique_id(doc.id)
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",
}
)
}
),
)
14 changes: 13 additions & 1 deletion homeassistant/components/google_sheets/const.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
"""Constants for Google Sheets integration."""
from __future__ import annotations

from enum import Enum
from typing import Final

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_CONFIG_ENTRY: Final = "config_entry"
DEFAULT_NAME = "Google Sheets"
DEFAULT_ACCESS = "https://www.googleapis.com/auth/drive.file"


class FeatureAccess(Enum):
Comment thread
tkdrob marked this conversation as resolved.
Outdated
"""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.value, FeatureAccess.read_only.value]
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": "Home Assistant access to Google Sheets"
}
}
}
},
"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
9 changes: 9 additions & 0 deletions homeassistant/components/google_sheets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,14 @@
"title": "Reauthenticate Integration"
}
}
},
"options": {
"step": {
"init": {
"data": {
"sheets_access": "Home Assistant access to Google Sheets"
}
}
}
}
}
86 changes: 86 additions & 0 deletions tests/components/google_sheets/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Test configuration and mocks for the google sheets integration."""

from collections.abc import Awaitable, Callable, Generator
import time

import pytest

from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.google_sheets.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component

from tests.common import MockConfigEntry

TEST_SHEET_ID = "google-sheet-id"

ComponentSetup = Callable[[], Awaitable[None]]


@pytest.fixture(name="scopes")
def mock_scopes() -> list[str]:
"""Fixture to set the scopes present in the OAuth token."""
return [
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/spreadsheets.readonly",
]


@pytest.fixture(name="expires_at")
def mock_expires_at() -> int:
"""Fixture to set the oauth token expiration time."""
return time.time() + 3600


@pytest.fixture(name="config_entry")
def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry:
"""Fixture for MockConfigEntry."""
return MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_SHEET_ID,
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"expires_at": expires_at,
"scope": " ".join(scopes),
},
},
options={"sheets_access": "read_only"},
)


@pytest.fixture(name="setup_integration")
async def mock_setup_integration(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> Generator[ComponentSetup, None, None]:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)

assert await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential("client-id", "client-secret"),
DOMAIN,
)

async def func() -> None:
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()

yield func

# Verify clean unload
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
await hass.config_entries.async_unload(entries[0].entry_id)
await hass.async_block_till_done()

assert not hass.data.get(DOMAIN)
assert entries[0].state is ConfigEntryState.NOT_LOADED
Loading