Skip to content

Commit

Permalink
WL-1939 | Transmitting the learner data to CSOD.
Browse files Browse the repository at this point in the history
Refined user completion status transmission to cornerstone

Further refinements

Improved base auth generation to take care of unicode chars

refined progress post call code

lowercase for status key

Fixed broken unit tests

Passing list of JSON data

Updated change log and version

Fixed broken unit test
  • Loading branch information
hasnain-naveed authored and ziafazal committed Jul 18, 2019
1 parent 3c7683c commit 71d2cde
Show file tree
Hide file tree
Showing 11 changed files with 398 additions and 56 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------

Expand Down
2 changes: 1 addition & 1 deletion enterprise/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion integrated_channels/cornerstone/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ class CornerstoneGlobalConfigurationAdmin(ConfigurationModelAdmin):

list_display = (
"completion_status_api_path",
"oauth_api_path",
"key",
"secret",
)

class Meta(object):
Expand Down
67 changes: 27 additions & 40 deletions integrated_channels/cornerstone/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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()
)
62 changes: 62 additions & 0 deletions integrated_channels/cornerstone/exporters/learner_data.py
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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),
),
]
67 changes: 56 additions & 11 deletions integrated_channels/cornerstone/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -20,6 +21,7 @@
for integrated_channel_class in (
SAPSuccessFactorsEnterpriseCustomerConfiguration,
DegreedEnterpriseCustomerConfiguration,
CornerstoneEnterpriseCustomerConfiguration,
)
])

Expand Down
Loading

0 comments on commit 71d2cde

Please sign in to comment.