diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f4dde00740..cfa6cad0c3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,12 @@ Change Log Unreleased ---------- +[1.7.2] - 2019-07-18 +-------------------- + +* Added ability to send user's progress to cornerstone + + [1.7.1] - 2019-07-15 -------------------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 949f7a9cb6..f32270b4cc 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -4,6 +4,6 @@ from __future__ import absolute_import, unicode_literals -__version__ = "1.7.1" +__version__ = "1.7.2" default_app_config = "enterprise.apps.EnterpriseConfig" # pylint: disable=invalid-name diff --git a/integrated_channels/cornerstone/admin/__init__.py b/integrated_channels/cornerstone/admin/__init__.py index b586e21043..feb3caef0e 100644 --- a/integrated_channels/cornerstone/admin/__init__.py +++ b/integrated_channels/cornerstone/admin/__init__.py @@ -22,7 +22,8 @@ class CornerstoneGlobalConfigurationAdmin(ConfigurationModelAdmin): list_display = ( "completion_status_api_path", - "oauth_api_path", + "key", + "secret", ) class Meta(object): diff --git a/integrated_channels/cornerstone/client.py b/integrated_channels/cornerstone/client.py index 1844eceeea..5ecd483750 100644 --- a/integrated_channels/cornerstone/client.py +++ b/integrated_channels/cornerstone/client.py @@ -5,10 +5,10 @@ from __future__ import absolute_import, unicode_literals -import datetime +import base64 +import json import requests -from six.moves.urllib.parse import urljoin # pylint: disable=import-error from django.apps import apps @@ -20,12 +20,9 @@ class CornerstoneAPIClient(IntegratedChannelApiClient): Client for connecting to Cornerstone. Specifically, this class supports obtaining access tokens - and posting user's proogres to completion status endpoints. + and posting user's course completion status to progress endpoints. """ - COMPLETION_PROVIDER_SCOPE = 'provider_completion' - SESSION_TIMEOUT = 60 - def __init__(self, enterprise_configuration): """ Instantiate a new client. @@ -74,43 +71,33 @@ def create_course_completion(self, user_id, payload): # pylint: disable=unused- Raises: HTTPError: if we received a failure response code from Cornerstone """ - return self._post( - urljoin( - self.enterprise_configuration.cornerstone_base_url, - self.global_cornerstone_config.completion_status_api_path - ), - payload, - self.COMPLETION_PROVIDER_SCOPE + json_payload = json.loads(payload) + callback_url = json_payload['data'].pop('callbackUrl') + session_token = json_payload['data'].pop('sessionToken') + url = '{base_url}{callback_url}{completion_path}?sessionToken={session_token}'.format( + base_url=self.enterprise_configuration.cornerstone_base_url, + callback_url=callback_url, + completion_path=self.global_cornerstone_config.completion_status_api_path, + session_token=session_token, ) - def _post(self, url, data, scope): - """ - Make a POST request using the session object to a Cornerstone endpoint. - - Args: - url (str): The url to send a POST request to. - data (str): The json encoded payload to POST. - scope (str): Must be one of the scopes Cornerstone expects: - - `COMPLETION_PROVIDER_SCOPE` - """ - self._create_session(scope) - response = self.session.post(url, data=data) + response = requests.post( + url, + json=[json_payload['data']], + headers={ + 'Authorization': self.authorization_header, + 'Content-Type': 'application/json' + } + ) return response.status_code, response.text - def _create_session(self, scope): # pylint: disable=unused-argument + @property + def authorization_header(self): """ - Instantiate a new session object for use in connecting with Cornerstone + Authorization header for authenticating requests to cornerstone progress API. """ - now = datetime.datetime.utcnow() - if self.session is None or self.expires_at is None or now >= self.expires_at: - # Create a new session with a valid token - if self.session: - self.session.close() - # TODO: logic to get oauth access token needs to be implemented here - oauth_access_token, expires_at = None, None - session = requests.Session() - session.timeout = self.SESSION_TIMEOUT - session.headers['Authorization'] = 'Bearer {}'.format(oauth_access_token) - session.headers['content-type'] = 'application/json' - self.session = session - self.expires_at = expires_at + return 'Basic {}'.format( + base64.b64encode(u'{key}:{secret}'.format( + key=self.global_cornerstone_config.key, secret=self.global_cornerstone_config.secret + ).encode('utf-8')).decode() + ) diff --git a/integrated_channels/cornerstone/exporters/learner_data.py b/integrated_channels/cornerstone/exporters/learner_data.py new file mode 100644 index 0000000000..b528c740c0 --- /dev/null +++ b/integrated_channels/cornerstone/exporters/learner_data.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +""" +Learner data exporter for Enterprise Integrated Channel Cornerstone. +""" + + +from __future__ import absolute_import, unicode_literals + +from logging import getLogger + +from django.apps import apps + +from enterprise.api_client.discovery import get_course_catalog_api_service_client +from integrated_channels.integrated_channel.exporters.learner_data import LearnerExporter + +LOGGER = getLogger(__name__) + + +class CornerstoneLearnerExporter(LearnerExporter): + """ + Class to provide a Cornerstone learner data transmission audit prepared for serialization. + """ + + def get_learner_data_records(self, enterprise_enrollment, completed_date=None, grade=None, is_passing=False): + """ + Return a CornerstoneLearnerDataTransmissionAudit with the given enrollment and course completion data. + + If completed_date is None, then course completion has not been met. + + CornerstoneLearnerDataTransmissionAudit object should exit already if not then return None. + """ + course_completed = False + if completed_date is not None: + course_completed = True + + CornerstoneLearnerDataTransmissionAudit = apps.get_model( # pylint: disable=invalid-name + 'cornerstone', + 'CornerstoneLearnerDataTransmissionAudit' + ) + + course_catalog_client = get_course_catalog_api_service_client( + site=enterprise_enrollment.enterprise_customer_user.enterprise_customer.site + ) + try: + csod_learner_data_transmission = CornerstoneLearnerDataTransmissionAudit.objects.get( + user_id=enterprise_enrollment.enterprise_customer_user.user.id, + course_id=course_catalog_client.get_course_id(enterprise_enrollment.course_id), + ) + csod_learner_data_transmission.enterprise_course_enrollment_id = enterprise_enrollment.id + csod_learner_data_transmission.grade = grade + csod_learner_data_transmission.course_completed = course_completed + csod_learner_data_transmission.completed_timestamp = completed_date + return [ + csod_learner_data_transmission + ] + except CornerstoneLearnerDataTransmissionAudit.DoesNotExist: + LOGGER.info( + 'No learner data was sent for user [%s] because Cornerstone user ID could not be found ' + 'for customer [%s]', + enterprise_enrollment.enterprise_customer_user.username, + enterprise_enrollment.enterprise_customer_user.enterprise_customer.name + ) diff --git a/integrated_channels/cornerstone/migrations/0003_auto_20190621_1000.py b/integrated_channels/cornerstone/migrations/0003_auto_20190621_1000.py new file mode 100644 index 0000000000..3a9a115d3b --- /dev/null +++ b/integrated_channels/cornerstone/migrations/0003_auto_20190621_1000.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.21 on 2019-06-21 10:00 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cornerstone', '0002_cornerstoneglobalconfiguration_subject_mapping'), + ] + + operations = [ + migrations.AddField( + model_name='cornerstoneglobalconfiguration', + name='key', + field=models.CharField(default='key', help_text='Basic auth username for sending user completion status to cornerstone.', max_length=255, verbose_name='Basic Auth username'), + ), + migrations.AddField( + model_name='cornerstoneglobalconfiguration', + name='secret', + field=models.CharField(default='secret', help_text='Basic auth password for sending user completion status to cornerstone.', max_length=255, verbose_name='Basic Auth password'), + ), + migrations.AddField( + model_name='cornerstonelearnerdatatransmissionaudit', + name='grade', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='cornerstonelearnerdatatransmissionaudit', + name='enterprise_course_enrollment_id', + field=models.PositiveIntegerField(blank=True, db_index=True, null=True), + ), + ] diff --git a/integrated_channels/cornerstone/models.py b/integrated_channels/cornerstone/models.py index f0bd6bffbc..783c0c0ef2 100644 --- a/integrated_channels/cornerstone/models.py +++ b/integrated_channels/cornerstone/models.py @@ -15,11 +15,14 @@ from django.contrib.auth.models import User from django.db import models from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext_lazy as _ from model_utils.models import TimeStampedModel from integrated_channels.cornerstone.exporters.content_metadata import CornerstoneContentMetadataExporter +from integrated_channels.cornerstone.exporters.learner_data import CornerstoneLearnerExporter from integrated_channels.cornerstone.transmitters.content_metadata import CornerstoneContentMetadataTransmitter +from integrated_channels.cornerstone.transmitters.learner_data import CornerstoneLearnerTransmitter from integrated_channels.integrated_channel.models import EnterpriseCustomerPluginConfiguration LOGGER = getLogger(__name__) @@ -37,20 +40,34 @@ class CornerstoneGlobalConfiguration(ConfigurationModel): completion_status_api_path = models.CharField( max_length=255, verbose_name="Completion Status API Path", - help_text="The API path for making completion POST requests to Cornerstone." + help_text=_("The API path for making completion POST requests to Cornerstone.") ) oauth_api_path = models.CharField( max_length=255, verbose_name="OAuth API Path", - help_text=( + help_text=_( "The API path for making OAuth-related POST requests to Cornerstone. " "This will be used to gain the OAuth access token which is required for other API calls." ) ) + + key = models.CharField( + max_length=255, + default='key', + verbose_name="Basic Auth username", + help_text=_('Basic auth username for sending user completion status to cornerstone.') + ) + secret = models.CharField( + max_length=255, + default='secret', + verbose_name="Basic Auth password", + help_text=_('Basic auth password for sending user completion status to cornerstone.') + ) + subject_mapping = JSONField( default={}, - help_text="Key/value mapping cornerstone subjects to edX subjects list", + help_text=_("Key/value mapping cornerstone subjects to edX subjects list"), ) class Meta: @@ -80,7 +97,7 @@ class CornerstoneEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfigu max_length=255, blank=True, verbose_name="Cornerstone Base URL", - help_text="The base URL used for API requests to Cornerstone, i.e. https://portalName.csod.com" + help_text=_("The base URL used for API requests to Cornerstone, i.e. https://portalName.csod.com") ) history = HistoricalRecords() @@ -121,6 +138,18 @@ def get_content_metadata_exporter(self, user): """ return CornerstoneContentMetadataExporter(user, self) + def get_learner_data_transmitter(self): + """ + Return a ``CornerstoneLearnerTransmitter`` instance. + """ + return CornerstoneLearnerTransmitter(self) + + def get_learner_data_exporter(self, user): + """ + Return a ``CornerstoneLearnerExporter`` instance. + """ + return CornerstoneLearnerExporter(user, self) + @python_2_unicode_compatible class CornerstoneLearnerDataTransmissionAudit(TimeStampedModel): @@ -144,14 +173,15 @@ class CornerstoneLearnerDataTransmissionAudit(TimeStampedModel): enterprise_course_enrollment_id = models.PositiveIntegerField( blank=True, - null=True + null=True, + db_index=True, ) course_id = models.CharField( max_length=255, blank=False, null=False, - help_text="The course run's key which is used to uniquely identify the course for Cornerstone." + help_text=_("The course run's key which is used to uniquely identify the course for Cornerstone.") ) session_token = models.CharField(max_length=255, null=False, blank=False) @@ -160,17 +190,17 @@ class CornerstoneLearnerDataTransmissionAudit(TimeStampedModel): course_completed = models.BooleanField( default=False, - help_text="The learner's course completion status transmitted to Cornerstone." + help_text=_("The learner's course completion status transmitted to Cornerstone.") ) completed_timestamp = models.DateTimeField( null=True, blank=True, - help_text=( + help_text=_( 'Date time when user completed course' ) ) - + grade = models.CharField(max_length=255, null=True, blank=True) # Request-related information. status = models.CharField(max_length=100, blank=True, null=True) error_message = models.TextField(blank=True, null=True) @@ -205,5 +235,20 @@ def serialize(self, *args, **kwargs): # pylint: disable=unused-argument Sort the keys so the result is consistent and testable. """ - # TODO: serialize data to be sent to cornerstone - return json.dumps({"data": "data"}, sort_keys=True) + data = { + 'courseId': self.course_id, + 'userGuid': self.user_guid, + 'callbackUrl': self.callback_url, + 'sessionToken': self.session_token, + 'status': 'Completed' if self.grade in ['Pass', 'Fail'] else 'In Progress', + 'completionDate': + self.completed_timestamp.replace(microsecond=0).isoformat() if self.completed_timestamp else None, + } + if self.grade != 'In Progress': + data['successStatus'] = self.grade + return json.dumps( + { + "data": data + }, + sort_keys=True + ) diff --git a/integrated_channels/cornerstone/transmitters/learner_data.py b/integrated_channels/cornerstone/transmitters/learner_data.py index 0150957ef4..6eeef4cfdd 100644 --- a/integrated_channels/cornerstone/transmitters/learner_data.py +++ b/integrated_channels/cornerstone/transmitters/learner_data.py @@ -33,5 +33,5 @@ def transmit(self, payload, **kwargs): """ kwargs['app_label'] = 'cornerstone' kwargs['model_name'] = 'CornerstoneLearnerDataTransmissionAudit' - kwargs['remote_user_id'] = 'cornerstone_user_email' + kwargs['remote_user_id'] = 'user_guid' super(CornerstoneLearnerTransmitter, self).transmit(payload, **kwargs) diff --git a/integrated_channels/integrated_channel/management/commands/__init__.py b/integrated_channels/integrated_channel/management/commands/__init__.py index 3d2cf73c6a..a824c0af9f 100644 --- a/integrated_channels/integrated_channel/management/commands/__init__.py +++ b/integrated_channels/integrated_channel/management/commands/__init__.py @@ -11,6 +11,7 @@ from django.utils.translation import ugettext as _ from enterprise.models import EnterpriseCustomer +from integrated_channels.cornerstone.models import CornerstoneEnterpriseCustomerConfiguration from integrated_channels.degreed.models import DegreedEnterpriseCustomerConfiguration from integrated_channels.sap_success_factors.models import SAPSuccessFactorsEnterpriseCustomerConfiguration @@ -20,6 +21,7 @@ for integrated_channel_class in ( SAPSuccessFactorsEnterpriseCustomerConfiguration, DegreedEnterpriseCustomerConfiguration, + CornerstoneEnterpriseCustomerConfiguration, ) ]) diff --git a/test_utils/factories.py b/test_utils/factories.py index 9c4393f568..15101ec688 100644 --- a/test_utils/factories.py +++ b/test_utils/factories.py @@ -30,6 +30,7 @@ from integrated_channels.cornerstone.models import ( CornerstoneEnterpriseCustomerConfiguration, CornerstoneGlobalConfiguration, + CornerstoneLearnerDataTransmissionAudit, ) from integrated_channels.degreed.models import ( DegreedEnterpriseCustomerConfiguration, @@ -88,6 +89,8 @@ class Meta(object): site = factory.SubFactory(SiteFactory) enable_data_sharing_consent = True enforce_data_sharing_consent = EnterpriseCustomer.AT_ENROLLMENT + enable_audit_enrollment = False + enable_audit_data_reporting = False hide_course_original_price = False country = 'US' @@ -500,6 +503,28 @@ class Meta(object): status = factory.LazyAttribute(lambda x: FAKER.word()) +class CornerstoneLearnerDataTransmissionAuditFactory(factory.django.DjangoModelFactory): + """ + ``CornerstoneLearnerDataTransmissionAudit`` factory. + + Creates an instance of ``CornerstoneLearnerDataTransmissionAudit`` with minimal boilerplate. + """ + + class Meta(object): + """ + Meta for ``CornerstoneLearnerDataTransmissionAuditFactory``. + """ + + model = CornerstoneLearnerDataTransmissionAudit + + user_id = factory.LazyAttribute(lambda x: FAKER.pyint()) + course_id = factory.LazyAttribute(lambda x: FAKER.slug()) + user_guid = factory.LazyAttribute(lambda x: FAKER.slug()) + session_token = factory.LazyAttribute(lambda x: FAKER.slug()) + callback_url = factory.LazyAttribute(lambda x: FAKER.slug()) + subdomain = factory.LazyAttribute(lambda x: FAKER.slug()) + + class CornerstoneEnterpriseCustomerConfigurationFactory(factory.django.DjangoModelFactory): """ ``CornerstoneEnterpriseCustomerConfiguration`` factory. @@ -516,7 +541,7 @@ class Meta(object): enterprise_customer = factory.SubFactory(EnterpriseCustomerFactory) active = True - cornerstone_base_url = factory.LazyAttribute(lambda x: FAKER.file_path()) + cornerstone_base_url = factory.LazyAttribute(lambda x: FAKER.url()) class CornerstoneGlobalConfigurationFactory(factory.django.DjangoModelFactory): @@ -534,7 +559,9 @@ class Meta(object): model = CornerstoneGlobalConfiguration id = factory.LazyAttribute(lambda x: FAKER.random_int(min=1)) - completion_status_api_path = factory.LazyAttribute(lambda x: FAKER.file_path()) + completion_status_api_path = '/progress' + key = factory.LazyAttribute(lambda x: FAKER.slug()) + secret = factory.LazyAttribute(lambda x: FAKER.uuid4()) oauth_api_path = factory.LazyAttribute(lambda x: FAKER.file_path()) subject_mapping = { "Technology": ["Computer Science"], diff --git a/tests/test_integrated_channels/test_cornerstone/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_cornerstone/test_exporters/test_learner_data.py new file mode 100644 index 0000000000..939ec1bceb --- /dev/null +++ b/tests/test_integrated_channels/test_cornerstone/test_exporters/test_learner_data.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +""" +Tests for Cornerstone Learner Data exporters. +""" + +from __future__ import absolute_import, unicode_literals + +import datetime +import unittest + +import ddt +import mock +import responses +from freezegun import freeze_time +from pytest import mark +from requests.compat import urljoin + +from django.core.management import call_command +from django.utils import timezone + +from enterprise.api_client import lms as lms_api +from integrated_channels.cornerstone.exporters.learner_data import CornerstoneLearnerExporter +from test_utils import factories +from test_utils.fake_catalog_api import setup_course_catalog_api_client_mock + + +@mark.django_db +@ddt.ddt +class TestCornerstoneLearnerExporter(unittest.TestCase): + """ + Tests of CornerstoneLearnerExporter class. + """ + + NOW = datetime.datetime(2017, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + + def setUp(self): + self.user = factories.UserFactory() + self.other_user = factories.UserFactory() + self.staff_user = factories.UserFactory(is_staff=True, is_active=True) + self.subdomain = 'fake-subdomain' + self.session_token = 'fake-session-token' + self.callback_url = '/services/x/content-online-content-api/v1' + self.user_guid = "fake-guid" + self.course_id = 'course-v1:edX+DemoX+DemoCourse' + self.course_key = 'edX+DemoX' + self.enterprise_customer = factories.EnterpriseCustomerFactory( + enable_audit_enrollment=True, + enable_audit_data_reporting=True, + ) + self.enterprise_customer_user = factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=self.enterprise_customer, + ) + self.data_sharing_consent = factories.DataSharingConsentFactory( + username=self.user.username, + course_id=self.course_id, + enterprise_customer=self.enterprise_customer, + granted=True, + ) + self.config = factories.CornerstoneEnterpriseCustomerConfigurationFactory( + enterprise_customer=self.enterprise_customer, + active=True, + ) + self.global_config = factories.CornerstoneGlobalConfigurationFactory(key='test_key', secret='test_secret') + self.csod_transmission_audit = factories.CornerstoneLearnerDataTransmissionAuditFactory( + user_id=self.user.id, + session_token=self.session_token, + callback_url=self.callback_url, + subdomain=self.subdomain, + course_id=self.course_key, + user_guid=self.user_guid + ) + self.enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( + enterprise_customer_user=self.enterprise_customer_user, + course_id=self.course_id, + ) + + course_catalog_api_client_mock = mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient') + course_catalog_client = course_catalog_api_client_mock.start() + setup_course_catalog_api_client_mock(course_catalog_client) + self.addCleanup(course_catalog_api_client_mock.stop) + super(TestCornerstoneLearnerExporter, self).setUp() + + @ddt.data(NOW, None) + @freeze_time(NOW) + def test_get_learner_data_record(self, completed_date): + """ + The base ``get_learner_data_record`` method returns a ``LearnerDataTransmissionAudit`` with appropriate values. + """ + exporter = CornerstoneLearnerExporter('fake-user', self.config) + learner_data_records = exporter.get_learner_data_records( + self.enterprise_course_enrollment, + completed_date=completed_date, + ) + assert learner_data_records[0].course_id == self.course_key + assert learner_data_records[0].user_id == self.user.id + assert learner_data_records[0].user_guid == self.user_guid + assert learner_data_records[0].subdomain == self.subdomain + assert learner_data_records[0].callback_url == self.callback_url + assert learner_data_records[0].session_token == self.session_token + assert learner_data_records[0].course_completed == (completed_date is not None) + assert learner_data_records[0].enterprise_course_enrollment_id == self.enterprise_course_enrollment.id + assert learner_data_records[0].completed_timestamp == ( + self.NOW if completed_date is not None else None + ) + + def test_get_learner_data_record_not_exist(self): + """ + If learner data is not already exist, nothing is returned. + """ + exporter = CornerstoneLearnerExporter('fake-user', self.config) + enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( + enterprise_customer_user=factories.EnterpriseCustomerUserFactory( + user_id=self.other_user.id, + enterprise_customer=self.enterprise_customer, + ), + course_id=self.course_id, + ) + assert exporter.get_learner_data_records(enterprise_course_enrollment) is None + + @responses.activate + @mock.patch('enterprise.api_client.lms.JwtBuilder', mock.Mock()) + @mock.patch('integrated_channels.cornerstone.client.requests.post') + def test_api_client_called_with_appropriate_payload(self, mock_post_request): + """ + Test sending of course completion data to cornerstone progress API + """ + # Course API course_details response + responses.add( + responses.GET, + urljoin( + lms_api.CourseApiClient.API_BASE_URL, + "courses/{course}/".format(course=self.course_id) + ), + json={ + "course_id": self.course_key, + "pacing": "instructor", + "end": "2022-06-21T12:58:17.428373Z", + } + ) + + # Certificates API user's grade response + responses.add( + responses.GET, + urljoin( + lms_api.CertificatesApiClient.API_BASE_URL, + "certificates/{user}/courses/{course}/".format(course=self.course_id, user=self.user.username) + ), + json={"is_passing": "true", "created_date": "2019-06-21T12:58:17.428373Z", "grade": "0.8"}, + ) + + call_command('transmit_learner_data', '--api_user', self.staff_user.username, '--channel', 'CSOD') + expected_url = '{base_url}{callback_url}{completion_path}?sessionToken={session_token}'.format( + base_url=self.config.cornerstone_base_url, + callback_url=self.callback_url, + completion_path=self.global_config.completion_status_api_path, + session_token=self.session_token, + ) + expected_payload = { + "status": "Completed", + "completionDate": "2019-06-21T12:58:17+00:00", + "courseId": self.course_key, + "successStatus": "Pass", + "userGuid": self.user_guid, + } + expected_headers = { + "Content-Type": "application/json", + "Authorization": "Basic dGVzdF9rZXk6dGVzdF9zZWNyZXQ=", + } + + mock_post_request.assert_called_once() + actual_url = mock_post_request.call_args[0][0] + actual_payload = mock_post_request.call_args[1]['json'][0] + actual_headers = mock_post_request.call_args[1]['headers'] + self.assertEqual(actual_url, expected_url) + assert sorted(expected_payload.items()) == sorted(actual_payload.items()) + assert sorted(expected_headers.items()) == sorted(actual_headers.items())