diff --git a/channel_integrations/blackboard/__init__.py b/channel_integrations/blackboard/__init__.py new file mode 100644 index 0000000..acdee81 --- /dev/null +++ b/channel_integrations/blackboard/__init__.py @@ -0,0 +1,5 @@ +""" +The Blackboard Integrated Channel package. +""" + +__version__ = "0.0.1" diff --git a/channel_integrations/blackboard/admin/__init__.py b/channel_integrations/blackboard/admin/__init__.py new file mode 100644 index 0000000..00e9b55 --- /dev/null +++ b/channel_integrations/blackboard/admin/__init__.py @@ -0,0 +1,146 @@ +""" +Admin integration for configuring Blackboard app to communicate with Blackboard systems. +""" +from config_models.admin import ConfigurationModelAdmin +from django_object_actions import DjangoObjectActions + +from django.contrib import admin, messages +from django.core.exceptions import ValidationError +from django.http import HttpResponseRedirect +from django.utils.html import format_html + +from channel_integrations.blackboard.models import ( + BlackboardEnterpriseCustomerConfiguration, + BlackboardGlobalConfiguration, + BlackboardLearnerDataTransmissionAudit, +) +from channel_integrations.integrated_channel.admin import BaseLearnerDataTransmissionAuditAdmin + + +@admin.register(BlackboardGlobalConfiguration) +class BlackboardGlobalConfigurationAdmin(ConfigurationModelAdmin): + """ + Django admin model for BlackboardGlobalConfiguration. + """ + list_display = ( + "app_key", + "app_secret", + ) + + class Meta: + model = BlackboardGlobalConfiguration + + +@admin.register(BlackboardEnterpriseCustomerConfiguration) +class BlackboardEnterpriseCustomerConfigurationAdmin(DjangoObjectActions, admin.ModelAdmin): + """ + Django admin model for BlackEnterpriseCustomerConfiguration. + """ + list_display = ( + "enterprise_customer_name", + "blackboard_base_url", + ) + + readonly_fields = ( + "enterprise_customer_name", + "refresh_token", + "customer_oauth_authorization_url", + "uuid", + "transmission_chunk_size", + ) + + raw_id_fields = ( + "enterprise_customer", + ) + + search_fields = ("enterprise_customer_name",) + change_actions = ("force_content_metadata_transmission",) + + class Meta: + model = BlackboardEnterpriseCustomerConfiguration + + def enterprise_customer_name(self, obj): + """ + Returns: the name for the attached EnterpriseCustomer. + + Args: + obj: The instance of BlackboardEnterpriseCustomerConfiguration + being rendered with this admin form. + """ + return obj.enterprise_customer.name + + def customer_oauth_authorization_url(self, obj): + """ + Returns: an html formatted oauth authorization link when the blackboard_base_url and client_id are available. + + Args: + obj: The instance of BlackboardEnterpriseCustomerConfiguration + being rendered with this admin form. + """ + if obj.oauth_authorization_url: + return format_html((f'Authorize Link')) + else: + return None + + @admin.action( + description="Force content metadata transmission for this Enterprise Customer" + ) + def force_content_metadata_transmission(self, request, obj): + """ + Updates the modified time of the customer record to retransmit courses metadata + and redirects to configuration view with success or error message. + """ + try: + obj.enterprise_customer.save() + messages.success( + request, + f'''The blackboard enterprise customer content metadata + “” was updated successfully.''', + ) + except ValidationError: + messages.error( + request, + f'''The blackboard enterprise customer content metadata + “” was not updated successfully.''', + ) + return HttpResponseRedirect( + "/admin/blackboard/blackboardenterprisecustomerconfiguration" + ) + force_content_metadata_transmission.label = "Force content metadata transmission" + + +@admin.register(BlackboardLearnerDataTransmissionAudit) +class BlackboardLearnerDataTransmissionAuditAdmin(BaseLearnerDataTransmissionAuditAdmin): + """ + Django admin model for BlackboardLearnerDataTransmissionAudit. + """ + list_display = ( + "enterprise_course_enrollment_id", + "course_id", + "status", + "modified", + ) + + readonly_fields = ( + "blackboard_user_email", + "progress_status", + "content_title", + "enterprise_customer_name", + "friendly_status_message", + "api_record", + ) + + search_fields = ( + "blackboard_user_email", + "enterprise_course_enrollment_id", + "course_id", + "content_title", + "friendly_status_message" + ) + + list_per_page = 1000 + + class Meta: + model = BlackboardLearnerDataTransmissionAudit diff --git a/channel_integrations/blackboard/apps.py b/channel_integrations/blackboard/apps.py new file mode 100644 index 0000000..37d7d5d --- /dev/null +++ b/channel_integrations/blackboard/apps.py @@ -0,0 +1,20 @@ +""" +Enterprise Integrated Channel Blackboard Django application initialization. +""" + +from django.apps import AppConfig + +CHANNEL_NAME = 'channel_integrations.blackboard' +VERBOSE_NAME = 'Enterprise Blackboard Integration' +BRIEF_CHANNEL_NAME = 'blackboard' + + +class BlackboardConfig(AppConfig): + """ + Configuration for the Enterprise Integrated Channel Blackboard Django application. + """ + name = CHANNEL_NAME + verbose_name = VERBOSE_NAME + oauth_token_auth_path = "learn/api/public/v1/oauth2/token" + brief_channel_name = BRIEF_CHANNEL_NAME + label = 'blackboard_channel' diff --git a/channel_integrations/blackboard/client.py b/channel_integrations/blackboard/client.py new file mode 100644 index 0000000..87bc1d1 --- /dev/null +++ b/channel_integrations/blackboard/client.py @@ -0,0 +1,969 @@ +""" +Client for connecting to Blackboard. +""" +import base64 +import copy +import json +import logging +import time +from http import HTTPStatus +from urllib.parse import urljoin + +import requests + +from django.apps import apps +from django.db import transaction + +from channel_integrations.blackboard.exporters.content_metadata import BLACKBOARD_COURSE_CONTENT_NAME +from channel_integrations.exceptions import ClientError +from channel_integrations.integrated_channel.client import IntegratedChannelApiClient +from channel_integrations.utils import generate_formatted_log, refresh_session_if_expired, stringify_and_store_api_record + +LOGGER = logging.getLogger(__name__) + +# TODO: Refactor candidate (duplication with canvas client) +GRADEBOOK_PATH = '/learn/api/public/v1/courses/{course_id}/gradebook/columns' +ENROLLMENT_PATH = '/learn/api/public/v1/courses/{course_id}/users' +COURSE_PATH = '/learn/api/public/v1/courses' +POST_GRADE_COLUMN_PATH = '/learn/api/public/v2/courses/{course_id}/gradebook/columns' +PATCH_GRADE_COLUMN_PATH = '/learn/api/public/v2/courses/{course_id}/gradebook/columns/{column_id}' +POST_GRADE_PATH = '/learn/api/public/v2/courses/{course_id}/gradebook/columns/{column_id}/users/{user_id}' +COURSE_V3_PATH = '/learn/api/public/v3/courses/{course_id}' +COURSES_V3_PATH = '/learn/api/public/v3/courses' +COURSE_CONTENT_PATH = '/learn/api/public/v1/courses/{course_id}/contents' +COURSE_CONTENT_CHILDREN_PATH = '/learn/api/public/v1/courses/{course_id}/contents/{content_id}/children' +COURSE_CONTENT_DELETE_PATH = '/learn/api/public/v1/courses/{course_id}/contents/{content_id}' +GRADEBOOK_COLUMN_DESC = "edX learner's grade" +PAGE_TRAVERSAL_LIMIT = 250 + + +class BlackboardAPIClient(IntegratedChannelApiClient): + """ + Client for connecting to Blackboard. + """ + + def __init__(self, enterprise_configuration): + """ + Instantiate a new client. + + Args: + enterprise_configuration (BlackboardEnterpriseCustomerConfiguration): An enterprise customers's + configuration model for connecting with Blackboard + """ + super().__init__(enterprise_configuration) + BlackboardGlobalConfiguration = apps.get_model( + 'blackboard', + 'BlackboardGlobalConfiguration' + ) + self.global_blackboard_config = BlackboardGlobalConfiguration.current() + self.config = apps.get_app_config('blackboard') + self.session = None + self.expires_at = None + + def create_content_metadata(self, serialized_data): + """ + Create a course from serialized course metadata + Returns: (int, str) Status code, Status message + """ + channel_metadata_item = json.loads(serialized_data.decode('utf-8')) + BlackboardAPIClient._validate_channel_metadata(channel_metadata_item) + BlackboardAPIClient._validate_full_course_metadata(channel_metadata_item) + + external_id = channel_metadata_item.get('externalId') + + # blackboard does not support all characters in our courseIds so let's gen a hash instead + course_id_generated = self.generate_blackboard_course_id(external_id) + + copy_of_channel_metadata = copy.deepcopy(channel_metadata_item) + copy_of_channel_metadata['course_metadata']['courseId'] = course_id_generated + + self._create_session() + create_url = self.generate_course_create_url() + try: + response = self._post(create_url, copy_of_channel_metadata['course_metadata']) + except ClientError as error: + if error.status_code == 409 and 'Unique ID conflicts' in error.message: + # course already exists! + msg_body = (f"Course already exists with course_id {external_id}," + f" and generated course_id: {course_id_generated}, not attempting creation") + LOGGER.warning(generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + None, + external_id, + msg_body, + )) + return HTTPStatus.NOT_MODIFIED.value, msg_body + else: + raise error + + # We wrap error handling in the post, but sanity check for the ID + bb_course_id = response.json().get('id') + if not bb_course_id: + raise ClientError( + 'Something went wrong while creating base course object on Blackboard. Could not retrieve course ID.', + HTTPStatus.NOT_FOUND.value + ) + + course_created_response = self.create_integration_content_for_course(bb_course_id, copy_of_channel_metadata) + + success_body = 'Successfully created Blackboard integration course={bb_course_id} with integration ' \ + 'content={bb_child_id}'.format( + bb_course_id=bb_course_id, + bb_child_id=course_created_response.json().get('id'), + ) + + return course_created_response.status_code, success_body + + def update_content_metadata(self, serialized_data): + """Apply changes to a course if applicable""" + self._create_session() + channel_metadata_item = json.loads(serialized_data.decode('utf-8')) + + BlackboardAPIClient._validate_channel_metadata(channel_metadata_item) + external_id = channel_metadata_item.get('externalId') + course_id = self._resolve_blackboard_course_id(external_id) + BlackboardAPIClient._validate_course_id(course_id, external_id) + + update_url = self.generate_course_update_url(course_id) + response = self._patch(update_url, channel_metadata_item.get('course_metadata')) + + bb_course_id = response.json().get('id') + + if not bb_course_id: + raise ClientError( + 'Unable to update course={} content on Blackboard. Failed to retrieve ID from course update response:' + '(status_code={}, body={})'.format(external_id, response.status_code, response.text) + ) + + course_contents_url = self.generate_create_course_content_url(bb_course_id) + course_contents_response = self._get(course_contents_url) + + bb_contents = course_contents_response.json().get('results') + bb_content_id = None + + for content in bb_contents: + if content.get('title') == BLACKBOARD_COURSE_CONTENT_NAME: + bb_content_id = content.get('id') + + if not bb_content_id: + LOGGER.info( + generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + None, + course_id, + 'Blackboard integrated course content not found. Generating content.' + ) + ) + else: + course_content_delete_url = self.generate_course_content_delete_url(bb_course_id, bb_content_id) + self._delete(course_content_delete_url) + + course_updated_response = self.update_integration_content_for_course( + bb_course_id, + channel_metadata_item, + ) + + return course_updated_response.status_code, course_updated_response.text + + def delete_content_metadata(self, serialized_data): + """Delete course from blackboard (performs full delete as of now)""" + self._create_session() + channel_metadata_item = json.loads(serialized_data.decode("utf-8")) + + BlackboardAPIClient._validate_channel_metadata(channel_metadata_item) + external_id = channel_metadata_item.get('externalId') + course_id = self._resolve_blackboard_course_id(external_id) + + if not course_id: + return HTTPStatus.OK.value, 'Course:{} already removed.'.format(external_id) + + LOGGER.info( + generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + None, + course_id, + f'Deleting course with courseId: {course_id}' + ) + ) + update_url = self.generate_course_update_url(course_id) + response = self._delete(update_url) + return response.status_code, response.text + + def create_assessment_reporting(self, user_id, payload): + """ + Post a learner's subsection assessment grade to the integrated Blackboard course. + + Parameters: + ----------- + user_id (str): The shared email between a user's edX account and Blackboard account + payload (str): The (string representation) of the learner data information + """ + self._create_session() + learner_data = json.loads(payload) + external_id = learner_data.get('courseID') + + course_id = self._resolve_blackboard_course_id(external_id) + BlackboardAPIClient._validate_course_id(course_id, external_id) + + blackboard_user_id = self._get_bb_user_id_from_enrollments(user_id, course_id) + grade_column_id = self._get_or_create_integrated_grade_column( + course_id, + learner_data.get('subsection_name'), + learner_data.get('subsectionID'), + learner_data.get('points_possible'), + ) + + grade = learner_data.get('points_earned') + submission_response = self._submit_grade_to_blackboard(grade, course_id, grade_column_id, blackboard_user_id) + + success_body = 'Successfully posted grade of {grade} to course:{course_id} for user:{user_email}.'.format( + grade=grade, + course_id=external_id, + user_email=user_id, + ) + return submission_response.status_code, success_body + + def create_course_completion(self, user_id, payload): + """ + Post a final course grade to the integrated Blackboard course. + + Parameters: + ----------- + user_id (str): The shared email between a user's edX account and Blackboard account + payload (str): The (string representation) of the learner data information + + Example payload: + --------------- + '{ + courseID: course-edx+555+3T2020, + score: 0.85, + completedTimestamp: 1602265162589, + }' + + """ + self._create_session() + learner_data = json.loads(payload) + external_id = learner_data.get('courseID') + + course_id = self._resolve_blackboard_course_id(external_id) + BlackboardAPIClient._validate_course_id(course_id, external_id) + + blackboard_user_id = self._get_bb_user_id_from_enrollments(user_id, course_id) + grade_column_id = self._get_or_create_integrated_grade_column( + course_id, + "(edX Integration) Final Grade", + "edx_final_grade", + include_in_calculations=True, + ) + + grade = learner_data.get('grade') * 100 + submission_response = self._submit_grade_to_blackboard(grade, course_id, grade_column_id, blackboard_user_id) + + success_body = 'Successfully posted grade of {grade} to course:{course_id} for user:{user_email}.'.format( + grade=grade, + course_id=external_id, + user_email=user_id, + ) + return submission_response.status_code, success_body + + def delete_course_completion(self, user_id, payload): + """TODO: course completion deletion is currently not easily supported""" + + @staticmethod + def _validate_channel_metadata(channel_metadata_item): + """ + Raise error if external_id invalid or not found + """ + if 'externalId' not in channel_metadata_item: + raise ClientError("No externalId found in metadata, please check json data format", 400) + + @staticmethod + def _validate_course_id(course_id, external_id): + """ + Raise error if course_id invalid + """ + if not course_id: + raise ClientError( + 'Could not find course:{} on Blackboard'.format(external_id), + HTTPStatus.NOT_FOUND.value + ) + + @staticmethod + def _validate_full_course_metadata(channel_metadata_items): + """ + Raise error if course_metadata, course_content_metadata or course_child_content_metadata are invalid. + """ + metadata_set = set(channel_metadata_items.keys()) + course_metadata_groups = {'course_metadata', 'course_content_metadata', 'course_child_content_metadata'} + if not course_metadata_groups.issubset(metadata_set): + raise ClientError( + 'Could not find course metadata group(s):{} necessary to create Blackboard integrated course.'.format( + course_metadata_groups - metadata_set + ), + HTTPStatus.NOT_FOUND.value + ) + + def _create_session(self): + """ + Will only create a new session if token expiry has been reached + """ + self.session, self.expires_at = refresh_session_if_expired( + self._get_oauth_access_token, + self.session, + self.expires_at, + ) + + def _formatted_message(self, msg): + return generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + None, + None, + msg, + ) + + def _log_info(self, msg): + LOGGER.info(self._formatted_message(msg)) + + def _log_error(self, msg): + LOGGER.error(self._formatted_message(msg)) + + def _get_oauth_access_token(self): + """Fetch access token using refresh_token workflow from Blackboard + + Using atomic block for refresh token handling code, because we need to use the + most recently obtained refresh token always + Since any time we use a refresh token to get a new one, the prior one is invalidated + and we MUST use the new one for the next request. + + Returns: + access_token (str): the OAuth access token to access the Blackboard server + expires_in (int): the number of seconds after which token will expire + Raises: + HTTPError: If we received a failure response code. + ClientError: If an unexpected response format was received that we could not parse. + """ + + if (not self.enterprise_configuration.blackboard_base_url + or not self.config.oauth_token_auth_path): + raise ClientError( + "Failed to generate oauth access token: oauth path missing from configuration.", + HTTPStatus.INTERNAL_SERVER_ERROR.value + ) + auth_token_url = urljoin( + self.enterprise_configuration.blackboard_base_url, + self.config.oauth_token_auth_path, + ) + + # Refresh token handling atomic block + # DO NOT USE self.enterprise_config in this block! + # Here we don't use the config passed from the constructor directly + # instead we fetch a transaction safe instance using select_for_update + # so that multiple sessions such as this + # don't interfere with each other's Blackboard refresh token state. Blackboard only allows + # only valid refresh token at a time, and once it's used it's invalidated and needs to be + # replaced by a new token + with transaction.atomic(): + channel_config = apps.get_model( + 'blackboard', + 'BlackboardEnterpriseCustomerConfiguration' + ).objects.select_for_update().get(pk=self.enterprise_configuration.pk) + if not channel_config.refresh_token: + raise ClientError( + "Failed to generate oauth access token: Refresh token required.", + HTTPStatus.INTERNAL_SERVER_ERROR.value + ) + + auth_token_params = { + 'grant_type': 'refresh_token', + 'refresh_token': channel_config.refresh_token, + } + + auth_response = requests.post( + auth_token_url, + auth_token_params, + headers={ + 'Authorization': self._create_auth_header(), + 'Content-Type': 'application/x-www-form-urlencoded' + } + ) + if auth_response.status_code >= 400: + raise ClientError( + f"BLACKBOARD: Access/Refresh token fetch failure, " + f"enterprise_customer_uuid: {channel_config.enterprise_customer.uuid}, " + f"blackboard_base_url: {channel_config.blackboard_base_url}, " + f"auth_response_text: {auth_response.text}" + f"config_last_modified: {channel_config.modified}", + auth_response.status_code, + ) + try: + data = auth_response.json() + # do not forget to save the new refresh token otherwise subsequent requests will fail + if "refresh_token" not in data: + self._log_info("Server did not return refresh_token, keeping existing one") + else: + # refresh token was returned by server, needs to be used + fetched_refresh_token = data["refresh_token"] + if not fetched_refresh_token.strip(): + # we are out of luck, can't use this invalid token + # and we probably can't get unstuck without customer re-doing oauth url workflow + self._log_error("Fetched a new refresh token, but it was empty, not using it!") + else: + channel_config.refresh_token = fetched_refresh_token + self._log_info("Fetched a new refresh token, replacing current one") + channel_config.save() + # We do not want any fail-prone code in this atomic block after this line + # it's because if something else fails, it will roll back the just-saved + # refresh token! + return data['access_token'], data["expires_in"] + except (KeyError, ValueError) as error: + raise ClientError(auth_response.text, auth_response.status_code) from error + + def _create_auth_header(self): + """ + auth header in oauth2 token format as required by blackboard doc + """ + app_key = self.enterprise_configuration.decrypted_client_id + if not app_key: + if not self.global_blackboard_config.app_key: + raise ClientError( + "Failed to generate oauth access token: Client ID required.", + HTTPStatus.INTERNAL_SERVER_ERROR.value + ) + app_key = self.global_blackboard_config.app_key + + app_secret = self.enterprise_configuration.decrypted_client_secret + if not app_secret: + if not self.global_blackboard_config.app_secret: + raise ClientError( + "Failed to generate oauth access token: Client secret required.", + HTTPStatus.INTERNAL_SERVER_ERROR.value + ) + app_secret = self.global_blackboard_config.app_secret + return f"Basic {base64.b64encode(f'{app_key}:{app_secret}'.encode('utf-8')).decode()}" + + def generate_blackboard_course_id(self, external_id): + """ + A course_id suitable for use with blackboard + """ + return str(abs(hash(external_id))) + + def generate_blackboard_gradebook_column_data(self, external_id, grade_column_name, points_possible, + include_in_calculations=False): + """ + Properly formatted json data to create a new gradebook column in a blackboard course + + Note: Potential customization here per-customer, if the need arises. + """ + return { + "externalId": external_id, + "name": grade_column_name, + "displayName": grade_column_name, + "description": GRADEBOOK_COLUMN_DESC, + "externalGrade": False, + "score": { + "possible": points_possible + }, + "availability": { + "available": "Yes" + }, + "grading": { + "type": "Manual", + "scoringModel": "Last", + "anonymousGrading": { + "type": "None", + } + }, + "includeInCalculations": include_in_calculations, + } + + def generate_gradebook_url(self, course_id): + """ + Blackboard API url helper method. + Path: Get course gradebook + """ + return '{base}{path}'.format( + base=self.enterprise_configuration.blackboard_base_url, + path=GRADEBOOK_PATH.format(course_id=course_id), + ) + + def generate_enrollment_url(self, course_id): + """ + Blackboard API url helper method. + Path: Get course enrollments + """ + # By including `expand=user` we get access to the user's contact info + return '{base}{path}?expand=user'.format( + base=self.enterprise_configuration.blackboard_base_url, + path=ENROLLMENT_PATH.format(course_id=course_id), + ) + + def generate_course_create_url(self): + """ + Url to create a course + """ + return "{base}{path}".format( + base=self.enterprise_configuration.blackboard_base_url, + path=COURSES_V3_PATH, + ) + + def generate_course_update_url(self, course_id): + """ + Url to update one course + """ + return '{base}{path}'.format( + base=self.enterprise_configuration.blackboard_base_url, + path=COURSE_V3_PATH.format(course_id=course_id), + ) + + def generate_courses_url(self): + """ + Blackboard API url helper method. + Path: Get course courses + """ + return '{base}{path}'.format( + base=self.enterprise_configuration.blackboard_base_url, + path=COURSE_PATH, + ) + + def generate_create_grade_column_url(self, course_id): + """ + Blackboard API url helper method. + Path: Create course grade column + """ + return '{base}{path}'.format( + base=self.enterprise_configuration.blackboard_base_url, + path=POST_GRADE_COLUMN_PATH.format(course_id=course_id), + ) + + def generate_update_grade_column_url(self, course_id, column_id): + """ + Blackboard API url helper method. + Path: Update course grade column + """ + return '{base}{path}'.format( + base=self.enterprise_configuration.blackboard_base_url, + path=PATCH_GRADE_COLUMN_PATH.format( + course_id=course_id, + column_id=column_id, + ), + ) + + def generate_post_users_grade_url(self, course_id, column_id, user_id): + """ + Blackboard API url helper method. + Path: User's grade column entry + """ + return '{base}{path}'.format( + base=self.enterprise_configuration.blackboard_base_url, + path=POST_GRADE_PATH.format( + course_id=course_id, + column_id=column_id, + user_id=user_id, + ), + ) + + def generate_create_course_content_url(self, course_id): + """ + Blackboard API url helper method. + Path: Course's content objects + """ + return '{base}{path}'.format( + base=self.enterprise_configuration.blackboard_base_url, + path=COURSE_CONTENT_PATH.format(course_id=course_id) + ) + + def generate_create_course_content_child_url(self, course_id, content_id): + """ + Blackboard API url helper method. + Path: Course content's children objects + """ + return '{base}{path}'.format( + base=self.enterprise_configuration.blackboard_base_url, + path=COURSE_CONTENT_CHILDREN_PATH.format(course_id=course_id, content_id=content_id) + ) + + def generate_course_content_delete_url(self, course_id, content_id): + """ + Blackboard API url helper method. + Path: Course content deletion + """ + return '{base}{path}'.format( + base=self.enterprise_configuration.blackboard_base_url, + path=COURSE_CONTENT_DELETE_PATH.format(course_id=course_id, content_id=content_id) + ) + + def _get(self, url, data=None): + """ + Returns request's get response and raises Client Errors if appropriate. + """ + start_time = time.time() + get_response = self.session.get(url, params=data) + time_taken = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, + data=data, + time_taken=time_taken, + status_code=get_response.status_code, + response_body=get_response.text, + channel_name=self.enterprise_configuration.channel_code() + ) + if get_response.status_code >= 400: + raise ClientError(get_response.text, get_response.status_code) + return get_response + + def _patch(self, url, data): + """ + Returns request's patch response and raises Client Errors if appropriate. + """ + start_time = time.time() + patch_response = self.session.patch(url, json=data) + time_taken = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, + data=data, + time_taken=time_taken, + status_code=patch_response.status_code, + response_body=patch_response.text, + channel_name=self.enterprise_configuration.channel_code() + ) + if patch_response.status_code >= 400: + raise ClientError(patch_response.text, patch_response.status_code) + return patch_response + + def _post(self, url, data): + """ + Returns request's post response and raises Client Errors if appropriate. + """ + start_time = time.time() + post_response = self.session.post(url, json=data) + time_taken = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, + data=data, + time_taken=time_taken, + status_code=post_response.status_code, + response_body=post_response.text, + channel_name=self.enterprise_configuration.channel_code() + ) + + if post_response.status_code >= 400: + raise ClientError(post_response.text, post_response.status_code) + return post_response + + def _delete(self, url): + """ + Returns request's delete response and raises Client Errors if appropriate. + """ + start_time = time.time() + response = self.session.delete(url) + time_taken = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, + data='', + time_taken=time_taken, + status_code=response.status_code, + response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() + ) + if response.status_code >= 400: + raise ClientError(response.text, response.status_code) + return response + + def _get_bb_user_id_from_enrollments(self, user_email, course_id): + """ + Helper method to retrieve a user's Blackboard ID from a list of enrollments in a + Blackboard class. + + Parameters: + ----------- + user_email (str): The shared email of the user for both Blackboard and edX + course_id (str): The Blackboard course ID of which to search enrollments. + """ + enrollments_response = self._get(self.generate_enrollment_url(course_id)).json() + for enrollment in enrollments_response.get('results'): + # No point in checking non-students + if enrollment.get('courseRoleId') == 'Student': + contact = enrollment.get('user').get('contact') + if contact.get('email') == user_email: + return enrollment.get('userId') + raise ClientError( + 'Could not find user={} enrolled in Blackboard course={}'.format(user_email, course_id), + HTTPStatus.NOT_FOUND.value + ) + + def _get_or_create_integrated_grade_column(self, bb_course_id, grade_column_name, external_id, points_possible=100, + include_in_calculations=False): + """ + Helper method to search an edX integrated Blackboard course for the designated edX grade column. + If the column does not yet exist within the course, create it. + + Parameters: + ----------- + bb_course_id (str): The Blackboard course ID in which to search for the edX final grade, + grade column. + """ + gradebook_column_url = self.generate_gradebook_url(bb_course_id) + grade_column_id = None + more_pages_present = True + current_page_count = 0 + + # Page count of 250 is a preventative of infinite while loops + while more_pages_present and current_page_count <= PAGE_TRAVERSAL_LIMIT: + grade_column_response = self._get(gradebook_column_url) + parsed_response = grade_column_response.json() + grade_columns = parsed_response.get('results') + + for grade_column in grade_columns: + if grade_column.get('externalId') == external_id: + grade_column_id = grade_column.get('id') + # if includeInCalculations has legacy value, correct it + if ( + grade_column.get('includeInCalculations') and + grade_column.get('includeInCalculations') != include_in_calculations + ): + calculations_data = {"includeInCalculations": include_in_calculations} + self._patch( + self.generate_update_grade_column_url(bb_course_id, grade_column_id), + calculations_data + ) + break + # Blackboard's pagination is returned within the response json if it exists + # Example: + # Response = { + # 'results': [{ + # 'id': 1, + # ... + # }, { + # 'id', 2, + # ... + # }], + # 'paging': { + # 'nextPage': '/learn/api/public/v1/courses//gradebook/columns?offset=200' + # } + # } + if parsed_response.get('paging') and not grade_column_id: + gradebook_column_url = '{}{}'.format( + self.enterprise_configuration.blackboard_base_url, + parsed_response.get('paging').get('nextPage'), + ) + current_page_count += 1 + else: + more_pages_present = False + + if current_page_count == PAGE_TRAVERSAL_LIMIT: + LOGGER.warning( + generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + None, + None, + f'Max page limit hit while traversing blackboard API for course={external_id}' + ) + ) + + if not grade_column_id: + grade_column_data = self.generate_blackboard_gradebook_column_data( + external_id, grade_column_name, points_possible, include_in_calculations + ) + response = self._post(self.generate_create_grade_column_url(bb_course_id), grade_column_data) + parsed_response = response.json() + grade_column_id = parsed_response.get('id') + + # Sanity check that we created the grade column properly + if not grade_column_id: + raise ClientError( + 'Something went wrong while create edX integration grade column for course={}.'.format( + bb_course_id + ), + HTTPStatus.INTERNAL_SERVER_ERROR.value + ) + + return grade_column_id + + def _submit_grade_to_blackboard(self, grade, course_id, grade_column_id, user_id): + """ + Helper method to post learner data to the integrated blackboard course and then validate the submission of + grade reporting. + """ + grade_percent = {'score': grade} + response = self._patch( + self.generate_post_users_grade_url(course_id, grade_column_id, user_id), + grade_percent + ) + + if response.json().get('score') != grade: + raise ClientError( + 'Failed to post new grade for user={} enrolled in course={}'.format(user_id, course_id), + HTTPStatus.INTERNAL_SERVER_ERROR.value + ) + + return response + + def _resolve_blackboard_course_id(self, external_id): + """ + Extract course id from blackboard, given it's externalId + """ + params = {'externalId': external_id} + courses_responses = self._get(self.generate_courses_url(), params).json() + course_response = courses_responses.get('results') + + for course in course_response: + if course.get('externalId') == external_id: + return course.get('id') + return None + + def delete_course_content_from_blackboard(self, bb_course_id, bb_content_id): + """ + Error handling Helper method that will delete any successful content posts that occurred during + the error'd transmission. + """ + delete_course_content_url = urljoin( + self.enterprise_configuration.blackboard_base_url, + COURSE_CONTENT_DELETE_PATH.format(course_id=bb_course_id, content_id=bb_content_id), + ) + resp = self._delete(delete_course_content_url) + return resp + + def delete_course_from_blackboard(self, bb_course_id, bb_content_id=None): + """ + Error handling Helper method that will delete any already made, successful post requests that occurred during + the content creation flow. + """ + # Remove the content if it exists + if bb_content_id: + self.delete_course_content_from_blackboard(bb_course_id, bb_content_id) + + # Remove the course + delete_course_path = urljoin( + self.enterprise_configuration.blackboard_base_url, + COURSE_V3_PATH.format(course_id=bb_course_id), + ) + resp = self._delete(delete_course_path) + return resp + + def update_integration_content_for_course(self, bb_course_id, channel_metadata_item): + """ + Helper method to update the blackboard course content page with the integration custom information. + Differs from course customization creation in that we don't delete the course if things fail. + """ + content_url = self.generate_create_course_content_url(bb_course_id) + error_message = '' + try: + content_resp = self._post(content_url, channel_metadata_item.get('course_content_metadata')) + bb_content_id = content_resp.json().get('id') + except ClientError as error: + bb_content_id = None + error_message = ' and received error response={}'.format(error.message) + + if not bb_content_id: + error_message = '' if not error_message else error_message + raise ClientError( + 'Something went wrong while creating course content object on Blackboard. Could not retrieve content ' + 'ID{}.'.format(error_message), + HTTPStatus.NOT_FOUND.value + ) + + try: + content_child_url = self.generate_create_course_content_child_url(bb_course_id, bb_content_id) + course_fully_created_response = self._post( + content_child_url, channel_metadata_item.get('course_child_content_metadata') + ) + content_child_id = course_fully_created_response.json().get('id') + except ClientError as error: + content_child_id = None + error_message = ' and received error response={}'.format(error.message) + + if not content_child_id: + error_message = '' if not error_message else error_message + error_handling_response = self.delete_course_content_from_blackboard(bb_course_id, bb_content_id) + + raise ClientError( + 'Something went wrong while creating course content child object on Blackboard. Could not retrieve a ' + 'content child ID and got error response={}. Deleted associated course content shell with response: ' + '(status_code={}, body={})'.format( + error_message, + error_handling_response.status_code, + error_handling_response.text + ), + HTTPStatus.NOT_FOUND.value + ) + + return course_fully_created_response + + def create_integration_content_for_course(self, bb_course_id, channel_metadata_item): + """ + Helper method to generate the default blackboard course content page with the integration custom information. + """ + content_url = self.generate_create_course_content_url(bb_course_id) + error_message = '' + try: + content_resp = self._post(content_url, channel_metadata_item.get('course_content_metadata')) + bb_content_id = content_resp.json().get('id') + except ClientError as error: + bb_content_id = None + error_message = ' and received error response={}'.format(error.message) + + if not bb_content_id: + error_handling_response = self.delete_course_from_blackboard(bb_course_id) + error_message = '' if not error_message else error_message + raise ClientError( + 'Something went wrong while creating course content object on Blackboard. Could not retrieve content ' + 'ID{}. Deleted course with response (status_code={}, body={})'.format( + error_message, + error_handling_response.status_code, + error_handling_response.text + ), + HTTPStatus.NOT_FOUND.value + ) + + try: + content_child_url = self.generate_create_course_content_child_url(bb_course_id, bb_content_id) + course_fully_created_response = self._post( + content_child_url, channel_metadata_item.get('course_child_content_metadata') + ) + content_child_id = course_fully_created_response.json().get('id') + except ClientError as error: + content_child_id = None + error_message = ' and received error response={}'.format(error.message) + + if not content_child_id: + error_message = '' if not error_message else error_message + error_handling_response = self.delete_course_from_blackboard(bb_course_id, bb_content_id) + raise ClientError( + 'Something went wrong while creating course content child object on Blackboard. Could not retrieve a ' + 'content child ID and got error response={}. Deleted associated course and content with response: ' + '(status_code={}, body={})'.format( + error_message, + error_handling_response.status_code, + error_handling_response.text + ), + HTTPStatus.NOT_FOUND.value + ) + + return course_fully_created_response + + def cleanup_duplicate_assignment_records(self, courses): + """ + Not implemented yet. + """ + LOGGER.error( + generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + None, + None, + "Blackboard integrated channel does not yet support assignment deduplication." + ) + ) diff --git a/channel_integrations/blackboard/exporters/__init__.py b/channel_integrations/blackboard/exporters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/channel_integrations/blackboard/exporters/content_metadata.py b/channel_integrations/blackboard/exporters/content_metadata.py new file mode 100644 index 0000000..7da95f1 --- /dev/null +++ b/channel_integrations/blackboard/exporters/content_metadata.py @@ -0,0 +1,86 @@ +""" +Content metadata exporter for Canvas +""" + +from logging import getLogger + +from channel_integrations.integrated_channel.exporters.content_metadata import ContentMetadataExporter + +LOGGER = getLogger(__name__) +BLACKBOARD_COURSE_CONTENT_NAME = 'edX Course Details' + + +class BlackboardContentMetadataExporter(ContentMetadataExporter): + """ + Blackboard implementation of ContentMetadataExporter. + Note: courseId is not being exported here (instead done in client during content send) + """ + DATA_TRANSFORM_MAPPING = { + 'externalId': 'key', + 'course_metadata': 'course_metadata', + 'course_content_metadata': 'course_content_metadata', + 'course_child_content_metadata': 'course_child_content_metadata', + } + + DESCRIPTION_TEXT_TEMPLATE = "Go to edX course page
" + LARGE_DESCRIPTION_TEXT_TEMPLATE = "" \ + "Go to edX course page
" + + COURSE_TITLE_TEMPLATE = '

