├── .gitattributes ├── .gitignore ├── LICENSE.TXT ├── README.md ├── contacts ├── __init__.py ├── admin.py ├── clientreg.py ├── migrations │ └── __init__.py ├── models.py ├── o365service.py ├── static │ └── css │ │ └── styles.css ├── templates │ └── contacts │ │ ├── details.html │ │ ├── error.html │ │ └── index.html ├── tests.py ├── urls.py └── views.py ├── manage.py ├── pythoncontacts ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py ├── setup_project.bat └── templates ├── base.html └── registration ├── logged_out.html └── login.html /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Windows image file caches 2 | Thumbs.db 3 | ehthumbs.db 4 | 5 | # Folder config file 6 | Desktop.ini 7 | 8 | # Recycle Bin used on file shares 9 | $RECYCLE.BIN/ 10 | 11 | # Windows Installer files 12 | *.cab 13 | *.msi 14 | *.msm 15 | *.msp 16 | 17 | # Windows shortcuts 18 | *.lnk 19 | 20 | # ========================= 21 | # Operating System Files 22 | # ========================= 23 | 24 | # OSX 25 | # ========================= 26 | 27 | .DS_Store 28 | .AppleDouble 29 | .LSOverride 30 | 31 | # Thumbnails 32 | ._* 33 | 34 | # Files that might appear on external disk 35 | .Spotlight-V100 36 | .Trashes 37 | 38 | # Directories potentially created on remote AFP share 39 | .AppleDB 40 | .AppleDesktop 41 | Network Trash Folder 42 | Temporary Items 43 | .apdisk 44 | 45 | # Django stuff 46 | debug.log 47 | *.sqlite3 48 | /contacts/__pycache__/* 49 | !/contacts/migrations 50 | /contacts/migrations/* 51 | !/contacts/migrations/__init__.py 52 | /pythoncontacts/__pycache__/* 53 | 54 | # Misc 55 | *.zip -------------------------------------------------------------------------------- /LICENSE.TXT: -------------------------------------------------------------------------------- 1 | pythoncontacts, https://github.com/jasonjoh/python-contacts 2 | 3 | Copyright (c) Microsoft Corporation 4 | All rights reserved. 5 | 6 | MIT License: 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining 9 | a copy of this software and associated documentation files (the 10 | ""Software""), to deal in the Software without restriction, including 11 | without limitation the rights to use, copy, modify, merge, publish, 12 | distribute, sublicense, and/or sell copies of the Software, and to 13 | permit persons to whom the Software is furnished to do so, subject to 14 | the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be 17 | included in all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, 20 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 21 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 22 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 23 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 24 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 25 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Contacts Sample # 2 | 3 | This sample is an ongoing project with two main goals: to show how to easily use the [Office 365 APIs](http://msdn.microsoft.com/en-us/office/office365/api/api-catalog) from Python, and to help me learn Python and Django. A couple of things to keep in mind: 4 | 5 | - I am a complete newbie to Python and Django. Other than the "polls" app created as part of the [Django tutorial](https://docs.djangoproject.com/en/1.7/intro/tutorial01/), this is my first ever Python app. Because of that, I may do things in a "less-than-optimal" way from a Python perspective. Feel free to let me know! 6 | - I chose to target the [Contacts API](http://msdn.microsoft.com/office/office365/APi/contacts-rest-operations) for this sample. However, the same methodology should work for any of the REST APIs. 7 | - I used the built in Django development server, so I haven't tested this with any production-level servers. 8 | - I used the SQLite testing database that gets created with the Django project, so anything that gets stored database is in a local file on your development machine. 9 | 10 | ## Required software ## 11 | 12 | - [Python 3.4.2](https://www.python.org/downloads/) 13 | - [Django 1.7.1](https://docs.djangoproject.com/en/1.7/intro/install/) 14 | - [Requests: HTTP for Humans](http://docs.python-requests.org/en/latest/) 15 | 16 | ## Running the sample ## 17 | 18 | It's assumed that you have Python and Django installed before starting. Windows users should add the Python install directory and Scripts subdirectory to their PATH environment variable. 19 | 20 | 1. Download or fork the sample project. 21 | 2. Open your command prompt or shell to the directory where `manage.py` is located. 22 | 3. If you can run BAT files, run setup_project.bat. If not, run the three commands in the file manually. The last command prompts you to create a superuser, which you'll use later to logon. 23 | 4. Install the Requests: HTTP for Humans module from the command line: `pip install requests` 24 | 5. [Register the app in Azure Active Directory](https://github.com/jasonjoh/office365-azure-guides/blob/master/RegisterAnAppInAzure.md). The app should be registered as a web app with a Sign-on URL of "http://127.0.0.1:8000/contacts", and should be given permission to "Read users' contacts". 25 | 6. Edit the `.\contacts\clientreg.py` file. Copy the client ID for your app obtained during app registration and paste it as the value for the `id` variable. Copy the key you created during app registration and paste it as the value for the `secret` variable. Save the file. 26 | 7. Start the development server: `python manage.py runserver` 27 | 8. You should see output like: 28 | Performing system checks... 29 | 30 | System check identified no issues (0 silenced). 31 | December 18, 2014 - 12:36:32 32 | Django version 1.7.1, using settings 'pythoncontacts.settings' 33 | Starting development server at http://127.0.0.1:8000/ 34 | Quit the server with CTRL-BREAK. 35 | 9. Use your browser to go to http://127.0.0.1:8000/contacts. 36 | 10. Login with your superuser account. 37 | 11. You should now be prompted to connect your Office 365 account. Click the link to do so and login with an Office 365 account. 38 | 12. You should see a table listing the existing contacts in your Office 365 account. You can click the "New Contact" button to create a new contact, or use the "Edit" or "Delete" buttons on existing contacts. 39 | 13. If you want to see what gets stored in the Django database for the user, go too http://127.0.0.1:8000/admin and click on the Office365 connections link. You can delete the user's record from the admin site too, in case you want to go through the consent process again. 40 | 41 | ## Release history ## 42 | 43 | To get a specific release version, go to https://github.com/jasonjoh/pythoncontacts/releases 44 | 45 | - **1.2: Mail and Calendar functions.** The o365service module now has some Mail API and Calendar API functionality. No UI for these functions, but they can be invoked via the new test classes added to test.py. Also added a flag to disable SSL cert validation so that you can capture requests and responses with Fiddler. ([Blog Post](http://blogs.msdn.com/b/exchangedev/archive/2015/01/15/office-365-apis-and-python-part-3-mail-and-calendar-api.aspx)) 46 | - **1.1: Contact functions.** App now displays a list of contacts and allows the user to create new contacts and edit or delete existing ones. ([Blog Post](http://blogs.msdn.com/b/exchangedev/archive/2015/01/09/office-365-apis-and-python-part-2-contacts-api.aspx)) 47 | - **1.0: Initial release.** App allows user to connect an Office 365 account with a local app account. App does OAuth2 code grant flow and displays the user's access token. ([Blog Post](http://blogs.msdn.com/b/exchangedev/archive/2015/01/05/office-365-apis-and-python-part-1-oauth2.aspx)) 48 | 49 | ## Copyright ## 50 | 51 | Copyright (c) Microsoft. All rights reserved. 52 | 53 | ---------- 54 | Connect with me on Twitter [@JasonJohMSFT](https://twitter.com/JasonJohMSFT) 55 | 56 | Follow the [Exchange Dev Blog](http://blogs.msdn.com/b/exchangedev/) -------------------------------------------------------------------------------- /contacts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonjoh/pythoncontacts/7929f614eb281af00d9b68a409240c7c1334ac7d/contacts/__init__.py -------------------------------------------------------------------------------- /contacts/admin.py: -------------------------------------------------------------------------------- 1 | #Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See full license at the bottom of this file. 2 | from django.contrib import admin 3 | from contacts.models import Office365Connection 4 | 5 | # Register your models here. 6 | # Register the Office365Connection model so super users 7 | # can use the admin site to view and delete connections 8 | admin.site.register(Office365Connection) 9 | 10 | # MIT License: 11 | 12 | # Permission is hereby granted, free of charge, to any person obtaining 13 | # a copy of this software and associated documentation files (the 14 | # ""Software""), to deal in the Software without restriction, including 15 | # without limitation the rights to use, copy, modify, merge, publish, 16 | # distribute, sublicense, and/or sell copies of the Software, and to 17 | # permit persons to whom the Software is furnished to do so, subject to 18 | # the following conditions: 19 | 20 | # The above copyright notice and this permission notice shall be 21 | # included in all copies or substantial portions of the Software. 22 | 23 | # THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, 24 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 25 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 26 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 27 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 28 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 29 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /contacts/clientreg.py: -------------------------------------------------------------------------------- 1 | # The client ID (register app in Azure AD to get this value) 2 | id = ''; 3 | # The client secret (register app in Azure AD to get this value) 4 | secret = ''; 5 | 6 | class client_registration: 7 | @staticmethod 8 | def client_id(): 9 | return id; 10 | 11 | @staticmethod 12 | def client_secret(): 13 | return secret; -------------------------------------------------------------------------------- /contacts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonjoh/pythoncontacts/7929f614eb281af00d9b68a409240c7c1334ac7d/contacts/migrations/__init__.py -------------------------------------------------------------------------------- /contacts/models.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See full license at the bottom of this file. 2 | from django.db import models 3 | 4 | # Create your models here. 5 | # Represents a connection between a local account and an Office 365 account 6 | class Office365Connection(models.Model): 7 | # The local username (the one used to sign into the website) 8 | username = models.CharField(max_length = 30) 9 | # The user's Office 365 account email address 10 | user_email = models.CharField(max_length = 254) #for RFC compliance 11 | # The access token from Azure 12 | access_token = models.TextField() 13 | # The refresh token from Azure 14 | refresh_token = models.TextField() 15 | # The resource ID for Outlook services (usually https://outlook.office365.com/) 16 | outlook_resource_id = models.URLField() 17 | # The API endpoint for Outlook services (usually https://outlook.office365.com/api/v1.0) 18 | outlook_api_endpoint = models.URLField() 19 | 20 | def __str__(self): 21 | return self.username 22 | 23 | # Represents a contact item 24 | class DisplayContact: 25 | given_name = '' 26 | last_name = '' 27 | mobile_phone = '' 28 | email1_address = '' 29 | email1_name = '' 30 | email2_address = '' 31 | email2_name = '' 32 | email3_address = '' 33 | email3_name = '' 34 | id = '' 35 | 36 | # Initializes fields based on the JSON representation of a contact 37 | # returned by Office 365 38 | # parameters: 39 | # json: dict. The JSON dictionary object returned from Office 365. 40 | def load_json(self, json): 41 | self.given_name = json['GivenName'] 42 | self.last_name = json['Surname'] 43 | 44 | if (not json['MobilePhone1'] is None): 45 | self.mobile_phone = json['MobilePhone1'] 46 | 47 | email_address_list = json['EmailAddresses'] 48 | if (not email_address_list[0] is None): 49 | self.email1_address = email_address_list[0]['Address'] 50 | self.email1_name = email_address_list[0]['Name'] 51 | if (not email_address_list[1] is None): 52 | self.email2_address = email_address_list[1]['Address'] 53 | self.email2_name = email_address_list[1]['Name'] 54 | if (not email_address_list[2] is None): 55 | self.email3_address = email_address_list[2]['Address'] 56 | self.email3_name = email_address_list[2]['Name'] 57 | 58 | self.id = json['Id'] 59 | 60 | # Generates a JSON payload for updating or creating a 61 | # contact. 62 | # parameters: 63 | # return_nulls: Boolean. Controls how the EmailAddresses 64 | # array is generated. If True, empty entries 65 | # will be represented by "null". This style works 66 | # for update, and allows you to remove entries. 67 | # If False, empty entries are skipped. This is needed 68 | # in the create scenario, because passing null for any entry 69 | # results in a 500 error. 70 | def get_json(self, return_nulls): 71 | json_string = '{' 72 | json_string += '"GivenName": "{0}"'.format(self.given_name) 73 | json_string += ',"Surname": "{0}"'.format(self.last_name) 74 | json_string += ',"MobilePhone1": "{0}"'.format(self.mobile_phone) 75 | json_string += ',"EmailAddresses": [' 76 | 77 | email_entry_added = False 78 | if (self.email1_address == '' and self.email1_name == ''): 79 | if (return_nulls == True): 80 | email_entry_added = True 81 | json_string += 'null' 82 | else: 83 | email_entry_added = True 84 | json_string += '{' 85 | json_string += '"@odata.type": "#Microsoft.OutlookServices.EmailAddress"' 86 | json_string += ',"Address": "{0}"'.format(self.email1_address) 87 | json_string += ',"Name": "{0}"'.format(self.email1_name) 88 | json_string += '}' 89 | 90 | if (self.email2_address == '' and self.email2_name == ''): 91 | if (return_nulls == True): 92 | if (email_entry_added == True): 93 | json_string += ',' 94 | email_entry_added = True; 95 | json_string += 'null' 96 | else: 97 | if (email_entry_added == True): 98 | json_string += ',' 99 | email_entry_added = True; 100 | json_string += '{' 101 | json_string += '"@odata.type": "#Microsoft.OutlookServices.EmailAddress"' 102 | json_string += ',"Address": "{0}"'.format(self.email2_address) 103 | json_string += ',"Name": "{0}"'.format(self.email2_name) 104 | json_string += '}' 105 | 106 | if (self.email3_address == '' and self.email3_name == ''): 107 | if (return_nulls == True): 108 | if (email_entry_added == True): 109 | json_string += ',' 110 | email_entry_added = True; 111 | json_string += 'null' 112 | else: 113 | if (email_entry_added == True): 114 | json_string += ',' 115 | email_entry_added = True; 116 | json_string += '{' 117 | json_string += '"@odata.type": "#Microsoft.OutlookServices.EmailAddress"' 118 | json_string += ',"Address": "{0}"'.format(self.email3_address) 119 | json_string += ',"Name": "{0}"'.format(self.email3_name) 120 | json_string += '}' 121 | 122 | json_string += ']' 123 | json_string += '}' 124 | 125 | return json_string 126 | 127 | # MIT License: 128 | 129 | # Permission is hereby granted, free of charge, to any person obtaining 130 | # a copy of this software and associated documentation files (the 131 | # ""Software""), to deal in the Software without restriction, including 132 | # without limitation the rights to use, copy, modify, merge, publish, 133 | # distribute, sublicense, and/or sell copies of the Software, and to 134 | # permit persons to whom the Software is furnished to do so, subject to 135 | # the following conditions: 136 | 137 | # The above copyright notice and this permission notice shall be 138 | # included in all copies or substantial portions of the Software. 139 | 140 | # THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, 141 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 142 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 143 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 144 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 145 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 146 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /contacts/o365service.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See full license at the bottom of this file. 2 | from urllib.parse import quote 3 | import requests 4 | import json 5 | import base64 6 | import logging 7 | import uuid 8 | import datetime 9 | from contacts.clientreg import client_registration 10 | 11 | # Constant strings for OAuth2 flow 12 | # The OAuth authority 13 | authority = 'https://login.microsoftonline.com' 14 | # The authorize URL that initiates the OAuth2 authorization code grant flow for user consent 15 | authorize_url = '{0}{1}'.format(authority, 16 | '/common/oauth2/authorize?client_id={0}&redirect_uri={1}&response_type=code') 17 | # The token endpoint, where the app sends the auth code to get an access token 18 | access_token_url = '{0}{1}'.format(authority, 19 | '/common/oauth2/token') 20 | 21 | # The discovery service resource and endpoint are constant 22 | discovery_resource = 'https://api.office.com/discovery/' 23 | discovery_endpoint = 'https://api.office.com/discovery/v1.0/me/services' 24 | 25 | # Used for debug logging 26 | logger = logging.getLogger('contacts') 27 | 28 | # Set to False to bypass SSL verification 29 | # Useful for capturing API calls in Fiddler 30 | verifySSL = True 31 | 32 | # Plugs in client ID and redirect URL to the authorize URL 33 | # App will call this to get a URL to redirect the user for sign in 34 | def get_authorization_url(redirect_uri): 35 | logger.debug('Entering get_authorization_url.') 36 | logger.debug(' redirect_uri: {0}'.format(redirect_uri)) 37 | 38 | authorization_url = authorize_url.format(client_registration.client_id(), quote(redirect_uri)) 39 | 40 | logger.debug('Authorization url: {0}'.format(authorization_url)) 41 | logger.debug('Leaving get_authorization_url.') 42 | return authorization_url 43 | 44 | # Once the app has obtained an authorization code, it will call this function 45 | # The function will request an access token for the discovery service, then 46 | # call the discovery service to find resource IDs and endpoints for all services 47 | # the app has permssions for 48 | def get_access_info_from_authcode(auth_code, redirect_uri): 49 | logger.debug('Entering get_access_info_from_authcode.') 50 | logger.debug(' auth_code: {0}'.format(auth_code)) 51 | logger.debug(' redirect_uri: {0}'.format(redirect_uri)) 52 | 53 | logger.debug('Sending request to access token endpoint.') 54 | post_data = { 'grant_type' : 'authorization_code', 55 | 'code' : auth_code, 56 | 'redirect_uri' : redirect_uri, 57 | 'resource' : discovery_resource, 58 | 'client_id' : client_registration.client_id(), 59 | 'client_secret' : client_registration.client_secret() } 60 | r = requests.post(access_token_url, data = post_data, verify = verifySSL) 61 | logger.debug('Received response from token endpoint.') 62 | logger.debug(r.json()) 63 | 64 | # Get the discovery service access token and do discovery 65 | try: 66 | discovery_service_token = r.json()['access_token'] 67 | logger.debug('Extracted access token from response: {0}'.format(discovery_service_token)) 68 | except: 69 | logger.debug('Exception encountered, setting token to None.') 70 | discovery_service_token = None 71 | 72 | if (discovery_service_token): 73 | # Add the refresh token to the dictionary to be returned 74 | # so that the app can use it to request additional access tokens 75 | # for other resources without having to re-prompt the user. 76 | discovery_result = do_discovery(discovery_service_token) 77 | logger.debug('Discovery completed.') 78 | discovery_result['refresh_token'] = r.json()['refresh_token'] 79 | 80 | # Get the user's email from the access token and add to the 81 | # dictionary to be returned. 82 | json_token = parse_token(discovery_service_token) 83 | logger.debug('Discovery token after parsing: {0}'.format(json_token)) 84 | discovery_result['user_email'] = json_token['upn'] 85 | logger.debug('Extracted email from token: {0}'.format(json_token['upn'])) 86 | logger.debug('Leaving get_access_info_from_authcode.') 87 | return discovery_result 88 | else: 89 | logger.debug('Leaving get_access_info_from_authcode.') 90 | return None 91 | 92 | # This function calls the discovery service and parses 93 | # the result. It builds a dictionary of resource IDs and API endpoints 94 | # from the results. 95 | def do_discovery(token): 96 | logger.debug('Entering do_discovery.') 97 | logger.debug(' token: {0}'.format(token)) 98 | 99 | headers = { 'Authorization' : 'Bearer {0}'.format(token), 100 | 'Accept' : 'application/json' } 101 | r = requests.get(discovery_endpoint, headers = headers, verify = verifySSL) 102 | 103 | discovery_result = {} 104 | 105 | for entry in r.json()['value']: 106 | capability = entry['capability'] 107 | logger.debug('Capability found: {0}'.format(capability)) 108 | discovery_result['{0}_resource_id'.format(capability)] = entry['serviceResourceId'] 109 | discovery_result['{0}_api_endpoint'.format(capability)] = entry['serviceEndpointUri'] 110 | logger.debug(' Resource ID: {0}'.format(entry['serviceResourceId'])) 111 | logger.debug(' API endpoint: {0}'.format(entry['serviceEndpointUri'])) 112 | 113 | logger.debug('Leaving do_discovery.') 114 | return discovery_result 115 | 116 | # Once the app has obtained access information (resource IDs and API endpoints) 117 | # it will call this function to get an access token for a specific resource. 118 | def get_access_token_from_refresh_token(refresh_token, resource_id): 119 | logger.debug('Entering get_access_token_from_refresh_token.') 120 | logger.debug(' refresh_token: {0}'.format(refresh_token)) 121 | logger.debug(' resource_id: {0}'.format(resource_id)) 122 | 123 | post_data = { 'grant_type' : 'refresh_token', 124 | 'client_id' : client_registration.client_id(), 125 | 'client_secret' : client_registration.client_secret(), 126 | 'refresh_token' : refresh_token, 127 | 'resource' : resource_id } 128 | 129 | r = requests.post(access_token_url, data = post_data, verify = verifySSL) 130 | 131 | logger.debug('Response: {0}'.format(r.json())) 132 | # Return the token as a JSON object 133 | logger.debug('Leaving get_access_token_from_refresh_token.') 134 | return r.json() 135 | 136 | # This function takes the base64-encoded token value and breaks 137 | # it into header and payload, base64-decodes the payload, then 138 | # loads that into a JSON object. 139 | def parse_token(encoded_token): 140 | logger.debug('Entering parse_token.') 141 | logger.debug(' encoded_token: {0}'.format(encoded_token)) 142 | 143 | try: 144 | # First split the token into header and payload 145 | token_parts = encoded_token.split('.') 146 | 147 | # Header is token_parts[0] 148 | # Payload is token_parts[1] 149 | logger.debug('Token part to decode: {0}'.format(token_parts[1])) 150 | 151 | decoded_token = decode_token_part(token_parts[1]) 152 | logger.debug('Decoded token part: {0}'.format(decoded_token)) 153 | logger.debug('Leaving parse_token.') 154 | return json.loads(decoded_token) 155 | except: 156 | return 'Invalid token value: {0}'.format(encoded_token) 157 | 158 | def decode_token_part(base64data): 159 | logger.debug('Entering decode_token_part.') 160 | logger.debug(' base64data: {0}'.format(base64data)) 161 | 162 | # base64 strings should have a length divisible by 4 163 | # If this one doesn't, add the '=' padding to fix it 164 | leftovers = len(base64data) % 4 165 | logger.debug('String length % 4 = {0}'.format(leftovers)) 166 | if leftovers == 2: 167 | base64data += '==' 168 | elif leftovers == 3: 169 | base64data += '=' 170 | 171 | logger.debug('String with padding added: {0}'.format(base64data)) 172 | decoded = base64.b64decode(base64data) 173 | logger.debug('Decoded string: {0}'.format(decoded)) 174 | logger.debug('Leaving decode_token_part.') 175 | return decoded.decode('utf-8') 176 | 177 | # Generic API Sending 178 | def make_api_call(method, url, token, payload = None): 179 | # Send these headers with all API calls 180 | headers = { 'User-Agent' : 'pythoncontacts/1.2', 181 | 'Authorization' : 'Bearer {0}'.format(token), 182 | 'Accept' : 'application/json' } 183 | 184 | # Use these headers to instrument calls. Makes it easier 185 | # to correlate requests and responses in case of problems 186 | # and is a recommended best practice. 187 | request_id = str(uuid.uuid4()) 188 | instrumentation = { 'client-request-id' : request_id, 189 | 'return-client-request-id' : 'true' } 190 | 191 | headers.update(instrumentation) 192 | 193 | response = None 194 | 195 | if (method.upper() == 'GET'): 196 | logger.debug('{0}: Sending request id: {1}'.format(datetime.datetime.now(), request_id)) 197 | response = requests.get(url, headers = headers, verify = verifySSL) 198 | elif (method.upper() == 'DELETE'): 199 | logger.debug('{0}: Sending request id: {1}'.format(datetime.datetime.now(), request_id)) 200 | response = requests.delete(url, headers = headers, verify = verifySSL) 201 | elif (method.upper() == 'PATCH'): 202 | headers.update({ 'Content-Type' : 'application/json' }) 203 | logger.debug('{0}: Sending request id: {1}'.format(datetime.datetime.now(), request_id)) 204 | response = requests.patch(url, headers = headers, data = payload, verify = verifySSL) 205 | elif (method.upper() == 'POST'): 206 | headers.update({ 'Content-Type' : 'application/json' }) 207 | logger.debug('{0}: Sending request id: {1}'.format(datetime.datetime.now(), request_id)) 208 | response = requests.post(url, headers = headers, data = payload, verify = verifySSL) 209 | 210 | if (not response is None): 211 | logger.debug('{0}: Request id {1} completed. Server id: {2}, Status: {3}'.format(datetime.datetime.now(), 212 | request_id, 213 | response.headers.get('request-id'), 214 | response.status_code)) 215 | 216 | return response 217 | 218 | 219 | # Contacts API # 220 | 221 | # Retrieves a set of contacts from the user's default contacts folder 222 | # parameters: 223 | # contact_endpoint: string. The URL to the Contacts API endpoint (https://outlook.office365.com/api/v1.0) 224 | # token: string. The access token 225 | # parameters: string. An optional string containing query parameters to filter, sort, etc. 226 | # http://msdn.microsoft.com/office/office365/APi/complex-types-for-mail-contacts-calendar#UseODataqueryparameters 227 | def get_contacts(contact_endpoint, token, parameters = None): 228 | logger.debug('Entering get_contacts.') 229 | logger.debug(' contact_endpoint: {0}'.format(contact_endpoint)) 230 | logger.debug(' token: {0}'.format(token)) 231 | if (not parameters is None): 232 | logger.debug(' parameters: {0}'.format(parameters)) 233 | 234 | get_contacts = '{0}/Me/Contacts'.format(contact_endpoint) 235 | 236 | if (not parameters is None): 237 | get_contacts = '{0}{1}'.format(get_contacts, parameters) 238 | 239 | r = make_api_call('GET', get_contacts, token) 240 | 241 | if (r.status_code == requests.codes.unauthorized): 242 | logger.debug('Leaving get_contacts.') 243 | return None 244 | 245 | logger.debug('Response: {0}'.format(r.json())) 246 | logger.debug('Leaving get_contacts.') 247 | return r.json() 248 | 249 | # Retrieves a single contact 250 | # parameters: 251 | # contact_endpoint: string. The URL to the Contacts API endpoint (https://outlook.office365.com/api/v1.0) 252 | # token: string. The access token 253 | # contact_id: string. The ID of the contact to retrieve. 254 | # parameters: string. An optional string containing query parameters to limit the properties returned. 255 | # http://msdn.microsoft.com/office/office365/APi/complex-types-for-mail-contacts-calendar#UseODataqueryparameters 256 | def get_contact_by_id(contact_endpoint, token, contact_id, parameters = None): 257 | logger.debug('Entering get_contact_by_id.') 258 | logger.debug(' contact_endpoint: {0}'.format(contact_endpoint)) 259 | logger.debug(' token: {0}'.format(token)) 260 | logger.debug(' contact_id: {0}'.format(contact_id)) 261 | if (not parameters is None): 262 | logger.debug(' parameters: {0}'.format(parameters)) 263 | 264 | get_contact = '{0}/Me/Contacts/{1}'.format(contact_endpoint, contact_id) 265 | 266 | if (not parameters is None and 267 | parameters != ''): 268 | get_contact = '{0}{1}'.format(get_contact, parameters) 269 | 270 | r = make_api_call('GET', get_contact, token) 271 | 272 | if (r.status_code == requests.codes.ok): 273 | logger.debug('Response: {0}'.format(r.json())) 274 | logger.debug('Leaving get_contact_by_id(.') 275 | return r.json() 276 | else: 277 | logger.debug('Leaving get_contact_by_id.') 278 | return None 279 | 280 | # Deletes a single contact 281 | # parameters: 282 | # contact_endpoint: string. The URL to the Contacts API endpoint (https://outlook.office365.com/api/v1.0) 283 | # token: string. The access token 284 | # contact_id: string. The ID of the contact to delete. 285 | def delete_contact(contact_endpoint, token, contact_id): 286 | logger.debug('Entering delete_contact.') 287 | logger.debug(' contact_endpoint: {0}'.format(contact_endpoint)) 288 | logger.debug(' token: {0}'.format(token)) 289 | logger.debug(' contact_id: {0}'.format(contact_id)) 290 | 291 | delete_contact = '{0}/Me/Contacts/{1}'.format(contact_endpoint, contact_id) 292 | 293 | r = make_api_call('DELETE', delete_contact, token) 294 | 295 | logger.debug('Leaving delete_contact.') 296 | 297 | return r.status_code 298 | 299 | # Updates a single contact 300 | # parameters: 301 | # contact_endpoint: string. The URL to the Contacts API endpoint (https://outlook.office365.com/api/v1.0) 302 | # token: string. The access token 303 | # contact_id: string. The ID of the contact to update. 304 | # update_payload: string. A JSON representation of the properties to update. 305 | def update_contact(contact_endpoint, token, contact_id, update_payload): 306 | logger.debug('Entering update_contact.') 307 | logger.debug(' contact_endpoint: {0}'.format(contact_endpoint)) 308 | logger.debug(' token: {0}'.format(token)) 309 | logger.debug(' contact_id: {0}'.format(contact_id)) 310 | logger.debug(' update_payload: {0}'.format(update_payload)) 311 | 312 | update_contact = '{0}/Me/Contacts/{1}'.format(contact_endpoint, contact_id) 313 | 314 | r = make_api_call('PATCH', update_contact, token, update_payload) 315 | 316 | logger.debug('Response: {0}'.format(r.json())) 317 | logger.debug('Leaving update_contact.') 318 | 319 | return r.status_code 320 | 321 | # Creates a contact 322 | # parameters: 323 | # contact_endpoint: string. The URL to the Contacts API endpoint (https://outlook.office365.com/api/v1.0) 324 | # token: string. The access token 325 | # contact_payload: string. A JSON representation of the new contact. 326 | def create_contact(contact_endpoint, token, contact_payload): 327 | logger.debug('Entering create_contact.') 328 | logger.debug(' contact_endpoint: {0}'.format(contact_endpoint)) 329 | logger.debug(' token: {0}'.format(token)) 330 | logger.debug(' contact_payload: {0}'.format(contact_payload)) 331 | 332 | create_contact = '{0}/Me/Contacts'.format(contact_endpoint) 333 | 334 | r = make_api_call('POST', create_contact, token, contact_payload) 335 | 336 | logger.debug('Response: {0}'.format(r.json())) 337 | logger.debug('Leaving create_contact.') 338 | 339 | return r.status_code 340 | 341 | # Mail API # 342 | 343 | # Retrieves a set of messages from the user's Inbox 344 | # parameters: 345 | # mail_endpoint: string. The URL to the Mail API endpoint (https://outlook.office365.com/api/v1.0) 346 | # token: string. The access token 347 | # parameters: string. An optional string containing query parameters to filter, sort, etc. 348 | # http://msdn.microsoft.com/office/office365/APi/complex-types-for-mail-contacts-calendar#UseODataqueryparameters 349 | def get_messages(mail_endpoint, token, parameters = None): 350 | logger.debug('Entering get_messages.') 351 | logger.debug(' mail_endpoint: {0}'.format(mail_endpoint)) 352 | logger.debug(' token: {0}'.format(token)) 353 | if (not parameters is None): 354 | logger.debug(' parameters: {0}'.format(parameters)) 355 | 356 | get_messages = '{0}/Me/Messages'.format(mail_endpoint) 357 | 358 | if (not parameters is None): 359 | get_messages = '{0}{1}'.format(get_messages, parameters) 360 | 361 | r = make_api_call('GET', get_messages, token) 362 | 363 | if (r.status_code == requests.codes.unauthorized): 364 | logger.debug('Leaving get_messages.') 365 | return None 366 | 367 | logger.debug('Response: {0}'.format(r.json())) 368 | logger.debug('Leaving get_messages.') 369 | return r.json() 370 | 371 | # Retrieves a single message 372 | # parameters: 373 | # mail_endpoint: string. The URL to the Mail API endpoint (https://outlook.office365.com/api/v1.0) 374 | # token: string. The access token 375 | # message_id: string. The ID of the message to retrieve. 376 | # parameters: string. An optional string containing query parameters to limit the properties returned. 377 | # http://msdn.microsoft.com/office/office365/APi/complex-types-for-mail-contacts-calendar#UseODataqueryparameters 378 | def get_message_by_id(mail_endpoint, token, message_id, parameters = None): 379 | logger.debug('Entering get_message_by_id.') 380 | logger.debug(' mail_endpoint: {0}'.format(mail_endpoint)) 381 | logger.debug(' token: {0}'.format(token)) 382 | logger.debug(' message_id: {0}'.format(message_id)) 383 | if (not parameters is None): 384 | logger.debug(' parameters: {0}'.format(parameters)) 385 | 386 | get_message = '{0}/Me/Messages/{1}'.format(mail_endpoint, message_id) 387 | 388 | if (not parameters is None and 389 | parameters != ''): 390 | get_message = '{0}{1}'.format(get_message, parameters) 391 | 392 | r = make_api_call('GET', get_message, token) 393 | 394 | if (r.status_code == requests.codes.ok): 395 | logger.debug('Response: {0}'.format(r.json())) 396 | logger.debug('Leaving get_message_by_id.') 397 | return r.json() 398 | else: 399 | logger.debug('Leaving get_message_by_id.') 400 | return None 401 | 402 | # Deletes a single message 403 | # parameters: 404 | # mail_endpoint: string. The URL to the Mail API endpoint (https://outlook.office365.com/api/v1.0) 405 | # token: string. The access token 406 | # message_id: string. The ID of the message to delete. 407 | def delete_message(mail_endpoint, token, message_id): 408 | logger.debug('Entering delete_message.') 409 | logger.debug(' mail_endpoint: {0}'.format(mail_endpoint)) 410 | logger.debug(' token: {0}'.format(token)) 411 | logger.debug(' message_id: {0}'.format(message_id)) 412 | 413 | delete_message = '{0}/Me/Messages/{1}'.format(mail_endpoint, message_id) 414 | 415 | r = make_api_call('DELETE', delete_message, token) 416 | 417 | logger.debug('Leaving delete_message.') 418 | 419 | return r.status_code 420 | 421 | # Updates a single message 422 | # parameters: 423 | # mail_endpoint: string. The URL to the Mail API endpoint (https://outlook.office365.com/api/v1.0) 424 | # token: string. The access token 425 | # message_id: string. The ID of the message to update. 426 | # update_payload: string. A JSON representation of the properties to update. 427 | def update_message(mail_endpoint, token, message_id, update_payload): 428 | logger.debug('Entering update_message.') 429 | logger.debug(' mail_endpoint: {0}'.format(mail_endpoint)) 430 | logger.debug(' token: {0}'.format(token)) 431 | logger.debug(' message_id: {0}'.format(message_id)) 432 | logger.debug(' update_payload: {0}'.format(update_payload)) 433 | 434 | update_message = '{0}/Me/Messages/{1}'.format(mail_endpoint, message_id) 435 | 436 | r = make_api_call('PATCH', update_message, token, update_payload) 437 | 438 | logger.debug('Response: {0}'.format(r.json())) 439 | logger.debug('Leaving update_message.') 440 | 441 | return r.status_code 442 | 443 | # Creates a message in the Drafts folder 444 | # parameters: 445 | # mail_endpoint: string. The URL to the Mail API endpoint (https://outlook.office365.com/api/v1.0) 446 | # token: string. The access token 447 | # message_payload: string. A JSON representation of the new message. 448 | def create_message(mail_endpoint, token, message_payload): 449 | logger.debug('Entering create_message.') 450 | logger.debug(' mail_endpoint: {0}'.format(mail_endpoint)) 451 | logger.debug(' token: {0}'.format(token)) 452 | logger.debug(' message_payload: {0}'.format(message_payload)) 453 | 454 | create_message = '{0}/Me/Messages'.format(mail_endpoint) 455 | 456 | r = make_api_call('POST', create_message, token, message_payload) 457 | 458 | logger.debug('Response: {0}'.format(r.json())) 459 | logger.debug('Leaving create_message.') 460 | 461 | return r.status_code 462 | 463 | # Sends an existing message in the Drafts folder 464 | # parameters: 465 | # mail_endpoint: string. The URL to the Mail API endpoint (https://outlook.office365.com/api/v1.0) 466 | # token: string. The access token 467 | # message_id: string. The ID of the message to send. 468 | def send_draft_message(mail_endpoint, token, message_id): 469 | logger.debug('Entering send_draft_message.') 470 | logger.debug(' mail_endpoint: {0}'.format(mail_endpoint)) 471 | logger.debug(' token: {0}'.format(token)) 472 | logger.debug(' message_id: {0}'.format(message_id)) 473 | 474 | send_message = '{0}/Me/Messages/{1}/Send'.format(mail_endpoint, message_id) 475 | 476 | r = make_api_call('POST', send_message, token) 477 | 478 | logger.debug('Leaving send_draft_message.') 479 | return r.status_code 480 | 481 | # Sends an new message in the Drafts folder 482 | # parameters: 483 | # mail_endpoint: string. The URL to the Mail API endpoint (https://outlook.office365.com/api/v1.0) 484 | # token: string. The access token 485 | # message_payload: string. The JSON representation of the message. 486 | # save_to_sentitems: boolean. True = save a copy in sent items, False = don't. 487 | def send_new_message(mail_endpoint, token, message_payload, save_to_sentitems = True): 488 | logger.debug('Entering send_new_message.') 489 | logger.debug(' mail_endpoint: {0}'.format(mail_endpoint)) 490 | logger.debug(' token: {0}'.format(token)) 491 | logger.debug(' message_payload: {0}'.format(message_payload)) 492 | logger.debug(' save_to_sentitems: {0}'.format(save_to_sentitems)) 493 | 494 | send_message = '{0}/Me/SendMail'.format(mail_endpoint) 495 | 496 | message_json = json.loads(message_payload) 497 | send_message_json = { 'Message' : message_json, 498 | 'SaveToSentItems' : str(save_to_sentitems).lower() } 499 | 500 | send_message_payload = json.dumps(send_message_json) 501 | 502 | logger.debug('Created payload for send: {0}'.format(send_message_payload)) 503 | 504 | r = make_api_call('POST', send_message, token, send_message_payload) 505 | 506 | logger.debug('Leaving send_new_message.') 507 | return r.status_code 508 | 509 | # Calendar API # 510 | 511 | # Retrieves a set of events from the user's Calendar 512 | # parameters: 513 | # calendar_endpoint: string. The URL to the Calendar API endpoint (https://outlook.office365.com/api/v1.0) 514 | # token: string. The access token 515 | # parameters: string. An optional string containing query parameters to filter, sort, etc. 516 | # http://msdn.microsoft.com/office/office365/APi/complex-types-for-mail-contacts-calendar#UseODataqueryparameters 517 | def get_events(calendar_endpoint, token, parameters = None): 518 | logger.debug('Entering get_events.') 519 | logger.debug(' calendar_endpoint: {0}'.format(calendar_endpoint)) 520 | logger.debug(' token: {0}'.format(token)) 521 | if (not parameters is None): 522 | logger.debug(' parameters: {0}'.format(parameters)) 523 | 524 | get_events = '{0}/Me/Events'.format(calendar_endpoint) 525 | 526 | if (not parameters is None): 527 | get_events = '{0}{1}'.format(get_events, parameters) 528 | 529 | r = make_api_call('GET', get_events, token) 530 | 531 | if (r.status_code == requests.codes.unauthorized): 532 | logger.debug('Leaving get_events.') 533 | return None 534 | 535 | logger.debug('Response: {0}'.format(r.json())) 536 | logger.debug('Leaving get_events.') 537 | return r.json() 538 | 539 | # Retrieves a single event 540 | # parameters: 541 | # calendar_endpoint: string. The URL to the Calendar API endpoint (https://outlook.office365.com/api/v1.0) 542 | # token: string. The access token 543 | # event_id: string. The ID of the event to retrieve. 544 | # parameters: string. An optional string containing query parameters to limit the properties returned. 545 | # http://msdn.microsoft.com/office/office365/APi/complex-types-for-mail-contacts-calendar#UseODataqueryparameters 546 | def get_event_by_id(calendar_endpoint, token, event_id, parameters = None): 547 | logger.debug('Entering get_event_by_id.') 548 | logger.debug(' calendar_endpoint: {0}'.format(calendar_endpoint)) 549 | logger.debug(' token: {0}'.format(token)) 550 | logger.debug(' event_id: {0}'.format(event_id)) 551 | if (not parameters is None): 552 | logger.debug(' parameters: {0}'.format(parameters)) 553 | 554 | get_event = '{0}/Me/Events/{1}'.format(calendar_endpoint, event_id) 555 | 556 | if (not parameters is None and 557 | parameters != ''): 558 | get_event = '{0}{1}'.format(get_event, parameters) 559 | 560 | r = make_api_call('GET', get_event, token) 561 | 562 | if (r.status_code == requests.codes.ok): 563 | logger.debug('Response: {0}'.format(r.json())) 564 | logger.debug('Leaving get_event_by_id.') 565 | return r.json() 566 | else: 567 | logger.debug('Leaving get_event_by_id.') 568 | return None 569 | 570 | # Deletes a single event 571 | # parameters: 572 | # calendar_endpoint: string. The URL to the Calendar API endpoint (https://outlook.office365.com/api/v1.0) 573 | # token: string. The access token 574 | # event_id: string. The ID of the event to delete. 575 | def delete_event(calendar_endpoint, token, event_id): 576 | logger.debug('Entering delete_event.') 577 | logger.debug(' calendar_endpoint: {0}'.format(calendar_endpoint)) 578 | logger.debug(' token: {0}'.format(token)) 579 | logger.debug(' event_id: {0}'.format(event_id)) 580 | 581 | delete_event = '{0}/Me/Events/{1}'.format(calendar_endpoint, event_id) 582 | 583 | r = make_api_call('DELETE', delete_event, token) 584 | 585 | logger.debug('Leaving delete_event.') 586 | 587 | return r.status_code 588 | 589 | # Updates a single event 590 | # parameters: 591 | # calendar_endpoint: string. The URL to the Calendar API endpoint (https://outlook.office365.com/api/v1.0) 592 | # token: string. The access token 593 | # event_id: string. The ID of the event to update. 594 | # update_payload: string. A JSON representation of the properties to update. 595 | def update_event(calendar_endpoint, token, event_id, update_payload): 596 | logger.debug('Entering update_event.') 597 | logger.debug(' calendar_endpoint: {0}'.format(calendar_endpoint)) 598 | logger.debug(' token: {0}'.format(token)) 599 | logger.debug(' event_id: {0}'.format(event_id)) 600 | logger.debug(' update_payload: {0}'.format(update_payload)) 601 | 602 | update_event = '{0}/Me/Events/{1}'.format(calendar_endpoint, event_id) 603 | 604 | r = make_api_call('PATCH', update_event, token, update_payload) 605 | 606 | logger.debug('Response: {0}'.format(r.json())) 607 | logger.debug('Leaving update_event.') 608 | 609 | return r.status_code 610 | 611 | # Creates an event in the Calendar 612 | # parameters: 613 | # calendar_endpoint: string. The URL to the Calendar API endpoint (https://outlook.office365.com/api/v1.0) 614 | # token: string. The access token 615 | # event_payload: string. A JSON representation of the new event. 616 | def create_event(calendar_endpoint, token, event_payload): 617 | logger.debug('Entering create_event.') 618 | logger.debug(' calendar_endpoint: {0}'.format(calendar_endpoint)) 619 | logger.debug(' token: {0}'.format(token)) 620 | logger.debug(' event_payload: {0}'.format(event_payload)) 621 | 622 | create_event = '{0}/Me/Events'.format(calendar_endpoint) 623 | 624 | r = make_api_call('POST', create_event, token, event_payload) 625 | 626 | logger.debug('Response: {0}'.format(r.json())) 627 | logger.debug('Leaving create_event.') 628 | 629 | return r.status_code 630 | 631 | # MIT License: 632 | 633 | # Permission is hereby granted, free of charge, to any person obtaining 634 | # a copy of this software and associated documentation files (the 635 | # ""Software""), to deal in the Software without restriction, including 636 | # without limitation the rights to use, copy, modify, merge, publish, 637 | # distribute, sublicense, and/or sell copies of the Software, and to 638 | # permit persons to whom the Software is furnished to do so, subject to 639 | # the following conditions: 640 | 641 | # The above copyright notice and this permission notice shall be 642 | # included in all copies or substantial portions of the Software. 643 | 644 | # THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, 645 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 646 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 647 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 648 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 649 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 650 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /contacts/static/css/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Trebuchet MS", Arial, Helvetica, sans-serif; 3 | } 4 | 5 | #info-bar { 6 | width: 100%; 7 | background-color: #0072C6; 8 | color: #ffffff; 9 | font-size: 1.5em; 10 | text-align: left; 11 | padding: 5px; 12 | margin-bottom: 10px; 13 | } 14 | 15 | #user-info { 16 | width: auto; 17 | float: right; 18 | } 19 | 20 | #table-title { 21 | font-style: bold; 22 | font-size: 2em; 23 | } 24 | 25 | #user-email { 26 | margin-left: 5px; 27 | font-style: italic; 28 | width: auto; 29 | } 30 | 31 | #contacts { 32 | width: 100%; 33 | border-collapse: collapse; 34 | margin-top: 10px; 35 | } 36 | 37 | #contacts td, #contacts th { 38 | font-size: 1em; 39 | border: 1px solid #0072C6; 40 | padding: 3px 7px 2px 7px; 41 | } 42 | 43 | #contacts th { 44 | font-size: 1.1em; 45 | text-align: left; 46 | padding-top: 5px; 47 | padding-bottom: 4px; 48 | background-color: #0072C6; 49 | color: #ffffff; 50 | } 51 | 52 | #contacts tr.alt td { 53 | color: #000000; 54 | background-color: #CDE6F7; 55 | } 56 | 57 | a.user:link { 58 | color: #ffffff; 59 | } 60 | 61 | a.title:link { 62 | color: #ffffff; 63 | text-decoration: none; 64 | } 65 | 66 | a.create:link, a.create:visited { 67 | display: block; 68 | width: 200px; 69 | font-weight: bold; 70 | color: #FFFFFF; 71 | background-color: #0072C6; 72 | text-align: center; 73 | padding: 4px; 74 | text-decoration: none; 75 | text-transform: uppercase; 76 | margin-top: 10px; 77 | margin-bottom: 10px; 78 | } 79 | 80 | a.action:link, a.action:visited { 81 | display: block; 82 | width: 85px; 83 | font-weight: bold; 84 | color: #FFFFFF; 85 | background-color: #0072C6; 86 | text-align: center; 87 | padding: 4px; 88 | text-decoration: none; 89 | text-transform: uppercase; 90 | margin-left: 5px; 91 | float: left; 92 | } 93 | 94 | input.contact-field { 95 | width: 50%; 96 | margin-bottom: 5px; 97 | } -------------------------------------------------------------------------------- /contacts/templates/contacts/details.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "base.html" %} 3 | 4 | {% block content %} 5 | {% if contact %} 6 |
7 | {% else %} 8 | 9 | {% endif %} 10 | {% csrf_token %} 11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | 30 |
31 | {% endblock %} 32 | 33 | -------------------------------------------------------------------------------- /contacts/templates/contacts/error.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "base.html" %} 3 | 4 | {% block content %} 5 | 6 | {% if error_message %} 7 |

