logos-sdk 0.0.23__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- logos_sdk/__init__.py +4 -0
- logos_sdk/big_query/BigQuery.py +152 -0
- logos_sdk/big_query/__init__.py +22 -0
- logos_sdk/logging/LogosLogger.py +66 -0
- logos_sdk/logging/__init__.py +76 -0
- logos_sdk/services/CampaignManager.py +346 -0
- logos_sdk/services/Collabim.py +210 -0
- logos_sdk/services/DV360.py +245 -0
- logos_sdk/services/Facebook.py +413 -0
- logos_sdk/services/GoogleAds.py +150 -0
- logos_sdk/services/GoogleSheets.py +293 -0
- logos_sdk/services/MarketMiner.py +39 -0
- logos_sdk/services/MerchantCenter.py +157 -0
- logos_sdk/services/MicrosoftAdvertising.py +178 -0
- logos_sdk/services/Sklik.py +330 -0
- logos_sdk/services/__init__.py +35 -0
- logos_sdk-0.0.23.dist-info/LICENSE +17 -0
- logos_sdk-0.0.23.dist-info/METADATA +244 -0
- logos_sdk-0.0.23.dist-info/RECORD +21 -0
- logos_sdk-0.0.23.dist-info/WHEEL +5 -0
- logos_sdk-0.0.23.dist-info/top_level.txt +1 -0
logos_sdk/__init__.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
import google.auth.exceptions
|
|
5
|
+
from typing import List, Dict, Union, Optional
|
|
6
|
+
|
|
7
|
+
from google.api_core.exceptions import NotFound
|
|
8
|
+
from google.cloud.logging import Client as LoggerClient
|
|
9
|
+
from google.auth.exceptions import DefaultCredentialsError
|
|
10
|
+
from google.cloud.bigquery import Table
|
|
11
|
+
from google.cloud import bigquery
|
|
12
|
+
import numpy as np
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from google.api_core.retry import Retry
|
|
16
|
+
|
|
17
|
+
from logos_sdk.big_query import retry_on_not_found
|
|
18
|
+
from dotenv import load_dotenv
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BigQueryException(Exception):
|
|
22
|
+
def __init__(self, messages):
|
|
23
|
+
self.messages = messages
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BigQuery:
|
|
27
|
+
BQ_ROWS_LIMIT = 10000
|
|
28
|
+
_service = None
|
|
29
|
+
|
|
30
|
+
def __init__(self):
|
|
31
|
+
load_dotenv()
|
|
32
|
+
self.project_id = os.environ.get("PROJECT_ID")
|
|
33
|
+
self._service = bigquery.Client(project=self.project_id)
|
|
34
|
+
try:
|
|
35
|
+
self.logger = LoggerClient(_use_grpc=False).logger(name="logos-logging")
|
|
36
|
+
except DefaultCredentialsError:
|
|
37
|
+
self.logger = None
|
|
38
|
+
|
|
39
|
+
def get_dataset(self, dataset_id: str):
|
|
40
|
+
return self._service.get_dataset(dataset_id)
|
|
41
|
+
|
|
42
|
+
def create_dataset(self, dataset_id: str):
|
|
43
|
+
return self._service.create_dataset(dataset_id)
|
|
44
|
+
|
|
45
|
+
def delete_dataset(self, dataset_id: str):
|
|
46
|
+
self._service.delete_dataset(dataset_id, not_found_ok=True)
|
|
47
|
+
|
|
48
|
+
def _get_table_id_sql_format(self, dataset_id: str, table_id: str):
|
|
49
|
+
return f"{self.project_id}.{dataset_id}.{table_id}"
|
|
50
|
+
|
|
51
|
+
def run_query(self, query: str) -> List[Dict]:
|
|
52
|
+
df = self._service.query(query).result().to_dataframe().fillna(np.nan)
|
|
53
|
+
return df.replace([np.nan], [None]).to_dict("records")
|
|
54
|
+
|
|
55
|
+
def get_table(self, dataset_id: str, table_id: str) -> Table:
|
|
56
|
+
sql_format = self._get_table_id_sql_format(dataset_id, table_id)
|
|
57
|
+
return self._service.get_table(sql_format)
|
|
58
|
+
|
|
59
|
+
def insert_into_table(
|
|
60
|
+
self, dataset_id: str, table_id: str, records: List[Dict]
|
|
61
|
+
) -> None:
|
|
62
|
+
bq_table = self.get_table(dataset_id, table_id)
|
|
63
|
+
self._insert_into_table(bq_table, records)
|
|
64
|
+
|
|
65
|
+
def insert_create_table(
|
|
66
|
+
self,
|
|
67
|
+
dataset_id: str,
|
|
68
|
+
table_id: str,
|
|
69
|
+
records: List[Dict],
|
|
70
|
+
schema_columns: List[Dict],
|
|
71
|
+
) -> None:
|
|
72
|
+
bq_table = self.check_table_exists(dataset_id, table_id)
|
|
73
|
+
if bq_table is None:
|
|
74
|
+
bq_table = self.create_table(dataset_id, table_id, schema_columns)
|
|
75
|
+
|
|
76
|
+
self._insert_into_table(bq_table, records)
|
|
77
|
+
|
|
78
|
+
def delete_table(self, dataset_id: str, table_id: str) -> None:
|
|
79
|
+
if self.check_table_exists(dataset_id, table_id):
|
|
80
|
+
sql_format = self._get_table_id_sql_format(dataset_id, table_id)
|
|
81
|
+
self._service.delete_table(sql_format)
|
|
82
|
+
|
|
83
|
+
def check_table_exists(self, dataset_id: str, table_id: str) -> Optional[Table]:
|
|
84
|
+
try:
|
|
85
|
+
return self.get_table(dataset_id, table_id)
|
|
86
|
+
except google.cloud.exceptions.NotFound:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def create_table(
|
|
90
|
+
self, dataset_id: str, table_id: str, schema_columns: List[Dict]
|
|
91
|
+
) -> Union[bool, Table]:
|
|
92
|
+
table_schema = [
|
|
93
|
+
bigquery.schema.SchemaField(row["name"], row["col_type"], mode=row["mode"])
|
|
94
|
+
for row in schema_columns
|
|
95
|
+
]
|
|
96
|
+
try:
|
|
97
|
+
sql_format = self._get_table_id_sql_format(dataset_id, table_id)
|
|
98
|
+
table_object = bigquery.Table(sql_format, schema=table_schema)
|
|
99
|
+
return self._service.create_table(table_object)
|
|
100
|
+
except google.cloud.exceptions.Conflict:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def create_view(self, dataset_id, view_id, sql_string):
|
|
104
|
+
try:
|
|
105
|
+
sql_format = self._get_table_id_sql_format(dataset_id, view_id)
|
|
106
|
+
view = bigquery.Table(sql_format)
|
|
107
|
+
view.view_query = sql_string
|
|
108
|
+
return self._service.create_table(view)
|
|
109
|
+
except google.cloud.exceptions.Conflict:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
def get_table_last_modified_date(self, dataset_id: str, table_id: str):
|
|
113
|
+
last_modified_timestamp = (
|
|
114
|
+
self._service.query(
|
|
115
|
+
f"SELECT TIMESTAMP_MILLIS(last_modified_time) as time_stamp "
|
|
116
|
+
f"FROM `{self.project_id}.{dataset_id}.__TABLES__` "
|
|
117
|
+
f"WHERE table_id = '{table_id}'"
|
|
118
|
+
)
|
|
119
|
+
.result()
|
|
120
|
+
.to_dataframe()
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if last_modified_timestamp.empty:
|
|
124
|
+
raise NotFound("Table does not exist!")
|
|
125
|
+
|
|
126
|
+
last_modified_timestamp = last_modified_timestamp["time_stamp"].iloc[0]
|
|
127
|
+
last_modified_date = datetime.strptime(
|
|
128
|
+
str(last_modified_timestamp), "%Y-%m-%d %H:%M:%S.%f+00:00"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return last_modified_date
|
|
132
|
+
|
|
133
|
+
@retry_on_not_found
|
|
134
|
+
def _insert_into_table(
|
|
135
|
+
self, bq_table: Table, records: List[Dict], attempts: int
|
|
136
|
+
) -> None:
|
|
137
|
+
if len(records) > self.BQ_ROWS_LIMIT:
|
|
138
|
+
for index in range(0, len(records), self.BQ_ROWS_LIMIT):
|
|
139
|
+
errors = self._service.insert_rows(
|
|
140
|
+
bq_table,
|
|
141
|
+
records[index: (index + self.BQ_ROWS_LIMIT)],
|
|
142
|
+
retry=Retry(
|
|
143
|
+
total=2, connect=4, backoff_factor=2, allowed_methods=None
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
time.sleep(2)
|
|
147
|
+
if errors:
|
|
148
|
+
raise BigQueryException(errors)
|
|
149
|
+
else:
|
|
150
|
+
errors = self._service.insert_rows(bq_table, records)
|
|
151
|
+
if errors:
|
|
152
|
+
raise BigQueryException(errors)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
from google.api_core.exceptions import NotFound
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
MAX_NUMBER_OF_ATTEMPTS = 2
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def retry_on_not_found(wrapped_function):
|
|
9
|
+
"""This decorator retry call when table is not found. Insert into newly created table often fails with error
|
|
10
|
+
because API probably needs few seconds to see new created table"""
|
|
11
|
+
|
|
12
|
+
@wraps(wrapped_function)
|
|
13
|
+
def inner(*args, **kwargs):
|
|
14
|
+
for i in range(1, MAX_NUMBER_OF_ATTEMPTS + 1):
|
|
15
|
+
try:
|
|
16
|
+
kwargs["attempts"] = i
|
|
17
|
+
return wrapped_function(*args, **kwargs)
|
|
18
|
+
# this is because all request share same service
|
|
19
|
+
except NotFound as err:
|
|
20
|
+
time.sleep(2)
|
|
21
|
+
|
|
22
|
+
return inner
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
from google.cloud.logging import Client as LoggerClient
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
from logos_sdk import DEVELOPMENT, TESTING, CLOUD_DEVELOPMENT, PRODUCTION
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LogosLogger:
|
|
9
|
+
|
|
10
|
+
def __init__(self, name="logos-logging", labels=None, trace=None):
|
|
11
|
+
|
|
12
|
+
load_dotenv()
|
|
13
|
+
|
|
14
|
+
self.labels = labels
|
|
15
|
+
self.trace = trace
|
|
16
|
+
self.name = name
|
|
17
|
+
self.settings = {}
|
|
18
|
+
self.accesses = []
|
|
19
|
+
|
|
20
|
+
# if current environment is local development or Bitbucket pipeline testing, we do not care
|
|
21
|
+
# we want the name of the logger to be 'logos-logging', because it is not being sent to Cloud
|
|
22
|
+
# and we want to be able to test it as a real thing. On the other hand, if we are in production or
|
|
23
|
+
# cloud development, we need to be logging the logs into Cloud, therefore these two environments need
|
|
24
|
+
# to be separated, so that the logs do not clash.
|
|
25
|
+
|
|
26
|
+
if os.environ.get(DEVELOPMENT):
|
|
27
|
+
self.env = DEVELOPMENT
|
|
28
|
+
elif os.environ.get(TESTING):
|
|
29
|
+
self.env = TESTING
|
|
30
|
+
elif os.environ.get(CLOUD_DEVELOPMENT):
|
|
31
|
+
self.env = CLOUD_DEVELOPMENT
|
|
32
|
+
else:
|
|
33
|
+
self.env = PRODUCTION
|
|
34
|
+
|
|
35
|
+
if self.env == DEVELOPMENT or self.env == TESTING:
|
|
36
|
+
self.stream_logger = logging.getLogger(name=self.name)
|
|
37
|
+
else:
|
|
38
|
+
if self.env == CLOUD_DEVELOPMENT and self.name == "logos-logging":
|
|
39
|
+
self.name = "logos-logging-development"
|
|
40
|
+
|
|
41
|
+
self.cloud_client = LoggerClient()
|
|
42
|
+
self.cloud_logger = self.cloud_client.logger(name=self.name)
|
|
43
|
+
|
|
44
|
+
def get_name(self):
|
|
45
|
+
return self.name
|
|
46
|
+
|
|
47
|
+
def log(self, message, severity, log_type=None):
|
|
48
|
+
labels = self.labels if log_type is None else {"log_type": log_type, **(self.labels or {})}
|
|
49
|
+
if self.env == DEVELOPMENT or self.env == TESTING:
|
|
50
|
+
self.stream_logger.log(
|
|
51
|
+
msg={"message": message, "settings": self.settings, "accesses": self.accesses},
|
|
52
|
+
level=severity,
|
|
53
|
+
extra={
|
|
54
|
+
"json_fields": {
|
|
55
|
+
"logging.googleapis.com/trace": self.trace,
|
|
56
|
+
"logging.googleapis.com/labels": labels
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
self.cloud_logger.log_struct(
|
|
62
|
+
info={"message": message, "settings": self.settings, "accesses": self.accesses},
|
|
63
|
+
labels=labels,
|
|
64
|
+
severity=logging.getLevelName(severity),
|
|
65
|
+
trace=self.trace
|
|
66
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from logos_sdk.logging.LogosLogger import LogosLogger
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
# log types in labels
|
|
5
|
+
NOTIFICATION = "notification"
|
|
6
|
+
DEBUG = "debug"
|
|
7
|
+
RESULT = "result"
|
|
8
|
+
RESULT_UI_ONLY = "result-ui-only"
|
|
9
|
+
|
|
10
|
+
def setup_from_request(request, logger_name="logos-logging"):
|
|
11
|
+
# We want to parse as much as possible without raising an exception.
|
|
12
|
+
# we want to be able to at least create a complete notification log, for this we need labels without accesses and trace.
|
|
13
|
+
try:
|
|
14
|
+
trace = request.headers["X-Cloud-Trace-Context"]
|
|
15
|
+
except Exception as err:
|
|
16
|
+
raise Exception(f"Unable to retrieve trace from request ({err})")
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
logger = LogosLogger(logger_name, {}, trace)
|
|
20
|
+
except Exception as err:
|
|
21
|
+
raise Exception(f"Unable to ser up LogosLogger ({err})")
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
body = request.get_json()
|
|
25
|
+
except Exception as err:
|
|
26
|
+
message = f"Unable to parse request body as a valid json ({err})"
|
|
27
|
+
logger.log(message=message, severity=logging.ERROR, log_type=NOTIFICATION)
|
|
28
|
+
raise Exception(message)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
labels = {
|
|
32
|
+
"id": str(body["id"]),
|
|
33
|
+
"client": str(body["client"]),
|
|
34
|
+
"script": str(body["script"]),
|
|
35
|
+
"author": str(body["author"]),
|
|
36
|
+
}
|
|
37
|
+
logger.labels = labels
|
|
38
|
+
except Exception as err:
|
|
39
|
+
message = f"Unable to create labels, missing key ({err})"
|
|
40
|
+
logger.log(message=message, severity=logging.ERROR, log_type=NOTIFICATION)
|
|
41
|
+
raise Exception(message)
|
|
42
|
+
|
|
43
|
+
# To run the script itself, we need the rest. If we are not able to run it,
|
|
44
|
+
# we want to be able to at least create a complete notification log.
|
|
45
|
+
try:
|
|
46
|
+
settings = body["settings"]
|
|
47
|
+
logger.settings = body["settings"]
|
|
48
|
+
except Exception as err:
|
|
49
|
+
message = f"Missing required settings ({err})"
|
|
50
|
+
logger.log(message=message, severity=logging.ERROR, log_type=NOTIFICATION)
|
|
51
|
+
raise Exception(message)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
accesses = {
|
|
55
|
+
str(access["platform"]["short_name"]): str(access["account"]["account_platform_id"]) for access in
|
|
56
|
+
body["accesses"]
|
|
57
|
+
}
|
|
58
|
+
labels = {**labels, **accesses}
|
|
59
|
+
logger.labels = labels
|
|
60
|
+
logger.accesses = body["accesses"]
|
|
61
|
+
except Exception as err:
|
|
62
|
+
message = f"Unable to parse accesses ({err})"
|
|
63
|
+
logger.log(message=message, severity=logging.ERROR, log_type=NOTIFICATION)
|
|
64
|
+
raise Exception(message)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
secrets = {
|
|
68
|
+
str(access["platform"]["short_name"]): str(access["secret"]["name"]) for access in body["accesses"]
|
|
69
|
+
}
|
|
70
|
+
except Exception as err:
|
|
71
|
+
message = f"Unable to parse secrets ({err})"
|
|
72
|
+
logger.log(message=message, severity=logging.ERROR, log_type=NOTIFICATION)
|
|
73
|
+
raise Exception(message)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
return logger, labels, settings, secrets
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import httplib2
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from random import randint
|
|
8
|
+
from logos_sdk.services import get_headers
|
|
9
|
+
from requests import request
|
|
10
|
+
from typing import Dict, List
|
|
11
|
+
from http import HTTPStatus
|
|
12
|
+
from googleapiclient.http import MediaIoBaseDownload, HttpRequest
|
|
13
|
+
from pandas import read_csv
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CampaignManagerServiceException(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CampaignManagerService:
|
|
22
|
+
def __init__(self, url: str = None):
|
|
23
|
+
load_dotenv()
|
|
24
|
+
self._URL = url or os.environ.get("CM360_SERVICE_PATH")
|
|
25
|
+
self._CREATE_REPORT = self._URL + "/create-report"
|
|
26
|
+
self._GET_REPORT = self._URL + "/get-report"
|
|
27
|
+
self._RUN_REPORT = self._URL + "/run-report"
|
|
28
|
+
self._PATCH_REPORT = self._URL + "/patch-report"
|
|
29
|
+
self._DELETE_REPORT = self._URL + "/delete-report"
|
|
30
|
+
self._GET_FILE = self._URL + "/get-file"
|
|
31
|
+
self._GET_FILE_MEDIA_REQUEST = self._URL + "/get-file-media-request"
|
|
32
|
+
self._QUERY_DIMENSION_VALUES = self._URL + "/query-dimension-values"
|
|
33
|
+
|
|
34
|
+
def create_report(
|
|
35
|
+
self,
|
|
36
|
+
account_id: str,
|
|
37
|
+
name: str,
|
|
38
|
+
start_date: str,
|
|
39
|
+
end_date: str,
|
|
40
|
+
dimensions: list,
|
|
41
|
+
metrics_names: list,
|
|
42
|
+
dimension_filters: list,
|
|
43
|
+
secret_id: str,
|
|
44
|
+
) -> Dict:
|
|
45
|
+
"""
|
|
46
|
+
Method for creating a report in API and returning a dict containing its ID for further use
|
|
47
|
+
:param account_id: the Campaign Manager 360 account ID
|
|
48
|
+
:param name: the name for the report
|
|
49
|
+
:param start_date: start date for the report, in yyyy-mm-dd format
|
|
50
|
+
:param end_date: end date for the report, in yyyy-mm-dd format
|
|
51
|
+
:param dimensions: a list containing dimensions for the report
|
|
52
|
+
:param metrics_names: a list containing metrics for the report
|
|
53
|
+
:param dimension_filters: a list containing filters for the dimensions
|
|
54
|
+
:param secret_id: ID of the secret in Secret Manager to be used to access Campaign Manager 360
|
|
55
|
+
:return Dict
|
|
56
|
+
"""
|
|
57
|
+
body = {
|
|
58
|
+
"account_id": account_id,
|
|
59
|
+
"name": name,
|
|
60
|
+
"date_range": {"startDate": start_date, "endDate": end_date},
|
|
61
|
+
"dimensions": dimensions,
|
|
62
|
+
"metrics_names": metrics_names,
|
|
63
|
+
"dimension_filters": dimension_filters,
|
|
64
|
+
"secret_id": secret_id,
|
|
65
|
+
}
|
|
66
|
+
header = get_headers(self._CREATE_REPORT)
|
|
67
|
+
response = request(
|
|
68
|
+
method="post", url=self._CREATE_REPORT, json=body, headers=header
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if response.status_code == HTTPStatus.OK:
|
|
72
|
+
service_response = response.json()
|
|
73
|
+
return service_response["data"]
|
|
74
|
+
else:
|
|
75
|
+
raise CampaignManagerServiceException(response.content)
|
|
76
|
+
|
|
77
|
+
def get_report(self, account_id: str, report_id: str, secret_id: str) -> Dict:
|
|
78
|
+
"""
|
|
79
|
+
Method to return report
|
|
80
|
+
:param account_id: The ID of the account to fetch the report from
|
|
81
|
+
:param report_id: The ID of the report
|
|
82
|
+
:param secret_id: ID of the secret in Secret Manager to be used to access Campaign Manager 360
|
|
83
|
+
:return Dict
|
|
84
|
+
"""
|
|
85
|
+
body = {
|
|
86
|
+
"account_id": account_id,
|
|
87
|
+
"report_id": report_id,
|
|
88
|
+
"secret_id": secret_id,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
header = get_headers(self._GET_REPORT)
|
|
92
|
+
response = request("post", url=self._GET_REPORT, json=body, headers=header)
|
|
93
|
+
|
|
94
|
+
if response.status_code == HTTPStatus.OK:
|
|
95
|
+
service_response = response.json()
|
|
96
|
+
return service_response["data"]
|
|
97
|
+
else:
|
|
98
|
+
raise CampaignManagerServiceException(response.content)
|
|
99
|
+
|
|
100
|
+
def run_report(self, account_id: str, report_id: str, secret_id: str) -> Dict:
|
|
101
|
+
"""
|
|
102
|
+
Method to run report
|
|
103
|
+
:param account_id: The ID of the account to run the report for
|
|
104
|
+
:param report_id: The ID of the report
|
|
105
|
+
:param secret_id: ID of the secret in Secret Manager to be used to access Campaign Manager 360
|
|
106
|
+
:return Dict
|
|
107
|
+
"""
|
|
108
|
+
body = {
|
|
109
|
+
"account_id": account_id,
|
|
110
|
+
"report_id": report_id,
|
|
111
|
+
"secret_id": secret_id,
|
|
112
|
+
}
|
|
113
|
+
header = get_headers(self._RUN_REPORT)
|
|
114
|
+
response = request("post", url=self._RUN_REPORT, json=body, headers=header)
|
|
115
|
+
service_response = response.json()
|
|
116
|
+
|
|
117
|
+
if response.status_code == HTTPStatus.OK:
|
|
118
|
+
return service_response["data"]
|
|
119
|
+
else:
|
|
120
|
+
raise CampaignManagerServiceException(service_response)
|
|
121
|
+
|
|
122
|
+
# def patch_report(
|
|
123
|
+
# self,
|
|
124
|
+
# report_id: str,
|
|
125
|
+
# advertiser_name: str,
|
|
126
|
+
# start_date: str,
|
|
127
|
+
# end_date: str,
|
|
128
|
+
# dimensions: list,
|
|
129
|
+
# metrics_names: list,
|
|
130
|
+
# dimension_filters: list,
|
|
131
|
+
# secret_id: str,
|
|
132
|
+
# ) -> bool:
|
|
133
|
+
# """
|
|
134
|
+
# Method to patch report
|
|
135
|
+
# :param report_id: The ID of the report.
|
|
136
|
+
# :param advertiser_name: advertiser name of advertiser dimension filter.
|
|
137
|
+
# :param start_date: The start date for which this report should be run.
|
|
138
|
+
# :param end_date: The end date for which this report should be run.
|
|
139
|
+
# :param dimensions: The list of standard dimensions. Valid dimensions: dv360InsertionOrder, dv360LineItem, dv360Site.
|
|
140
|
+
# :param metrics_names: The list of names of metrics. Valid metric names: clickRate, impressions, totalConversions.
|
|
141
|
+
# :param dimension_filters: The list of filters on which dimensions are filtered.
|
|
142
|
+
# :param secret_id: ID of the secret in Secret Manager to be used to access Campaign Manager 360
|
|
143
|
+
# :return Boolean
|
|
144
|
+
# """
|
|
145
|
+
# body = {
|
|
146
|
+
# "report_id": report_id,
|
|
147
|
+
# "advertiser_name": advertiser_name,
|
|
148
|
+
# "date_range": {"startDate": start_date, "endDate": end_date},
|
|
149
|
+
# "dimensions": dimensions,
|
|
150
|
+
# "metrics_names": metrics_names,
|
|
151
|
+
# "dimension_filters": dimension_filters,
|
|
152
|
+
# "secret_id": secret_id,
|
|
153
|
+
# }
|
|
154
|
+
#
|
|
155
|
+
# header = get_headers(self._PATCH_REPORT)
|
|
156
|
+
# response = request("patch", url=self._PATCH_REPORT, json=body, headers=header)
|
|
157
|
+
# service_response = response.json()
|
|
158
|
+
# if response.status_code == HTTPStatus.OK:
|
|
159
|
+
# return True
|
|
160
|
+
# else:
|
|
161
|
+
# raise CampaignManagerServiceException(service_response)
|
|
162
|
+
|
|
163
|
+
def check_report_ready(self, report_id: str, file_id: str, secret_id: str) -> Dict:
|
|
164
|
+
"""
|
|
165
|
+
Method to check weather the report is already available for download
|
|
166
|
+
:param report_id: The ID of the report
|
|
167
|
+
:param file_id: The ID of the report file to be downloaded
|
|
168
|
+
:param secret_id: ID of the secret in Secret Manager to be used to access Campaign Manager 360
|
|
169
|
+
:return Dict
|
|
170
|
+
"""
|
|
171
|
+
body = {"report_id": report_id, "file_id": file_id, "secret_id": secret_id}
|
|
172
|
+
header = get_headers(self._GET_FILE)
|
|
173
|
+
response = request("post", url=self._GET_FILE, json=body, headers=header)
|
|
174
|
+
|
|
175
|
+
if response.status_code == HTTPStatus.OK:
|
|
176
|
+
service_response = response.json()
|
|
177
|
+
return service_response["data"]["status"] == "REPORT_AVAILABLE"
|
|
178
|
+
else:
|
|
179
|
+
raise CampaignManagerServiceException(response.content)
|
|
180
|
+
|
|
181
|
+
def check_report_ready_with_exponential_backoff(
|
|
182
|
+
self, report_id: str, file_id: str, secret_id: str, backoff_attempts: int = 10
|
|
183
|
+
) -> bool:
|
|
184
|
+
"""
|
|
185
|
+
Implements exponential backoff for pooling the API for readiness of the report, suggested in
|
|
186
|
+
algorithm suggested by https://developers.google.com/doubleclick-advertisers/upload#exp-backoff
|
|
187
|
+
:param report_id: The ID of the report
|
|
188
|
+
:param file_id: The ID of the report file to be downloaded
|
|
189
|
+
:param secret_id: ID of the secret in Secret Manager to be used to access Campaign Manager 360
|
|
190
|
+
:param backoff_attempts: number of attempts
|
|
191
|
+
:return Bool
|
|
192
|
+
"""
|
|
193
|
+
for attempt in range(0, backoff_attempts):
|
|
194
|
+
if self.check_report_ready(report_id, file_id, secret_id):
|
|
195
|
+
return True
|
|
196
|
+
else:
|
|
197
|
+
time.sleep((2 ** attempt) + randint(1, 20))
|
|
198
|
+
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
def get_report_results(self, report_id: str, file_id: str, secret_id: str) -> Dict:
|
|
202
|
+
"""
|
|
203
|
+
Method to fetch report, based on https://developers.google.com/doubleclick-advertisers/guides/download_reports
|
|
204
|
+
and https://googleapis.github.io/google-api-python-client/docs/epy/googleapiclient.http.MediaIoBaseDownload-class.html
|
|
205
|
+
:param account_id: The ID of the account to check for the report
|
|
206
|
+
:param report_id: The ID of the report
|
|
207
|
+
:param file_id: The ID of the report file to be downloaded
|
|
208
|
+
:param secret_id: ID of the secret in Secret Manager to be used to access Campaign Manager 360
|
|
209
|
+
:return Dict
|
|
210
|
+
"""
|
|
211
|
+
body = {"report_id": report_id, "file_id": file_id, "secret_id": secret_id}
|
|
212
|
+
header = get_headers(self._GET_FILE_MEDIA_REQUEST)
|
|
213
|
+
|
|
214
|
+
# fetch the authorized request from our service for downloading the report from API
|
|
215
|
+
response = request(
|
|
216
|
+
"post", url=self._GET_FILE_MEDIA_REQUEST, json=body, headers=header
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if response.status_code == HTTPStatus.OK:
|
|
220
|
+
service_response = response.json()
|
|
221
|
+
authorised_request = HttpRequest.from_json(
|
|
222
|
+
json.dumps(service_response["data"]),
|
|
223
|
+
http=httplib2.Http(),
|
|
224
|
+
postproc=None,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# crete tmp directory if not exist
|
|
228
|
+
if not os.path.exists("tmp"):
|
|
229
|
+
os.makedirs("tmp")
|
|
230
|
+
result_temp_file_location = os.path.join("tmp", "report-result-temp.csv")
|
|
231
|
+
# prepare a local file to download the report contents to
|
|
232
|
+
result_temp_file = io.FileIO(result_temp_file_location, mode="wb")
|
|
233
|
+
# 1024 * 1024 * 10 = 10 MB, based on best practices described in CM360 API documentation, default would be 1 MB
|
|
234
|
+
chunk_size = 10485760
|
|
235
|
+
# create a media downloader instance
|
|
236
|
+
media_downloader = MediaIoBaseDownload(
|
|
237
|
+
result_temp_file, authorised_request, chunksize=chunk_size
|
|
238
|
+
)
|
|
239
|
+
# execute the get request and download the file.
|
|
240
|
+
download_finished = False
|
|
241
|
+
while download_finished is False:
|
|
242
|
+
_, download_finished = media_downloader.next_chunk()
|
|
243
|
+
|
|
244
|
+
result_temp_file.close()
|
|
245
|
+
|
|
246
|
+
skip = 0
|
|
247
|
+
with open(result_temp_file_location, "r") as file:
|
|
248
|
+
for line in file:
|
|
249
|
+
skip += 1
|
|
250
|
+
if "Report Fields" == line.strip():
|
|
251
|
+
break
|
|
252
|
+
|
|
253
|
+
results = read_csv(
|
|
254
|
+
result_temp_file_location,
|
|
255
|
+
on_bad_lines="skip",
|
|
256
|
+
skiprows=skip, # skip metadata
|
|
257
|
+
skipfooter=1, # last row contains "grand total"
|
|
258
|
+
engine="python", # otherwise warning that skipfooter arg does not exist
|
|
259
|
+
)
|
|
260
|
+
results = results.to_dict(orient="records")
|
|
261
|
+
|
|
262
|
+
# get rid of the tmp file
|
|
263
|
+
os.remove(result_temp_file_location)
|
|
264
|
+
return results
|
|
265
|
+
|
|
266
|
+
else:
|
|
267
|
+
raise CampaignManagerServiceException(response.content)
|
|
268
|
+
|
|
269
|
+
def delete_report(self, account_id: str, report_id: str, secret_id: str) -> bool:
|
|
270
|
+
"""
|
|
271
|
+
Method to delete report
|
|
272
|
+
:param account_id: The ID of the account to check for the report
|
|
273
|
+
:param report_id: The ID of the report
|
|
274
|
+
:param secret_id: ID of the secret in Secret Manager to be used to access Campaign Manager 360
|
|
275
|
+
:return Boolean
|
|
276
|
+
"""
|
|
277
|
+
body = {
|
|
278
|
+
"account_id": account_id,
|
|
279
|
+
"report_id": report_id,
|
|
280
|
+
"secret_id": secret_id,
|
|
281
|
+
}
|
|
282
|
+
header = get_headers(self._DELETE_REPORT)
|
|
283
|
+
response = request("post", url=self._DELETE_REPORT, json=body, headers=header)
|
|
284
|
+
|
|
285
|
+
if response.status_code == HTTPStatus.OK:
|
|
286
|
+
return True
|
|
287
|
+
else:
|
|
288
|
+
raise CampaignManagerServiceException(response.content)
|
|
289
|
+
|
|
290
|
+
def query_dimension_values(
|
|
291
|
+
self,
|
|
292
|
+
account_id: str,
|
|
293
|
+
secret_id: str,
|
|
294
|
+
dimension_name: str,
|
|
295
|
+
filters: List,
|
|
296
|
+
start_date: str,
|
|
297
|
+
end_date: int,
|
|
298
|
+
max_results: int = 100,
|
|
299
|
+
) -> List[Dict]:
|
|
300
|
+
"""
|
|
301
|
+
Retrieves values of selected dimensions for data filtered according to rules set in list of filters
|
|
302
|
+
:param account_id: Account ID
|
|
303
|
+
:param dimension_name: The name of the dimension for which values should be requested
|
|
304
|
+
:param secret_id: The ID of the secret in secret manager
|
|
305
|
+
:param filters: The list of filters by which to filter values
|
|
306
|
+
:param start_date: The start date of the date range for which to retrieve dimension values
|
|
307
|
+
:param end_date: The end date of the date range for which to retrieve dimension values
|
|
308
|
+
:param max_results: Maximum number of results to return
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
return {"next_page_token": token, items: values of selected dimensions}
|
|
312
|
+
"""
|
|
313
|
+
body = {
|
|
314
|
+
"account_id": account_id,
|
|
315
|
+
"dimension_name": dimension_name,
|
|
316
|
+
"secret_id": secret_id,
|
|
317
|
+
"filters": filters,
|
|
318
|
+
"start_date": start_date,
|
|
319
|
+
"end_date": end_date,
|
|
320
|
+
"page_token": None,
|
|
321
|
+
"max_results": max_results,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
header = get_headers(self._QUERY_DIMENSION_VALUES)
|
|
325
|
+
response = request(
|
|
326
|
+
"post", url=self._QUERY_DIMENSION_VALUES, json=body, headers=header
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
if response.status_code != HTTPStatus.OK:
|
|
330
|
+
raise CampaignManagerServiceException(response.content)
|
|
331
|
+
|
|
332
|
+
service_response = response.json()
|
|
333
|
+
yield service_response["data"]["items"]
|
|
334
|
+
|
|
335
|
+
# if there was a last page response is empty string
|
|
336
|
+
while service_response["data"]["nextPageToken"]:
|
|
337
|
+
body["page_token"] = service_response["data"]["nextPageToken"]
|
|
338
|
+
response = request(
|
|
339
|
+
"post", url=self._QUERY_DIMENSION_VALUES, json=body, headers=header
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
if response.status_code != HTTPStatus.OK:
|
|
343
|
+
raise CampaignManagerServiceException(response.content)
|
|
344
|
+
|
|
345
|
+
service_response = response.json()
|
|
346
|
+
yield service_response["data"]["items"]
|