├── data ├── World_Wide_Corp_lorem.pdf ├── connect_listener_architecture.png └── World_Wide_Corp_Battle_Plan_Trafalgar.docx ├── date_pretty.py ├── .vscode └── launch.json ├── LICENSE ├── saving_envelope_test.py ├── ds_config_files.py ├── ds_config.ini ├── .gitignore ├── jwt_auth.py ├── README.md ├── aws_worker.py ├── run_test.py ├── create_envelope.py └── process_notification.py /data/World_Wide_Corp_lorem.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docusign/connect-python-worker-aws/master/data/World_Wide_Corp_lorem.pdf -------------------------------------------------------------------------------- /data/connect_listener_architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docusign/connect-python-worker-aws/master/data/connect_listener_architecture.png -------------------------------------------------------------------------------- /date_pretty.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime 3 | 4 | def date(): 5 | return datetime.now().strftime('%Y/%m/%d %H:%M:%S')+" " -------------------------------------------------------------------------------- /data/World_Wide_Corp_Battle_Plan_Trafalgar.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docusign/connect-python-worker-aws/master/data/World_Wide_Corp_Battle_Plan_Trafalgar.docx -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | { 9 | "name": "Python: Current File", 10 | "type": "python", 11 | "request": "launch", 12 | "program": "${file}", 13 | "console": "integratedTerminal" 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2019 DocuSign, Inc. (https://www.docusign.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /saving_envelope_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import time 3 | import os 4 | from process_notification import current_directory 5 | from create_envelope import * 6 | from date_pretty import date 7 | 8 | class SaveEnvelope(unittest.TestCase): 9 | @classmethod 10 | def test_send(cls): 11 | try: 12 | print(date() + "Starting\n") 13 | print("Sending an envelope. The envelope includes HTML, Word, and PDF documents.\n" + 14 | "It takes about 15 seconds for DocuSign to process the envelope request...") 15 | result = send_envelope() 16 | print(f"Envelope status: {result.status}. Envelope ID: {result.envelope_id}") 17 | SaveEnvelope.created() 18 | print(date() + "Done\n") 19 | 20 | except IOError as e: 21 | print(f"Could not open the file: {e}") 22 | 23 | except docusign.ApiException as e: 24 | print("DocuSign Exception!") 25 | 26 | @classmethod 27 | def created(cls): 28 | time.sleep(30) 29 | if(not os.path.exists(os.path.join(current_directory, "output", "order_Test_Mode.pdf"))): 30 | AssertionError() 31 | 32 | if __name__ == '__main__': 33 | unittest.main() -------------------------------------------------------------------------------- /ds_config_files.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import itertools 3 | import os 4 | 5 | def ds_config(str): 6 | config = {} 7 | client_id = os.environ.get('DS_CLIENT_ID', None) 8 | if client_id is not None: 9 | config[str] = os.environ.get(str) 10 | else: 11 | ini_file = 'ds_config.ini' 12 | if os.path.isfile(ini_file): 13 | config_parser = configparser.ConfigParser() 14 | with open(ini_file) as fp: 15 | # Enable ini file to not have explicit global section 16 | config_parser.read_file(itertools.chain(['[global]'], fp), source=ini_file) 17 | config = config_parser['global'] 18 | else: 19 | raise Exception(f"Missing config file |{ini_file}| and environment variables are not set.") 20 | return config[str] 21 | 22 | def aud(): 23 | auth_server = ds_config("DS_AUTH_SERVER") 24 | if 'https://' in auth_server: 25 | aud = auth_server[8:] 26 | else: # assuming http://blah 27 | aud = auth_server[7:] 28 | return aud 29 | 30 | def api(): 31 | return "restapi/v2" 32 | 33 | def permission_scopes(): 34 | return "signature impersonation" 35 | 36 | def jwt_scope(): 37 | return "signature" -------------------------------------------------------------------------------- /ds_config.ini: -------------------------------------------------------------------------------- 1 | # Configuration file for the example 2 | ## 3 | # Integrator Key is the same as client id 4 | DS_CLIENT_ID={DS_CLIENT_ID} 5 | 6 | # API Username 7 | DS_IMPERSONATED_USER_GUID={DS_IMPERSONATED_USER_GUID} 8 | 9 | # target account id. Use FALSE to indicate that the user's default 10 | # account should be used. 11 | DS_TARGET_ACCOUNT_ID=FALSE 12 | 13 | # Signer email 14 | DS_SIGNER_EMAIL={DS_SIGNER_EMAIL} 15 | 16 | # Signer name 17 | DS_SIGNER_NAME={DS_SIGNER_NAME} 18 | 19 | # Carbon copy email 20 | DS_CC_EMAIL={DS_CC_EMAIL} 21 | 22 | # Carbon copy name 23 | DS_CC_NAME={DS_CC_NAME} 24 | 25 | # The DS Authentication server 26 | DS_AUTH_SERVER=https://account-d.docusign.com 27 | 28 | QUEUE_URL={QUEUE_URL} 29 | 30 | QUEUE_REGION={QUEUE_REGION} 31 | 32 | AWS_ACCOUNT={AWS_ACCOUNT} 33 | 34 | AWS_SECRET={AWS_SECRET} 35 | 36 | BASIC_AUTH_NAME={BASIC_AUTH_NAME} 37 | 38 | BASIC_AUTH_PW={BASIC_AUTH_PW} 39 | 40 | DEBUG=True 41 | 42 | ENVELOPE_CUSTOM_FIELD=Sales order 43 | 44 | OUTPUT_FILE_PREFIX=order_ 45 | 46 | ENABLE_BREAK_TEST=True 47 | 48 | ENQUEUE_URL={ENQUEUE_URL} 49 | # private key string 50 | # NOTE: the Python config file parser requires that you 51 | # add a space at the beginning of the second and 52 | # subsequent lines of the multiline key value: 53 | DS_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY----- 54 | MIIEowIBAAKXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 55 | WwJzWZofjSKXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXxXX 56 | BN1Xh5PqQ88XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 57 | XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 58 | ... 59 | XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX0BoVMi32qyGrK 60 | -----END RSA PRIVATE KEY----- -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | output/ 2 | test_messages/ 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | .idea/ 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # Environments 88 | .env 89 | .venv 90 | env/ 91 | venv/ 92 | ENV/ 93 | env.bak/ 94 | venv.bak/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | 109 | .idea/ 110 | config.ini 111 | -------------------------------------------------------------------------------- /jwt_auth.py: -------------------------------------------------------------------------------- 1 | import time 2 | import base64 3 | import docusign_esign as docusign 4 | from docusign_esign import EnvelopesApi, ApiException 5 | from datetime import datetime, timedelta 6 | from ds_config_files import ds_config, aud 7 | #from .auth.oauth import OAuthUserInfo, OAuthToken, OAuth, Account, Organization, Link 8 | 9 | TOKEN_REPLACEMENT_IN_SECONDS = 10 * 60 10 | TOKEN_EXPIRATION_IN_SECONDS = 3600 11 | 12 | api_client = docusign.ApiClient() 13 | account = None 14 | _token_received = None 15 | expiresTimestamp = 0 16 | 17 | def check_token(): 18 | current_time = int(round(time.time())) 19 | if not _token_received \ 20 | or ((current_time + TOKEN_REPLACEMENT_IN_SECONDS) > expiresTimestamp): 21 | update_token() 22 | 23 | def update_token(): 24 | print ("Requesting an access token via JWT grant...", end='') 25 | private_key_bytes = str.encode(ds_config("DS_PRIVATE_KEY")) 26 | token = api_client.request_jwt_user_token(ds_config("DS_CLIENT_ID"), ds_config("DS_IMPERSONATED_USER_GUID"), aud(), private_key_bytes, TOKEN_EXPIRATION_IN_SECONDS) 27 | global account 28 | if account is None: 29 | account = get_account_info(api_client) 30 | base_uri = account['base_uri'] + '/restapi' 31 | api_client.host = base_uri 32 | api_client.token = token.access_token 33 | _token_received = True 34 | expiresTimestamp = (int(round(time.time())) + TOKEN_EXPIRATION_IN_SECONDS) 35 | print ("\nDone. Continuing...") 36 | 37 | def get_account_id(): 38 | return account['account_id'] 39 | 40 | def get_account_info(client): 41 | client.host = ds_config("DS_AUTH_SERVER") 42 | response = client.call_api("/oauth/userinfo", "GET", response_type="object") 43 | 44 | if len(response) > 1 and 200 > response[1] > 300: 45 | raise Exception("can not get user info:" ) # %d".format(response[1]) 46 | 47 | accounts = response[0]['accounts'] 48 | target = ds_config("DS_TARGET_ACCOUNT_ID") 49 | 50 | if target is None or target == "FALSE": 51 | # Look for default 52 | for acct in accounts: 53 | if acct['is_default']: 54 | return acct 55 | 56 | # Look for specific account 57 | for acct in accounts: 58 | if acct['account_id'] == target: 59 | return acct 60 | 61 | raise Exception(f"\n\nUser does not have access to account {target}\n\n") 62 | 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python: Connect Worker for AWS 2 | 3 | Repository: [connect-python-worker-aws](https://github.com/docusign/connect-python-worker-aws) 4 | 5 | ## Introduction 6 | 7 | This is an example worker application for 8 | Connect webhook notification messages sent 9 | via the [AWS SQS (Simple Queueing System)](https://aws.amazon.com/sqs/). 10 | 11 | This application receives DocuSign Connect 12 | messages from the queue and then processes them: 13 | 14 | * If the envelope is complete, the application 15 | uses a DocuSign JWT Grant token to retrieve 16 | the envelope's combined set of documents, 17 | and stores them in the `output` directory. 18 | 19 | For this example, the envelope **must** 20 | include an Envelope Custom Field 21 | named `Sales order.` The Sales order field is used 22 | to name the output file. 23 | 24 | ## Architecture 25 | 26 | ![Connect listener architecture](data/connect_listener_architecture.png) 27 | 28 | AWS has [SQS](https://aws.amazon.com/tools/) 29 | SDK libraries for C#, Java, Node.js, Python, Ruby, C++, and Go. 30 | 31 | ## Installation 32 | 33 | ### Introduction 34 | First, install the **Lambda listener** on AWS and set up the SQS queue. 35 | 36 | Then set up this code example to receive and process the messages 37 | received via the SQS queue. 38 | 39 | ### Installing the Lambda Listener 40 | 41 | Install the example 42 | [Connect listener for AWS](https://github.com/docusign/connect-node-listener-aws) 43 | on AWS. 44 | At the end of this step, you will have the 45 | `Queue URL`, `Queue Region` and `Enqueue url` that you need for the next step. 46 | 47 | ### Installing the worker (this repository) 48 | 49 | #### Requirements 50 | 51 | This example requires Python v3.6 or later. 52 | The SDK itself works with Python v2.7 or later. 53 | 54 | 1. Download or clone this repository. Then: 55 | 56 | ```` 57 | cd connect-python-worker-aws 58 | pip install docusign_esign 59 | # boto3 is the AWS Python SDK 60 | pip install boto3 61 | # defusedxml is an XML parser with InfoSec fixes 62 | pip install defusedxml 63 | ```` 64 | 1. Using AWS IAM, create an IAM `User` with access to your SQS queue. 65 | 66 | 1. Configure the **ds_config.ini** file: [ds_config.ini](ds_config.ini) 67 | The application uses the OAuth JWT Grant flow. 68 | 69 | If consent has not been granted to the application by 70 | the user, then the application provides a url 71 | that can be used to grant individual consent. 72 | 73 | **To enable individual consent:** either 74 | add the URL [https://www.docusign.com](https://www.docusign.com) as a redirect URI 75 | for the Integration Key, or add a different URL and 76 | update the `oAuthConsentRedirectURI` setting 77 | in the ds_config.ini file. 78 | 79 | 1. Creating the Integration Key 80 | Your DocuSign Integration Key must be configured for a JWT OAuth authentication flow: 81 | * Create a public/private key pair for the key. Store the private key 82 | in a secure location. You can use a file or a key vault. 83 | * The example requires the private key. Store the private key in the 84 | [ds_config.ini](ds_config.ini) file. 85 | 86 | **Note:** the private key's second and subsequent 87 | lines need to have a space added at the beginning due 88 | to requirements from the Python configuration file 89 | parser. Example: 90 | 91 | ```` 92 | # private key string 93 | # NOTE: the Python config file parser requires that you 94 | # add a space at the beginning of the second and 95 | # subsequent lines of the multiline key value: 96 | DS_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY----- 97 | N7b6a66DYU8/0BwXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 98 | 7lBHBbJcc76v+18XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 99 | jCt15ZT4aux//2ZXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 100 | .... 101 | PEHgznlGh/vUboCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 102 | -----END RSA PRIVATE KEY----- 103 | ```` 104 | 105 | ## Run the examples 106 | ```` 107 | python aws_worker.py 108 | ```` 109 | you can also right click on the file and choose the `Run Python File in Terminal` option. 110 | 111 | ## Testing 112 | Configure a DocuSign Connect subscription to send notifications to 113 | the Cloud Function. Create / complete a DocuSign envelope. 114 | The envelope **must include an Envelope Custom Field named "Sales order".** 115 | 116 | * Check the Connect logs for feedback. 117 | * Check the console output of this app for log output. 118 | * Check the `output` directory to see if the envelope's 119 | combined documents and CoC were downloaded. 120 | 121 | For this code example, the 122 | envelope's documents will only be downloaded if 123 | the envelope is `complete` and includes a 124 | `Sales order` custom field. 125 | 126 | ## Unit Tests 127 | Includes three types of testing: 128 | * [SavingEnvelopeTest.cs](UnitTests/SavingEnvelopeTest.cs) allow you to send an envelope to your amazon sqs from the program. The envelope is saved at `output` directory although its status is `sent`. 129 | 130 | * [RunTest.cs](UnitTests/RunTest.cs) divides into two types of tests, both submits tests for 8 hours and updates every hour about the amount of successes or failures that occurred in that hour, the differences between the two are: 131 | * `few` - Submits 5 tests every hour. 132 | * `many` - Submits many tests every hour. 133 | 134 | In order to run the tests you need to split the terminal in two inside Visual Studio Code, In the first terminal run the connect-csharp-worker-aws program. In the second terminal choose the wanted test. You can see above at ` Run the examples` part how the files can be run. 135 | 136 | ## Support, Contributions, License 137 | 138 | Submit support questions to [StackOverflow](https://stackoverflow.com). Use tag `docusignapi`. 139 | 140 | Contributions via Pull Requests are appreciated. 141 | All contributions must use the MIT License. 142 | 143 | This repository uses the MIT license, see the 144 | [LICENSE](https://github.com/docusign/connect-python-worker-aws/blob/master/LICENSE) file. 145 | -------------------------------------------------------------------------------- /aws_worker.py: -------------------------------------------------------------------------------- 1 | import docusign_esign as docusign 2 | import time 3 | import boto3 4 | import json 5 | import queue 6 | import sys 7 | from date_pretty import date 8 | from process_notification import process 9 | from jwt_auth import * 10 | from ds_config_files import ds_config 11 | sqs = boto3.client('sqs', region_name=ds_config("QUEUE_REGION"), aws_access_key_id = ds_config("AWS_ACCOUNT"), aws_secret_access_key = ds_config("AWS_SECRET")) 12 | checkLogQ = queue.Queue() 13 | restart = True 14 | 15 | def main(): 16 | listenForever() 17 | 18 | # The function will listen forever, dispatching incoming notifications 19 | # to the processNotification library. 20 | # See https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/sqs-examples-send-receive-messages.html#sqs-examples-send-receive-messages-receiving 21 | def listenForever(): 22 | # Check that we can get a DocuSign token 23 | testToken() 24 | while(True): 25 | global restart 26 | if(restart): 27 | print(date() + "Starting queue worker") 28 | restart = False 29 | # Start the queue worker 30 | startQueue() 31 | time.sleep(5) 32 | 33 | # Check that we can get a DocuSign token and handle common error 34 | # cases: ds_configuration not configured, need consent. 35 | def testToken(): 36 | try: 37 | if(ds_config("DS_CLIENT_ID") == "{CLIENT_ID}"): 38 | print(date() + "Problem: you need to configure this example, either via environment variables (recommended)\n" 39 | "or via the ds_configuration.js file.\n" 40 | "See the README file for more information\n") 41 | 42 | check_token() 43 | 44 | # An API problem 45 | except docusign.ApiException as e: 46 | print("\n\nDocuSign Exception!") 47 | # Special handling for consent_required 48 | body = e.body.decode('utf8') 49 | if("consent_required" in body): 50 | consent_scopes = "signature%20impersonation" 51 | consent_redirect_URL = "https://www.docusign.com" 52 | consent_url = "{}/oauth/auth?response_type=code&scope={}&client_id={}&redirect_uri={}".format(ds_config("DS_AUTH_SERVER"), consent_scopes, ds_config("DS_CLIENT_ID"),consent_redirect_URL) 53 | print(f"""\nC O N S E N T R E Q U I R E D 54 | Ask the user who will be impersonated to run the following url: 55 | {consent_url} 56 | It will ask the user to login and to approve access by your application. 57 | Alternatively, an Administrator can use Organization Administration to 58 | pre-approve one or more users.""") 59 | sys.exit(0) 60 | 61 | else: 62 | # Some other DocuSign API problem 63 | print (f" Reason: {e.reason}") 64 | print (f" Error response: {e.body.decode('utf8')}") 65 | sys.exit(0) 66 | 67 | # Not an API problem 68 | except Exception as e: 69 | print(date() + e) 70 | 71 | # Receive and wait for messages from queue 72 | def startQueue(): 73 | 74 | # Maintain the array checkLogQ as a FIFO buffer with length 4. 75 | # When a new entry is added, remove oldest entry and shuffle. 76 | def addCheckLogQ(message): 77 | length = 4 78 | # If checkLogQ size is smaller than 4 add the message 79 | if(checkLogQ.qsize() < length): 80 | checkLogQ.put(message) 81 | # If checkLogQ size is bigger than 4 82 | else: 83 | # Remove the oldest message and add the new one 84 | checkLogQ.get() 85 | checkLogQ.put(message) 86 | 87 | # Prints all checkLogQ messages to the console 88 | def printCheckLogQ(): 89 | # Prints and Deletes all the elements in the checkLogQ 90 | for index in range(checkLogQ.qsize()): 91 | print(checkLogQ.get()) 92 | 93 | try: 94 | while(True): 95 | # Receive messages from queue, maximum waits for 20 seconds for message 96 | # receive_request - contain all the queue messages 97 | receive_request = (sqs.receive_message(QueueUrl=ds_config("QUEUE_URL"), WaitTimeSeconds=20, MaxNumberOfMessages=10)).get("Messages") 98 | addCheckLogQ(date() +"Awaiting a message...") 99 | # If receive_request is not None (when message is received) 100 | if(receive_request is not None): 101 | msgCount = len(receive_request) 102 | else: 103 | msgCount=0 104 | addCheckLogQ(date() +"found {} message(s)".format(msgCount)) 105 | # If at least one message has been received 106 | if(msgCount): 107 | printCheckLogQ() 108 | for message in receive_request: 109 | messageHandler(message) 110 | 111 | # Catches all types of errors that may occur during the program 112 | except Exception as e: 113 | printCheckLogQ() 114 | print(date() + "Queue receive error: {}".format(e)) 115 | time.sleep(5) 116 | # Restart the program 117 | global restart 118 | restart = True 119 | 120 | # Process a message 121 | # See https://github.com/Azure/azure-sdk-for-js/tree/master/sdk/servicebus/service-bus#register-message-handler 122 | def messageHandler(message): 123 | if(ds_config("DEBUG") == "True"): 124 | print(date() + "Processing message id: {}".format(message["MessageId"])) 125 | 126 | try: 127 | # Creates a Json object from the message body 128 | body = json.loads(message["Body"]) 129 | except Exception as e: 130 | body = False 131 | 132 | if(body): 133 | # Parse the information from message body. the information contains contains fields like test and xml 134 | test = body["test"] 135 | xml = body["xml"] 136 | process(test, xml) 137 | else: 138 | print(date() + "Null or bad body in message id {}. Ignoring.".format(message["MessageId"])) 139 | 140 | # Delete received message from queue 141 | sqs.delete_message(QueueUrl=ds_config("QUEUE_URL"),ReceiptHandle=message["ReceiptHandle"]) 142 | 143 | if __name__ == '__main__': 144 | main() -------------------------------------------------------------------------------- /run_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import time 3 | import os 4 | import urllib.request 5 | import base64 6 | from pathlib import Path 7 | from datetime import datetime, timedelta 8 | from process_notification import current_directory 9 | from ds_config_files import ds_config 10 | from date_pretty import date 11 | 12 | class RunTest(unittest.TestCase): 13 | 14 | timeCheckNumber=0 15 | timeChecks = [] 16 | modeName = "Select at the bottom of the file" # many or few 17 | successes = 0 18 | enqueueErrors = 0 19 | dequeueErrors = 0 20 | testsSent = [] 21 | foundAll = False 22 | 23 | @classmethod 24 | def test_run(cls): 25 | for index in range(8): 26 | RunTest.timeChecks.append(datetime.now() + timedelta(hours=index+1)) 27 | print(date() + "Starting") 28 | RunTest.doTests() 29 | print(date() + "Done") 30 | 31 | @classmethod 32 | def doTests(cls): 33 | while(RunTest.timeCheckNumber <= 7): 34 | while(RunTest.timeChecks[RunTest.timeCheckNumber]>datetime.now()): 35 | RunTest.doTest() 36 | if(RunTest.modeName == "few"): 37 | seconds_to_sleep = (RunTest.timeChecks[RunTest.timeCheckNumber] - datetime.now() + timedelta(minutes=2)).seconds 38 | time.sleep(seconds_to_sleep) 39 | RunTest.showStatus() 40 | RunTest.timeCheckNumber += 1 41 | RunTest.showStatus() 42 | 43 | @classmethod 44 | def showStatus(cls): 45 | rate = (100.0 * RunTest.successes) / (RunTest.enqueueErrors + RunTest.dequeueErrors + RunTest.successes) 46 | print("#### Test statistics: {} ({}%) successes, {} enqueue errors, {} dequeue errors.".format(RunTest.successes, ("%.2f" % rate), RunTest.enqueueErrors, RunTest.dequeueErrors)) 47 | 48 | @classmethod 49 | def doTest(cls): 50 | RunTest.send() #Sets testSent 51 | endTime = datetime.now() + timedelta(minutes=3) 52 | RunTest.foundAll = False 53 | tests = len(RunTest.testsSent) 54 | successesStart = RunTest.successes 55 | while(not RunTest.foundAll and endTime>datetime.now()): 56 | time.sleep(1) 57 | RunTest.checkResults() # Sets foundAll and updates testsSent 58 | if(not RunTest.foundAll): 59 | RunTest.dequeueErrors += len(RunTest.testsSent) 60 | print("Test: {} sent, {} successes, {} failures.".format(tests, RunTest.successes-successesStart,len(RunTest.testsSent))) 61 | 62 | # Look for the reception of the testsSent values 63 | @classmethod 64 | def checkResults(cls): 65 | testsReceived = [] 66 | file_data = "" 67 | for i in range(20): 68 | file_data="" 69 | try: 70 | # The path of the files created of Test mode 71 | testOutputDirPath = os.path.join(current_directory, "test_messages", "test" + str(i) + ".txt") 72 | test_file = Path(testOutputDirPath) 73 | if test_file.is_file(): 74 | with open(test_file) as f: 75 | file_data = f.readlines() 76 | testsReceived.append(file_data[0]) 77 | f.close() 78 | 79 | except IOError as e: 80 | print(f"Could not open the file: {e}") 81 | 82 | except OSError as e: 83 | print(f"OSError {e}") 84 | 85 | # Create a private copy of testsSent (testsSentOrig) and reset testsSent 86 | # Then, for each element in testsSentOrig not found, add back to testsSent. 87 | testsSentOrig = [] 88 | testsSentOrig.extend(RunTest.testsSent) 89 | RunTest.testsSent.clear() 90 | for testValue in testsSentOrig: 91 | if testValue in testsReceived: 92 | RunTest.successes += 1 93 | else: 94 | RunTest.testsSent.append(testValue) 95 | # Update foundAll 96 | RunTest.foundAll = len(RunTest.testsSent)==0 97 | 98 | # Send 5 messages 99 | @classmethod 100 | def send(cls): 101 | RunTest.testsSent.clear() 102 | for i in range(5): 103 | try: 104 | now = time.time_ns() // 1000000 105 | testValue = "" + str(now) 106 | RunTest.send1(testValue) 107 | RunTest.testsSent.append(testValue) 108 | except Exception as e: 109 | RunTest.enqueueErrors += 1 110 | print(f"send: Enqueue error: {e}") 111 | time.sleep(30) 112 | 113 | # Send one enqueue request. Errors will be caught by caller 114 | @classmethod 115 | def send1(cls, test): 116 | try: 117 | time.sleep(0.5) 118 | url = ds_config("ENQUEUE_URL") + "?test=" + test 119 | request = urllib.request.Request(url) 120 | request.method = "GET" 121 | auth = RunTest.authObject() 122 | if(auth): 123 | base64string = base64.b64encode (bytes(auth, "utf-8")) 124 | request.add_header("Authorization", "Basic %s" % base64string.decode("utf-8")) 125 | response = urllib.request.urlopen(request) 126 | if(response.getcode() is not 200): 127 | print("send1: GET not worked, StatusCode= {}".format(response.getcode())) 128 | response.close() 129 | 130 | except Exception as e: 131 | print(f"send1: https error: {e}") 132 | 133 | # Returns a string for the HttpsURLConnection request 134 | @classmethod 135 | def authObject(cls): 136 | if(not ds_config("BASIC_AUTH_NAME") is None and not ds_config("BASIC_AUTH_NAME") == "{BASIC_AUTH_NAME}" 137 | and not ds_config("BASIC_AUTH_PW") is None and not ds_config("BASIC_AUTH_PW") == "{BASIC_AUTH_PS}"): 138 | return ds_config("BASIC_AUTH_NAME") + ":" + ds_config("BASIC_AUTH_PW") 139 | else: 140 | return False 141 | 142 | if __name__ == '__main__': 143 | # choose the test mode: many - for many tests. few - for 5 tests every hour 144 | RunTest.modeName = "few" 145 | unittest.main() -------------------------------------------------------------------------------- /create_envelope.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # coding: utf-8 3 | import base64 4 | from os import path 5 | from docusign_esign import EnvelopesApi, EnvelopeDefinition, Signer, CarbonCopy, SignHere, Tabs, Recipients, Document, TextCustomField, CustomFields 6 | from ds_config_files import * 7 | from jwt_auth import * 8 | 9 | demo_docs_path = path.abspath(path.join(path.dirname(path.realpath(__file__)), 'data')) 10 | DOC_2_DOCX = "World_Wide_Corp_Battle_Plan_Trafalgar.docx" 11 | DOC_3_PDF = "World_Wide_Corp_lorem.pdf" 12 | envelope_id = None 13 | status = None 14 | 15 | def send_envelope(): 16 | check_token() 17 | 18 | # document 1 (html) has sign here anchor tag **signature_1** 19 | # document 2 (docx) has sign here anchor tag /sn1/ 20 | # document 3 (pdf) has sign here anchor tag /sn1/ 21 | # 22 | # The envelope has two recipients. 23 | # recipient 1 - signer 24 | # recipient 2 - cc 25 | # The envelope will be sent first to the signer. 26 | # After it is signed, a copy is sent to the cc person. 27 | 28 | args = { 29 | 'signer_email': ds_config("DS_SIGNER_EMAIL"), 30 | 'signer_name': ds_config("DS_SIGNER_NAME"), 31 | 'cc_email':ds_config("DS_CC_EMAIL"), 32 | 'cc_name': ds_config("DS_CC_NAME"), 33 | } 34 | 35 | # create the envelope definition 36 | env = EnvelopeDefinition( 37 | email_subject='Document sent from the Test Mode' 38 | ) 39 | doc1_b64 = base64.b64encode( 40 | bytes(create_document1(args), 'utf-8')).decode('ascii') 41 | # read files 2 and 3 from a local directory 42 | # The reads could raise an exception if the file is not available! 43 | with open(path.join(demo_docs_path, DOC_2_DOCX), 44 | "rb") as file: 45 | doc2_docx_bytes = file.read() 46 | doc2_b64 = base64.b64encode(doc2_docx_bytes).decode('ascii') 47 | with open(path.join(demo_docs_path, DOC_3_PDF), 48 | "rb") as file: 49 | doc3_pdf_bytes = file.read() 50 | doc3_b64 = base64.b64encode(doc3_pdf_bytes).decode('ascii') 51 | 52 | # Create the document models 53 | document1 = Document( # create the DocuSign document object 54 | document_base64=doc1_b64, 55 | name='Order acknowledgement', 56 | # can be different from actual file name 57 | file_extension='html', # many different document types are accepted 58 | document_id='1' # a label used to reference the doc 59 | ) 60 | document2 = Document( # create the DocuSign document object 61 | document_base64=doc2_b64, 62 | name='Battle Plan', # can be different from actual file name 63 | file_extension='docx', # many different document types are accepted 64 | document_id='2' # a label used to reference the doc 65 | ) 66 | document3 = Document( # create the DocuSign document object 67 | document_base64=doc3_b64, 68 | name='Lorem Ipsum', # can be different from actual file name 69 | file_extension='pdf', # many different document types are accepted 70 | document_id='3' # a label used to reference the doc 71 | ) 72 | # The order in the docs array determines the order in the envelope 73 | env.documents = [document1, document2, document3] 74 | 75 | # Create the signer recipient model 76 | signer1 = Signer( 77 | email=args['signer_email'], name=args['signer_name'], 78 | recipient_id="1", routing_order="1" 79 | ) 80 | # routingOrder (lower means earlier) determines the order of deliveries 81 | # to the recipients. Parallel routing order is supported by using the 82 | # same integer as the order for two or more recipients. 83 | 84 | # create a cc recipient to receive a copy of the documents 85 | cc1 = CarbonCopy( 86 | email=args['cc_email'], name=args['cc_name'], 87 | recipient_id="2", routing_order="2") 88 | 89 | # Create signHere fields (also known as tabs) on the documents, 90 | # We're using anchor (autoPlace) positioning 91 | # 92 | # The DocuSign platform searches throughout your envelope's 93 | # documents for matching anchor strings. So the 94 | # signHere2 tab will be used in both document 2 and 3 since they 95 | # use the same anchor string for their "signer 1" tabs. 96 | sign_here1 = SignHere( 97 | anchor_string='**signature_1**', anchor_units='pixels', 98 | anchor_y_offset='10', anchor_x_offset='20') 99 | sign_here2 = SignHere( 100 | anchor_string='/sn1/', anchor_units='pixels', 101 | anchor_y_offset='10', anchor_x_offset='20') 102 | 103 | # Add the tabs model (including the sign_here tabs) to the signer 104 | # The Tabs object wants arrays of the different field/tab types 105 | signer1.tabs = Tabs(sign_here_tabs=[sign_here1, sign_here2]) 106 | 107 | # Add the recipients to the envelope object 108 | recipients = Recipients(signers=[signer1], carbon_copies=[cc1]) 109 | env.recipients = recipients 110 | 111 | # Request that the envelope be sent by setting |status| to "sent". 112 | # To request that the envelope be created as a draft, set to "created" 113 | env.status = "sent" 114 | 115 | # Creates the Salse order Custom Field 116 | text_custom_field = TextCustomField(name='Sales order', value='Test_Mode', show='true', 117 | required='true') 118 | custom_fields = CustomFields(text_custom_fields=[text_custom_field]) 119 | env.custom_fields = custom_fields 120 | 121 | envelope_api = EnvelopesApi(api_client) 122 | results = envelope_api.create_envelope(get_account_id(), envelope_definition=env) 123 | 124 | return results 125 | 126 | def create_document1(args): 127 | """ Creates document 1 -- an html document""" 128 | 129 | return f""" 130 | 131 | 132 | 133 | 134 | 135 | 136 |

World Wide Corp

138 |

Order Processing Division

141 |

Ordered by {args['signer_name']}

142 |

Email: {args['signer_email']}

143 |

Copy to: {args['cc_name']}, {args['cc_email']}

144 |

145 | Candy bonbon pastry jujubes lollipop wafer biscuit biscuit. Topping brownie sesame snaps sweet roll pie. Croissant danish biscuit soufflé caramels jujubes jelly. Dragée danish caramels lemon drops dragée. Gummi bears cupcake biscuit tiramisu sugar plum pastry. Dragée gummies applicake pudding liquorice. Donut jujubes oat cake jelly-o. Dessert bear claw chocolate cake gummies lollipop sugar plum ice cream gummies cheesecake. 146 |

147 | 148 |

Agreed: **signature_1**/

149 | 150 | 151 | """ 152 | -------------------------------------------------------------------------------- /process_notification.py: -------------------------------------------------------------------------------- 1 | import defusedxml.ElementTree as ET # Guarding against an XML external entity injection attack 2 | import docusign_esign as docusign 3 | import re 4 | import os 5 | import sys 6 | import subprocess 7 | import re 8 | from jwt_auth import * 9 | from ds_config_files import ds_config 10 | from docusign_esign import EnvelopesApi, ApiException 11 | from date_pretty import date 12 | 13 | current_directory = os.getcwd() 14 | 15 | # Process the notification message 16 | def process(test, xml): 17 | # Guarding against injection attacks 18 | # Check the incoming test variable to ensure that it ONLY contains the expected data (empty string "", "/break" or integers string) 19 | # matcher equals true when it finds wrong input 20 | pattern = "[^0-9]" 21 | matcher = re.search(pattern, test) 22 | validInput = test == "" or test == "/break" or not matcher 23 | if(validInput): 24 | if(not test == ""): 25 | # Message from test mode 26 | processTest(test) 27 | else: 28 | print(date() +"Wrong test value: {}".format(test)) 29 | print("test can only be: /break, empty string or integers string") 30 | 31 | # In test mode there is no xml sting, should be checked before trying to parse it 32 | if(not xml == ""): 33 | # Step 1. parse the xml 34 | root = ET.fromstring(xml) 35 | # get the namespace from the xml 36 | def get_namespace(element): 37 | ns = re.match(r'\{.*\}', element.tag) 38 | return ns.group(0) if ns else '' 39 | nameSpace = get_namespace(root) 40 | 41 | # Extract from the XML the fields values 42 | envelopeId = root.find(f'{nameSpace}EnvelopeStatus/{nameSpace}EnvelopeID').text 43 | subject = root.find(f'{nameSpace}EnvelopeStatus/{nameSpace}Subject').text 44 | senderName = root.find(f'{nameSpace}EnvelopeStatus/{nameSpace}UserName').text 45 | senderEmail = root.find(f'{nameSpace}EnvelopeStatus/{nameSpace}Email').text 46 | status = root.find(f'{nameSpace}EnvelopeStatus/{nameSpace}Status').text 47 | created = root.find(f'{nameSpace}EnvelopeStatus/{nameSpace}Created').text 48 | orderNumber = root.find(f'{nameSpace}EnvelopeStatus/{nameSpace}CustomFields/{nameSpace}CustomField/{nameSpace}Value').text 49 | 50 | if(status == "Completed"): 51 | completedMsg = "Completed: True" 52 | else: 53 | completedMsg = "" 54 | 55 | # For debugging, you can print the entire notification 56 | print("EnvelopeId: {}".format(envelopeId)) 57 | print("Subject: {}".format(subject)) 58 | print("Sender: {}, {}".format(senderName, senderEmail)) 59 | print("Order Number: {}".format(orderNumber)) 60 | print("Status: {}".format(status)) 61 | print("Sent: {}, {}".format(created, completedMsg)) 62 | 63 | # Step 2. Filter the notifications 64 | ignore = False 65 | # Guarding against injection attacks 66 | # Check the incoming orderNumber variable to ensure that it ONLY contains the expected data ("Test_Mode" or integers string) 67 | # Envelope might not have Custom field when orderNumber == None 68 | # matcher equals true when it finds wrong input 69 | matcher = re.search(pattern, orderNumber) 70 | validInput = orderNumber == "Test_Mode" or orderNumber == None or not matcher 71 | if(validInput): 72 | # Check if the envelope was sent from the test mode 73 | # If sent from test mode - ok to continue even if the status not equals to Completed 74 | if(not orderNumber == "Test_Mode"): 75 | if(not status == "Completed"): 76 | ignore = True 77 | if(ds_config("DEBUG") == "True"): 78 | print(date() +"IGNORED: envelope status is {}".format(status)) 79 | 80 | if(orderNumber == None or orderNumber == ""): 81 | ignore = True 82 | if(ds_config("DEBUG") == "True"): 83 | print(date() +"IGNORED: envelope does not have a {} envelope custom field.".format(ds_config("ENVELOPE_CUSTOM_FIELD"))) 84 | else: 85 | ignore = True 86 | print(date() + "Wrong orderNumber value: {}".format(orderNumber)) 87 | print("orderNumber can only be: Test_Mode or integers string") 88 | # Step 3. (Future) Check that this is not a duplicate notification 89 | # The queuing system delivers on an "at least once" basis. So there is a 90 | # chance that we have already processes this notification. 91 | # 92 | # For this example, we'll just repeat the document fetch if it is duplicate notification 93 | 94 | # Step 4 save the document - it can raise an exception which will be caught at startQueue 95 | if(not ignore): 96 | saveDoc(envelopeId, orderNumber) 97 | 98 | # Creates a new file that contains the envelopeId and orderNumber 99 | def saveDoc(envelopeId, orderNumber): 100 | try: 101 | # api_client object created when checkToken() function was called in aws_worker 102 | api_client.set_default_header("Authorization", "Bearer " + api_client.token) 103 | accountID = get_account_id() 104 | envelope_api = EnvelopesApi(api_client) 105 | 106 | results_file = envelope_api.get_document(accountID , "combined" , envelopeId) 107 | 108 | # Create the output directory if needed 109 | output_directory = os.path.join(current_directory, r'output') 110 | if not os.path.exists(output_directory): 111 | os.makedirs(output_directory) 112 | if(not os.path.exists(output_directory)): 113 | print(date() + "Failed to create directory") 114 | 115 | filePath = os.path.join(current_directory, "output", ds_config("OUTPUT_FILE_PREFIX") + orderNumber + ".pdf") 116 | # Cannot create a file when file with the same name already exists 117 | if(os.path.exists(filePath)): 118 | # Remove the existing file 119 | os.remove(filePath) 120 | # Save the results file in the output directory and change the name of the file 121 | os.rename(results_file,filePath) 122 | 123 | # Create a file 124 | except ApiException as e: 125 | print(date() + "API exception: {}. saveDoc error".format(e)) 126 | 127 | # Catch exception while fetching and saving docs for envelope 128 | except Exception as e: 129 | print(date() + "Error while fetching and saving docs for envelope {}, order {}".format(envelopeId, orderNumber)) 130 | print(date() + "saveDoc error {}".format(e)) 131 | 132 | # Process test details into files 133 | def processTest(test): 134 | # Exit the program if BREAK_TEST equals to true or if orderNumber contains "/break" 135 | if(ds_config("ENABLE_BREAK_TEST") == "True" and "/break" in ("" + test)): 136 | print(date() +"BREAKING worker test!") 137 | sys.exit(2) 138 | 139 | print(date() + "Processing test value {}".format(test)) 140 | 141 | # Create the test directory if needed 142 | test_directory = os.path.join(current_directory, r'test_messages') 143 | if not os.path.exists(test_directory): 144 | os.makedirs(test_directory) 145 | if(not os.path.exists(test_directory)): 146 | print(date() + "Failed to create directory") 147 | 148 | # First shuffle test1 to test2 (if it exists) and so on 149 | for i in range(9,0,-1): 150 | old_File_path = os.path.join(test_directory, "test" + str(i) + ".txt") 151 | new_File_path = os.path.join(test_directory, "test" + str(i+1) + ".txt") 152 | # If the old file exists 153 | if(os.path.exists(old_File_path)): 154 | # If the new file exists - remove it 155 | if(os.path.exists(new_File_path)): 156 | os.remove(new_File_path) 157 | # Rename the file name - only works if new_File_path does not exist 158 | os.rename(old_File_path, new_File_path) 159 | 160 | # The new test message will be placed in test1 - creating new file 161 | newFile= open(os.path.join(test_directory, "test1.txt"), "w+") 162 | newFile.write(test) 163 | print(date() + "New file created") 164 | newFile.close 165 | --------------------------------------------------------------------------------