brynq-sdk-salesforce 2.0.0__tar.gz → 2.0.1__tar.gz
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.
- {brynq_sdk_salesforce-2.0.0 → brynq_sdk_salesforce-2.0.1}/PKG-INFO +1 -1
- brynq_sdk_salesforce-2.0.1/brynq_sdk_salesforce/__init__.py +1 -0
- brynq_sdk_salesforce-2.0.1/brynq_sdk_salesforce/salesforce.py +317 -0
- {brynq_sdk_salesforce-2.0.0 → brynq_sdk_salesforce-2.0.1}/brynq_sdk_salesforce.egg-info/PKG-INFO +1 -1
- {brynq_sdk_salesforce-2.0.0 → brynq_sdk_salesforce-2.0.1}/brynq_sdk_salesforce.egg-info/SOURCES.txt +2 -0
- brynq_sdk_salesforce-2.0.1/brynq_sdk_salesforce.egg-info/top_level.txt +1 -0
- {brynq_sdk_salesforce-2.0.0 → brynq_sdk_salesforce-2.0.1}/setup.py +1 -1
- brynq_sdk_salesforce-2.0.0/brynq_sdk_salesforce.egg-info/top_level.txt +0 -1
- {brynq_sdk_salesforce-2.0.0 → brynq_sdk_salesforce-2.0.1}/brynq_sdk_salesforce.egg-info/dependency_links.txt +0 -0
- {brynq_sdk_salesforce-2.0.0 → brynq_sdk_salesforce-2.0.1}/brynq_sdk_salesforce.egg-info/not-zip-safe +0 -0
- {brynq_sdk_salesforce-2.0.0 → brynq_sdk_salesforce-2.0.1}/brynq_sdk_salesforce.egg-info/requires.txt +0 -0
- {brynq_sdk_salesforce-2.0.0 → brynq_sdk_salesforce-2.0.1}/setup.cfg +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .salesforce import Salesforce
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
from brynq_sdk_brynq import BrynQ
|
|
2
|
+
import urllib.parse
|
|
3
|
+
import warnings
|
|
4
|
+
import requests
|
|
5
|
+
import json
|
|
6
|
+
from typing import Union, List
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Salesforce(BrynQ):
|
|
12
|
+
"""
|
|
13
|
+
This class is meant to be a simple wrapper around the Salesforce API. In order to start using it, authorize your application is BrynQ.
|
|
14
|
+
You will receive a code which you can use to obtain a refresh token using the get_refresh_token method. Use this refresh token to refresh your access token always before you make a data call.
|
|
15
|
+
"""
|
|
16
|
+
def __init__(self, label: Union[str, List], debug: bool = False, sandbox: bool = False):
|
|
17
|
+
super().__init__()
|
|
18
|
+
if sandbox:
|
|
19
|
+
self.system = 'salesforce-sandbox'
|
|
20
|
+
else:
|
|
21
|
+
self.system = 'salesforce'
|
|
22
|
+
self.credentials = self.get_system_credential(system=self.system, label=label)
|
|
23
|
+
self.credential_id = self.credentials['id']
|
|
24
|
+
self.customer_url = self.credentials['auth']['instance_url']
|
|
25
|
+
self.debug = debug
|
|
26
|
+
self.api_version = 56.0
|
|
27
|
+
|
|
28
|
+
def __get_headers(self) -> dict:
|
|
29
|
+
credentials = self.refresh_system_credential(system=self.system, system_id=self.credential_id)
|
|
30
|
+
headers = {"Authorization": f"Bearer {credentials['access_token']}",
|
|
31
|
+
"Content-Type": "application/json"}
|
|
32
|
+
if self.debug:
|
|
33
|
+
print(f"Headers: {headers}")
|
|
34
|
+
|
|
35
|
+
return headers
|
|
36
|
+
|
|
37
|
+
def query_data(self, query: str) -> pd.DataFrame:
|
|
38
|
+
"""
|
|
39
|
+
This method is used to send raw queries to Salesforce.
|
|
40
|
+
:param query: Querystring. Something like: 'select+Name,Id+from+Account'
|
|
41
|
+
:return: data or error
|
|
42
|
+
"""
|
|
43
|
+
params = {
|
|
44
|
+
"q": query
|
|
45
|
+
}
|
|
46
|
+
if self.debug:
|
|
47
|
+
print(f"Query: {query}")
|
|
48
|
+
params_str = urllib.parse.urlencode(params, safe=':+')
|
|
49
|
+
df = pd.DataFrame()
|
|
50
|
+
done = False
|
|
51
|
+
url = f"{self.customer_url}/services/data/v37.0/query/?"
|
|
52
|
+
while done is False:
|
|
53
|
+
response = requests.get(url=url, params=params_str, headers=self.__get_headers())
|
|
54
|
+
response.raise_for_status()
|
|
55
|
+
response = response.json()
|
|
56
|
+
done = response['done']
|
|
57
|
+
if done is False:
|
|
58
|
+
url = f"{self.customer_url}{response['nextRecordsUrl']}"
|
|
59
|
+
df = pd.concat([df, pd.DataFrame(response['records'])])
|
|
60
|
+
|
|
61
|
+
return df
|
|
62
|
+
|
|
63
|
+
def get_data(self, fields: Union[str, List], object_name: str, filter: str = None) -> pd.DataFrame:
|
|
64
|
+
"""
|
|
65
|
+
This method is used to send queries in a somewhat userfriendly wayt to Salesforce.
|
|
66
|
+
:param fields: fields you want to get
|
|
67
|
+
:param object_name: table or object name that the fields need to be retrieved from
|
|
68
|
+
:param filter: statement that evaluates to True or False
|
|
69
|
+
:return: data or error
|
|
70
|
+
"""
|
|
71
|
+
fields = ",".join(fields) if isinstance(fields, List) else fields
|
|
72
|
+
params = {
|
|
73
|
+
"q": f"SELECT {fields} FROM {object_name}{' WHERE ' + filter if filter is not None else ''}"
|
|
74
|
+
}
|
|
75
|
+
if self.debug:
|
|
76
|
+
print(f"Query: {params['q']}")
|
|
77
|
+
params_str = urllib.parse.urlencode(params, safe=':+')
|
|
78
|
+
df = pd.DataFrame()
|
|
79
|
+
done = False
|
|
80
|
+
url = f"{self.customer_url}/services/data/v37.0/query/?"
|
|
81
|
+
while done is False:
|
|
82
|
+
response = requests.get(url=url, params=params_str, headers=self.__get_headers())
|
|
83
|
+
response.raise_for_status()
|
|
84
|
+
response = response.json()
|
|
85
|
+
done = response['done']
|
|
86
|
+
if done is False:
|
|
87
|
+
url = f"{self.customer_url}{response['nextRecordsUrl']}"
|
|
88
|
+
df = pd.concat([df, pd.DataFrame(response['records'])])
|
|
89
|
+
|
|
90
|
+
return df
|
|
91
|
+
|
|
92
|
+
def create_contact(self, data: dict) -> json:
|
|
93
|
+
"""
|
|
94
|
+
This method is used to send queries in a somewhat userfriendly wayt to Salesforce.
|
|
95
|
+
:param data: fields you want to update
|
|
96
|
+
:return: data or error
|
|
97
|
+
"""
|
|
98
|
+
allowed_fields = {
|
|
99
|
+
'salure_customer': 'Klant_van_Salure__c',
|
|
100
|
+
# 'full_name': 'Name',
|
|
101
|
+
'first_name': 'FirstName',
|
|
102
|
+
'last_name': 'LastName',
|
|
103
|
+
'phone': 'Phone',
|
|
104
|
+
'email': 'Email',
|
|
105
|
+
'salesforce_account_id': 'AccountId',
|
|
106
|
+
'organisation_person_id': 'AFAS_persoons_ID__C'
|
|
107
|
+
}
|
|
108
|
+
required_fields = []
|
|
109
|
+
|
|
110
|
+
self.__check_fields(data=data, required_fields=required_fields, allowed_fields=list(allowed_fields.keys()))
|
|
111
|
+
|
|
112
|
+
body = {}
|
|
113
|
+
|
|
114
|
+
# Add allowed fields to the body
|
|
115
|
+
for field in (allowed_fields.keys() & data.keys()):
|
|
116
|
+
body.update({allowed_fields[field]: data[field]})
|
|
117
|
+
|
|
118
|
+
body = json.dumps(body)
|
|
119
|
+
if self.debug:
|
|
120
|
+
print(f"Payload: {body}")
|
|
121
|
+
|
|
122
|
+
response = requests.post(url=f"{self.customer_url}/services/data/v37.0/sobjects/Contact", data=body, headers=self.__get_headers())
|
|
123
|
+
response.raise_for_status()
|
|
124
|
+
if self.debug:
|
|
125
|
+
print(f"Response: {response.content, response.text}")
|
|
126
|
+
|
|
127
|
+
return response.json()
|
|
128
|
+
|
|
129
|
+
def update_contact(self, data: dict):
|
|
130
|
+
"""
|
|
131
|
+
This method is used to send queries in a somewhat userfriendly way to Salesforce.
|
|
132
|
+
:param data: fields you want to update
|
|
133
|
+
:return: nothing is returned when update is successful, otherwise raises error
|
|
134
|
+
"""
|
|
135
|
+
allowed_fields = {
|
|
136
|
+
'salure_customer': 'Klant_van_Salure__c',
|
|
137
|
+
# 'full_name': 'Name',
|
|
138
|
+
'first_name': 'FirstName',
|
|
139
|
+
'last_name': 'LastName',
|
|
140
|
+
'phone': 'Phone',
|
|
141
|
+
'email': 'Email',
|
|
142
|
+
'salesforce_account_id': 'AccountId',
|
|
143
|
+
'organisation_person_id': 'AFAS_persoons_ID__C'
|
|
144
|
+
}
|
|
145
|
+
required_fields = ['contact_id']
|
|
146
|
+
|
|
147
|
+
self.__check_fields(data=data, required_fields=required_fields, allowed_fields=list(allowed_fields.keys()))
|
|
148
|
+
|
|
149
|
+
body = {}
|
|
150
|
+
|
|
151
|
+
# Add allowed fields to the body
|
|
152
|
+
for field in (allowed_fields.keys() & data.keys()):
|
|
153
|
+
body.update({allowed_fields[field]: data[field]})
|
|
154
|
+
|
|
155
|
+
body = json.dumps(body)
|
|
156
|
+
if self.debug:
|
|
157
|
+
print(f"Payload: {body}")
|
|
158
|
+
|
|
159
|
+
response = requests.patch(url=f"{self.customer_url}/services/data/v37.0/sobjects/Contact/{data['contact_id']}", data=body, headers=self.__get_headers())
|
|
160
|
+
response.raise_for_status()
|
|
161
|
+
if self.debug:
|
|
162
|
+
print(f"Response: {response.content, response.text}")
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def __check_fields(data: Union[dict, List], required_fields: List, allowed_fields: List):
|
|
166
|
+
if isinstance(data, dict):
|
|
167
|
+
data = data.keys()
|
|
168
|
+
|
|
169
|
+
for field in data:
|
|
170
|
+
if field not in allowed_fields and field not in required_fields:
|
|
171
|
+
warnings.warn('Field {field} is not implemented. Optional fields are: {allowed_fields}'.format(field=field, allowed_fields=tuple(allowed_fields)))
|
|
172
|
+
|
|
173
|
+
for field in required_fields:
|
|
174
|
+
if field not in data:
|
|
175
|
+
raise ValueError('Field {field} is required. Required fields are: {required_fields}'.format(field=field, required_fields=tuple(required_fields)))
|
|
176
|
+
|
|
177
|
+
def query_table_metadata(self, table: str) -> requests.Response:
|
|
178
|
+
"""
|
|
179
|
+
This method is used to get the metadata of a table in Salesforce.
|
|
180
|
+
:param table: table or object name that the fields need to be retrieved from
|
|
181
|
+
:return: data or error
|
|
182
|
+
"""
|
|
183
|
+
url = f"{self.customer_url}/services/data/v{self.api_version}/sobjects/{table}/describe/"
|
|
184
|
+
response = requests.get(url, headers=self.__get_headers())
|
|
185
|
+
return response
|
|
186
|
+
|
|
187
|
+
def query_table(self, data_dir: str, table: str, fields: Union[str, List], filter: str = None, filename: str = None) -> pd.DataFrame:
|
|
188
|
+
"""
|
|
189
|
+
for information about the tables, see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_query.htm
|
|
190
|
+
With this method, you give a certain table you want to retrieve data from. This function contains a list of tables that are available in this function.
|
|
191
|
+
If you want to use an table that is not in this list, you can use the query_data method. In this function, there is extra information available per table like if it is
|
|
192
|
+
possible to get a full or an incremental load. This function will also check your previous loaded data and add new data to the previous data. Deleted data will also be deleted from
|
|
193
|
+
your dataset
|
|
194
|
+
:param data_dir: directory where the data will be stored. Both the full and incremental data will be stored here
|
|
195
|
+
:param table: table (it's a SQL query) you want to retrieve data from. If you call an table which is not in the approved tables, you will always get the full (not incremental) dataset.
|
|
196
|
+
:param fields: fields you want to get from the table
|
|
197
|
+
:param filter: possible filter you want to apply to the table
|
|
198
|
+
:param filename: filename you want to use for the data. If not given, the table will be used as filename
|
|
199
|
+
return: the dataset in pandas format
|
|
200
|
+
"""
|
|
201
|
+
approved_tables = {
|
|
202
|
+
'Account': 'incremental',
|
|
203
|
+
'AccountHistory': 'full',
|
|
204
|
+
'Appliaction__c': 'incremental',
|
|
205
|
+
'Beneficiary__c': 'incremental',
|
|
206
|
+
'Campaign': 'incremental',
|
|
207
|
+
'CampaignMember': 'incremental',
|
|
208
|
+
'Case': 'incremental',
|
|
209
|
+
'Contact': 'incremental',
|
|
210
|
+
'cpm__Installment__c': 'incremental',
|
|
211
|
+
'cpm__Payment__c': 'incremental',
|
|
212
|
+
'Document__c': 'incremental',
|
|
213
|
+
'Donaction_contracts__c': 'incremental',
|
|
214
|
+
'Donor_Type_Budget__c': 'incremental',
|
|
215
|
+
'Dorcas_Exchange_Rates__c': 'incremental',
|
|
216
|
+
'Dorcas_Report__c': 'incremental',
|
|
217
|
+
'General_Ledger_Account__c': 'incremental',
|
|
218
|
+
'Lead': 'incremental',
|
|
219
|
+
'npe03__Recurring_Donation__c': 'incremental',
|
|
220
|
+
'npsp__General_Accounting_Unit__c': 'incremental',
|
|
221
|
+
'Opportunity': 'incremental',
|
|
222
|
+
'pmnc__Project__c': 'incremental',
|
|
223
|
+
'Project_Budget__c': 'incremental',
|
|
224
|
+
'Project_Budget_Line__c': 'incremental',
|
|
225
|
+
'Project_Expense__c': 'incremental',
|
|
226
|
+
'Project_Indicator__c': 'incremental',
|
|
227
|
+
'Project_Result__c': 'incremental',
|
|
228
|
+
'Reporting_Unit__c': 'incremental',
|
|
229
|
+
'Result_Framework__c': 'incremental',
|
|
230
|
+
'Stakeholder__c': 'incremental',
|
|
231
|
+
'Volunteer_Assignment__c': 'incremental',
|
|
232
|
+
'User': 'full'
|
|
233
|
+
}
|
|
234
|
+
if table not in approved_tables.keys():
|
|
235
|
+
approved_tables[table] = 'full'
|
|
236
|
+
|
|
237
|
+
# First create a folder for the raw feather files
|
|
238
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
239
|
+
os.makedirs(f'{data_dir}/cache/', exist_ok=True)
|
|
240
|
+
|
|
241
|
+
# Check if there is allready a file for the called table. If not, it's always the first and thus full load
|
|
242
|
+
filename = table if filename is None else filename
|
|
243
|
+
load_type = approved_tables[table]
|
|
244
|
+
initial_load = False if os.path.exists(f'{data_dir}/cache/{filename}.ftr') else True
|
|
245
|
+
|
|
246
|
+
fields = fields.split(',') if isinstance(fields, str) else fields
|
|
247
|
+
# Add metadata fields to the fields, then use set to avoid duplicates
|
|
248
|
+
fields.extend(['Id', 'CreatedDate', 'LastModifiedDate']) if load_type == 'incremental' else fields.extend(['Id'])
|
|
249
|
+
fields = ','.join(list(set(fields)))
|
|
250
|
+
|
|
251
|
+
# If it's an incremental load with a filter, load the records that are created or updated in the last 14 days (double records will be removed later) and apply the filter
|
|
252
|
+
if initial_load is False and load_type == 'incremental':
|
|
253
|
+
params = {"q": f"SELECT {fields} FROM {table} WHERE LastModifiedDate >= LAST_N_DAYS:7 {'' if filter is None or filter == '*' else ' AND ' + filter }"}
|
|
254
|
+
# In all other cases, just load the full dataset without any filter and any field which is needed for incremental loads
|
|
255
|
+
else:
|
|
256
|
+
params = {"q": f"SELECT {fields} FROM {table} {'' if filter is None or filter == '*' else ' WHERE ' + filter }"}
|
|
257
|
+
|
|
258
|
+
params_str = urllib.parse.urlencode(params, safe=':+')
|
|
259
|
+
url = f'{self.customer_url}/services/data/v{self.api_version}/query/?'
|
|
260
|
+
done = False
|
|
261
|
+
df = pd.DataFrame()
|
|
262
|
+
|
|
263
|
+
# With the created URL and parameters, call the API
|
|
264
|
+
while not done:
|
|
265
|
+
response = requests.get(url=url, params=params_str, headers=self.__get_headers())
|
|
266
|
+
response.raise_for_status()
|
|
267
|
+
done = response.json()['done']
|
|
268
|
+
df_temp = pd.DataFrame(response.json()['records'])
|
|
269
|
+
if 'attributes' in df_temp.columns:
|
|
270
|
+
del df_temp['attributes']
|
|
271
|
+
if not done:
|
|
272
|
+
url = f"{self.customer_url}{response.json()['nextRecordsUrl']}"
|
|
273
|
+
df = pd.concat([df_temp, df])
|
|
274
|
+
|
|
275
|
+
if load_type == 'incremental':
|
|
276
|
+
# Now get the previously fetched data which is stored in feather files and concat it with the new data. keep only the new data in case of duplicates
|
|
277
|
+
if os.path.exists(f'{data_dir}/cache/{filename}.ftr'):
|
|
278
|
+
df_old = pd.read_feather(f'{data_dir}/cache/{filename}.ftr')
|
|
279
|
+
df = pd.concat([df, df_old])
|
|
280
|
+
df.sort_values(by=['Id', 'LastModifiedDate'], ascending=False, inplace=True)
|
|
281
|
+
df = df.drop_duplicates(subset=['Id'], keep='first')
|
|
282
|
+
|
|
283
|
+
# Get the deleted rows from the table with a new call to Salesforce. Get all the deleted records and not only recent deleted ones because very old rows can be deleted as well since the last time the data was fetched
|
|
284
|
+
params = {"q": f"SELECT+Id,isDeleted+FROM+{table}+WHERE+isDeleted+=TRUE"}
|
|
285
|
+
params_str = urllib.parse.urlencode(params, safe=':+')
|
|
286
|
+
done = False
|
|
287
|
+
df_del = pd.DataFrame()
|
|
288
|
+
url = f'{self.customer_url}/services/data/v{self.api_version}/queryAll/?'
|
|
289
|
+
while done is False:
|
|
290
|
+
response = requests.get(url=url, params=params_str, headers=self.__get_headers())
|
|
291
|
+
response.raise_for_status()
|
|
292
|
+
done = response.json()['done']
|
|
293
|
+
df_temp = pd.DataFrame(response.json()['records'])
|
|
294
|
+
if done is False:
|
|
295
|
+
url = f"{self.customer_url}{response.json()['nextRecordsUrl']}"
|
|
296
|
+
df_del = pd.concat([df_temp, df_del])
|
|
297
|
+
|
|
298
|
+
# Join the deleted rows to the dataframe and filter out the deleted rows
|
|
299
|
+
if len(df_del) > 0:
|
|
300
|
+
del df_del['attributes']
|
|
301
|
+
df = df.merge(df_del, how='left', on='Id')
|
|
302
|
+
df = df[df['IsDeleted'].isna()].copy()
|
|
303
|
+
del df['IsDeleted']
|
|
304
|
+
|
|
305
|
+
# Save the final result to the cache as a feather file and to csv
|
|
306
|
+
if 'attributes' in df.columns:
|
|
307
|
+
del df['attributes']
|
|
308
|
+
df.reset_index(drop=True, inplace=True)
|
|
309
|
+
if df.empty:
|
|
310
|
+
return df
|
|
311
|
+
try:
|
|
312
|
+
df.to_feather(f'{data_dir}cache/{filename}.ftr')
|
|
313
|
+
except Exception as e:
|
|
314
|
+
df = df.astype(str)
|
|
315
|
+
df.to_feather(f'{data_dir}cache/{filename}.ftr', compression='lz4')
|
|
316
|
+
|
|
317
|
+
return df
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
brynq_sdk_salesforce
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
|
File without changes
|
{brynq_sdk_salesforce-2.0.0 → brynq_sdk_salesforce-2.0.1}/brynq_sdk_salesforce.egg-info/not-zip-safe
RENAMED
|
File without changes
|
{brynq_sdk_salesforce-2.0.0 → brynq_sdk_salesforce-2.0.1}/brynq_sdk_salesforce.egg-info/requires.txt
RENAMED
|
File without changes
|
|
File without changes
|