Scheduling Dynamics 365 workflows with Azure Functions and Python
Last week I shared a solution for Scheduling Dynamics 365 workflows with Azure Functions and Node.js. In this post, I will show how to achieve equivalent functionality using Python. The actual Python code is simpler than my Node.js example, but the Azure Functions configuration is much more complicated.
First, here's the Python script I am using. It does this following:
- Request an OAuth token using a username and password.
- Query the Dynamics 365 Web API for accounts with names that start with the letter "F."
- Execute a workflow for each record that was retrieved in the previous step. The workflow that I am executing is the same workflow I used in my Node.js example to create a note on an account.
import os
import sys
cwd = os.getcwd()
sitepackage = cwd + "\site-packages"
sys.path.append(sitepackage)
import requests
import json
#set these values to retrieve the oauth token
crmorg = 'https://CRMORG.crm.dynamics.com' #base url for crm org
clientid = '00000000-0000-0000-0000-000000000000' #application client id
username = 'xxxxxx@xxxxxxxx' #username
userpassword = 'xxxxxxxx' #password
tokenendpoint = 'https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/token' #oauth token endpoint
#set these values to query your crm data
crmwebapi = 'https://lucasdemo01.api.crm.dynamics.com/api/data/v8.2'
crmwebapiquery = "/accounts?$select=name,accountid&$filter=startswith(name,'f')"
workflowid = 'DC8519EC-F3CE-4BC9-BB79-DF2AD70217A1'; #guid for the workflow you want to execute
def start():
#build the authorization request
tokenpost = {
'client_id':clientid,
'resource':crmorg,
'username':username,
'password':userpassword,
'grant_type':'password'
}
#make the token request
print('requesting token . . .')
tokenres = requests.post(tokenendpoint, data=tokenpost)
print('token response received. . .')
accesstoken = ''
#extract the access token
try:
print('parsing token response . . .')
accesstoken = tokenres.json()['access_token']
#print('accesstoken is - ' + accesstoken)
except(KeyError):
print('Could not get access token')
if(accesstoken!=''):
crmrequestheaders = {
'Authorization': 'Bearer ' + accesstoken,
'OData-MaxVersion': '4.0',
'OData-Version': '4.0',
'Accept': 'application/json',
'Content-Type': 'application/json; charset=utf-8',
'Prefer': 'odata.maxpagesize=500',
'Prefer': 'odata.include-annotations=OData.Community.Display.V1.FormattedValue'
}
print('making crm account request . . .')
crmres = requests.get(crmwebapi+crmwebapiquery, headers=crmrequestheaders)
print('crm account response received . . .')
try:
print('parsing crm account response . . .')
crmresults = crmres.json()
for x in crmresults['value']:
print (x['name'] + ' - ' + x['accountid'])
runWorkflow(accesstoken, x['accountid'])
except KeyError:
print('Could not parse CRM account results')
def runWorkflow(token, entityid):
crmwebapiworkflowpath = "/workflows("+workflowid+")/Microsoft.Dynamics.CRM.ExecuteWorkflow"
#set the web api request headers
requestheaders = {
'Authorization': 'Bearer ' + token,
'OData-MaxVersion': '4.0',
'OData-Version': '4.0',
'Accept': 'application/json',
'Content-Type': 'application/json; charset=utf-8'
};
reqobj = {'EntityId': entityid}
print(' calling workflow for ' + entityid)
crmres = requests.post(crmwebapi+crmwebapiworkflowpath, headers=requestheaders, data=json.dumps(reqobj))
if(crmres.status_code == requests.codes.ok):
print(' success ' + entityid)
else:
print(' error ' + entityid)
start()
To set it up as an Azure Function, after you have either created a new Function app or opened an existing Function app, do the following:
-
Create a new function. Select "create your own custom function" at the bottom.
-
Set language to "Python" and scenario to "Experimental." Currently there is no timer trigger template for Python, but you can work around that by selecting the "QueueTrigger-Python" template in this step and manually changing the trigger later.
-
Give your function a name and click create. Ignore the queue name and storage account connection values.
-
Once the function is created, select the "integrate" tab from the menu on the left and delete the Azure Queue Storage trigger.
-
Click "new trigger" at the top, and then select "timer" from the list that appears.
-
Give it a name (or leave the default) and set the schedule. The
0 */5 * * * *
value here will execute every five minutes, just like in my previous Node.js example. -
After the function is configured to run on a timer trigger, you have to upload the Python Requests library that is used to make the web service calls. Open a new browser window to download the Requests library code from GitHub at https://github.com/kennethreitz/requests/releases/. Select the most recent .zip file.
-
After it downloads, open it and extract the entire "requests" directory. It should contain a directory called "packages."
-
Zip up just the "requests" directory in a separate "requests.zip" file.
-
Back in the Azure portal Function App blade, select "function app settings" from the menu on the left and then click "Go to Kudu."
-
In the window that opens, click the "site" link at the top.
-
Then click the "wwwroot" link.
-
Then click the name of your function. In this case it is "CrmWorkflowTimerPython."
-
Go to the cmd prompt at the bottom of the page and type
mkdir site-packages
. Click enter. You should see a new "site-packages" directory get created. -
Click on the new "site-packages" link to enter that directory.
-
Drag the "requests" .zip file you created in step 9 above into the "site-packages" directory like is shown.
-
Once it is uploaded, extract it by typing
unzip requests.zip
at the cmd prompt and clicking enter. You should see something like this when it's complete. -
At this point, you can close the Kudu window.
-
From the Function App blade, select your Python function and click "develop." Highlight everything and delete it.
-
Paste the Python code from the beginning of this post. The highlighted lines in the image are how the script knows how to find the Requests library you uploaded earlier. Set any specifics relative to your Dynamics 365 organization, and click save. At this point it should just start running on the schedule you set earlier.
-
You can see all the function invocation logs on the monitor tab.
-
Here are the notes that the workflow created in my Dynamics 365 online org.
A few notes/caveats:
- My Python code has hardly any error handling right now. If the workflow execution call returns an error, the Python code will not recognize it as an error.
- My CRM record retrieval is set to retrieve a maximum of 500 records. You would need to modify the Web API request logic to handle more.
- Per the "Best Practices for Azure Functions" guide:
Assume your function could encounter an exception at any time. Design your functions with the ability to continue from a previous fail point during the next execution.
This means you should put logic in your workflow to make sure that duplicate executions are avoided (unless that's what you intend to happen).
That's it for today. Until next time, happy coding!