├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── dynamics365crm ├── __init__.py ├── client.py └── test.py ├── pyproject.toml └── test └── test_client.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | .idea/* 103 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 GearPlug 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dynamics365crm-python 2 | Dynamics365CRM API wrapper for Dynamics 365 written in Python. 3 | This library works for API version: v9.0 4 | 5 | ## Installing 6 | ``` 7 | pip install dynamics365crm-python 8 | ``` 9 | 10 | ## Usage 11 | 12 | This library provides a client that is initialized with the following arguments 13 | 14 | - domain: the dynamics 365 tenant domain (yours or someone else's) 15 | - access_token: the retrieved token after authentication 16 | 17 | Arguments for OAuth2 flow 18 | - client_id: your Azure AD application client id 19 | - client_secret: your Azure AD application client secret 20 | 21 | ```python 22 | from dynamics365crm.client import Client 23 | 24 | ## Normal use to make calls to the api 25 | client = Client("https://tenant_name.crmX.dynamics.com", access_token="access_token") 26 | 27 | ## OAuth2 configuration required arguments 28 | client = Client( 29 | "https://tenant_name.crmX.dynamics.com", 30 | client_id=CLIENT_ID, 31 | client_secret=CLIENT_SECRET, 32 | ) 33 | ``` 34 | 35 | ### OAuth2 Protocol 36 | #### Get authorization url 37 | 38 | This will return a [MSAL](https://github.com/AzureAD/microsoft-authentication-library-for-python) valid authorization url, the following are required: 39 | 40 | - tenant_id: someone else's Azure AD tenant_id 41 | - Ask the dynamics tenant owner to go to the [Azure Portal](portal.azure.com) and retrieve the Tenant ID from the Azure Active Directory/Overview 42 | - If your app is configured as multi-tenant (for any enterprise or personal account to use) you could pass "common" instead od the Tenant ID 43 | - However microsoft azure app configuration is a mess so the Tenant ID is preferable 44 | - redirect_uri: your service callback url 45 | - state: your unique generated state to identify the requester 46 | - you could also initiate an oauth flow with msal manually with initiate_auth_code_flow method, check the [official example](https://github.com/Azure-Samples/ms-identity-python-webapp) 47 | 48 | ```python 49 | authorization_url = client.build_authorization_url("tenant_id", "redirect_uri", "state") 50 | 51 | >>> "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=XXXX&response_type=code&redirect_uri=https%3A%2F%your_domain/%2Fcallback%2F&scope=https%3A%2F%2tenant_name.crmX.dynamics.com%2Fuser_impersonation+offline_access+openid+profile&state=XXXX&prompt=consent" 52 | ``` 53 | 54 | #### Exchange the callback code for an access token 55 | 56 | To finish the oauth protocol microsoft will redirect to your callback endpoint with a temporal code in the url query params to be exchanged for the full-fledged token (a json with the access_token, refresh_token, expires_in, etc.) 57 | 58 | Again the (**tenant_id** or "common") and **redirect_uri** are required, the third argument is the code sent by microsoft 59 | 60 | ```python 61 | token = client.exchange_code("tenant_id", "redirect_uri", "code") 62 | ``` 63 | 64 | #### Refresh token 65 | 66 | If the access token expires you could get a new **access_token** exchanging the long-lived **refresh_token** 67 | 68 | Again the **tenant_id** or "common" is required 69 | 70 | ```python 71 | token = client.refresh_access_token("tenant_id", "refresh_token") 72 | ``` 73 | 74 | #### Set access token 75 | 76 | You could pass the access_token in the constructor or set it with 77 | 78 | ```python 79 | client.set_access_token("access_token") 80 | ``` 81 | 82 | ## Dynamics Web API 83 | 84 | ### Contacts 85 | - See the documentation https://docs.microsoft.com/es-es/dynamics365/customer-engagement/web-api/contact?view=dynamics-ce-odata-9 86 | 87 | #### Get Contacts 88 | can receive orderby, filter, select, top, expand 89 | ``` 90 | list_contacts = client.get_contacts() 91 | ``` 92 | 93 | #### Create Contact 94 | ``` 95 | create_contact = client.create_contact(firstname="FIRSTNAME", lastname="LASTNAME", middlename="MIDDLENAME", emailaddress1="EMAILADDRESS") 96 | ``` 97 | 98 | #### Delete Contact 99 | ``` 100 | delete_contact = client.delete_contact('ID') 101 | ``` 102 | 103 | #### Update Contact 104 | ``` 105 | update_contact = client.update_contact('ID', firstname="FIRSTNAME", lastname="LASTNAME", middlename="MIDDLENAME", emailaddress1="EMAILADDRESS") 106 | ``` 107 | 108 | ### Accounts 109 | - See the documentation https://docs.microsoft.com/es-es/dynamics365/customer-engagement/web-api/account?view=dynamics-ce-odata-9 110 | 111 | #### Get Accounts 112 | can receive orderby, filter, select, top, expand 113 | ``` 114 | get_accounts = client.get_accounts() 115 | ``` 116 | 117 | #### Create Account 118 | ``` 119 | create_account = client.create_account(name="NAME", websiteurl="WWW.WEBSITE.COM") 120 | ``` 121 | 122 | #### Delete Account 123 | ``` 124 | create_account = client.delete_account('ID') 125 | ``` 126 | 127 | #### Update Account 128 | ``` 129 | update_account = client.update_account(id="ID", name="NAME") 130 | ``` 131 | 132 | ### Opportunities 133 | - See the documentation https://docs.microsoft.com/es-es/dynamics365/customer-engagement/web-api/opportunity?view=dynamics-ce-odata-9 134 | 135 | #### Get Opportunities 136 | can receive orderby, filter, select, top, expand 137 | ``` 138 | list_opportunities = client.get_opportunities() 139 | ``` 140 | 141 | #### Create Opportunities 142 | ``` 143 | create_opportunities = client.create_opportunity(name="OPPORTUNITY NAME") 144 | ``` 145 | 146 | #### Delete Opportunities 147 | ``` 148 | delete_opportunities = client.delete_opportunity(id="OPPORTUNITY ID") 149 | ``` 150 | 151 | #### Update Opportunities 152 | ``` 153 | update_opportunities = client.update_opportunity(id="OPPORTUNITY ID", name="OPPORTUNITY NAME", description="SOME DESCRIPTION") 154 | ``` 155 | 156 | ### Leads 157 | - See the documentation https://docs.microsoft.com/es-es/dynamics365/customer-engagement/web-api/lead?view=dynamics-ce-odata-9 158 | 159 | #### Get Leads 160 | can receive orderby, filter, select, top, expand 161 | ``` 162 | list_leads = client.get_leads() 163 | ``` 164 | 165 | #### Create Lead 166 | ``` 167 | create_leads = client.create_lead(fullname="LEAD NAME", subject="LEAD SUBJECT", mobilephone="123456", websiteurl="WWW.WEBSITE.COM", middlename="MIDDLE LEAD NAME") 168 | ``` 169 | 170 | #### Delete Lead 171 | ``` 172 | delete_leads = client.delete_lead("ID") 173 | ``` 174 | 175 | #### Update Lead 176 | ``` 177 | update_leads = client.update_lead(fullname="LEAD NAME", subject="LEAD SUBJECT", mobilephone="123456", websiteurl="WWW.WEBSITE.COM", middlename="MIDDLE LEAD NAME") 178 | ``` 179 | 180 | ### Campaigns 181 | - See the documentation https://docs.microsoft.com/es-es/dynamics365/customer-engagement/web-api/campaign?view=dynamics-ce-odata-9 182 | 183 | #### Get Campaigns 184 | can receive orderby, filter, select, top, expand 185 | ``` 186 | list_campaigns = client.get_campaigns() 187 | ``` 188 | 189 | #### Create Campaign 190 | ``` 191 | create_campaign = client.create_campaign(name="CAMPAIGN NAME", description="SOME DESCRIPTION") 192 | ``` 193 | 194 | #### Delete Campaign 195 | ``` 196 | delete_campaign = client.delete_campaign(id="ID") 197 | ``` 198 | 199 | #### Update Campaign 200 | ``` 201 | update_campaign = client.update_campaign(id="ID", name="CAMPAIGN NAME", description="SOME DESCRIPTION") 202 | ``` 203 | 204 | ## Requirements 205 | - requests 206 | - msal 207 | 208 | ## Contributing 209 | We are always grateful for any kind of contribution including but not limited to bug reports, code enhancements, bug fixes, and even functionality suggestions. 210 | #### You can report any bug you find or suggest new functionality with a new [issue](https://github.com/GearPlug/dynamics365crm-python/issues). 211 | #### If you want to add yourself some functionality to the wrapper: 212 | 1. Fork it ( https://github.com/GearPlug/dynamics365crm-python ) 213 | 2. Create your feature branch (git checkout -b my-new-feature) 214 | 3. Commit your changes (git commit -am 'Adds my new feature') 215 | 4. Push to the branch (git push origin my-new-feature) 216 | 5. Create a new Pull Request 217 | -------------------------------------------------------------------------------- /dynamics365crm/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GearPlug/dynamics365crm-python/80344a032ea9883a52b2543ce80bb62a0c682273/dynamics365crm/__init__.py -------------------------------------------------------------------------------- /dynamics365crm/client.py: -------------------------------------------------------------------------------- 1 | import msal 2 | from urllib.parse import urlencode 3 | import re 4 | 5 | import requests 6 | 7 | 8 | class Client: 9 | api_path = "api/data/v9.0" 10 | 11 | def __init__(self, domain, client_id=None, client_secret=None, access_token=None): 12 | self.domain = domain.strip("/") 13 | self.scopes = [f"{domain}/user_impersonation"] 14 | self.client_id = client_id 15 | self.client_secret = client_secret 16 | 17 | self.headers = { 18 | "Accept": "application/json, */*", 19 | "content-type": "application/json; charset=utf-8", 20 | # "OData-MaxVersion": "4.0", 21 | # "OData-Version": "4.0", 22 | } 23 | if access_token is not None: 24 | self.set_access_token(access_token) 25 | 26 | def set_access_token(self, token): 27 | """ 28 | Sets the Token for its use in this library. 29 | :param token: A string with the Token. 30 | :return: 31 | """ 32 | assert token is not None, "The token cannot be None." 33 | self.access_token = token 34 | self.headers["Authorization"] = "Bearer " + self.access_token 35 | 36 | def build_msal_client(self, tenant_id): 37 | return msal.ConfidentialClientApplication( 38 | self.client_id, 39 | client_credential=self.client_secret, 40 | authority=f"https://login.microsoftonline.com/{tenant_id}", 41 | ) 42 | 43 | def make_request( 44 | self, 45 | method, 46 | endpoint, 47 | expand=None, 48 | filter=None, 49 | orderby=None, 50 | select=None, 51 | skip=None, 52 | top=None, 53 | data=None, 54 | json=None, 55 | **kwargs, 56 | ): 57 | """ 58 | this method do the request petition, receive the different methods (post, delete, patch, get) that the api allow, see the documentation to check how to use the filters 59 | https://msdn.microsoft.com/en-us/library/gg309461(v=crm.7).aspx 60 | :param method: 61 | :param endpoint: 62 | :param expand: 63 | :param filter: 64 | :param orderby: 65 | :param select: 66 | :param skip: 67 | :param top: 68 | :param data: 69 | :param json: 70 | :param kwargs: 71 | :return: 72 | """ 73 | extra = {} 74 | if expand is not None and isinstance(expand, str): 75 | extra["$expand"] = str(expand) 76 | if filter is not None and isinstance(filter, str): 77 | extra["$filter"] = filter 78 | if orderby is not None and isinstance(orderby, str): 79 | extra["$orderby"] = orderby 80 | if select is not None and isinstance(select, str): 81 | extra["$select"] = select 82 | if skip is not None and isinstance(skip, str): 83 | extra["$skip"] = skip 84 | if top is not None and isinstance(top, str): 85 | extra["$top"] = str(top) 86 | 87 | assert self.domain is not None, "'domain' is required" 88 | assert self.access_token is not None, "You must provide a 'token' to make requests" 89 | url = f"{self.domain}/{self.api_path}/{endpoint}?" + urlencode(extra) 90 | if method == "get": 91 | response = requests.request(method, url, headers=self.headers, params=kwargs) 92 | else: 93 | response = requests.request(method, url, headers=self.headers, data=data, json=json) 94 | 95 | return self.parse_response(response) 96 | 97 | def _get(self, endpoint, data=None, **kwargs): 98 | return self.make_request("get", endpoint, data=data, **kwargs) 99 | 100 | def _post(self, endpoint, data=None, json=None, **kwargs): 101 | return self.make_request("post", endpoint, data=data, json=json, **kwargs) 102 | 103 | def _delete(self, endpoint, **kwargs): 104 | return self.make_request("delete", endpoint, **kwargs) 105 | 106 | def _patch(self, endpoint, data=None, json=None, **kwargs): 107 | return self.make_request("patch", endpoint, data=data, json=json, **kwargs) 108 | 109 | def parse_response(self, response): 110 | """ 111 | This method get the response request and returns json data or raise exceptions 112 | :param response: 113 | :return: 114 | """ 115 | if response.status_code == 204 or response.status_code == 201: 116 | if 'OData-EntityId' in response.headers: 117 | entity_id = response.headers['OData-EntityId'] 118 | if entity_id[-38:-37] == '(' and entity_id[-1:] == ')': # Check container 119 | guid = entity_id[-37:-1] 120 | guid_pattern = re.compile(r'^[\da-f]{8}-([\da-f]{4}-){3}[\da-f]{12}$', re.IGNORECASE) 121 | if guid_pattern.match(guid): 122 | return guid 123 | else: 124 | return True # Not all calls return a guid 125 | else: 126 | return True 127 | elif response.status_code == 400: 128 | raise Exception( 129 | "The URL {0} retrieved an {1} error. Please check your request body and try again.\nRaw message: {2}".format( 130 | response.url, response.status_code, response.text 131 | ) 132 | ) 133 | elif response.status_code == 401: 134 | raise Exception( 135 | "The URL {0} retrieved and {1} error. Please check your credentials, make sure you have permission to perform this action and try again.".format( 136 | response.url, response.status_code 137 | ) 138 | ) 139 | elif response.status_code == 403: 140 | raise Exception( 141 | "The URL {0} retrieved and {1} error. Please check your credentials, make sure you have permission to perform this action and try again.".format( 142 | response.url, response.status_code 143 | ) 144 | ) 145 | elif response.status_code == 404: 146 | raise Exception( 147 | "The URL {0} retrieved an {1} error. Please check the URL and try again.\nRaw message: {2}".format( 148 | response.url, response.status_code, response.text 149 | ) 150 | ) 151 | elif response.status_code == 412: 152 | raise Exception( 153 | "The URL {0} retrieved an {1} error. Please check the URL and try again.\nRaw message: {2}".format( 154 | response.url, response.status_code, response.text 155 | ) 156 | ) 157 | elif response.status_code == 413: 158 | raise Exception( 159 | "The URL {0} retrieved an {1} error. Please check the URL and try again.\nRaw message: {2}".format( 160 | response.url, response.status_code, response.text 161 | ) 162 | ) 163 | elif response.status_code == 500: 164 | raise Exception( 165 | "The URL {0} retrieved an {1} error. Please check the URL and try again.\nRaw message: {2}".format( 166 | response.url, response.status_code, response.text 167 | ) 168 | ) 169 | elif response.status_code == 501: 170 | raise Exception( 171 | "The URL {0} retrieved an {1} error. Please check the URL and try again.\nRaw message: {2}".format( 172 | response.url, response.status_code, response.text 173 | ) 174 | ) 175 | elif response.status_code == 503: 176 | raise Exception( 177 | "The URL {0} retrieved an {1} error. Please check the URL and try again.\nRaw message: {2}".format( 178 | response.url, response.status_code, response.text 179 | ) 180 | ) 181 | return response.json() 182 | 183 | def build_authorization_url(self, tenant_id: str, redirect_uri: str, state: str) -> str: 184 | msal_client = self.build_msal_client(tenant_id) 185 | return msal_client.get_authorization_request_url( 186 | self.scopes, 187 | state=state, 188 | redirect_uri=redirect_uri, 189 | prompt="consent", # Forces microsoft to show the consent page 190 | ) 191 | 192 | def exchange_code(self, tenant_id, redirect_uri, code) -> dict: 193 | msal_client = self.build_msal_client(tenant_id) 194 | return msal_client.acquire_token_by_authorization_code(code, self.scopes, redirect_uri) 195 | 196 | def refresh_access_token(self, tenant_id, refresh_token) -> dict: 197 | """ 198 | This method takes the refresh token and returns a new access token 199 | 200 | If an error ocurred a dict with an error key will be returned 201 | """ 202 | msal_client = self.build_msal_client(tenant_id) 203 | return msal_client.acquire_token_by_refresh_token(refresh_token, self.scopes) 204 | 205 | # TODO: four main methods (CRUD) 206 | def get_data(self, type=None, **kwargs): 207 | if type is not None: 208 | return self._get(type, **kwargs) 209 | raise Exception("A type is necessary. Example: contacts, leads, accounts, etc... check the library") 210 | 211 | def create_data(self, type=None, **kwargs): 212 | if type is not None and kwargs is not None: 213 | params = {} 214 | params.update(kwargs) 215 | return self._post(type, json=params) 216 | raise Exception("A type is necessary. Example: contacts, leads, accounts, etc... check the library") 217 | 218 | def update_data(self, type=None, id=None, **kwargs): 219 | if type is not None and id is not None: 220 | url = "{0}({1})".format(type, id) 221 | params = {} 222 | if kwargs is not None: 223 | params.update(kwargs) 224 | return self._patch(url, json=params) 225 | raise Exception("A type is necessary. Example: contacts, leads, accounts, etc... check the library") 226 | 227 | def delete_data(self, type=None, id=None): 228 | if type is not None and id is not None: 229 | return self._delete("{0}({1})".format(type, id)) 230 | raise Exception("A type is necessary. Example: contacts, leads, accounts, etc... check the library") 231 | 232 | # contact section, see the documentation https://docs.microsoft.com/es-es/dynamics365/customer-engagement/web-api/contact?view=dynamics-ce-odata-9 233 | def get_contacts(self, **kwargs): 234 | return self._get("contacts", **kwargs) 235 | 236 | def create_contact(self, **kwargs): 237 | if kwargs is not None: 238 | params = {} 239 | params.update(kwargs) 240 | return self._post("contacts", json=params) 241 | 242 | def delete_contact(self, id): 243 | if id != "": 244 | return self._delete("contacts({0})".format(id)) 245 | raise Exception("To delete a contact is necessary the ID") 246 | 247 | def update_contact(self, id, **kwargs): 248 | if id != "": 249 | url = "contacts({0})".format(id) 250 | params = {} 251 | if kwargs is not None: 252 | params.update(kwargs) 253 | return self._patch(url, json=params) 254 | raise Exception("To update a contact is necessary the ID") 255 | 256 | # account section, see the documentation https://docs.microsoft.com/es-es/dynamics365/customer-engagement/web-api/account?view=dynamics-ce-odata-9 257 | def get_accounts(self, **kwargs): 258 | return self._get("accounts", **kwargs) 259 | 260 | def create_account(self, **kwargs): 261 | if kwargs is not None: 262 | return self._post("accounts", json=kwargs) 263 | 264 | def delete_account(self, id): 265 | if id != "": 266 | return self._delete("accounts({0})".format(id)) 267 | raise Exception("To delete an account is necessary the ID") 268 | 269 | def update_account(self, id, **kwargs): 270 | if id != "": 271 | url = "accounts({0})".format(id) 272 | params = {} 273 | if kwargs is not None: 274 | params.update(kwargs) 275 | return self._patch(url, json=params) 276 | raise Exception("To update an account is necessary the ID") 277 | 278 | # opportunity section, see the documentation https://docs.microsoft.com/es-es/dynamics365/customer-engagement/web-api/opportunity?view=dynamics-ce-odata-9 279 | def get_opportunities(self, **kwargs): 280 | return self._get("opportunities", **kwargs) 281 | 282 | def create_opportunity(self, **kwargs): 283 | if kwargs is not None: 284 | params = {} 285 | params.update(kwargs) 286 | return self._post("opportunities", json=params) 287 | 288 | def delete_opportunity(self, id): 289 | if id != "": 290 | return self._delete("opportunities({0})".format(id)) 291 | raise Exception("To delete an account is necessary the ID") 292 | 293 | def update_opportunity(self, id, **kwargs): 294 | if id != "": 295 | url = "opportunities({0})".format(id) 296 | params = {} 297 | if kwargs is not None: 298 | params.update(kwargs) 299 | return self._patch(url, json=params) 300 | raise Exception("To update an opportunity is necessary the ID") 301 | 302 | # leads section, see the documentation https://docs.microsoft.com/es-es/dynamics365/customer-engagement/web-api/lead?view=dynamics-ce-odata-9 303 | def get_leads(self, **kwargs): 304 | return self._get("leads", **kwargs) 305 | 306 | def create_lead(self, **kwargs): 307 | if kwargs is not None: 308 | params = {} 309 | params.update(kwargs) 310 | return self._post("leads", json=params) 311 | 312 | def update_lead(self, id, **kwargs): 313 | if id != "": 314 | url = "leads({0})".format(id) 315 | params = {} 316 | if kwargs is not None: 317 | params.update(kwargs) 318 | return self._patch(url, json=params) 319 | raise Exception("To update a lead is necessary the ID") 320 | 321 | def delete_lead(self, id): 322 | if id != "": 323 | return self._delete("leads({0})".format(id)) 324 | raise Exception("To delete a lead is necessary the ID") 325 | 326 | # campaign section, see the documentation https://docs.microsoft.com/es-es/dynamics365/customer-engagement/web-api/campaign?view=dynamics-ce-odata-9 327 | def get_campaigns(self, **kwargs): 328 | return self._get("campaigns", **kwargs) 329 | 330 | def create_campaign(self, **kwargs): 331 | if kwargs is not None: 332 | params = {} 333 | params.update(kwargs) 334 | return self._post("campaigns", json=params) 335 | 336 | def update_campaign(self, id, **kwargs): 337 | if id != "": 338 | url = "campaigns({0})".format(id) 339 | params = {} 340 | if kwargs is not None: 341 | params.update(kwargs) 342 | return self._patch(url, json=params) 343 | raise Exception("To update a campaign is necessary the ID") 344 | 345 | def delete_campaign(self, id): 346 | if id != "": 347 | return self._delete("campaigns({0})".format(id)) 348 | raise Exception("To delete a campaign is necessary the ID") 349 | -------------------------------------------------------------------------------- /dynamics365crm/test.py: -------------------------------------------------------------------------------- 1 | from client import Client 2 | import pprint 3 | 4 | """ 5 | MAIN INSTANCE (petition) 6 | """ 7 | tenant_id = "" 8 | client_id = "" 9 | client_secret = "" 10 | dynamics_resource = "" 11 | CRM_resource = "" 12 | refresh_token = "" 13 | token = "" 14 | petition = Client(client_id=client_id, client_secret=client_secret, token=token) 15 | 16 | """ 17 | API ENDPOINTS EXAMPLES 18 | "contacts", "accounts", "opportunities", "leads", "campaigns", "EntityDefinitions(LogicalName='contact')/Attributes" 19 | """ 20 | 21 | """ 22 | REFRESH TOKEN 23 | to refresh the token you have to send the client_id, the client_secret, the refresh_token, the redirect_uri, and the resource 24 | Example: 25 | refresh = petition.refresh_token(refresh_token, redirect_uri, resource) 26 | pprint.pprint(refresh) 27 | """ 28 | 29 | """ 30 | GET DATA METHOD 31 | for get data just have to indicate the endpoint and the other filter options if you want 32 | Example: 33 | get_data = petition.get_data('contacts') 34 | pprint.pprint(get_data) 35 | """ 36 | 37 | """ 38 | CREATE DATA METHOD 39 | to create data you have to specify the endpoint where you will create data and send the data in **kwarg 40 | Example: 41 | data = {"firstname": "TEST", "lastname": "ITS A TEST", "middlename": "TESTING", "emailaddress1": "check@test.com"} 42 | create_data = petition.create_data('contacts', **data) 43 | pprint.pprint(create_data) 44 | """ 45 | 46 | """ 47 | UPDATE DATA METHOD 48 | to update data you have to specify the endpoint where you will update data, also you have to specify the id of the thing to update and send te update data in **kwargs 49 | Example: 50 | data = {"firstname": "TESTCHANGE", "lastname": "UPDATE THE TEST", "middlename": "UPDATE TESTING", "emailaddress1": "upcheck@test.com"} 51 | update_data = petition.update_data(type='contacts', id='ID', **data) 52 | pprint.pprint(update_data) 53 | """ 54 | 55 | """ 56 | DELETE DATA METHOD 57 | is simple, just send the endpoint where you'll delete data and the id of the data to delete 58 | Example: 59 | delete_data = petition.delete_data(type='contacts', id='ID') 60 | pprint.pprint(delete_data) 61 | """ 62 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "dynamics365crm-python" 3 | version = "1.0.2" 4 | description = "API wrapper for Dynamics365CRM written in Python" 5 | authors = ["Miguel Ferrer "] 6 | license = "MIT" 7 | readme = "README.md" 8 | packages = [{include = "dynamics365crm"}] 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.7" 12 | requests = "^2.26.0" 13 | msal = "^1.21.0" 14 | 15 | 16 | [build-system] 17 | requires = ["poetry-core"] 18 | build-backend = "poetry.core.masonry.api" 19 | 20 | -------------------------------------------------------------------------------- /test/test_client.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import TestCase 3 | from urllib.parse import urlparse, parse_qs 4 | from dynamics365crm.client import Client 5 | 6 | 7 | class Dynamics365CRMTestCases(TestCase): 8 | 9 | def setUp(self): 10 | self.client_id = os.environ.get('CLIENT_ID') 11 | self.client_secret = os.environ.get('CLIENT_SECRET') 12 | self.token = os.environ.get('ACCESS_TOKEN') 13 | self.redirect_url = os.environ.get('REDIRECT_URL') 14 | self.resource = os.environ.get('RESOURCE') 15 | self.client = Client(client_id=self.client_id, client_secret=self.client_secret, token=self.token) 16 | 17 | def test_oauth_access(self): 18 | url = self.client.url_petition(self.redirect_url, self.resource) 19 | self.assertIsInstance(url, str) 20 | o = urlparse(url) 21 | query = parse_qs(o.query) 22 | self.assertIn('client_id', query) 23 | self.assertEqual(query['client_id'][0], self.client_id) 24 | self.assertIn('redirect_uri', query) 25 | self.assertEqual(query['redirect_uri'][0], self.redirect_url) 26 | 27 | def test_get_data(self): 28 | response = self.client.get_data(type="contacts", select="fullname, emailaddress1, createdon", orderby="createdon desc", top="1") 29 | self.assertIsInstance(response, dict) 30 | 31 | def test_create_data(self): 32 | response = self.client.create_data(type='contacts', firstname="NAME", lastname="LASTNAME", middlename="MIDDLENAME", emailaddress1="EMAIL@EMAIL.COM") 33 | print(response) 34 | self.assertIsInstance(response, bool) --------------------------------------------------------------------------------