diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c77c86867..c28ba1bb71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [master] pull_request: - branches: [master] concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 74b6247217..52f0ff02c8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,11 @@ Change Log Unreleased ---------- +[4.6.1] +--------- +This version is based on v4.6.0 and contains some backports needed to OpenCraft's clients. There is no "official" v4.6.0. +See this PR for more details: https://github.com/open-craft/edx-enterprise/pull/13 + [4.6.0] ------- feat: Added enable_source_demo_data_for_analytics_and_lpr field to EnterpriseCustomer. diff --git a/enterprise/__init__.py b/enterprise/__init__.py index c629ee4f9b..2364a6257c 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.6.0" +__version__ = "4.6.1" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index 665745c1d0..5bb4906973 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -202,6 +202,7 @@ class EnterpriseCustomerAdmin(DjangoObjectActions, SimpleHistoryAdmin): ('Integration and learning platform settings', { 'fields': ('enable_portal_lms_configurations_screen', 'enable_portal_saml_configuration_screen', 'enable_slug_login', 'replace_sensitive_sso_username', 'hide_course_original_price', + 'hide_course_price_when_zero', 'allow_enrollment_in_invite_only_courses', 'enable_generation_of_api_credentials') }), ('Recommended default settings for all enterprise customers', { diff --git a/enterprise/admin/forms.py b/enterprise/admin/forms.py index fd076aa4d4..787fc7c0c3 100644 --- a/enterprise/admin/forms.py +++ b/enterprise/admin/forms.py @@ -68,6 +68,11 @@ class ManageLearnersForm(forms.Form): label=_("Enroll these learners in this course"), required=False, help_text=_("To enroll learners in a course, enter a course ID."), ) + force_enrollment = forms.BooleanField( + label=_("Force Enrollment"), + help_text=_("The selected course is 'Invite Only'. Only staff can enroll learners to this course."), + required=False, + ) course_mode = forms.ChoiceField( label=_("Course enrollment track"), required=False, choices=BLANK_CHOICE_DASH + [ @@ -130,6 +135,7 @@ class Fields: REASON = "reason" SALES_FORCE_ID = "sales_force_id" DISCOUNT = "discount" + FORCE_ENROLLMENT = "force_enrollment" class CsvColumns: """ @@ -395,6 +401,8 @@ class Meta: "enable_audit_data_reporting", "replace_sensitive_sso_username", "hide_course_original_price", + "hide_course_price_when_zero", + "allow_enrollment_in_invite_only_courses", "enable_portal_code_management_screen", "enable_portal_subscription_management_screen", "enable_learner_portal", diff --git a/enterprise/admin/views.py b/enterprise/admin/views.py index da997683b5..c1e9979e67 100644 --- a/enterprise/admin/views.py +++ b/enterprise/admin/views.py @@ -678,7 +678,8 @@ def _enroll_users( notify=True, enrollment_reason=None, sales_force_id=None, - discount=0.0 + discount=0.0, + force_enrollment=False, ): """ Enroll the users with the given email addresses to the course. @@ -691,6 +692,7 @@ def _enroll_users( mode: The enrollment mode the users will be enrolled in the course with course_id: The ID of the course in which we want to enroll notify: Whether to notify (by email) the users that have been enrolled + force_enrollment: Force enrollment into "Invite Only" courses """ pending_messages = [] paid_modes = ['verified', 'professional'] @@ -704,6 +706,7 @@ def _enroll_users( enrollment_reason=enrollment_reason, discount=discount, sales_force_id=sales_force_id, + force_enrollment=force_enrollment, ) all_successes = succeeded + pending if notify: @@ -820,6 +823,7 @@ def post(self, request, customer_uuid): sales_force_id = manage_learners_form.cleaned_data.get(ManageLearnersForm.Fields.SALES_FORCE_ID) course_mode = manage_learners_form.cleaned_data.get(ManageLearnersForm.Fields.COURSE_MODE) course_id = None + force_enrollment = manage_learners_form.cleaned_data.get(ManageLearnersForm.Fields.FORCE_ENROLLMENT) if not course_id_with_emails: course_details = manage_learners_form.cleaned_data.get(ManageLearnersForm.Fields.COURSE) or {} @@ -834,7 +838,8 @@ def post(self, request, customer_uuid): notify=notify, enrollment_reason=manual_enrollment_reason, sales_force_id=sales_force_id, - discount=discount + discount=discount, + force_enrollment=force_enrollment, ) else: for course_id, emails in course_id_with_emails.items(): @@ -849,7 +854,8 @@ def post(self, request, customer_uuid): notify=notify, enrollment_reason=manual_enrollment_reason, sales_force_id=sales_force_id, - discount=discount + discount=discount, + force_enrollment=force_enrollment, ) # Redirect to GET if everything went smooth. diff --git a/enterprise/api/filters.py b/enterprise/api/filters.py index 0ea4115085..92c1bb4551 100644 --- a/enterprise/api/filters.py +++ b/enterprise/api/filters.py @@ -7,7 +7,7 @@ from django.contrib import auth -from enterprise.models import EnterpriseCustomerUser, SystemWideEnterpriseUserRoleAssignment +from enterprise.models import EnterpriseCustomer, EnterpriseCustomerUser, SystemWideEnterpriseUserRoleAssignment User = auth.get_user_model() @@ -33,6 +33,36 @@ def filter_queryset(self, request, queryset, view): return queryset +class EnterpriseCourseEnrollmentFilterBackend(filters.BaseFilterBackend): + """ + Filter backend to return enrollments under the user's enterprise(s) only. + + * Staff users will bypass this filter. + * Non-staff users will receive enrollments under their linked enterprises, + only if they have the `enterprise.can_enroll_learners` permission. + * Non-staff users without the `enterprise.can_enroll_learners` permission + will receive only their own enrollments. + """ + + def filter_queryset(self, request, queryset, view): + """ + Filter out enrollments if learner is not linked + """ + + if request.user.is_staff: + return queryset + + if request.user.has_perm('enterprise.can_enroll_learners'): + enterprise_customers = EnterpriseCustomer.objects.filter(enterprise_customer_users__user_id=request.user.id) + return queryset.filter(enterprise_customer_user__enterprise_customer__in=enterprise_customers) + + filter_kwargs = { + view.USER_ID_FILTER: request.user.id, + } + + return queryset.filter(**filter_kwargs) + + class EnterpriseCustomerUserFilterBackend(filters.BaseFilterBackend): """ Allow filtering on the enterprise customer user api endpoint. diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index d44ce837cd..a0b09a446a 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -356,6 +356,32 @@ class Meta: ) +class EnterpriseCourseEnrollmentWithAdditionalFieldsReadOnlySerializer(EnterpriseCourseEnrollmentReadOnlySerializer): + """ + Serializer for EnterpriseCourseEnrollment model with additional fields. + """ + + class Meta: + model = models.EnterpriseCourseEnrollment + fields = ( + 'enterprise_customer_user', + 'course_id', + 'created', + 'unenrolled_at', + 'enrollment_date', + 'enrollment_track', + 'user_email', + 'course_start', + 'course_end', + ) + + enrollment_track = serializers.CharField() + enrollment_date = serializers.DateTimeField() + user_email = serializers.EmailField() + course_start = serializers.DateTimeField() + course_end = serializers.DateTimeField() + + class EnterpriseCourseEnrollmentWriteSerializer(serializers.ModelSerializer): """ Serializer for writing to the EnterpriseCourseEnrollment model. diff --git a/enterprise/api/v1/views/enterprise_course_enrollment.py b/enterprise/api/v1/views/enterprise_course_enrollment.py index c7aef5ffc0..59ebf75c01 100644 --- a/enterprise/api/v1/views/enterprise_course_enrollment.py +++ b/enterprise/api/v1/views/enterprise_course_enrollment.py @@ -1,17 +1,68 @@ """ Views for the ``enterprise-course-enrollment`` API endpoint. """ +from django_filters.rest_framework import DjangoFilterBackend +from edx_rest_framework_extensions.paginators import DefaultPagination +from rest_framework import filters + +from django.core.paginator import Paginator +from django.utils.functional import cached_property + from enterprise import models +from enterprise.api.filters import EnterpriseCourseEnrollmentFilterBackend from enterprise.api.v1 import serializers from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet +try: + from common.djangoapps.util.query import read_replica_or_default +except ImportError: + def read_replica_or_default(): + return None + + +class PaginatorWithOptimizedCount(Paginator): + """ + Django < 4.2 ORM doesn't strip unused annotations from count queries. + + For example, if we execute this query: + + Book.objects.annotate(Count('chapters')).count() + + it will generate SQL like this: + + SELECT COUNT(*) FROM (SELECT COUNT(...), ... FROM ...) subquery + + This isn't optimal on its own, but it's not a big deal. However, this + becomes problematic when annotations use subqueries, because it's terribly + inefficient to execute the subquery for every row in the outer query. + + This class overrides the count() method of Django's Paginator to strip + unused annotations from the query by requesting only the primary key + instead of all fields. + + This is a temporary workaround until Django is updated to 4.2, which will + include a fix for this issue. + + See https://code.djangoproject.com/ticket/32169 for more details. + + TODO: remove this class once Django is updated to 4.2 or higher. + """ + @cached_property + def count(self): + return self.object_list.values("pk").count() + + +class EnterpriseCourseEnrollmentPagination(DefaultPagination): + django_paginator_class = PaginatorWithOptimizedCount + class EnterpriseCourseEnrollmentViewSet(EnterpriseReadWriteModelViewSet): """ API views for the ``enterprise-course-enrollment`` API endpoint. """ - queryset = models.EnterpriseCourseEnrollment.objects.all() + queryset = models.EnterpriseCourseEnrollment.with_additional_fields.all() + filter_backends = (filters.OrderingFilter, DjangoFilterBackend, EnterpriseCourseEnrollmentFilterBackend) USER_ID_FILTER = 'enterprise_customer_user__user_id' FIELDS = ( @@ -20,10 +71,18 @@ class EnterpriseCourseEnrollmentViewSet(EnterpriseReadWriteModelViewSet): filterset_fields = FIELDS ordering_fields = FIELDS + pagination_class = EnterpriseCourseEnrollmentPagination + + def get_queryset(self): + queryset = super().get_queryset() + if self.request.method == 'GET': + queryset = queryset.using(read_replica_or_default()) + return queryset + def get_serializer_class(self): """ Use a special serializer for any requests that aren't read-only. """ if self.request.method in ('GET',): - return serializers.EnterpriseCourseEnrollmentReadOnlySerializer + return serializers.EnterpriseCourseEnrollmentWithAdditionalFieldsReadOnlySerializer return serializers.EnterpriseCourseEnrollmentWriteSerializer diff --git a/enterprise/api/v1/views/enterprise_customer.py b/enterprise/api/v1/views/enterprise_customer.py index 1685304d17..fcd61cc80f 100644 --- a/enterprise/api/v1/views/enterprise_customer.py +++ b/enterprise/api/v1/views/enterprise_customer.py @@ -4,10 +4,11 @@ from urllib.parse import quote_plus, unquote +from algoliasearch.search_client import SearchClient from edx_rbac.decorators import permission_required from rest_framework import permissions from rest_framework.decorators import action -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import NotFound, ValidationError from rest_framework.response import Response from rest_framework.status import ( HTTP_200_OK, @@ -17,12 +18,15 @@ HTTP_409_CONFLICT, ) +from django.conf import settings from django.contrib import auth from django.core import exceptions from django.db import transaction from django.db.models import Q +from django.http import Http404 from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator +from django.utils.translation import gettext_lazy as _ from enterprise import models from enterprise.api.filters import EnterpriseLinkedUserFilterBackend @@ -436,3 +440,41 @@ def unlink_users(self, request, pk=None): # pylint: disable=unused-argument raise UnlinkUserFromEnterpriseError(msg) from exc return Response(status=HTTP_200_OK) + + @action(detail=False) + def algolia_key(self, request, *args, **kwargs): + """ + Returns an Algolia API key that is secured to only allow searching for + objects associated with enterprise customers that the user is linked to. + + This endpoint is used with `frontend-app-learner-portal-enterprise` MFE + currently. + """ + + if not (api_key := getattr(settings, "ENTERPRISE_ALGOLIA_SEARCH_API_KEY", "")): + LOGGER.warning("Algolia search API key is not configured. To enable this view, " + "set `ENTERPRISE_ALGOLIA_SEARCH_API_KEY` in settings.") + raise Http404 + + queryset = self.queryset.filter( + **{ + self.USER_ID_FILTER: request.user.id, + "enterprise_customer_users__linked": True + } + ).values_list("uuid", flat=True) + + if len(queryset) == 0: + raise NotFound(_("User is not linked to any enterprise customers.")) + + secured_key = SearchClient.generate_secured_api_key( + api_key, + { + "filters": " OR ".join( + f"enterprise_customer_uuids:{enterprise_customer_uuid}" + for enterprise_customer_uuid + in queryset + ), + } + ) + + return Response({"key": secured_key}, status=HTTP_200_OK) diff --git a/enterprise/api_client/lms.py b/enterprise/api_client/lms.py index 47e08edb49..cb06742e69 100644 --- a/enterprise/api_client/lms.py +++ b/enterprise/api_client/lms.py @@ -128,7 +128,15 @@ def has_course_mode(self, course_run_id, mode): course_modes = self.get_course_modes(course_run_id) return any(course_mode for course_mode in course_modes if course_mode['slug'] == mode) - def enroll_user_in_course(self, username, course_id, mode, cohort=None, enterprise_uuid=None): + def enroll_user_in_course( + self, + username, + course_id, + mode, + cohort=None, + enterprise_uuid=None, + force_enrollment=False, + ): """ Call the enrollment API to enroll the user in the course specified by course_id. @@ -138,6 +146,7 @@ def enroll_user_in_course(self, username, course_id, mode, cohort=None, enterpri mode (str): The enrollment mode which should be used for the enrollment cohort (str): Add the user to this named cohort enterprise_uuid (str): Add course enterprise uuid + force_enrollment (bool): Force the enrollment even if course is Invite Only Returns: dict: A dictionary containing details of the enrollment, including course details, mode, username, etc. @@ -152,7 +161,8 @@ def enroll_user_in_course(self, username, course_id, mode, cohort=None, enterpri 'is_active': True, 'mode': mode, 'cohort': cohort, - 'enterprise_uuid': str(enterprise_uuid) + 'enterprise_uuid': str(enterprise_uuid), + 'force_enrollment': force_enrollment, } ) response.raise_for_status() diff --git a/enterprise/migrations/0193_alter_enterprisecourseenrollment_options.py b/enterprise/migrations/0193_alter_enterprisecourseenrollment_options.py new file mode 100644 index 0000000000..54a3c3eb87 --- /dev/null +++ b/enterprise/migrations/0193_alter_enterprisecourseenrollment_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2 on 2023-12-29 17:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0192_auto_20231009_1302'), + ] + + operations = [ + migrations.AlterModelOptions( + name='enterprisecourseenrollment', + options={'ordering': ['id']}, + ), + ] diff --git a/enterprise/migrations/0194_hide_course_price_when_zero.py b/enterprise/migrations/0194_hide_course_price_when_zero.py new file mode 100644 index 0000000000..9e5ce7ab5e --- /dev/null +++ b/enterprise/migrations/0194_hide_course_price_when_zero.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.23 on 2023-12-08 05:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0193_alter_enterprisecourseenrollment_options'), + ] + + operations = [ + migrations.AddField( + model_name='enterprisecustomer', + name='hide_course_price_when_zero', + field=models.BooleanField(default=False, help_text='Specify whether course cost should be hidden in the landing page when the final price is zero.'), + ), + migrations.AddField( + model_name='historicalenterprisecustomer', + name='hide_course_price_when_zero', + field=models.BooleanField(default=False, help_text='Specify whether course cost should be hidden in the landing page when the final price is zero.'), + ), + ] diff --git a/enterprise/migrations/0195_allow_enrollment_in_invite_only_courses.py b/enterprise/migrations/0195_allow_enrollment_in_invite_only_courses.py new file mode 100644 index 0000000000..36038e48a5 --- /dev/null +++ b/enterprise/migrations/0195_allow_enrollment_in_invite_only_courses.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.23 on 2023-12-08 09:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0194_hide_course_price_when_zero'), + ] + + operations = [ + migrations.AddField( + model_name='enterprisecustomer', + name='allow_enrollment_in_invite_only_courses', + field=models.BooleanField(default=False, help_text="Specifies if learners are allowed to enroll into courses marked as 'invitation-only', when they attempt to enroll from the landing page."), + ), + migrations.AddField( + model_name='historicalenterprisecustomer', + name='allow_enrollment_in_invite_only_courses', + field=models.BooleanField(default=False, help_text="Specifies if learners are allowed to enroll into courses marked as 'invitation-only', when they attempt to enroll from the landing page."), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index a976cf40b2..b4bcc763f5 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -83,15 +83,21 @@ ) try: - from common.djangoapps.student.models import CourseEnrollment + from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed except ImportError: CourseEnrollment = None + CourseEnrollmentAllowed = None try: from common.djangoapps.entitlements.models import CourseEntitlement except ImportError: CourseEntitlement = None +try: + from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +except ImportError: + CourseOverview = None + LOGGER = getEnterpriseLogger(__name__) User = auth.get_user_model() mark_safe_lazy = lazy(mark_safe, str) @@ -470,6 +476,11 @@ class Meta: help_text=_("Email address that will receive learner replies to automated edX emails.") ) + hide_course_price_when_zero = models.BooleanField( + default=False, + help_text=_("Specify whether course cost should be hidden in the landing page when the final price is zero.") + ) + enable_generation_of_api_credentials = models.BooleanField( verbose_name="Allow generation of API credentials", default=False, @@ -482,6 +493,14 @@ class Meta: ), ) + allow_enrollment_in_invite_only_courses = models.BooleanField( + default=False, + help_text=_( + "Specifies if learners are allowed to enroll into courses marked as 'invitation-only', " + "when they attempt to enroll from the landing page." + ) + ) + @property def enterprise_customer_identity_provider(self): """ @@ -744,7 +763,21 @@ def enroll_user_pending_registration_with_status(self, email, course_mode, *cour license_uuid = None new_enrollments = {} + enrollment_api_client = EnrollmentApiClient() + for course_id in course_ids: + # Check if the course is "Invite Only" and add CEA if it is. + course_details = enrollment_api_client.get_course_details(course_id) + + if course_details["invite_only"]: + if not CourseEnrollmentAllowed: + raise NotConnectedToOpenEdX() + + CourseEnrollmentAllowed.objects.update_or_create( + email=email, + course_id=course_id + ) + __, created = PendingEnrollment.objects.update_or_create( user=pending_ecu, course_id=course_id, @@ -1855,11 +1888,61 @@ def get_queryset(self): """ Override to return only those enrollment records for which learner is linked to an enterprise. """ + return super().get_queryset().select_related('enterprise_customer_user').filter( enterprise_customer_user__linked=True ) +class EnterpriseCourseEnrollmentWithAdditionalFieldsManager(models.Manager): + """ + Model manager for `EnterpriseCourseEnrollment`. + """ + + def get_queryset(self): + """ + Override to return only those enrollment records for which learner is linked to an enterprise. + """ + + return super().get_queryset().select_related('enterprise_customer_user').filter( + enterprise_customer_user__linked=True + ).annotate(**self._get_additional_data_annotations()) + + def _get_additional_data_annotations(self): + """ + Return annotations with additional data for the queryset. + Additional fields are None in the test environment, where platform models are not available. + """ + + if not CourseEnrollment or not CourseOverview: + return { + 'enrollment_track': models.Value(None, output_field=models.CharField()), + 'enrollment_date': models.Value(None, output_field=models.DateTimeField()), + 'user_email': models.Value(None, output_field=models.EmailField()), + 'course_start': models.Value(None, output_field=models.DateTimeField()), + 'course_end': models.Value(None, output_field=models.DateTimeField()), + } + + enrollment_subquery = CourseEnrollment.objects.filter( + user=models.OuterRef('enterprise_customer_user__user_id'), + course_id=models.OuterRef('course_id'), + ) + user_subquery = auth.get_user_model().objects.filter( + id=models.OuterRef('enterprise_customer_user__user_id'), + ).values('email')[:1] + course_subquery = CourseOverview.objects.filter( + id=models.OuterRef('course_id'), + ) + + return { + 'enrollment_track': models.Subquery(enrollment_subquery.values('mode')[:1]), + 'enrollment_date': models.Subquery(enrollment_subquery.values('created')[:1]), + 'user_email': models.Subquery(user_subquery), + 'course_start': models.Subquery(course_subquery.values('start')[:1]), + 'course_end': models.Subquery(course_subquery.values('end')[:1]), + } + + class EnterpriseCourseEnrollment(TimeStampedModel): """ Store information about the enrollment of a user in a course. @@ -1879,11 +1962,12 @@ class EnterpriseCourseEnrollment(TimeStampedModel): """ objects = EnterpriseCourseEnrollmentManager() + with_additional_fields = EnterpriseCourseEnrollmentWithAdditionalFieldsManager() class Meta: unique_together = (('enterprise_customer_user', 'course_id',),) app_label = 'enterprise' - ordering = ['created'] + ordering = ['id'] enterprise_customer_user = models.ForeignKey( EnterpriseCustomerUser, diff --git a/enterprise/settings/test.py b/enterprise/settings/test.py index 20c1490ccd..2691b83660 100644 --- a/enterprise/settings/test.py +++ b/enterprise/settings/test.py @@ -224,6 +224,8 @@ def root(*args): 'status': 'published' } +ENTERPRISE_ALGOLIA_SEARCH_API_KEY = 'test' + SNOWFLAKE_SERVICE_USER = 'TEST@EDX.ORG' SNOWFLAKE_SERVICE_USER_PASSWORD = 'secret' diff --git a/enterprise/static/enterprise/js/manage_learners.js b/enterprise/static/enterprise/js/manage_learners.js index 5b12d4ad0b..940092467b 100644 --- a/enterprise/static/enterprise/js/manage_learners.js +++ b/enterprise/static/enterprise/js/manage_learners.js @@ -9,7 +9,7 @@ function makeOption(name, value) { return $("").text(name).val(value); } -function fillModeDropdown(data) { +function updateCourseData(data) { /* Given a set of data fetched from the enrollment API, populate the Course Mode dropdown with those options that are valid for the course entered in the @@ -19,6 +19,11 @@ function fillModeDropdown(data) { var previous_value = $course_mode.val(); applyModes(data.course_modes); $course_mode.val(previous_value); + + // If the course is invite-only, show the force enrollment box. + if (data.invite_only) { + $("#id_force_enrollment").parent().show(); + } } function applyModes(modes) { @@ -43,7 +48,7 @@ function loadCourseModes(success, failure) { return; } $.ajax({method: 'get', url: enrollmentApiRoot + "course/" + courseId}) - .done(success || fillModeDropdown) + .done(success || updateCourseData) .fail(failure || function (err, jxHR, errstat) { disableMode(disableReason); }); }); } @@ -134,11 +139,38 @@ function loadPage() { programEnrollment.$control.oldValue = null; }); + // NOTE: As the course details won't be fetched for course id in the CSV + // file, this has a potential side-effect of enrolling learners into the courses + // which might be marked as closed for reasons other then being "Invite Only". + // + // This is considered as a reasonable tradeoff at the time of this addition. + // Currently, the EnrollmentListView does not support invitation only courses. + // This problem does not happen in the Instructor Dashboard because it doesn't + // invoke access checks when calling the enroll method. Modifying the enroll method + // is a high-risk change, and it seems that the API will need some changes in + // the near future anyway - when the Instructor Dashboard is converted into an + // MFE (it could be an excellent opportunity to eliminate many legacy behaviors + // there, too). + $("#id_bulk_upload_csv").change(function(e) { + if (e.target.value) { + var force_enrollment = $("#id_force_enrollment"); + force_enrollment.parent().show(); + force_enrollment.siblings(".helptext")[0].innerHTML = gettext( + "If any of the courses in the CSV file are marked 'Invite Only', " + + "this should be enabled for the enrollments to go through in those courses." + ); + } + }); + if (courseEnrollment.$control.val()) { courseEnrollment.$control.trigger("input"); } else if (programEnrollment.$control.val()) { programEnrollment.$control.trigger("input"); } + + // hide the force_invite_only checkbox by default + $("#id_force_enrollment").parent().hide(); + $("#learner-management-form").submit(addCheckedLearnersToEnrollBox); } diff --git a/enterprise/templates/enterprise/enterprise_course_enrollment_page.html b/enterprise/templates/enterprise/enterprise_course_enrollment_page.html index 1d4a777bdf..efb644a519 100644 --- a/enterprise/templates/enterprise/enterprise_course_enrollment_page.html +++ b/enterprise/templates/enterprise/enterprise_course_enrollment_page.html @@ -77,19 +77,21 @@