' \
+ '{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 "