Skip to content

Commit 88c0846

Browse files
vinodpandeyharveyrendell
authored andcommitted
Added query runner for Google Analytics Data API (GA4) and Google Search Console API (getredash#5868)
* quick and dirty GA4 integration to support limited queries request pattern: { "propertyId": 123456789, "dateRanges": [{ "startDate": "yesterday", "endDate": "yesterday" }], "dimensions": [{ "name": "date" }], "metrics": [{ "name": "activeUsers" }] } * Update Dockerfile * Update requirements_bundles.txt related issue: getredash#5851 https://stackoverflow.com/questions/73929564/entrypoints-object-has-no-attribute-get-digital-ocean * updated integration code to support all usecases for runReport endpoint (does not have support for runPivotReport yet) * added google search console as query runner * removed info logger * removed files that were causing merge conflicts * fixed failing testcases and pre-commit formatting changes * added testcases * fixed linting errors in test files * code optimization
1 parent 26b1709 commit 88c0846

File tree

7 files changed

+704
-0
lines changed

7 files changed

+704
-0
lines changed
14.3 KB
Loading
14.1 KB
Loading
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import datetime
2+
import logging
3+
from base64 import b64decode
4+
5+
import requests
6+
7+
from redash.query_runner import (
8+
TYPE_DATE,
9+
TYPE_DATETIME,
10+
TYPE_FLOAT,
11+
TYPE_INTEGER,
12+
TYPE_STRING,
13+
BaseQueryRunner,
14+
register,
15+
)
16+
from redash.utils import json_dumps, json_loads
17+
18+
logger = logging.getLogger(__name__)
19+
20+
try:
21+
import httplib2
22+
from apiclient.discovery import build
23+
from oauth2client.service_account import ServiceAccountCredentials
24+
25+
enabled = True
26+
except ImportError:
27+
enabled = False
28+
29+
types_conv = dict(
30+
STRING=TYPE_STRING,
31+
INTEGER=TYPE_INTEGER,
32+
FLOAT=TYPE_FLOAT,
33+
DATE=TYPE_DATE,
34+
DATETIME=TYPE_DATETIME,
35+
)
36+
37+
ga_report_endpoint = "https://analyticsdata.googleapis.com/v1beta/properties/{propertyId}:runReport"
38+
ga_metadata_endpoint = "https://analyticsdata.googleapis.com/v1beta/properties/{propertyId}/metadata"
39+
40+
41+
def format_column_value(column_name, value, columns):
42+
column_type = [col for col in columns if col["name"] == column_name][0]["type"]
43+
44+
if column_type == TYPE_DATE:
45+
value = datetime.datetime.strptime(value, "%Y%m%d")
46+
elif column_type == TYPE_DATETIME:
47+
if len(value) == 10:
48+
value = datetime.datetime.strptime(value, "%Y%m%d%H")
49+
elif len(value) == 12:
50+
value = datetime.datetime.strptime(value, "%Y%m%d%H%M")
51+
else:
52+
raise Exception("Unknown date/time format in results: '{}'".format(value))
53+
54+
return value
55+
56+
57+
def get_formatted_column_json(column_name):
58+
data_type = None
59+
60+
if column_name == "date":
61+
data_type = "DATE"
62+
elif column_name == "dateHour":
63+
data_type = "DATETIME"
64+
65+
result = {
66+
"name": column_name,
67+
"friendly_name": column_name,
68+
"type": types_conv.get(data_type, "string"),
69+
}
70+
71+
return result
72+
73+
74+
def parse_ga_response(response):
75+
columns = []
76+
77+
for dim_header in response["dimensionHeaders"]:
78+
columns.append(get_formatted_column_json(dim_header["name"]))
79+
80+
for met_header in response["metricHeaders"]:
81+
columns.append(get_formatted_column_json(met_header["name"]))
82+
83+
rows = []
84+
for r in response["rows"]:
85+
counter = 0
86+
d = {}
87+
for item in r["dimensionValues"]:
88+
column_name = columns[counter]["name"]
89+
value = item["value"]
90+
91+
d[column_name] = format_column_value(column_name, value, columns)
92+
counter = counter + 1
93+
94+
for item in r["metricValues"]:
95+
column_name = columns[counter]["name"]
96+
value = item["value"]
97+
98+
d[column_name] = format_column_value(column_name, value, columns)
99+
counter = counter + 1
100+
101+
rows.append(d)
102+
103+
return {"columns": columns, "rows": rows}
104+
105+
106+
class GoogleAnalytics4(BaseQueryRunner):
107+
should_annotate_query = False
108+
109+
@classmethod
110+
def type(cls):
111+
return "google_analytics4"
112+
113+
@classmethod
114+
def name(cls):
115+
return "Google Analytics 4"
116+
117+
@classmethod
118+
def enabled(cls):
119+
return enabled
120+
121+
@classmethod
122+
def configuration_schema(cls):
123+
return {
124+
"type": "object",
125+
"properties": {
126+
"propertyId": {"type": "number", "title": "Property Id"},
127+
"jsonKeyFile": {"type": "string", "title": "JSON Key File"},
128+
},
129+
"required": ["propertyId", "jsonKeyFile"],
130+
"secret": ["jsonKeyFile"],
131+
}
132+
133+
def _get_access_token(self):
134+
key = json_loads(b64decode(self.configuration["jsonKeyFile"]))
135+
136+
scope = ["https://www.googleapis.com/auth/analytics.readonly"]
137+
creds = ServiceAccountCredentials.from_json_keyfile_dict(key, scope)
138+
139+
build("analyticsdata", "v1beta", http=creds.authorize(httplib2.Http()))
140+
141+
return creds.access_token
142+
143+
def run_query(self, query, user):
144+
access_token = self._get_access_token()
145+
params = json_loads(query)
146+
147+
property_id = self.configuration["propertyId"]
148+
149+
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {access_token}"}
150+
151+
url = ga_report_endpoint.replace("{propertyId}", str(property_id))
152+
r = requests.post(url, json=params, headers=headers)
153+
r.raise_for_status()
154+
155+
raw_result = r.json()
156+
157+
data = parse_ga_response(raw_result)
158+
159+
error = None
160+
json_data = json_dumps(data)
161+
162+
return json_data, error
163+
164+
def test_connection(self):
165+
try:
166+
access_token = self._get_access_token()
167+
property_id = self.configuration["propertyId"]
168+
169+
url = ga_metadata_endpoint.replace("{propertyId}", str(property_id))
170+
171+
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {access_token}"}
172+
173+
r = requests.get(url, headers=headers)
174+
r.raise_for_status()
175+
except Exception as e:
176+
raise Exception(e)
177+
178+
179+
register(GoogleAnalytics4)
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import logging
2+
from base64 import b64decode
3+
from datetime import datetime
4+
5+
from redash.query_runner import (
6+
TYPE_DATE,
7+
TYPE_DATETIME,
8+
TYPE_FLOAT,
9+
TYPE_INTEGER,
10+
TYPE_STRING,
11+
BaseSQLQueryRunner,
12+
register,
13+
)
14+
from redash.utils import json_dumps, json_loads
15+
16+
logger = logging.getLogger(__name__)
17+
18+
try:
19+
import httplib2
20+
from apiclient.discovery import build
21+
from apiclient.errors import HttpError
22+
from oauth2client.service_account import ServiceAccountCredentials
23+
24+
enabled = True
25+
except ImportError:
26+
enabled = False
27+
28+
29+
types_conv = dict(
30+
STRING=TYPE_STRING,
31+
INTEGER=TYPE_INTEGER,
32+
FLOAT=TYPE_FLOAT,
33+
DATE=TYPE_DATE,
34+
DATETIME=TYPE_DATETIME,
35+
)
36+
37+
38+
def parse_ga_response(response, dimensions):
39+
columns = []
40+
41+
for item in dimensions:
42+
if item == "date":
43+
data_type = "date"
44+
else:
45+
data_type = "string"
46+
columns.append(
47+
{
48+
"name": item,
49+
"friendly_name": item,
50+
"type": data_type,
51+
}
52+
)
53+
54+
default_items = ["clicks", "impressions", "ctr", "position"]
55+
for item in default_items:
56+
columns.append({"name": item, "friendly_name": item, "type": "number"})
57+
58+
rows = []
59+
for r in response.get("rows", []):
60+
d = {}
61+
for k, value in r.items():
62+
if k == "keys":
63+
for index, val in enumerate(value):
64+
column_name = columns[index]["name"]
65+
column_type = columns[index]["type"]
66+
val = get_formatted_value(column_type, val)
67+
d[column_name] = val
68+
else:
69+
column_name = k
70+
column_type = [col for col in columns if col["name"] == column_name][0]["type"]
71+
value = get_formatted_value(column_type, value)
72+
d[column_name] = value
73+
rows.append(d)
74+
75+
return {"columns": columns, "rows": rows}
76+
77+
78+
def get_formatted_value(column_type, value):
79+
if column_type == "number":
80+
value = round(value, 2)
81+
elif column_type == TYPE_DATE:
82+
value = datetime.strptime(value, "%Y-%m-%d")
83+
elif column_type == TYPE_DATETIME:
84+
if len(value) == 10:
85+
value = datetime.strptime(value, "%Y%m%d%H")
86+
elif len(value) == 12:
87+
value = datetime.strptime(value, "%Y%m%d%H%M")
88+
else:
89+
raise Exception("Unknown date/time format in results: '{}'".format(value))
90+
return value
91+
92+
93+
class GoogleSearchConsole(BaseSQLQueryRunner):
94+
should_annotate_query = False
95+
96+
@classmethod
97+
def type(cls):
98+
return "google_search_console"
99+
100+
@classmethod
101+
def name(cls):
102+
return "Google Search Console"
103+
104+
@classmethod
105+
def enabled(cls):
106+
return enabled
107+
108+
@classmethod
109+
def configuration_schema(cls):
110+
return {
111+
"type": "object",
112+
"properties": {
113+
"siteURL": {"type": "string", "title": "Site URL"},
114+
"jsonKeyFile": {"type": "string", "title": "JSON Key File"},
115+
},
116+
"required": ["jsonKeyFile"],
117+
"secret": ["jsonKeyFile"],
118+
}
119+
120+
def __init__(self, configuration):
121+
super(GoogleSearchConsole, self).__init__(configuration)
122+
self.syntax = "json"
123+
124+
def _get_search_service(self):
125+
scope = ["https://www.googleapis.com/auth/webmasters.readonly"]
126+
key = json_loads(b64decode(self.configuration["jsonKeyFile"]))
127+
creds = ServiceAccountCredentials.from_json_keyfile_dict(key, scope)
128+
return build("searchconsole", "v1", http=creds.authorize(httplib2.Http()))
129+
130+
def test_connection(self):
131+
try:
132+
service = self._get_search_service()
133+
service.sites().list().execute()
134+
except HttpError as e:
135+
# Make sure we return a more readable error to the end user
136+
raise Exception(e._get_reason())
137+
138+
def run_query(self, query, user):
139+
logger.debug("Search Analytics is about to execute query: %s", query)
140+
params = json_loads(query)
141+
site_url = self.configuration["siteURL"]
142+
api = self._get_search_service()
143+
144+
if len(params) > 0:
145+
try:
146+
response = api.searchanalytics().query(siteUrl=site_url, body=params).execute()
147+
data = parse_ga_response(response, params["dimensions"])
148+
error = None
149+
json_data = json_dumps(data)
150+
except HttpError as e:
151+
# Make sure we return a more readable error to the end user
152+
error = e._get_reason()
153+
json_data = None
154+
else:
155+
error = "Wrong query format."
156+
json_data = None
157+
return json_data, error
158+
159+
160+
register(GoogleSearchConsole)

redash/settings/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,8 @@ def email_server_is_configured():
327327
"redash.query_runner.databend",
328328
"redash.query_runner.nz",
329329
"redash.query_runner.arango",
330+
"redash.query_runner.google_analytics4",
331+
"redash.query_runner.google_search_console",
330332
]
331333

332334
enabled_query_runners = array_from_string(

0 commit comments

Comments
 (0)