{{ error_message }}

8 | {% endif %} 9 | 10 |

Return home.

11 | 12 | {% endblock %} 13 | 14 | -------------------------------------------------------------------------------- /contacts/templates/contacts/index.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "base.html" %} 3 | 4 | {% block content %} 5 | 6 | {% if error_message %} 7 |
{{ error_message }}
8 | {% elif user_contacts %} 9 |
Your contacts(from {{user_email}})
10 | New Contact 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% for contact in user_contacts %} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 33 | 34 | {% endfor %} 35 |
First NameLast NameMobile PhoneEmail 1Email 2Email 3Actions
{{ contact.given_name }}{{ contact.last_name }}{{ contact.mobile_phone }}{{ contact.email1_address }}{{ contact.email2_address }}{{ contact.email3_address }} 30 | Edit 31 | Delete 32 |
36 | {% else %} 37 |
Please connect your Office 365 account to view your contacts.
38 | {% endif %} 39 | 40 | {% endblock %} 41 | 42 | -------------------------------------------------------------------------------- /contacts/tests.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See full license at the bottom of this file. 2 | from django.test import TestCase 3 | from django.core.exceptions import ObjectDoesNotExist 4 | from contacts.models import Office365Connection 5 | import contacts.o365service 6 | # Create your tests here. 7 | 8 | api_endpoint = 'https://outlook.office365.com/api/v1.0' 9 | 10 | # TODO: Copy a valid, non-expired access token here. You can get this from 11 | # an Office365Connection in the /admin/ page once you've successfully connected 12 | # an account to view contacts in the app. Remember these expire every hour, so 13 | # if you start getting 401's you need to get a new token. 14 | access_token = '' 15 | 16 | class MailApiTests(TestCase): 17 | 18 | def test_create_message(self): 19 | self.assertEqual(access_token, '', 'You must copy a valid access token into the access_token variable.') 20 | 21 | new_message_payload = '{ "Subject": "Did you see last night\'s game?", "Importance": "Low", "Body": { "ContentType": "HTML", "Content": "They were awesome!" }, "ToRecipients": [ { "EmailAddress": { "Address": "jasonjoh@alpineskihouse.com" } } ] }' 22 | 23 | r = contacts.o365service.create_message(api_endpoint, 24 | access_token, 25 | new_message_payload) 26 | 27 | self.assertEqual(r, 201, 'Create message returned {0}'.format(r)) 28 | 29 | def test_get_message_by_id(self): 30 | self.assertEqual(access_token, '', 'You must copy a valid access token into the access_token variable.') 31 | 32 | get_messages_params = '?$top=5&$select=Subject' 33 | 34 | r = contacts.o365service.get_messages(api_endpoint, 35 | access_token, 36 | get_messages_params) 37 | 38 | self.assertIsNotNone(r, 'Get messages returned None.') 39 | 40 | first_message = r['value'][0] 41 | 42 | first_message_id = first_message['Id'] 43 | 44 | r = contacts.o365service.get_message_by_id(api_endpoint, 45 | access_token, 46 | first_message_id) 47 | 48 | self.assertIsNotNone(r, 'Get message by id returned None.') 49 | 50 | def test_update_message(self): 51 | self.assertEqual(access_token, '', 'You must copy a valid access token into the access_token variable.') 52 | 53 | get_messages_params = '?$top=5&$select=Subject' 54 | 55 | r = contacts.o365service.get_messages(api_endpoint, 56 | access_token, 57 | get_messages_params) 58 | 59 | self.assertIsNotNone(r, 'Get messages returned None.') 60 | 61 | first_message = r['value'][0] 62 | 63 | first_message_id = first_message['Id'] 64 | 65 | update_payload = '{ "Subject" : "UPDATED" }' 66 | 67 | r = contacts.o365service.update_message(api_endpoint, 68 | access_token, 69 | first_message_id, 70 | update_payload) 71 | 72 | self.assertEqual(r, 200, 'Update message returned {0}.'.format(r)) 73 | 74 | def test_delete_message(self): 75 | self.assertEqual(access_token, '', 'You must copy a valid access token into the access_token variable.') 76 | 77 | get_messages_params = '?$top=5&$select=Subject' 78 | 79 | r = contacts.o365service.get_messages(api_endpoint, 80 | access_token, 81 | get_messages_params) 82 | 83 | self.assertIsNotNone(r, 'Get messages returned None.') 84 | 85 | first_message = r['value'][0] 86 | 87 | first_message_id = first_message['Id'] 88 | 89 | r = contacts.o365service.delete_message(api_endpoint, 90 | access_token, 91 | first_message_id) 92 | 93 | self.assertEqual(r, 204, 'Delete message returned {0}.'.format(r)) 94 | 95 | def test_send_draft_message(self): 96 | self.assertEqual(access_token, '', 'You must copy a valid access token into the access_token variable.') 97 | 98 | # Get drafts 99 | get_drafts = '{0}/Me/Folders/Drafts/Messages?$select=Subject'.format(api_endpoint) 100 | 101 | r = contacts.o365service.make_api_call('GET', get_drafts, access_token) 102 | 103 | response = r.json() 104 | 105 | first_message = response['value'][0] 106 | 107 | first_message_id = first_message['Id'] 108 | 109 | send_response = contacts.o365service.send_draft_message(api_endpoint, 110 | access_token, 111 | first_message_id) 112 | 113 | self.assertEqual(r, 200, 'Send draft returned {0}.'.format(r)) 114 | 115 | def test_send_new_mail(self): 116 | self.assertEqual(access_token, '', 'You must copy a valid access token into the access_token variable.') 117 | 118 | new_message_payload = '{ "Subject": "Sent from test_send_new_mail", "Importance": "Low", "Body": { "ContentType": "HTML", "Content": "They were awesome!" }, "ToRecipients": [ { "EmailAddress": { "Address": "allieb@jasonjohtest.onmicrosoft.com" } } ] }' 119 | 120 | r = contacts.o365service.send_new_message(api_endpoint, 121 | access_token, 122 | new_message_payload, 123 | True) 124 | 125 | self.assertEqual(r, 202, 'Send new message returned {0}.'.format(r)) 126 | 127 | class CalendarApiTests(TestCase): 128 | 129 | def test_create_event(self): 130 | self.assertEqual(access_token, '', 'You must copy a valid access token into the access_token variable.') 131 | 132 | new_event_payload = '{ "Subject": "Discuss the Calendar REST API", "Body": { "ContentType": "HTML", "Content": "I think it will meet our requirements!" }, "Start": "2015-01-15T18:00:00Z", "End": "2015-01-15T19:00:00Z", "Attendees": [ { "EmailAddress": { "Address": "alexd@alpineskihouse.com", "Name": "Alex Darrow" }, "Type": "Required" } ] }' 133 | 134 | r = contacts.o365service.create_event(api_endpoint, 135 | access_token, 136 | new_event_payload) 137 | 138 | self.assertEqual(r, 201, 'Create event returned {0}'.format(r)) 139 | 140 | def test_get_event_by_id(self): 141 | self.assertEqual(access_token, '', 'You must copy a valid access token into the access_token variable.') 142 | 143 | get_events_params = '?$top=5&$select=Subject,Start,End' 144 | 145 | r = contacts.o365service.get_events(api_endpoint, 146 | access_token, 147 | get_events_params) 148 | 149 | self.assertIsNotNone(r, 'Get events returned None.') 150 | 151 | first_event = r['value'][0] 152 | 153 | first_event_id = first_event['Id'] 154 | 155 | r = contacts.o365service.get_event_by_id(api_endpoint, 156 | access_token, 157 | first_event_id) 158 | 159 | self.assertIsNotNone(r, 'Get event by id returned None.') 160 | 161 | def test_update_event(self): 162 | self.assertEqual(access_token, '', 'You must copy a valid access token into the access_token variable.') 163 | 164 | get_events_params = '?$top=5&$select=Subject,Start,End' 165 | 166 | r = contacts.o365service.get_events(api_endpoint, 167 | access_token, 168 | get_events_params) 169 | 170 | self.assertIsNotNone(r, 'Get events returned None.') 171 | 172 | first_event = r['value'][0] 173 | 174 | first_event_id = first_event['Id'] 175 | 176 | update_payload = '{ "Subject" : "UPDATED" }' 177 | 178 | r = contacts.o365service.update_event(api_endpoint, 179 | access_token, 180 | first_event_id, 181 | update_payload) 182 | 183 | self.assertEqual(r, 200, 'Update event returned {0}.'.format(r)) 184 | 185 | def test_delete_event(self): 186 | self.assertEqual(access_token, '', 'You must copy a valid access token into the access_token variable.') 187 | 188 | get_events_params = '?$top=5&$select=Subject,Start,End' 189 | 190 | r = contacts.o365service.get_events(api_endpoint, 191 | access_token, 192 | get_events_params) 193 | 194 | self.assertIsNotNone(r, 'Get events returned None.') 195 | 196 | first_event = r['value'][0] 197 | 198 | first_event_id = first_event['Id'] 199 | 200 | r = contacts.o365service.delete_event(api_endpoint, 201 | access_token, 202 | first_event_id) 203 | 204 | self.assertEqual(r, 204, 'Delete event returned {0}.'.format(r)) 205 | 206 | class ContactsApiTests(TestCase): 207 | 208 | def test_create_contact(self): 209 | self.assertEqual(access_token, '', 'You must copy a valid access token into the access_token variable.') 210 | 211 | new_contact_payload = '{ "GivenName": "Pavel", "Surname": "Bansky", "EmailAddresses": [ { "Address": "pavelb@alpineskihouse.com", "Name": "Pavel Bansky" } ], "BusinessPhones": [ "+1 732 555 0102" ] }' 212 | 213 | r = contacts.o365service.create_contact(api_endpoint, 214 | access_token, 215 | new_contact_payload) 216 | 217 | self.assertEqual(r, 201, 'Create contact returned {0}'.format(r)) 218 | 219 | def test_get_contact_by_id(self): 220 | self.assertEqual(access_token, '', 'You must copy a valid access token into the access_token variable.') 221 | 222 | get_contacts_params = '?$top=5&$select=DisplayName' 223 | 224 | r = contacts.o365service.get_contacts(api_endpoint, 225 | access_token, 226 | get_contacts_params) 227 | 228 | self.assertIsNotNone(r, 'Get contacts returned None.') 229 | 230 | first_contact = r['value'][0] 231 | 232 | first_contact_id = first_contact['Id'] 233 | 234 | r = contacts.o365service.get_contact_by_id(api_endpoint, 235 | access_token, 236 | first_contact_id) 237 | 238 | self.assertIsNotNone(r, 'Get contact by id returned None.') 239 | 240 | def test_update_contact(self): 241 | self.assertEqual(access_token, '', 'You must copy a valid access token into the access_token variable.') 242 | 243 | get_contacts_params = '?$top=5&$select=DisplayName' 244 | 245 | r = contacts.o365service.get_contacts(api_endpoint, 246 | access_token, 247 | get_contacts_params) 248 | 249 | self.assertIsNotNone(r, 'Get contacts returned None.') 250 | 251 | first_contact = r['value'][0] 252 | 253 | first_contact_id = first_contact['Id'] 254 | 255 | update_payload = '{ "Surname" : "UPDATED" }' 256 | 257 | r = contacts.o365service.update_contact(api_endpoint, 258 | access_token, 259 | first_contact_id, 260 | update_payload) 261 | 262 | self.assertEqual(r, 200, 'Update contact returned {0}.'.format(r)) 263 | 264 | def test_delete_contact(self): 265 | self.assertEqual(access_token, '', 'You must copy a valid access token into the access_token variable.') 266 | 267 | get_contacts_params = '?$top=5&$select=DisplayName' 268 | 269 | r = contacts.o365service.get_contacts(api_endpoint, 270 | access_token, 271 | get_contacts_params) 272 | 273 | self.assertIsNotNone(r, 'Get contacts returned None.') 274 | 275 | first_contact = r['value'][0] 276 | 277 | first_contact_id = first_contact['Id'] 278 | 279 | r = contacts.o365service.delete_contact(api_endpoint, 280 | access_token, 281 | first_contact_id) 282 | 283 | self.assertEqual(r, 204, 'Delete contact returned {0}.'.format(r)) 284 | 285 | # MIT License: 286 | 287 | # Permission is hereby granted, free of charge, to any person obtaining 288 | # a copy of this software and associated documentation files (the 289 | # ""Software""), to deal in the Software without restriction, including 290 | # without limitation the rights to use, copy, modify, merge, publish, 291 | # distribute, sublicense, and/or sell copies of the Software, and to 292 | # permit persons to whom the Software is furnished to do so, subject to 293 | # the following conditions: 294 | 295 | # The above copyright notice and this permission notice shall be 296 | # included in all copies or substantial portions of the Software. 297 | 298 | # THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, 299 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 300 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 301 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 302 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 303 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 304 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /contacts/urls.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See full license at the bottom of this file. 2 | from django.conf.urls import patterns, url 3 | 4 | from contacts import views 5 | 6 | urlpatterns = patterns('', 7 | # The home view ('/contacts/') 8 | url(r'^$', views.index, name='index'), 9 | # Used to start OAuth2 flow ('/contacts/connect/') 10 | url(r'^connect/$', views.connect, name='connect'), 11 | # Used as redirect target in OAuth2 flow ('/contacts/authorize/') 12 | url(r'^authorize/$', views.authorize, name='authorize'), 13 | # Displays a form to create a new contact ('/contacts/new/') 14 | url(r'^new/$', views.new, name='new'), 15 | # Invoked to create a new contact in Office 365 ('/contacts/create/') 16 | url(r'^create/$', views.create, name='create'), 17 | # Displays an existing contact in an editable form ('/contacts/edit//') 18 | url(r'^edit/(?P.+)/$', views.edit, name='edit'), 19 | # Invoked to update an existing contact ('/contacts/update//') 20 | url(r'^update/(?P.+)/$', views.update, name='update'), 21 | # Invoked to delete an existing contact ('/contacts/delete//') 22 | url(r'^delete/(?P.+)/$', views.delete, name='delete'), 23 | ) 24 | 25 | # MIT License: 26 | 27 | # Permission is hereby granted, free of charge, to any person obtaining 28 | # a copy of this software and associated documentation files (the 29 | # ""Software""), to deal in the Software without restriction, including 30 | # without limitation the rights to use, copy, modify, merge, publish, 31 | # distribute, sublicense, and/or sell copies of the Software, and to 32 | # permit persons to whom the Software is furnished to do so, subject to 33 | # the following conditions: 34 | 35 | # The above copyright notice and this permission notice shall be 36 | # included in all copies or substantial portions of the Software. 37 | 38 | # THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, 39 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 40 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 41 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 42 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 43 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 44 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /contacts/views.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See full license at the bottom of this file. 2 | from django.shortcuts import render 3 | from django.http import HttpResponseRedirect, HttpResponse 4 | from django.contrib.auth.decorators import login_required 5 | from django.utils.decorators import method_decorator 6 | from django.views import generic 7 | from django.core.urlresolvers import reverse 8 | from django.core.exceptions import ObjectDoesNotExist 9 | from contacts.models import Office365Connection, DisplayContact 10 | import contacts.o365service 11 | import traceback 12 | 13 | contact_properties = '?$select=GivenName,Surname,MobilePhone1,EmailAddresses&$top=50' 14 | 15 | # Create your views here. 16 | # This is the index view for /contacts/ 17 | @login_required 18 | def index(request): 19 | try: 20 | # Get the user's connection info 21 | connection_info = Office365Connection.objects.get(username = request.user) 22 | 23 | except ObjectDoesNotExist: 24 | # If there is no connection object for the user, they haven't connected their 25 | # Office 365 account yet. The page will ask them to connect. 26 | return render(request, 'contacts/index.html', None) 27 | 28 | else: 29 | # If we don't have an access token, request one 30 | # NOTE: This doesn't check if the token is expired. We could, but 31 | # we'll just lazily assume it is good. If we try to use it and we 32 | # get an error, then we can refresh. 33 | if (connection_info.access_token is None or 34 | connection_info.access_token == ''): 35 | # Use the refresh token to request a token for the Contacts API 36 | access_token = contacts.o365service.get_access_token_from_refresh_token(connection_info.refresh_token, 37 | connection_info.outlook_resource_id) 38 | 39 | # Save the access token 40 | connection_info.access_token = access_token['access_token'] 41 | connection_info.save() 42 | 43 | user_contacts = contacts.o365service.get_contacts(connection_info.outlook_api_endpoint, 44 | connection_info.access_token, contact_properties) 45 | 46 | if (user_contacts is None): 47 | # Use the refresh token to request a token for the Contacts API 48 | access_token = contacts.o365service.get_access_token_from_refresh_token(connection_info.refresh_token, 49 | connection_info.outlook_resource_id) 50 | 51 | # Save the access token 52 | connection_info.access_token = access_token['access_token'] 53 | connection_info.save() 54 | 55 | user_contacts = contacts.o365service.get_contacts(connection_info.outlook_api_endpoint, 56 | connection_info.access_token, contact_properties) 57 | contact_list = list() 58 | 59 | for user_contact in user_contacts['value']: 60 | display_contact = DisplayContact() 61 | display_contact.load_json(user_contact) 62 | contact_list.append(display_contact) 63 | 64 | # For now just return the token and the user's email, the page will display it. 65 | context = { 'user_email': connection_info.user_email, 66 | 'user_contacts': contact_list } 67 | return render(request, 'contacts/index.html', context) 68 | 69 | # The /contacts/connect/ action. This will redirect to the Azure OAuth 70 | # login/consent page. 71 | def connect(request): 72 | redirect_uri = 'http://127.0.0.1:8000/contacts/authorize/' 73 | 74 | url = contacts.o365service.get_authorization_url(redirect_uri) 75 | return HttpResponseRedirect(url) 76 | 77 | # The /contacts/authorize action. This is where Azure's login/consent page 78 | # redirects after the user consents. 79 | def authorize(request): 80 | redirect_uri = 'http://127.0.0.1:8000/contacts/authorize/' 81 | if request.method == "GET": 82 | # Azure passes the auth code in the 'code' parameter 83 | try: 84 | auth_code = request.GET['code'] 85 | except: 86 | return render(request, 'contacts/error.html', { 87 | 'error_message' : 'Connection canceled.' 88 | }) 89 | else: 90 | # Get the user's connection info from database 91 | try: 92 | connection = Office365Connection.objects.get(username = request.user) 93 | except ObjectDoesNotExist: 94 | # If there is not one for the user, create a new one 95 | connection = Office365Connection(username = request.user) 96 | 97 | # Use the auth code to get an access token and do discovery 98 | access_info = contacts.o365service.get_access_info_from_authcode(auth_code, redirect_uri) 99 | 100 | if (access_info): 101 | try: 102 | user_email = access_info['user_email'] 103 | refresh_token = access_info['refresh_token'] 104 | resource_id = access_info['Contacts_resource_id'] 105 | api_endpoint = access_info['Contacts_api_endpoint'] 106 | 107 | # Save the access information in the user's connection info 108 | connection.user_email = user_email 109 | connection.refresh_token = refresh_token 110 | connection.outlook_resource_id = resource_id 111 | connection.outlook_api_endpoint = api_endpoint 112 | connection.save() 113 | except Exception as e: 114 | return render(request, 'contacts/error.html', 115 | { 116 | 'error_message': 'An exception occurred: {0}'.format(traceback.format_exception_only(type(e), e)) 117 | }) 118 | else: 119 | return render(request, 'contacts/error.html', 120 | { 121 | 'error_message': 'Unable to connect Office 365 account.', 122 | }) 123 | 124 | return HttpResponseRedirect(reverse('contacts:index')) 125 | else: 126 | return HttpResponseRedirect(reverse('contacts:index')) 127 | 128 | # The new view, used to display a blank details form for a contact. 129 | @login_required 130 | def new(request): 131 | return render(request, 'contacts/details.html', None) 132 | 133 | # The create action, invoked via POST by the details form when creating 134 | # a new contact. 135 | @login_required 136 | def create(request): 137 | try: 138 | # Initialize a DisplayContact object from the posted form data 139 | new_contact = DisplayContact() 140 | new_contact.given_name = request.POST['first_name'] 141 | new_contact.last_name = request.POST['last_name'] 142 | new_contact.mobile_phone = request.POST['mobile_phone'] 143 | new_contact.email1_address = request.POST['email1_address'] 144 | new_contact.email1_name = request.POST['email1_name'] 145 | new_contact.email2_address = request.POST['email2_address'] 146 | new_contact.email2_name = request.POST['email2_name'] 147 | new_contact.email3_address = request.POST['email3_address'] 148 | new_contact.email3_name = request.POST['email3_name'] 149 | 150 | except (KeyError): 151 | # If the form data is missing or incomplete, display an error. 152 | return render(request, 'contacts/error.html', 153 | { 154 | 'error_message': 'No contact data included in POST.', 155 | } 156 | ) 157 | else: 158 | try: 159 | # Get the user's connection info 160 | connection_info = Office365Connection.objects.get(username = request.user) 161 | 162 | except ObjectDoesNotExist: 163 | # If there is no connection object for the user, they haven't connected their 164 | # Office 365 account yet. The page will ask them to connect. 165 | return render(request, 'contacts/index.html', None) 166 | 167 | else: 168 | result = contacts.o365service.create_contact(connection_info.outlook_api_endpoint, 169 | connection_info.access_token, 170 | new_contact.get_json(False)) 171 | # Per MSDN, success should be a 201 status 172 | if (result == 201): 173 | return HttpResponseRedirect(reverse('contacts:index')) 174 | else: 175 | return render(request, 'contacts/error.html', 176 | { 177 | 'error_message': 'Unable to create contact: {0} HTTP status returned.'.format(result), 178 | } 179 | ) 180 | 181 | # The edit view, used to display an existing contact in a details form. 182 | # Note this view always retrieves the contact from Office 365 to get the latest version. 183 | @login_required 184 | def edit(request, contact_id): 185 | try: 186 | # Get the user's connection info 187 | connection_info = Office365Connection.objects.get(username = request.user) 188 | 189 | except ObjectDoesNotExist: 190 | # If there is no connection object for the user, they haven't connected their 191 | # Office 365 account yet. The page will ask them to connect. 192 | return render(request, 'contacts/index.html', None) 193 | 194 | else: 195 | if (connection_info.access_token is None or 196 | connection_info.access_token == ''): 197 | return render(request, 'contacts/index.html', None) 198 | 199 | contact_json = contacts.o365service.get_contact_by_id(connection_info.outlook_api_endpoint, 200 | connection_info.access_token, 201 | contact_id, contact_properties) 202 | 203 | if (not contact_json is None): 204 | # Load the contact into a DisplayContact object 205 | display_contact = DisplayContact() 206 | display_contact.load_json(contact_json) 207 | 208 | # Render a details form 209 | return render(request, 'contacts/details.html', { 'contact': display_contact }) 210 | else: 211 | return render(request, 'contacts/error.html', 212 | { 213 | 'error_message': 'Unable to get contact with ID: {0}'.format(contact_id), 214 | } 215 | ) 216 | 217 | # The update action, invoked via POST from the details form when editing 218 | # an existing contact. 219 | @login_required 220 | def update(request, contact_id): 221 | try: 222 | # Initialize a DisplayContact object from the posted form data 223 | updated_contact = DisplayContact() 224 | updated_contact.given_name = request.POST['first_name'] 225 | updated_contact.last_name = request.POST['last_name'] 226 | updated_contact.mobile_phone = request.POST['mobile_phone'] 227 | updated_contact.email1_address = request.POST['email1_address'] 228 | updated_contact.email1_name = request.POST['email1_name'] 229 | updated_contact.email2_address = request.POST['email2_address'] 230 | updated_contact.email2_name = request.POST['email2_name'] 231 | updated_contact.email3_address = request.POST['email3_address'] 232 | updated_contact.email3_name = request.POST['email3_name'] 233 | 234 | except (KeyError): 235 | # If the form data is missing or incomplete, display an error. 236 | return render(request, 'contacts/error.html', 237 | { 238 | 'error_message': 'No contact data included in POST.', 239 | } 240 | ) 241 | else: 242 | try: 243 | # Get the user's connection info 244 | connection_info = Office365Connection.objects.get(username = request.user) 245 | 246 | except ObjectDoesNotExist: 247 | # If there is no connection object for the user, they haven't connected their 248 | # Office 365 account yet. The page will ask them to connect. 249 | return render(request, 'contacts/index.html', None) 250 | 251 | else: 252 | result = contacts.o365service.update_contact(connection_info.outlook_api_endpoint, 253 | connection_info.access_token, 254 | contact_id, 255 | updated_contact.get_json(True)) 256 | 257 | # Per MSDN, success should be a 200 status 258 | if (result == 200): 259 | return HttpResponseRedirect(reverse('contacts:index')) 260 | else: 261 | return render(request, 'contacts/error.html', 262 | { 263 | 'error_message': 'Unable to update contact: {0} HTTP status returned.'.format(result), 264 | } 265 | ) 266 | 267 | # The delete action, invoked to delete a contact. 268 | @login_required 269 | def delete(request, contact_id): 270 | try: 271 | # Get the user's connection info 272 | connection_info = Office365Connection.objects.get(username = request.user) 273 | 274 | except ObjectDoesNotExist: 275 | # If there is no connection object for the user, they haven't connected their 276 | # Office 365 account yet. The page will ask them to connect. 277 | return render(request, 'contacts/index.html', None) 278 | 279 | else: 280 | if (connection_info.access_token is None or 281 | connection_info.access_token == ''): 282 | return render(request, 'contacts/index.html', None) 283 | 284 | result = contacts.o365service.delete_contact(connection_info.outlook_api_endpoint, 285 | connection_info.access_token, 286 | contact_id) 287 | 288 | # Per MSDN, success should be a 204 status 289 | if (result == 204): 290 | return HttpResponseRedirect(reverse('contacts:index')) 291 | else: 292 | return render(request, 'contacts/error.html', 293 | { 294 | 'error_message': 'Unable to delete contact: {0} HTTP status returned.'.format(result), 295 | } 296 | ) 297 | # MIT License: 298 | 299 | # Permission is hereby granted, free of charge, to any person obtaining 300 | # a copy of this software and associated documentation files (the 301 | # ""Software""), to deal in the Software without restriction, including 302 | # without limitation the rights to use, copy, modify, merge, publish, 303 | # distribute, sublicense, and/or sell copies of the Software, and to 304 | # permit persons to whom the Software is furnished to do so, subject to 305 | # the following conditions: 306 | 307 | # The above copyright notice and this permission notice shall be 308 | # included in all copies or substantial portions of the Software. 309 | 310 | # THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, 311 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 312 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 313 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 314 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 315 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 316 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pythoncontacts.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /pythoncontacts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonjoh/pythoncontacts/7929f614eb281af00d9b68a409240c7c1334ac7d/pythoncontacts/__init__.py -------------------------------------------------------------------------------- /pythoncontacts/settings.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See full license at the bottom of this file. 2 | """ 3 | Django settings for pythoncontacts project. 4 | 5 | For more information on this file, see 6 | https://docs.djangoproject.com/en/1.7/topics/settings/ 7 | 8 | For the full list of settings and their values, see 9 | https://docs.djangoproject.com/en/1.7/ref/settings/ 10 | """ 11 | 12 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 13 | import os 14 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 15 | 16 | 17 | # Quick-start development settings - unsuitable for production 18 | # See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/ 19 | 20 | # SECURITY WARNING: keep the secret key used in production secret! 21 | SECRET_KEY = 'yrwm*d_6%zw=zhe$x8e=a82b0*w9(7bviow12%5+ri9#f3s%q*' 22 | 23 | # SECURITY WARNING: don't run with debug turned on in production! 24 | DEBUG = True 25 | 26 | TEMPLATE_DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = ( 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'contacts', 41 | ) 42 | 43 | MIDDLEWARE_CLASSES = ( 44 | 'django.contrib.sessions.middleware.SessionMiddleware', 45 | 'django.middleware.common.CommonMiddleware', 46 | 'django.middleware.csrf.CsrfViewMiddleware', 47 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 48 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 49 | 'django.contrib.messages.middleware.MessageMiddleware', 50 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 51 | ) 52 | 53 | ROOT_URLCONF = 'pythoncontacts.urls' 54 | 55 | WSGI_APPLICATION = 'pythoncontacts.wsgi.application' 56 | 57 | 58 | # Database 59 | # https://docs.djangoproject.com/en/1.7/ref/settings/#databases 60 | 61 | DATABASES = { 62 | 'default': { 63 | 'ENGINE': 'django.db.backends.sqlite3', 64 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 65 | } 66 | } 67 | 68 | # Internationalization 69 | # https://docs.djangoproject.com/en/1.7/topics/i18n/ 70 | 71 | LANGUAGE_CODE = 'en-us' 72 | 73 | TIME_ZONE = 'America/New_York' 74 | 75 | USE_I18N = True 76 | 77 | USE_L10N = True 78 | 79 | USE_TZ = True 80 | 81 | 82 | # Static files (CSS, JavaScript, Images) 83 | # https://docs.djangoproject.com/en/1.7/howto/static-files/ 84 | 85 | STATIC_URL = '/static/' 86 | 87 | TEMPLATE_DIRS = [os.path.join(BASE_DIR, 'templates')] 88 | 89 | LOGIN_URL = '/login/' 90 | LOGIN_REDIRECT_URL = '/contacts/' 91 | 92 | LOGGING = { 93 | 'version': 1, 94 | 'disable_existing_loggers': False, 95 | 'handlers': { 96 | 'file': { 97 | 'level': 'DEBUG', 98 | 'class': 'logging.FileHandler', 99 | 'filename': './debug.log', 100 | }, 101 | }, 102 | 'loggers': { 103 | 'contacts': { 104 | 'handlers': ['file'], 105 | 'level': 'DEBUG', 106 | 'propagate': False, 107 | }, 108 | }, 109 | } 110 | 111 | # MIT License: 112 | 113 | # Permission is hereby granted, free of charge, to any person obtaining 114 | # a copy of this software and associated documentation files (the 115 | # ""Software""), to deal in the Software without restriction, including 116 | # without limitation the rights to use, copy, modify, merge, publish, 117 | # distribute, sublicense, and/or sell copies of the Software, and to 118 | # permit persons to whom the Software is furnished to do so, subject to 119 | # the following conditions: 120 | 121 | # The above copyright notice and this permission notice shall be 122 | # included in all copies or substantial portions of the Software. 123 | 124 | # THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, 125 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 126 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 127 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 128 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 129 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 130 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /pythoncontacts/urls.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See full license at the bottom of this file. 2 | from django.conf.urls import patterns, include, url 3 | from django.contrib import admin 4 | 5 | urlpatterns = patterns('', 6 | url(r'^login/$', 'django.contrib.auth.views.login'), 7 | url(r'^logout/$', 'django.contrib.auth.views.logout'), 8 | url(r'^contacts/', include('contacts.urls', namespace='contacts')), 9 | url(r'^admin/', include(admin.site.urls)), 10 | ) 11 | 12 | # MIT License: 13 | 14 | # Permission is hereby granted, free of charge, to any person obtaining 15 | # a copy of this software and associated documentation files (the 16 | # ""Software""), to deal in the Software without restriction, including 17 | # without limitation the rights to use, copy, modify, merge, publish, 18 | # distribute, sublicense, and/or sell copies of the Software, and to 19 | # permit persons to whom the Software is furnished to do so, subject to 20 | # the following conditions: 21 | 22 | # The above copyright notice and this permission notice shall be 23 | # included in all copies or substantial portions of the Software. 24 | 25 | # THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, 26 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 27 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 28 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 29 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 30 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 31 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /pythoncontacts/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for pythoncontacts project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.7/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pythoncontacts.settings") 12 | 13 | from django.core.wsgi import get_wsgi_application 14 | application = get_wsgi_application() 15 | -------------------------------------------------------------------------------- /setup_project.bat: -------------------------------------------------------------------------------- 1 | python manage.py makemigrations contacts 2 | python manage.py migrate 3 | python manage.py createsuperuser -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | {% load staticfiles %} 3 | 4 | 5 | 6 | 7 | 8 |
9 | Python Contacts App 10 | Current User: {{ user }} 11 | {% if user.is_anonymous %} 12 | login 13 | {% else %} 14 | logout 15 | {% endif %} 16 | 17 |
18 | {% block content %} 19 | {% endblock %} 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /templates/registration/logged_out.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "base.html" %} 3 | 4 | {% block content %} 5 | 6 |
You are now logged out.
7 | 8 |
Return home.
9 | 10 | {% endblock %} 11 | 12 | -------------------------------------------------------------------------------- /templates/registration/login.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "base.html" %} 3 | 4 | {% block content %} 5 | 6 | {% if form.errors %} 7 |

Your username and password didn't match. Please try again.

8 | {% endif %} 9 | 10 |
11 | {% csrf_token %} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
{{ form.username.label_tag }}{{ form.username }}
{{ form.password.label_tag }}{{ form.password }}
22 | 23 | 24 | 25 |
26 | 27 | {% endblock %} 28 | 29 | --------------------------------------------------------------------------------