brynq-sdk-salesforce 3.0.0__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.
@@ -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, Literal, Optional
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, system_type: Optional[Literal['source', 'target']] = None, debug: bool = False, sandbox: bool = False):
17
+ super().__init__()
18
+ if sandbox:
19
+ self.system = 'salesforce-sandbox'
20
+ else:
21
+ self.system = 'salesforce'
22
+ credentials = self.interfaces.credentials.get(system=self.system, system_type=system_type)
23
+ self.credentials = credentials.get('data')
24
+ self.customer_url = self.credentials['instance_url']
25
+ self.debug = debug
26
+ self.api_version = 56.0
27
+ self.timeout = 3600
28
+
29
+ def __get_headers(self) -> dict:
30
+ headers = {"Authorization": f"Bearer {self.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(), timeout=self.timeout)
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(), timeout=self.timeout)
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(), timeout=self.timeout)
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(), timeout=self.timeout)
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(), timeout=self.timeout)
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(), timeout=self.timeout)
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(), timeout=self.timeout)
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,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: brynq_sdk_salesforce
3
+ Version: 3.0.0
4
+ Summary: Salesforce wrapper from BrynQ
5
+ Author: BrynQ
6
+ Author-email: support@brynq.com
7
+ License: BrynQ License
8
+ Requires-Dist: brynq-sdk-brynq<5,>=4
9
+ Requires-Dist: requests<=3,>=2
10
+ Requires-Dist: pandas<3,>=1
11
+ Requires-Dist: pyarrow>=10
12
+ Dynamic: author
13
+ Dynamic: author-email
14
+ Dynamic: description
15
+ Dynamic: license
16
+ Dynamic: requires-dist
17
+ Dynamic: summary
18
+
19
+ Salesforce wrapper from BrynQ
@@ -0,0 +1,6 @@
1
+ brynq_sdk_salesforce/__init__.py,sha256=OYb1Ruzcfhxa_6Z8q0ukBGjW3h1xtE6HXl5ddPmHhDk,34
2
+ brynq_sdk_salesforce/salesforce.py,sha256=UdfntO09DxrkKxenfaJPCoYF8tUNNLzMARPe_6sq9ws,15293
3
+ brynq_sdk_salesforce-3.0.0.dist-info/METADATA,sha256=_q9ylszfQk3nIui10tUJwikp17L32RhCkC0PdGt_vAg,442
4
+ brynq_sdk_salesforce-3.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
5
+ brynq_sdk_salesforce-3.0.0.dist-info/top_level.txt,sha256=07uiWMxudlW1CgymZoYUonSgMdG0oCbl-GeMcYEp87k,21
6
+ brynq_sdk_salesforce-3.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ brynq_sdk_salesforce