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:

  1. Request an OAuth token using a username and password.
  2. Query the Dynamics 365 Web API for accounts with names that start with the letter "F."
  3. 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:

  1. Create a new function. Select "create your own custom function" at the bottom. Create new function

  2. 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. Select a template

  3. Give your function a name and click create. Ignore the queue name and storage account connection values. Complete function creation

  4. Once the function is created, select the "integrate" tab from the menu on the left and delete the Azure Queue Storage trigger. Delete the queue trigger

  5. Click "new trigger" at the top, and then select "timer" from the list that appears. Add new timer trigger

  6. 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. Save timer trigger

  7. 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.

  8. After it downloads, open it and extract the entire "requests" directory. It should contain a directory called "packages."

  9. Zip up just the "requests" directory in a separate "requests.zip" file.

  10. Back in the Azure portal Function App blade, select "function app settings" from the menu on the left and then click "Go to Kudu." Open Kudu

  11. In the window that opens, click the "site" link at the top. Navigate to "site" directory

  12. Then click the "wwwroot" link. Navigate to "wwwroot" directory

  13. Then click the name of your function. In this case it is "CrmWorkflowTimerPython." Navigate to function directory

  14. 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. Create "site-packages" directory

  15. Click on the new "site-packages" link to enter that directory.

  16. Drag the "requests" .zip file you created in step 9 above into the "site-packages" directory like is shown. Upload requests.zip

  17. 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. Unzip requests.zip

  18. At this point, you can close the Kudu window.

  19. From the Function App blade, select your Python function and click "develop." Highlight everything and delete it. Delete template code

  20. 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. Update script code

  21. You can see all the function invocation logs on the monitor tab. Invocation logs

  22. Here are the notes that the workflow created in my Dynamics 365 online org. Created notes

A few notes/caveats:

  1. 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.
  2. 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.
  3. 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!

comments powered by Disqus