{title}

' + + COURSE_DESCRIPTION_TEMPLATE = '

{description}

' + + COURSE_CONTENT_IMAGE_TEMPLATE = '' + + COURSE_CONTENT_BODY_TEMPLATE = '
' \ + '{course_title}{large_description_text}
' \ + '
{course_content_image}' \ + '


{course_description}' \ + '
{description_text}
'.format( + course_title=COURSE_TITLE_TEMPLATE, + large_description_text=LARGE_DESCRIPTION_TEXT_TEMPLATE, + course_content_image=COURSE_CONTENT_IMAGE_TEMPLATE, + course_description=COURSE_DESCRIPTION_TEMPLATE, + description_text=DESCRIPTION_TEXT_TEMPLATE, + ) + + def transform_course_metadata(self, content_metadata_item): + """ + Formats the metadata necessary to create a base course object in Blackboard + """ + return { + 'name': content_metadata_item.get('title', None), + 'externalId': content_metadata_item.get('key', None), + 'description': self.DESCRIPTION_TEXT_TEMPLATE.format( + enrollment_url=content_metadata_item.get('enrollment_url', None) + ) + } + + def transform_course_content_metadata(self, content_metadata_item): # pylint: disable=unused-argument + """ + Formats the metadata necessary to create a course content object in Blackboard + """ + return { + 'title': BLACKBOARD_COURSE_CONTENT_NAME, + 'position': 0, + "contentHandler": {"id": "resource/x-bb-folder"} + } + + def transform_course_child_content_metadata(self, content_metadata_item): + """ + Formats the metadata necessary to create a course content object in Blackboard + """ + title = content_metadata_item.get('title', None) + return { + 'title': BLACKBOARD_COURSE_CONTENT_NAME, + 'availability': 'Yes', + 'contentHandler': { + 'id': 'resource/x-bb-document', + }, + 'body': self.COURSE_CONTENT_BODY_TEMPLATE.format( + title=title, + description=content_metadata_item.get('full_description', None), + image_url=content_metadata_item.get('image_url', None), + enrollment_url=content_metadata_item.get('enrollment_url', None) + ) + } diff --git a/channel_integrations/blackboard/exporters/learner_data.py b/channel_integrations/blackboard/exporters/learner_data.py new file mode 100644 index 0000000..f5fef54 --- /dev/null +++ b/channel_integrations/blackboard/exporters/learner_data.py @@ -0,0 +1,137 @@ +""" +Learner data exporter for Enterprise Integrated Channel Blackboard. +""" + +from logging import getLogger + +from django.apps import apps + +from channel_integrations.catalog_service_utils import get_course_id_for_enrollment +from channel_integrations.integrated_channel.exporters.learner_data import LearnerExporter +from channel_integrations.utils import generate_formatted_log, parse_datetime_to_epoch_millis + +LOGGER = getLogger(__name__) + + +class BlackboardLearnerExporter(LearnerExporter): + """ + Class to provide a Blackboard learner data transmission audit prepared for serialization. + """ + + def get_learner_data_records( + self, + enterprise_enrollment, + completed_date=None, + content_title=None, + progress_status=None, + course_completed=False, + **kwargs + ): # pylint: disable=arguments-differ + """ + Return a BlackboardLearnerDataTransmissionAudit with the given enrollment and course completion data. + If no remote ID can be found, return None. + """ + enterprise_customer_user = enterprise_enrollment.enterprise_customer_user + if enterprise_customer_user.user_email is None: + LOGGER.debug(generate_formatted_log( + self.enterprise_configuration.channel_code(), + enterprise_customer_user.enterprise_customer.uuid, + enterprise_customer_user.user_id, + None, + ('get_learner_data_records finished. No learner data was sent for this LMS User Id because ' + 'Blackboard User ID not found for [{name}]'.format( + name=enterprise_customer_user.enterprise_customer.name + )))) + return None + percent_grade = kwargs.get('grade_percent', None) + blackboard_completed_timestamp = None + if completed_date is not None: + blackboard_completed_timestamp = parse_datetime_to_epoch_millis(completed_date) + + BlackboardLearnerDataTransmissionAudit = apps.get_model( + 'blackboard', + 'BlackboardLearnerDataTransmissionAudit' + ) + course_id = get_course_id_for_enrollment(enterprise_enrollment) + # We only want to send one record per enrollment and course, so we check if one exists first. + learner_transmission_record = BlackboardLearnerDataTransmissionAudit.objects.filter( + enterprise_course_enrollment_id=enterprise_enrollment.id, + course_id=course_id, + ).first() + if learner_transmission_record is None: + learner_transmission_record = BlackboardLearnerDataTransmissionAudit( + enterprise_course_enrollment_id=enterprise_enrollment.id, + blackboard_user_email=enterprise_customer_user.user_email, + user_email=enterprise_customer_user.user_email, + course_id=get_course_id_for_enrollment(enterprise_enrollment), + course_completed=course_completed, + grade=percent_grade, + completed_timestamp=completed_date, + content_title=content_title, + progress_status=progress_status, + blackboard_completed_timestamp=blackboard_completed_timestamp, + enterprise_customer_uuid=enterprise_customer_user.enterprise_customer.uuid, + plugin_configuration_id=self.enterprise_configuration.id, + ) + return [learner_transmission_record] + + def get_learner_assessment_data_records( + self, + enterprise_enrollment, + assessment_grade_data, + ): + """ + Return a blackboardLearnerDataTransmissionAudit with the given enrollment and assessment level data. + If there is no subsection grade then something has gone horribly wrong and it is recommended to look at the + return value of platform's gradebook view. + If no remote ID (enterprise user's email) can be found, return None as that is used to match the learner with + their blackboard account. + + Parameters: + ----------- + enterprise_enrollment (EnterpriseCourseEnrollment object): Django model containing the enterprise + customer, course ID, and enrollment source. + assessment_grade_data (Dict): learner data retrieved from platform's gradebook api. + """ + if enterprise_enrollment.enterprise_customer_user.user_email is None: + # We need an email to find the user on blackboard. + LOGGER.debug(generate_formatted_log( + self.enterprise_configuration.channel_code(), + enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, + enterprise_enrollment.enterprise_customer_user.user_id, + enterprise_enrollment.course_id, + ('get_learner_assessment_data_records finished. No learner data was sent for this LMS User Id because' + ' Blackboard User ID not found for [{name}]'.format( + name=enterprise_enrollment.enterprise_customer_user.enterprise_customer.name + )))) + return None + + BlackboardLearnerAssessmentDataTransmissionAudit = apps.get_model( + 'blackboard', + 'BlackboardLearnerAssessmentDataTransmissionAudit' + ) + + user_subsection_audits = [] + # Create an audit for each of the subsections exported. + for subsection_name, subsection_data in assessment_grade_data.items(): + subsection_percent_grade = subsection_data.get('grade') + subsection_id = subsection_data.get('subsection_id') + # Sanity check for a grade to report + if not subsection_percent_grade or not subsection_id: + continue + + transmission_audit = BlackboardLearnerAssessmentDataTransmissionAudit( + plugin_configuration_id=self.enterprise_configuration.id, + enterprise_customer_uuid=self.enterprise_configuration.enterprise_customer.uuid, + enterprise_course_enrollment_id=enterprise_enrollment.id, + blackboard_user_email=enterprise_enrollment.enterprise_customer_user.user_email, + course_id=get_course_id_for_enrollment(enterprise_enrollment), + subsection_id=subsection_id, + grade=subsection_percent_grade, + grade_point_score=subsection_data.get('grade_point_score'), + grade_points_possible=subsection_data.get('grade_points_possible'), + subsection_name=subsection_name + ) + user_subsection_audits.append(transmission_audit) + + return user_subsection_audits diff --git a/channel_integrations/blackboard/migrations/0001_initial.py b/channel_integrations/blackboard/migrations/0001_initial.py new file mode 100644 index 0000000..1dcc8bb --- /dev/null +++ b/channel_integrations/blackboard/migrations/0001_initial.py @@ -0,0 +1,132 @@ +# Generated by Django 4.2.18 on 2025-02-21 07:42 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import fernet_fields.fields +import model_utils.fields +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('enterprise', '0228_alter_defaultenterpriseenrollmentrealization_realized_enrollment'), + ('channel_integration', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='BlackboardLearnerDataTransmissionAudit', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('enterprise_customer_uuid', models.UUIDField(blank=True, null=True)), + ('user_email', models.CharField(blank=True, max_length=255, null=True)), + ('plugin_configuration_id', models.IntegerField(blank=True, null=True)), + ('enterprise_course_enrollment_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('course_id', models.CharField(max_length=255)), + ('content_title', models.CharField(blank=True, default=None, max_length=255, null=True)), + ('course_completed', models.BooleanField(default=True)), + ('progress_status', models.CharField(blank=True, max_length=255)), + ('completed_timestamp', models.DateTimeField(blank=True, null=True)), + ('instructor_name', models.CharField(blank=True, max_length=255)), + ('grade', models.FloatField(blank=True, null=True)), + ('total_hours', models.FloatField(blank=True, null=True)), + ('subsection_id', models.CharField(blank=True, db_index=True, max_length=255, null=True)), + ('subsection_name', models.CharField(max_length=255, null=True)), + ('status', models.CharField(blank=True, max_length=100, null=True)), + ('error_message', models.TextField(blank=True, null=True)), + ('is_transmitted', models.BooleanField(default=False)), + ('friendly_status_message', models.CharField(blank=True, default=None, help_text='A user-friendly API response status message.', max_length=255, null=True)), + ('blackboard_user_email', models.EmailField(help_text='The learner`s Blackboard email. This must match the email on edX in order for both learner and content metadata integrations.', max_length=255)), + ('blackboard_completed_timestamp', models.CharField(blank=True, help_text='Represents the Blackboard representation of a timestamp: yyyy-mm-dd, which is always 10 characters. Can be left unset for audit transmissions.', max_length=10, null=True)), + ('api_record', models.OneToOneField(blank=True, help_text='Data pertaining to the transmissions API request response.', null=True, on_delete=django.db.models.deletion.CASCADE, to='channel_integration.apiresponserecord')), + ], + ), + migrations.CreateModel( + name='BlackboardLearnerAssessmentDataTransmissionAudit', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('enterprise_customer_uuid', models.UUIDField(blank=True, null=True)), + ('user_email', models.CharField(blank=True, max_length=255, null=True)), + ('plugin_configuration_id', models.IntegerField(blank=True, null=True)), + ('enterprise_course_enrollment_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('course_id', models.CharField(max_length=255)), + ('content_title', models.CharField(blank=True, default=None, max_length=255, null=True)), + ('course_completed', models.BooleanField(default=True)), + ('progress_status', models.CharField(blank=True, max_length=255)), + ('completed_timestamp', models.DateTimeField(blank=True, null=True)), + ('instructor_name', models.CharField(blank=True, max_length=255)), + ('grade', models.FloatField(blank=True, null=True)), + ('total_hours', models.FloatField(blank=True, null=True)), + ('subsection_id', models.CharField(blank=True, db_index=True, max_length=255, null=True)), + ('subsection_name', models.CharField(max_length=255, null=True)), + ('status', models.CharField(blank=True, max_length=100, null=True)), + ('error_message', models.TextField(blank=True, null=True)), + ('is_transmitted', models.BooleanField(default=False)), + ('friendly_status_message', models.CharField(blank=True, default=None, help_text='A user-friendly API response status message.', max_length=255, null=True)), + ('blackboard_user_email', models.CharField(max_length=255)), + ('grade_point_score', models.FloatField(help_text='The amount of points that the learner scored on the subsection.')), + ('grade_points_possible', models.FloatField(help_text='The total amount of points that the learner could score on the subsection.')), + ('api_record', models.OneToOneField(blank=True, help_text='Data pertaining to the transmissions API request response.', null=True, on_delete=django.db.models.deletion.CASCADE, to='channel_integration.apiresponserecord')), + ], + ), + migrations.CreateModel( + name='BlackboardGlobalConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('app_key', models.CharField(blank=True, default='', help_text='The application API key identifying the edX integration application to be used in the API oauth handshake.', max_length=255, verbose_name='Blackboard Application Key')), + ('app_secret', models.CharField(blank=True, default='', help_text='The application API secret used to make to identify ourselves as the edX integration app to customer instances. Called Application Secret in Blackboard', max_length=255, verbose_name='API Client Secret or Application Secret')), + ('changed_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='blackboard_global_configuration_changed_by', to=settings.AUTH_USER_MODEL, verbose_name='Changed by')), + ], + ), + migrations.CreateModel( + name='BlackboardEnterpriseCustomerConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('display_name', models.CharField(blank=True, default='', help_text='A configuration nickname.', max_length=255)), + ('idp_id', models.CharField(blank=True, default='', help_text='If provided, will be used as IDP slug to locate remote id for learners', max_length=255)), + ('active', models.BooleanField(help_text='Is this configuration active?')), + ('dry_run_mode_enabled', models.BooleanField(default=False, help_text='Is this configuration in dry-run mode? (experimental)')), + ('show_course_price', models.BooleanField(default=False, help_text='Displays course price')), + ('channel_worker_username', models.CharField(blank=True, default='', help_text='Enterprise channel worker username to get JWT tokens for authenticating LMS APIs.', max_length=255)), + ('catalogs_to_transmit', models.TextField(blank=True, default='', help_text='A comma-separated list of catalog UUIDs to transmit. If blank, all customer catalogs will be transmitted. If there are overlapping courses in the customer catalogs, the overlapping course metadata will be selected from the newest catalog.')), + ('disable_learner_data_transmissions', models.BooleanField(default=False, help_text='When set to True, the configured customer will no longer receive learner data transmissions, both scheduled and signal based', verbose_name='Disable Learner Data Transmission')), + ('last_sync_attempted_at', models.DateTimeField(blank=True, help_text='The DateTime of the most recent Content or Learner data record sync attempt', null=True)), + ('last_content_sync_attempted_at', models.DateTimeField(blank=True, help_text='The DateTime of the most recent Content data record sync attempt', null=True)), + ('last_learner_sync_attempted_at', models.DateTimeField(blank=True, help_text='The DateTime of the most recent Learner data record sync attempt', null=True)), + ('last_sync_errored_at', models.DateTimeField(blank=True, help_text='The DateTime of the most recent failure of a Content or Learner data record sync attempt', null=True)), + ('last_content_sync_errored_at', models.DateTimeField(blank=True, help_text='The DateTime of the most recent failure of a Content data record sync attempt', null=True)), + ('last_learner_sync_errored_at', models.DateTimeField(blank=True, help_text='The DateTime of the most recent failure of a Learner data record sync attempt', null=True)), + ('last_modified_at', models.DateTimeField(auto_now=True, help_text='The DateTime of the last change made to this configuration.', null=True)), + ('decrypted_client_id', fernet_fields.fields.EncryptedCharField(blank=True, default='', help_text='The API Client ID (encrypted at db level) provided to edX by the enterprise customer to be used to make API calls to Degreed on behalf of the customer.', max_length=255, verbose_name='API Client ID encrypted at db level')), + ('decrypted_client_secret', fernet_fields.fields.EncryptedCharField(blank=True, default='', help_text='The API Client Secret (encrypted at db level) provided to edX by the enterprise customer to be used to make API calls to Degreed on behalf of the customer.', max_length=255, verbose_name='API Client Secret encrypted at db level')), + ('blackboard_base_url', models.CharField(blank=True, default='', help_text='The base URL used for API requests to Blackboard, i.e. https://blackboard.com.', max_length=255, verbose_name='Base URL')), + ('refresh_token', models.CharField(blank=True, help_text='The refresh token provided by Blackboard along with the access token request,used to re-request the access tokens over multiple client sessions.', max_length=255, verbose_name='Oauth2 Refresh Token')), + ('transmission_chunk_size', models.IntegerField(default=1, help_text='The maximum number of data items to transmit to the integrated channel with each request.')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, help_text='A UUID for use in public-facing urls such as oauth state variables.', unique=True)), + ('enterprise_customer', models.ForeignKey(help_text='Enterprise Customer associated with the configuration.', on_delete=django.db.models.deletion.CASCADE, related_name='blackboard_enterprisecustomerpluginconfiguration', to='enterprise.enterprisecustomer')), + ], + ), + migrations.AddConstraint( + model_name='blackboardlearnerdatatransmissionaudit', + constraint=models.UniqueConstraint(fields=('enterprise_course_enrollment_id', 'course_id'), name='blackboard_ch_unique_enrollment_course_id'), + ), + migrations.AlterIndexTogether( + name='blackboardlearnerdatatransmissionaudit', + index_together={('enterprise_customer_uuid', 'plugin_configuration_id')}, + ), + ] diff --git a/channel_integrations/blackboard/migrations/__init__.py b/channel_integrations/blackboard/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/channel_integrations/blackboard/models.py b/channel_integrations/blackboard/models.py new file mode 100644 index 0000000..64d04f1 --- /dev/null +++ b/channel_integrations/blackboard/models.py @@ -0,0 +1,456 @@ +""" +Database models for Enterprise Integrated Channel Blackboard. +""" + +import json +import uuid +from logging import getLogger + +from config_models.models import ConfigurationModel +from fernet_fields import EncryptedCharField +from six.moves.urllib.parse import urljoin + +from django.conf import settings +from django.db import models +from django.utils.encoding import force_bytes, force_str +from django.utils.translation import gettext_lazy as _ + +from enterprise.models import EnterpriseCustomer +from channel_integrations.blackboard.exporters.content_metadata import BlackboardContentMetadataExporter +from channel_integrations.blackboard.exporters.learner_data import BlackboardLearnerExporter +from channel_integrations.blackboard.transmitters.content_metadata import BlackboardContentMetadataTransmitter +from channel_integrations.blackboard.transmitters.learner_data import BlackboardLearnerTransmitter +from channel_integrations.integrated_channel.models import ( + EnterpriseCustomerPluginConfiguration, + LearnerDataTransmissionAudit, +) +from channel_integrations.utils import is_valid_url + +LOGGER = getLogger(__name__) +LMS_OAUTH_REDIRECT_URL = urljoin(settings.LMS_ROOT_URL, '/blackboard/oauth-complete') + + +class GlobalConfigurationManager(models.Manager): + """ + Model manager for :class:`.BlackboardGlobalConfiguration` model. + + Filters out inactive global configurations. + """ + + # This manager filters out some records, hence according to the Django docs it must not be used + # for related field access. Although False is default value, it still makes sense to set it explicitly + # https://docs.djangoproject.com/en/1.10/topics/db/managers/#base-managers + use_for_related_fields = False + + def get_queryset(self): + """ + Return a new QuerySet object. Filters out inactive Enterprise Customers. + """ + return super().get_queryset().filter(enabled=True) + + +class BlackboardGlobalConfiguration(ConfigurationModel): + """ + The global configuration for integrating with Blackboard. + + .. no_pii: + """ + + app_key = models.CharField( + max_length=255, + blank=True, + default='', + verbose_name="Blackboard Application Key", + help_text=( + "The application API key identifying the edX integration application to be used in the API oauth handshake." + ) + ) + + app_secret = models.CharField( + max_length=255, + blank=True, + default='', + verbose_name="API Client Secret or Application Secret", + help_text=( + "The application API secret used to make to identify ourselves as the edX integration app to customer " + "instances. Called Application Secret in Blackboard" + ) + ) + + # TODO: Remove this override when we switch to enterprise-integrated-channels completely + changed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + editable=False, + null=True, + on_delete=models.PROTECT, + # Translators: this label indicates the name of the user who made this change: + verbose_name=_("Changed by"), + related_name='blackboard_global_configuration_changed_by' + ) + + class Meta: + app_label = 'blackboard_channel' + + objects = models.Manager() + active_config = GlobalConfigurationManager() + + def __str__(self): + """ + Return a human-readable string representation of the object. + """ + return "".format(id=self.id) + + def __repr__(self): + """ + Return uniquely identifying string representation. + """ + return self.__str__() + + +class BlackboardEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfiguration): + """ + The Enterprise-specific configuration we need for integrating with Blackboard. + + .. no_pii: + """ + + # TODO: Remove this override when we switch to enterprise-integrated-channels completely + enterprise_customer = models.ForeignKey( + EnterpriseCustomer, + related_name='blackboard_enterprisecustomerpluginconfiguration', + blank=False, + null=False, + help_text=_("Enterprise Customer associated with the configuration."), + on_delete=models.deletion.CASCADE + ) + + decrypted_client_id = EncryptedCharField( + max_length=255, + blank=True, + default='', + verbose_name="API Client ID encrypted at db level", + help_text=( + "The API Client ID (encrypted at db level) provided to edX by the enterprise customer to be used" + " to make API calls to Degreed on behalf of the customer." + ) + ) + + @property + def encrypted_client_id(self): + """ + Return encrypted client_id as a string. + The data is encrypted in the DB at rest, but is unencrypted in the app when retrieved through the + decrypted_client_id field. This method will encrypt the client_id again before sending. + """ + if self.decrypted_client_id: + return force_str( + self._meta.get_field('decrypted_client_id').fernet.encrypt( + force_bytes(self.decrypted_client_id) + ) + ) + return self.decrypted_client_id + + @encrypted_client_id.setter + def encrypted_client_id(self, value): + """ + Set the encrypted client_id. + """ + self.decrypted_client_id = value + + decrypted_client_secret = EncryptedCharField( + max_length=255, + blank=True, + default='', + verbose_name="API Client Secret encrypted at db level", + help_text=( + "The API Client Secret (encrypted at db level) provided to edX by the enterprise customer to be " + "used to make API calls to Degreed on behalf of the customer." + ), + ) + + @property + def encrypted_client_secret(self): + """ + Return encrypted client_secret as a string. + The data is encrypted in the DB at rest, but is unencrypted in the app when retrieved through the + decrypted_client_secret field. This method will encrypt the client_secret again before sending. + """ + if self.decrypted_client_secret: + return force_str( + self._meta.get_field('decrypted_client_secret').fernet.encrypt( + force_bytes(self.decrypted_client_secret) + ) + ) + return self.decrypted_client_secret + + @encrypted_client_secret.setter + def encrypted_client_secret(self, value): + """ + Set the encrypted client_secret. + """ + self.decrypted_client_secret = value + + blackboard_base_url = models.CharField( + max_length=255, + blank=True, + default='', + verbose_name="Base URL", + help_text="The base URL used for API requests to Blackboard, i.e. https://blackboard.com." + ) + + refresh_token = models.CharField( + max_length=255, + blank=True, + verbose_name="Oauth2 Refresh Token", + help_text="The refresh token provided by Blackboard along with the access token request," + "used to re-request the access tokens over multiple client sessions." + ) + + # overriding base model field, to use chunk size 1 default + transmission_chunk_size = models.IntegerField( + default=1, + help_text=( + "The maximum number of data items to transmit to the integrated channel " + "with each request." + ) + ) + + uuid = models.UUIDField( + unique=True, + default=uuid.uuid4, + editable=False, + help_text=( + "A UUID for use in public-facing urls such as oauth state variables." + ) + ) + + class Meta: + app_label = 'blackboard_channel' + + @property + def oauth_authorization_url(self): + """ + Returns: the oauth authorization url when the blackboard_base_url and decrypted_client_id are available. + + Args: + obj: The instance of BlackboardEnterpriseCustomerConfiguration + being rendered with this admin form. + """ + if self.blackboard_base_url and self.decrypted_client_id: + return (f'{self.blackboard_base_url}/learn/api/public/v1/oauth2/authorizationcode' + f'?redirect_uri={LMS_OAUTH_REDIRECT_URL}&' + f'scope=read%20write%20delete%20offline&response_type=code&' + f'client_id={self.decrypted_client_id}&state={self.uuid}') + else: + return None + + @property + def is_valid(self): + """ + Returns whether or not the configuration is valid and ready to be activated + + Args: + obj: The instance of BlackboardEnterpriseCustomerConfiguration + being rendered with this admin form. + """ + missing_items = {'missing': []} + incorrect_items = {'incorrect': []} + if not self.blackboard_base_url: + missing_items.get('missing').append('blackboard_base_url') + if not self.refresh_token: + missing_items.get('missing').append('refresh_token') + if not is_valid_url(self.blackboard_base_url): + incorrect_items.get('incorrect').append('blackboard_base_url') + if len(self.display_name) > 20: + incorrect_items.get('incorrect').append('display_name') + return missing_items, incorrect_items + + def __str__(self): + """ + Return human-readable string representation. + """ + return "".format( + enterprise_name=self.enterprise_customer.name + ) + + def __repr__(self): + """ + Return uniquely identifying string representation. + """ + return self.__str__() + + @staticmethod + def channel_code(): + """ + Returns an capitalized identifier for this channel class, unique among subclasses. + """ + return 'BLACKBOARD' + + def get_content_metadata_exporter(self, user): + return BlackboardContentMetadataExporter(user, self) + + def get_content_metadata_transmitter(self): + return BlackboardContentMetadataTransmitter(self) + + def get_learner_data_exporter(self, user): + return BlackboardLearnerExporter(user, self) + + def get_learner_data_transmitter(self): + return BlackboardLearnerTransmitter(self) + + +class BlackboardLearnerAssessmentDataTransmissionAudit(LearnerDataTransmissionAudit): + """ + The payload correlated to a courses subsection learner data we send to blackboard at a given point in time for an + enterprise course enrollment. + + .. pii: user_email and blackboard_user_email contain PII. Declaring "retained" because I don't know if it's retired. + .. pii_types: email_address + .. pii_retirement: retained + """ + blackboard_user_email = models.CharField( + max_length=255, + blank=False, + null=False + ) + + grade_point_score = models.FloatField( + blank=False, + null=False, + help_text="The amount of points that the learner scored on the subsection." + ) + + grade_points_possible = models.FloatField( + blank=False, + null=False, + help_text="The total amount of points that the learner could score on the subsection." + ) + + class Meta: + app_label = 'blackboard_channel' + + def __str__(self): + return ( + ''.format( + transmission_id=self.id, + enrollment=self.enterprise_course_enrollment_id, + blackboard_user_email=self.blackboard_user_email, + course_id=self.course_id + ) + ) + + def __repr__(self): + """ + Return uniquely identifying string representation. + """ + return self.__str__() + + def serialize(self, *args, **kwargs): + """ + Return a JSON-serialized representation. + Sort the keys so the result is consistent and testable. + # TODO: When we refactor to use a serialization flow consistent with how course metadata + # is serialized, remove the serialization here and make the learner data exporter handle the work. + """ + return json.dumps(self._payload_data(), sort_keys=True) + + def _payload_data(self): + """ + Convert the audit record's fields into blackboard key/value pairs. + """ + return { + 'userID': self.blackboard_user_email, + 'courseID': self.course_id, + 'grade': self.grade, + 'subsectionID': self.subsection_id, + 'points_possible': self.grade_points_possible, + 'points_earned': self.grade_point_score, + 'subsection_name': self.subsection_name, + } + + @classmethod + def audit_type(cls): + """ + Assessment level audit type labeling + """ + return "assessment" + + +class BlackboardLearnerDataTransmissionAudit(LearnerDataTransmissionAudit): + """ + The payload we send to Blackboard at a given point in time for an enterprise course enrollment. + + """ + blackboard_user_email = models.EmailField( + max_length=255, + blank=False, + null=False, + help_text='The learner`s Blackboard email. This must match the email on edX in' + ' order for both learner and content metadata integrations.' + ) + + blackboard_completed_timestamp = models.CharField( + null=True, + blank=True, + max_length=10, + help_text=( + 'Represents the Blackboard representation of a timestamp: yyyy-mm-dd, ' + 'which is always 10 characters. Can be left unset for audit transmissions.' + ) + ) + + class Meta: + app_label = 'blackboard_channel' + constraints = [ + models.UniqueConstraint( + fields=['enterprise_course_enrollment_id', 'course_id'], + name='blackboard_ch_unique_enrollment_course_id' + ) + ] + index_together = ['enterprise_customer_uuid', 'plugin_configuration_id'] + + def __str__(self): + """ + Return a human-readable string representation of the object. + """ + return ( + ''.format( + transmission_id=self.id, + enterprise_course_enrollment_id=self.enterprise_course_enrollment_id, + blackboard_user_email=self.blackboard_user_email, + course_id=self.course_id + ) + ) + + def __repr__(self): + """ + Return uniquely identifying string representation. + """ + return self.__str__() + + def serialize(self, *args, **kwargs): + """ + Return a JSON-serialized representation. + + Sort the keys so the result is consistent and testable. + + # TODO: When we refactor to use a serialization flow consistent with how course metadata + # is serialized, remove the serialization here and make the learner data exporter handle the work. + """ + return json.dumps(self._payload_data(), sort_keys=True) + + def _payload_data(self): + """ + Convert the audit record's fields into Blackboard key/value pairs. + """ + return { + 'userID': self.blackboard_user_email, + 'courseID': self.course_id, + 'courseCompleted': 'true' if self.course_completed else 'false', + 'completedTimestamp': self.blackboard_completed_timestamp, + 'grade': self.grade, + 'totalHours': self.total_hours, + } diff --git a/channel_integrations/blackboard/transmitters/__init__.py b/channel_integrations/blackboard/transmitters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/channel_integrations/blackboard/transmitters/content_metadata.py b/channel_integrations/blackboard/transmitters/content_metadata.py new file mode 100644 index 0000000..cf2fb0a --- /dev/null +++ b/channel_integrations/blackboard/transmitters/content_metadata.py @@ -0,0 +1,27 @@ +""" +Transmitter for Blackboard content metadata +""" + +from channel_integrations.blackboard.client import BlackboardAPIClient +from channel_integrations.integrated_channel.transmitters.content_metadata import ContentMetadataTransmitter + + +class BlackboardContentMetadataTransmitter(ContentMetadataTransmitter): + """ + This transmitter transmits exported content metadata to Canvas. + """ + + def __init__(self, enterprise_configuration, client=BlackboardAPIClient): + """ + Use the ``BlackboardAPIClient`` for content metadata transmission. + """ + super().__init__( + enterprise_configuration=enterprise_configuration, + client=client + ) + + def _prepare_items_for_transmission(self, channel_metadata_items): + # here is a hack right now to send only one item + # we have to investigate how to handle multiple + # metadata items since there is no batch course create endpoint in blackboard + return channel_metadata_items[0] diff --git a/channel_integrations/blackboard/transmitters/learner_data.py b/channel_integrations/blackboard/transmitters/learner_data.py new file mode 100644 index 0000000..19a320f --- /dev/null +++ b/channel_integrations/blackboard/transmitters/learner_data.py @@ -0,0 +1,57 @@ +""" +Class for transmitting learner data to Blackboard. +""" + +from channel_integrations.blackboard.client import BlackboardAPIClient +from channel_integrations.integrated_channel.transmitters.learner_data import LearnerTransmitter + + +class BlackboardLearnerTransmitter(LearnerTransmitter): + """ + This endpoint is intended to receive learner data routed from the integrated_channel app that is ready to be + sent to Blackboard. + """ + + def __init__(self, enterprise_configuration, client=BlackboardAPIClient): + """ + By default, use the ``BlackboardAPIClient`` for learner data transmission to Blackboard. + """ + super().__init__( + enterprise_configuration=enterprise_configuration, + client=client + ) + + def transmit(self, payload, **kwargs): + """ + Send a completion status call to Blackboard using the client. + + Args: + payload: The learner completion exporter for Blackboard + """ + kwargs['app_label'] = 'blackboard' + kwargs['model_name'] = 'BlackboardLearnerDataTransmissionAudit' + kwargs['remote_user_id'] = 'blackboard_user_email' + super().transmit(payload, **kwargs) + + def single_learner_assessment_grade_transmit(self, exporter, **kwargs): + """ + Send an assessment level grade update status call for a single enterprise learner to blackboard using the + client. + Args: + exporter: The learner completion data payload to send to blackboard + """ + kwargs['app_label'] = 'blackboard' + kwargs['model_name'] = 'BlackboardLearnerAssessmentDataTransmissionAudit' + kwargs['remote_user_id'] = 'blackboard_user_email' + super().single_learner_assessment_grade_transmit(exporter, **kwargs) + + def assessment_level_transmit(self, exporter, **kwargs): + """ + Send a bulk assessment level grade update status call to blackboard using the client. + Args: + exporter: The learner completion data payload to send to blackboard + """ + kwargs['app_label'] = 'blackboard' + kwargs['model_name'] = 'BlackboardLearnerAssessmentDataTransmissionAudit' + kwargs['remote_user_id'] = 'blackboard_user_email' + super().assessment_level_transmit(exporter, **kwargs) diff --git a/channel_integrations/blackboard/urls.py b/channel_integrations/blackboard/urls.py new file mode 100644 index 0000000..6fdf0f6 --- /dev/null +++ b/channel_integrations/blackboard/urls.py @@ -0,0 +1,13 @@ +""" +URL definitions for Blackboard API. +""" + +from django.urls import path + +from channel_integrations.blackboard.views import BlackboardCompleteOAuthView + +urlpatterns = [ + path('oauth-complete', BlackboardCompleteOAuthView.as_view(), + name='blackboard-oauth-complete' + ), +] diff --git a/channel_integrations/blackboard/utils.py b/channel_integrations/blackboard/utils.py new file mode 100644 index 0000000..b091d8e --- /dev/null +++ b/channel_integrations/blackboard/utils.py @@ -0,0 +1,19 @@ +""" +Utilities for Blackboard integrated channels. +""" + + +def populate_decrypted_fields_blackboard(apps, schema_editor=None): # pylint: disable=unused-argument + """ + Populates the encryption fields in Blackboard config with the data previously stored in database. + """ + BlackboardEnterpriseCustomerConfiguration = apps.get_model( + 'blackboard', 'BlackboardEnterpriseCustomerConfiguration' + ) + + for blackboard_enterprise_configuration in BlackboardEnterpriseCustomerConfiguration.objects.all(): + blackboard_enterprise_configuration.decrypted_client_id = getattr( + blackboard_enterprise_configuration, 'client_id', '') + blackboard_enterprise_configuration.decrypted_client_secret = getattr( + blackboard_enterprise_configuration, 'client_secret', '') + blackboard_enterprise_configuration.save() diff --git a/channel_integrations/blackboard/views.py b/channel_integrations/blackboard/views.py new file mode 100644 index 0000000..0e99926 --- /dev/null +++ b/channel_integrations/blackboard/views.py @@ -0,0 +1,213 @@ +""" +Views containing APIs for Blackboard integrated channel +""" + +import base64 +import logging +from http import HTTPStatus +from urllib.parse import urljoin + +import requests +from rest_framework import generics +from rest_framework.exceptions import NotFound +from rest_framework.renderers import JSONRenderer + +from django.apps import apps +from django.conf import settings +from django.core.exceptions import ValidationError +from django.shortcuts import render + +from enterprise.utils import get_enterprise_customer +from channel_integrations.blackboard.models import BlackboardEnterpriseCustomerConfiguration + +LOGGER = logging.getLogger(__name__) + + +def log_auth_response(auth_token_url, data): + """ + Logs the response from a refresh_token fetch. + some fields may be absent in the response: + ref: https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/ + """ + scope = data['scope'] if 'scope' in data else 'not_found' + user_id = data['user_id'] if 'user_id' in data else 'not_found' + LOGGER.info("BLACKBOARD: response from {} contained: token_type={}," + "expires_in={}, scope={}, user_id={}".format( + auth_token_url, + data['token_type'], + data['expires_in'], + scope, + user_id, + )) + + +class BlackboardCompleteOAuthView(generics.ListAPIView): + """ + **Use Cases** + + Retrieve and save a Blackboard OAuth refresh token after an enterprise customer + authorizes to integrated courses. Typically for use to plug into the redirect_uri field + in visiting the 'authorizationcode' endpoint: + Ref: https://developer.blackboard.com/portal/displayApi/Learn + e.g. https://blackboard.edx.us.org/learn/api/public/v1/oauth2/ + authorizationcode?redirect_uri={{this_endpoint}}&response_type=code + &client_id={{app id}}&state={{blackboard_enterprise_customer_configuration.uuid}} + + **Example Requests** + + GET /blackboard/oauth-complete?code=123abc&state=abc123 + + **Query Parameters for GET** + + * code: The one time use string generated by the Blackboard API used to fetch the + access and refresh tokens for integrating with Blackboard. + + * state: The user's enterprise customer uuid used to associate the incoming + code with an enterprise configuration model. + + **Response Values** + + HTTP 200 "OK" if successful + + HTTP 400 if code/state is not provided + + HTTP 404 if state is not valid or contained in the set of registered enterprises + + """ + renderer_classes = [JSONRenderer, ] + + def render_page(self, request, error): + """ + Return a success or failure page based on Blackboard OAuth response + """ + success_template = 'enterprise/admin/oauth_authorization_successful.html' + error_template = 'enterprise/admin/oauth_authorization_failed.html' + template = error_template if error else success_template + + return render(request, template, context={}) + + def get(self, request, *args, **kwargs): + app_config = apps.get_app_config('blackboard') + oauth_token_path = app_config.oauth_token_auth_path + + # Check if encountered an error when generating the oauth code. + request_error = request.GET.get('error') + if request_error: + LOGGER.error( + "Blackboard OAuth API encountered an error when generating client code - " + "error: {} description: {}".format( + request_error, + request.GET.get('error_description') + ) + ) + return self.render_page(request, 'error') + + # Retrieve the newly generated code and state (Enterprise user's ID) + client_code = request.GET.get('code') + state_uuid = request.GET.get('state') + + if not state_uuid: + LOGGER.error("Blackboard Configuration uuid (as 'state' url param) needed to obtain refresh token") + return self.render_page(request, 'error') + + if not client_code: + LOGGER.error("'code' url param was not provided, needed to obtain refresh token") + return self.render_page(request, 'error') + + try: + enterprise_config = BlackboardEnterpriseCustomerConfiguration.objects.get(uuid=state_uuid) + except (BlackboardEnterpriseCustomerConfiguration.DoesNotExist, ValidationError): + enterprise_config = None + + # old urls may use the enterprise customer uuid in place of the config uuid, so lets fallback + if not enterprise_config: + enterprise_customer = get_enterprise_customer(state_uuid) + + if not enterprise_customer: + LOGGER.error(f"No state data found for given uuid: {state_uuid}.") + return self.render_page(request, 'error') + + try: + enterprise_config = BlackboardEnterpriseCustomerConfiguration.objects.filter( + enterprise_customer=enterprise_customer + ).first() + except BlackboardEnterpriseCustomerConfiguration.DoesNotExist: + LOGGER.error(f"No state data found for given uuid: {state_uuid}") + return self.render_page(request, 'error') + + BlackboardGlobalConfiguration = apps.get_model( + 'blackboard', + 'BlackboardGlobalConfiguration' + ) + blackboard_global_config = BlackboardGlobalConfiguration.current() + if not blackboard_global_config: + LOGGER.error("No global Blackboard configuration found") + return self.render_page(request, 'error') + + auth_header = self._create_auth_header(enterprise_config, blackboard_global_config) + + access_token_request_params = { + 'grant_type': 'authorization_code', + 'redirect_uri': settings.LMS_INTERNAL_ROOT_URL + "/blackboard/oauth-complete", + 'code': client_code, + } + + auth_token_url = urljoin(enterprise_config.blackboard_base_url, oauth_token_path) + + auth_response = requests.post( + auth_token_url, + access_token_request_params, + headers={ + 'Authorization': auth_header, + 'Content-Type': 'application/x-www-form-urlencoded' + }) + + try: + data = auth_response.json() + if 'refresh_token' not in data: + LOGGER.error( + "BLACKBOARD: failed to find refresh_token in auth response. " + "Auth response text: {}, Response code: {}, JSON response: {}".format( + auth_response.text, auth_response.status_code, data)) + return self.render_page(request, 'error') + + log_auth_response(auth_token_url, data) + refresh_token = data['refresh_token'] + if refresh_token.strip(): + enterprise_config.refresh_token = refresh_token + enterprise_config.save() + else: + LOGGER.error("BLACKBOARD: Invalid/empty refresh_token! Cannot use it.") + return self.render_page(request, 'error') + except KeyError: + LOGGER.error( + "BLACKBOARD: failed to find required data in auth response. " + "Auth response text: {}, Response code: {}, JSON response: {}".format( + auth_response.text, auth_response.status_code, data)) + return self.render_page(request, 'error') + + status = '' if auth_response.status_code == 200 else 'error' + + return self.render_page(request, status) + + def _create_auth_header(self, enterprise_config, blackboard_global_config): + """ + Auth header in oauth2 token format as per Blackboard doc + """ + app_key = enterprise_config.decrypted_client_id + if not app_key: + if not blackboard_global_config.app_key: + raise NotFound( + "Failed to generate oauth access token: Client ID required.", + HTTPStatus.INTERNAL_SERVER_ERROR.value + ) + app_key = blackboard_global_config.app_key + app_secret = enterprise_config.decrypted_client_secret + if not app_secret: + if not blackboard_global_config.app_secret: + raise NotFound( + "Failed to generate oauth access token: Client secret required.", + HTTPStatus.INTERNAL_SERVER_ERROR.value + ) + app_secret = blackboard_global_config.app_secret + return f"Basic {base64.b64encode(f'{app_key}:{app_secret}'.encode('utf-8')).decode()}"