mirror of
https://github.com/home-assistant/core.git
synced 2025-07-26 22:57:17 +00:00
Address Google Sheets PR feedback (#78889)
This commit is contained in:
parent
13c8d22baf
commit
d03553bbf0
@ -2,7 +2,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from google.auth.exceptions import RefreshError
|
from google.auth.exceptions import RefreshError
|
||||||
@ -105,12 +104,13 @@ async def async_setup_service(hass: HomeAssistant) -> None:
|
|||||||
|
|
||||||
async def append_to_sheet(call: ServiceCall) -> None:
|
async def append_to_sheet(call: ServiceCall) -> None:
|
||||||
"""Append new line of data to a Google Sheets document."""
|
"""Append new line of data to a Google Sheets document."""
|
||||||
|
entry: ConfigEntry | None = hass.config_entries.async_get_entry(
|
||||||
entry = cast(
|
call.data[DATA_CONFIG_ENTRY]
|
||||||
ConfigEntry,
|
|
||||||
hass.config_entries.async_get_entry(call.data[DATA_CONFIG_ENTRY]),
|
|
||||||
)
|
)
|
||||||
session: OAuth2Session = hass.data[DOMAIN][entry.entry_id]
|
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 session.async_ensure_token_valid()
|
||||||
await hass.async_add_executor_job(_append_to_sheet, call, entry)
|
await hass.async_add_executor_job(_append_to_sheet, call, entry)
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ from typing import Any
|
|||||||
from google.oauth2.credentials import Credentials
|
from google.oauth2.credentials import Credentials
|
||||||
from gspread import Client, GSpreadException
|
from gspread import Client, GSpreadException
|
||||||
|
|
||||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
from homeassistant.helpers import config_entry_oauth2_flow
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
@ -25,6 +25,8 @@ class OAuth2FlowHandler(
|
|||||||
|
|
||||||
DOMAIN = DOMAIN
|
DOMAIN = DOMAIN
|
||||||
|
|
||||||
|
reauth_entry: ConfigEntry | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logger(self) -> logging.Logger:
|
def logger(self) -> logging.Logger:
|
||||||
"""Return logger."""
|
"""Return logger."""
|
||||||
@ -42,6 +44,9 @@ class OAuth2FlowHandler(
|
|||||||
|
|
||||||
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
|
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
|
||||||
"""Perform reauth upon an API authentication error."""
|
"""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()
|
return await self.async_step_reauth_confirm()
|
||||||
|
|
||||||
async def async_step_reauth_confirm(
|
async def async_step_reauth_confirm(
|
||||||
@ -52,40 +57,27 @@ class OAuth2FlowHandler(
|
|||||||
return self.async_show_form(step_id="reauth_confirm")
|
return self.async_show_form(step_id="reauth_confirm")
|
||||||
return await self.async_step_user()
|
return await self.async_step_user()
|
||||||
|
|
||||||
def _async_reauth_entry(self) -> ConfigEntry | None:
|
|
||||||
"""Return existing entry for reauth."""
|
|
||||||
if self.source != SOURCE_REAUTH or not (
|
|
||||||
entry_id := self.context.get("entry_id")
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
return next(
|
|
||||||
(
|
|
||||||
entry
|
|
||||||
for entry in self._async_current_entries()
|
|
||||||
if entry.entry_id == entry_id
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
|
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
|
||||||
"""Create an entry for the flow, or update existing entry."""
|
"""Create an entry for the flow, or update existing entry."""
|
||||||
service = Client(Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]))
|
service = Client(Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]))
|
||||||
|
|
||||||
if entry := self._async_reauth_entry():
|
if self.reauth_entry:
|
||||||
_LOGGER.debug("service.open_by_key")
|
_LOGGER.debug("service.open_by_key")
|
||||||
try:
|
try:
|
||||||
await self.hass.async_add_executor_job(
|
await self.hass.async_add_executor_job(
|
||||||
service.open_by_key,
|
service.open_by_key,
|
||||||
entry.unique_id,
|
self.reauth_entry.unique_id,
|
||||||
)
|
)
|
||||||
except GSpreadException as err:
|
except GSpreadException as err:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Could not find spreadsheet '%s': %s", entry.unique_id, str(err)
|
"Could not find spreadsheet '%s': %s",
|
||||||
|
self.reauth_entry.unique_id,
|
||||||
|
str(err),
|
||||||
)
|
)
|
||||||
return self.async_abort(reason="open_spreadsheet_failure")
|
return self.async_abort(reason="open_spreadsheet_failure")
|
||||||
|
|
||||||
self.hass.config_entries.async_update_entry(entry, data=data)
|
self.hass.config_entries.async_update_entry(self.reauth_entry, data=data)
|
||||||
await self.hass.config_entries.async_reload(entry.entry_id)
|
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
|
||||||
return self.async_abort(reason="reauth_successful")
|
return self.async_abort(reason="reauth_successful")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -97,6 +89,7 @@ class OAuth2FlowHandler(
|
|||||||
return self.async_abort(reason="create_spreadsheet_failure")
|
return self.async_abort(reason="create_spreadsheet_failure")
|
||||||
|
|
||||||
await self.async_set_unique_id(doc.id)
|
await self.async_set_unique_id(doc.id)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
return self.async_create_entry(
|
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}
|
||||||
)
|
)
|
||||||
|
@ -10,6 +10,10 @@
|
|||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "Link Google Account"
|
"title": "Link Google Account"
|
||||||
|
},
|
||||||
|
"reauth_confirm": {
|
||||||
|
"title": "[%key:common::config_flow::title::reauth%]",
|
||||||
|
"description": "The Google Sheets integration needs to re-authenticate your account"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
|
@ -312,3 +312,66 @@ async def test_reauth_abort(
|
|||||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
assert result.get("type") == "abort"
|
assert result.get("type") == "abort"
|
||||||
assert result.get("reason") == "open_spreadsheet_failure"
|
assert result.get("reason") == "open_spreadsheet_failure"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_already_configured(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
hass_client_no_auth,
|
||||||
|
aioclient_mock,
|
||||||
|
current_request_with_host,
|
||||||
|
setup_credentials,
|
||||||
|
mock_client,
|
||||||
|
) -> None:
|
||||||
|
"""Test case where config flow discovers unique id was already configured."""
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
unique_id=SHEET_ID,
|
||||||
|
data={
|
||||||
|
"token": {
|
||||||
|
"access_token": "mock-access-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
"google_sheets", context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
state = config_entry_oauth2_flow._encode_jwt(
|
||||||
|
hass,
|
||||||
|
{
|
||||||
|
"flow_id": result["flow_id"],
|
||||||
|
"redirect_uri": "https://example.com/auth/external/callback",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["url"] == (
|
||||||
|
f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}"
|
||||||
|
"&redirect_uri=https://example.com/auth/external/callback"
|
||||||
|
f"&state={state}&scope=https://www.googleapis.com/auth/drive.file"
|
||||||
|
"&access_type=offline&prompt=consent"
|
||||||
|
)
|
||||||
|
|
||||||
|
client = await hass_client_no_auth()
|
||||||
|
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||||
|
assert resp.status == 200
|
||||||
|
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||||
|
|
||||||
|
# Prepare fake client library response when creating the sheet
|
||||||
|
mock_create = Mock()
|
||||||
|
mock_create.return_value.id = SHEET_ID
|
||||||
|
mock_client.return_value.create = mock_create
|
||||||
|
|
||||||
|
aioclient_mock.post(
|
||||||
|
oauth2client.GOOGLE_TOKEN_URI,
|
||||||
|
json={
|
||||||
|
"refresh_token": "mock-refresh-token",
|
||||||
|
"access_token": "mock-access-token",
|
||||||
|
"type": "Bearer",
|
||||||
|
"expires_in": 60,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
assert result.get("type") == "abort"
|
||||||
|
assert result.get("reason") == "already_configured"
|
||||||
|
@ -14,6 +14,7 @@ from homeassistant.components.application_credentials import (
|
|||||||
from homeassistant.components.google_sheets import DOMAIN
|
from homeassistant.components.google_sheets import DOMAIN
|
||||||
from homeassistant.config_entries import ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ServiceNotFound
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
@ -75,16 +76,6 @@ async def mock_setup_integration(
|
|||||||
|
|
||||||
yield func
|
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 len(hass.services.async_services().get(DOMAIN, {}))
|
|
||||||
|
|
||||||
assert not hass.data.get(DOMAIN)
|
|
||||||
assert entries[0].state is ConfigEntryState.NOT_LOADED
|
|
||||||
|
|
||||||
|
|
||||||
async def test_setup_success(
|
async def test_setup_success(
|
||||||
hass: HomeAssistant, setup_integration: ComponentSetup
|
hass: HomeAssistant, setup_integration: ComponentSetup
|
||||||
@ -96,6 +87,13 @@ async def test_setup_success(
|
|||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
assert entries[0].state is ConfigEntryState.LOADED
|
assert entries[0].state is ConfigEntryState.LOADED
|
||||||
|
|
||||||
|
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
|
||||||
|
assert not len(hass.services.async_services().get(DOMAIN, {}))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"scopes",
|
"scopes",
|
||||||
@ -194,7 +192,7 @@ async def test_append_sheet(
|
|||||||
setup_integration: ComponentSetup,
|
setup_integration: ComponentSetup,
|
||||||
config_entry: MockConfigEntry,
|
config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test successful setup and unload."""
|
"""Test service call appending to a sheet."""
|
||||||
await setup_integration()
|
await setup_integration()
|
||||||
|
|
||||||
entries = hass.config_entries.async_entries(DOMAIN)
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
@ -213,3 +211,79 @@ async def test_append_sheet(
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
assert len(mock_client.mock_calls) == 8
|
assert len(mock_client.mock_calls) == 8
|
||||||
|
|
||||||
|
|
||||||
|
async def test_append_sheet_invalid_config_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
setup_integration: ComponentSetup,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
expires_at: int,
|
||||||
|
scopes: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Test service call with invalid config entries."""
|
||||||
|
config_entry2 = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
unique_id=TEST_SHEET_ID + "2",
|
||||||
|
data={
|
||||||
|
"auth_implementation": DOMAIN,
|
||||||
|
"token": {
|
||||||
|
"access_token": "mock-access-token",
|
||||||
|
"refresh_token": "mock-refresh-token",
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"scope": " ".join(scopes),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
config_entry2.add_to_hass(hass)
|
||||||
|
|
||||||
|
await setup_integration()
|
||||||
|
|
||||||
|
assert config_entry.state is ConfigEntryState.LOADED
|
||||||
|
assert config_entry2.state is ConfigEntryState.LOADED
|
||||||
|
|
||||||
|
# Exercise service call on a config entry that does not exist
|
||||||
|
with pytest.raises(ValueError, match="Invalid config entry"):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
"append_sheet",
|
||||||
|
{
|
||||||
|
"config_entry": config_entry.entry_id + "XXX",
|
||||||
|
"worksheet": "Sheet1",
|
||||||
|
"data": {"foo": "bar"},
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unload the config entry invoke the service on the unloaded entry id
|
||||||
|
await hass.config_entries.async_unload(config_entry2.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert config_entry2.state is ConfigEntryState.NOT_LOADED
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Config entry not loaded"):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
"append_sheet",
|
||||||
|
{
|
||||||
|
"config_entry": config_entry2.entry_id,
|
||||||
|
"worksheet": "Sheet1",
|
||||||
|
"data": {"foo": "bar"},
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unloading the other config entry will de-register the service
|
||||||
|
await hass.config_entries.async_unload(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||||
|
|
||||||
|
with pytest.raises(ServiceNotFound):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
"append_sheet",
|
||||||
|
{
|
||||||
|
"config_entry": config_entry.entry_id,
|
||||||
|
"worksheet": "Sheet1",
|
||||||
|
"data": {"foo": "bar"},
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user