diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5eb029b76..77c98efa5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,10 +17,14 @@ Unreleased ---------- * nothing unreleased -[4.29.1] +[4.30.1] -------- * feat: Creating enterprise customer members endpoint for admin portal +[4.30.0] +-------- +* feat: REST APIs for default-enterprise-enrollment-intentions + [4.29.0] -------- * feat: Create django admin for default enrollments diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 06d46d723..c39375715 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.29.1" +__version__ = "4.30.1" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index cc4edccb1..1fc184870 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -1394,7 +1394,7 @@ class DefaultEnterpriseEnrollmentIntentionAdmin(admin.ModelAdmin): class Meta: model = models.DefaultEnterpriseEnrollmentIntention - def get_queryset(self, request): # pylint: disable=unused-argument + def get_queryset(self, request): """ Return a QuerySet of all model instances. """ diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index 63b94d859..bfe59d66f 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -1921,3 +1921,170 @@ def get_enrollments(self, obj): ) return len(enrollments) return 0 + + +class DefaultEnterpriseEnrollmentIntentionSerializer(serializers.ModelSerializer): + """ + Serializer for the DefaultEnterpriseEnrollmentIntention model. + """ + + course_run_key = serializers.SerializerMethodField() + is_course_run_enrollable = serializers.SerializerMethodField() + course_run_normalized_metadata = serializers.SerializerMethodField() + applicable_enterprise_catalog_uuids = serializers.SerializerMethodField() + + class Meta: + model = models.DefaultEnterpriseEnrollmentIntention + fields = ( + 'uuid', + 'content_key', + 'enterprise_customer', + 'course_key', + 'course_run_key', + 'is_course_run_enrollable', + 'applicable_enterprise_catalog_uuids', + 'course_run_normalized_metadata', + 'created', + 'modified', + ) + + def get_course_run_key(self, obj): + """ + Get the course run key for the enrollment intention + """ + return obj.course_run_key + + def get_is_course_run_enrollable(self, obj): + """ + Get the course run enrollable status for the enrollment intention + """ + return obj.is_course_run_enrollable + + def get_course_run_normalized_metadata(self, obj): + """ + Get the course run for the enrollment intention + """ + return obj.course_run_normalized_metadata + + def get_applicable_enterprise_catalog_uuids(self, obj): + return obj.applicable_enterprise_catalog_uuids + + +class DefaultEnterpriseEnrollmentIntentionWithEnrollmentStateSerializer(DefaultEnterpriseEnrollmentIntentionSerializer): + """ + Serializer for the DefaultEnterpriseEnrollmentIntention model with enrollment state. + """ + has_existing_enrollment = serializers.SerializerMethodField() + is_existing_enrollment_active = serializers.SerializerMethodField() + is_existing_enrollment_audit = serializers.SerializerMethodField() + + class Meta(DefaultEnterpriseEnrollmentIntentionSerializer.Meta): + fields = DefaultEnterpriseEnrollmentIntentionSerializer.Meta.fields + ( + 'has_existing_enrollment', + 'is_existing_enrollment_active', + 'is_existing_enrollment_audit', + ) + + def get_has_existing_enrollment(self, obj): # pylint: disable=unused-argument + return bool(self.context.get('existing_enrollment', None)) + + def get_is_existing_enrollment_active(self, obj): # pylint: disable=unused-argument + existing_enrollment = self.context.get('existing_enrollment', None) + if not existing_enrollment: + return None + return existing_enrollment.is_active + + def get_is_existing_enrollment_audit(self, obj): # pylint: disable=unused-argument + existing_enrollment = self.context.get('existing_enrollment', None) + if not existing_enrollment: + return None + return existing_enrollment.is_audit_enrollment + + +class DefaultEnterpriseEnrollmentIntentionLearnerStatusSerializer(serializers.Serializer): + """ + Serializer for the DefaultEnterpriseEnrollmentIntentionLearnerStatus model. + """ + + lms_user_id = serializers.IntegerField() + user_email = serializers.EmailField() + enterprise_customer_uuid = serializers.UUIDField() + enrollment_statuses = serializers.SerializerMethodField() + metadata = serializers.SerializerMethodField() + + def needs_enrollment_counts(self): + """ + Return the counts of needs_enrollment. + """ + needs_enrollment = self.context.get('needs_enrollment', {}) + needs_enrollment_enrollable = needs_enrollment.get('enrollable', []) + needs_enrollment_not_enrollable = needs_enrollment.get('not_enrollable', []) + + return { + 'enrollable': len(needs_enrollment_enrollable), + 'not_enrollable': len(needs_enrollment_not_enrollable), + } + + def already_enrolled_count(self): + """ + Return the count of already enrolled. + """ + already_enrolled = self.context.get('already_enrolled', {}) + return len(already_enrolled) + + def total_default_enrollment_intention_count(self): + """ + Return the total count of default enrollment intentions. + """ + needs_enrollment_counts = self.needs_enrollment_counts() + total_needs_enrollment_enrollable = needs_enrollment_counts['enrollable'] + total_needs_enrollment_not_enrollable = needs_enrollment_counts['not_enrollable'] + return total_needs_enrollment_enrollable + total_needs_enrollment_not_enrollable + self.already_enrolled_count() + + def serialize_intentions(self, default_enrollment_intentions): + """ + Helper function to handle tuple unpacking and serialization. + """ + serialized_data = [] + for intention_tuple in default_enrollment_intentions: + intention, existing_enrollment = intention_tuple + data = DefaultEnterpriseEnrollmentIntentionWithEnrollmentStateSerializer( + intention, + context={'existing_enrollment': existing_enrollment}, + ).data + serialized_data.append(data) + return serialized_data + + def get_enrollment_statuses(self, obj): # pylint: disable=unused-argument + """ + Return default enterprise enrollment intentions partitioned by + the enrollment statuses for the learner. + """ + needs_enrollment = self.context.get('needs_enrollment', {}) + needs_enrollment_enrollable = needs_enrollment.get('enrollable', []) + needs_enrollment_not_enrollable = needs_enrollment.get('not_enrollable', []) + already_enrolled = self.context.get('already_enrolled', {}) + + needs_enrollment_enrollable_data = self.serialize_intentions(needs_enrollment_enrollable) + needs_enrollment_not_enrollable_data = self.serialize_intentions(needs_enrollment_not_enrollable) + already_enrolled_data = self.serialize_intentions(already_enrolled) + + return { + 'needs_enrollment': { + 'enrollable': needs_enrollment_enrollable_data, + 'not_enrollable': needs_enrollment_not_enrollable_data, + }, + 'already_enrolled': already_enrolled_data, + } + + def get_metadata(self, obj): # pylint: disable=unused-argument + """ + Return the metadata for the default enterprise enrollment intention, including + number of default enterprise enrollment intentions that need enrollment, are already + enrolled by the learner. + """ + return { + 'total_default_enterprise_course_enrollments': self.total_default_enrollment_intention_count(), + 'total_needs_enrollment': self.needs_enrollment_counts(), + 'total_already_enrolled': self.already_enrolled_count(), + } diff --git a/enterprise/api/v1/urls.py b/enterprise/api/v1/urls.py index 456f7975f..3d276bd93 100644 --- a/enterprise/api/v1/urls.py +++ b/enterprise/api/v1/urls.py @@ -9,6 +9,7 @@ from enterprise.api.v1.views import ( analytics_summary, coupon_codes, + default_enterprise_enrollments, enterprise_catalog_query, enterprise_course_enrollment, enterprise_customer, @@ -83,6 +84,11 @@ router.register( "enterprise_group", enterprise_group.EnterpriseGroupViewSet, 'enterprise-group' ) +router.register( + "default-enterprise-enrollment-intentions", + default_enterprise_enrollments.DefaultEnterpriseEnrollmentIntentionViewSet, + 'default-enterprise-enrollment-intentions' +) urlpatterns = [ diff --git a/enterprise/api/v1/views/default_enterprise_enrollments.py b/enterprise/api/v1/views/default_enterprise_enrollments.py new file mode 100644 index 000000000..2409317d7 --- /dev/null +++ b/enterprise/api/v1/views/default_enterprise_enrollments.py @@ -0,0 +1,272 @@ +""" +Views for default enterprise enrollments. +""" + +from uuid import UUID + +from edx_rbac.mixins import PermissionRequiredForListingMixin +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.utils.functional import cached_property + +from enterprise import models +from enterprise.api.v1 import serializers +from enterprise.api.v1.views.base_views import EnterpriseViewSet +from enterprise.constants import ( + DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_PERMISSION, + DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE, +) + + +class DefaultEnterpriseEnrollmentIntentionViewSet( + PermissionRequiredForListingMixin, + EnterpriseViewSet, + viewsets.ReadOnlyModelViewSet, +): + """ + API views for default enterprise enrollment intentions + """ + + permission_required = DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_PERMISSION + list_lookup_field = 'enterprise_customer__uuid' + allowed_roles = [DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE] + serializer_class = serializers.DefaultEnterpriseEnrollmentIntentionSerializer + + @property + def requested_enterprise_customer_uuid(self): + """ + Get and validate the enterprise customer UUID from the query parameters. + """ + if not (enterprise_customer_uuid := self.request.query_params.get('enterprise_customer_uuid')): + raise ValidationError({"detail": "enterprise_customer_uuid is a required query parameter."}) + + try: + return UUID(enterprise_customer_uuid) + except ValueError as exc: + raise ValidationError({ + "detail": "enterprise_customer_uuid query parameter is not a valid UUID." + }) from exc + + @property + def requested_lms_user_id(self): + """ + Get the (optional) LMS user ID from the request. + """ + return self.request.query_params.get('lms_user_id') + + @property + def base_queryset(self): + """ + Required by the `PermissionRequiredForListingMixin`. + For non-list actions, this is what's returned by `get_queryset()`. + For list actions, some non-strict subset of this is what's returned by `get_queryset()`. + """ + return models.DefaultEnterpriseEnrollmentIntention.objects.filter( + enterprise_customer=self.requested_enterprise_customer_uuid, + ) + + @cached_property + def user_for_learner_status(self): + """ + Get the user for learner status based on the request. + """ + if self.request.user.is_staff and self.requested_lms_user_id is not None: + # If the user is staff and a lms_user_id is provided, return the specified user. + User = get_user_model() + try: + return User.objects.get(id=self.requested_lms_user_id) + except User.DoesNotExist: + return None + + # Otherwise, return the request user. + return self.request.user + + def get_permission_object(self): + """ + Used for "retrieve" actions. Determines the context (enterprise UUID) to check + against for role-based permissions. + """ + return str(self.requested_enterprise_customer_uuid) + + @action(detail=False, methods=['get'], url_path='learner-status') + def learner_status(self, request): + """ + Get the status of the learner's enrollment in the default enterprise course. + """ + # Validate the enterprise customer uuid. + try: + enterprise_customer_uuid = self.requested_enterprise_customer_uuid + except ValidationError as exc: + return Response(exc, status=status.HTTP_400_BAD_REQUEST) + + # Validate the user for learner status exists and is associated + # with the enterprise customer. + if not self.user_for_learner_status: + return Response( + {'detail': f'User with lms_user_id {self.requested_lms_user_id} not found.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + enterprise_customer_user = models.EnterpriseCustomerUser.objects.get( + user_id=self.user_for_learner_status.id, + enterprise_customer=enterprise_customer_uuid, + ) + except models.EnterpriseCustomerUser.DoesNotExist: + return Response( + { + 'detail': ( + f'User with lms_user_id {self.user_for_learner_status.id} is not associated with ' + f'the enterprise customer {enterprise_customer_uuid}.' + ), + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Retrieve configured default enrollment intentions for the enterprise customer + default_enrollment_intentions_for_customer = models.DefaultEnterpriseEnrollmentIntention.objects.filter( + enterprise_customer=enterprise_customer_uuid, + ) + + # Retrieve the course enrollments for the learner + enterprise_course_enrollments_for_learner = models.EnterpriseCourseEnrollment.objects.filter( + enterprise_customer_user=enterprise_customer_user, + ) + + serializer_data = { + 'lms_user_id': self.user_for_learner_status.id, + 'user_email': self.user_for_learner_status.email, + 'enterprise_customer_uuid': enterprise_customer_uuid, + } + serializer = serializers.DefaultEnterpriseEnrollmentIntentionLearnerStatusSerializer( + data=serializer_data, + context=self._get_serializer_context_for_learner_status( + default_enrollment_intentions_for_customer, + enterprise_course_enrollments_for_learner, + ), + ) + serializer.is_valid(raise_exception=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def _partition_course_enrollments_by_audit(self, enterprise_course_enrollments): + """ + Partition active enterprise course enrollments into audit and non-audit. + + Arguments: + enterprise_enrollments: List of tuples containing course_id and whether enrollment is audit. + + Returns: + Tuple of two lists containing course ids: (enrolled_non_audit, enrolled_audit) + """ + enrolled_non_audit = [] + enrolled_audit = [] + for enrollment in enterprise_course_enrollments: + (enrolled_audit if enrollment.is_audit_enrollment else enrolled_non_audit).append(enrollment) + return enrolled_non_audit, enrolled_audit + + def _get_audit_modes(self): + """ + Get the configured audit modes from settings. + """ + return getattr(settings, 'ENTERPRISE_COURSE_ENROLLMENT_AUDIT_MODES', ['audit', 'honor']) + + def _partition_default_enrollment_intentions_by_enrollment_status( + self, + default_enrollment_intentions, + enrolled_non_audit, + enrolled_audit, + ): + """ + Partition default enrollment intentions into enrollable and non-enrollable. + + Arguments: + default_enrollment_intentiosn: List of default enrollment intentions. + enrolled_non_audit: List of course runs in which the learner is enrolled in non-audit mode. + enrolled_audit: List of course runs in which the learner is enrolled in audit mode. + + Returns: + Tuple of three lists: (already_enrolled, needs_enrollment_enrollable, needs_enrollment_not_enrollable) + """ + already_enrolled = [] + needs_enrollment_enrollable = [] + needs_enrollment_not_enrollable = [] + + non_audit_enrollments_dict = {enrollment.course_id: enrollment for enrollment in enrolled_non_audit} + audit_enrollments_dict = {enrollment.course_id: enrollment for enrollment in enrolled_audit} + + for intention in default_enrollment_intentions: + has_applicable_catalogs = intention.applicable_enterprise_catalog_uuids + non_audit_enrollment = non_audit_enrollments_dict.get(intention.course_run_key, None) + audit_enrollment = audit_enrollments_dict.get(intention.course_run_key, None) + + if non_audit_enrollment and non_audit_enrollment.is_active: + # Learner is already enrolled (is_active=True) in non-audit mode for this course run + already_enrolled.append((intention, non_audit_enrollment)) + continue + + if not intention.is_course_run_enrollable: + # Course run is not enrollable + needs_enrollment_not_enrollable.append((intention, audit_enrollment)) + continue + + has_non_audit_mode_for_course_run = intention.best_mode_for_course_run not in self._get_audit_modes() + is_audit_enrollment_with_non_audit_modes = audit_enrollment and has_non_audit_mode_for_course_run + + # NOTE: The order of the following conditions is crucial for correctly categorizing + # default enrollment intentions based on the learner's enrollment state. Changing the + # order may result in incorrect handling of different enrollment scenarios, such as + # unenrolled vs. enrolled states (audit vs. verified). If you need to modify the order, + # ensure you understand, verify, and test the changes. + if is_audit_enrollment_with_non_audit_modes and has_applicable_catalogs: + # Learner is enrolled in this course run in audit, there exists a non-audit mode, and + # there are applicable catalogs for potential upgrade to paid mode. + needs_enrollment_enrollable.append((intention, audit_enrollment)) + elif is_audit_enrollment_with_non_audit_modes and not has_applicable_catalogs: + # Learner is enrolled in this course run in audit, there exists a non-audit mode, but + # there are no applicable catalogs. + needs_enrollment_not_enrollable.append((intention, audit_enrollment)) + elif audit_enrollment and audit_enrollment.is_active and not has_non_audit_mode_for_course_run: + # Learner is enrolled in this course run in audit, there are no non-audit modes. As such, + # there's no potential upgrade needed and should be considered already enrolled. + already_enrolled.append((intention, audit_enrollment)) + elif not has_applicable_catalogs: + # Learner is not enrolled in this course run, audit or otherwise; though enrollment is needed + # there are no applicable catalogs containing the course run (not enrollable). + needs_enrollment_not_enrollable.append((intention, non_audit_enrollment or audit_enrollment)) + else: + # Learner is not yet enrolled in this course run, audit or otherwise; enrollment is needed (enrollable). + needs_enrollment_enrollable.append((intention, non_audit_enrollment or audit_enrollment)) + + return already_enrolled, needs_enrollment_enrollable, needs_enrollment_not_enrollable + + def _get_serializer_context_for_learner_status( + self, + default_enrollment_intentions_for_customer, + enterprise_course_enrollments_for_learner, + ): + """ + Get the serializer context for learner status, grouping the default enrollment intentions + based on the learner's enrollment status and whether the course run is currently enrollable. + """ + enrolled_non_audit, enrolled_audit = self._partition_course_enrollments_by_audit( + enterprise_course_enrollments_for_learner + ) + already_enrolled, needs_enrollment_enrollable, needs_enrollment_not_enrollable = ( + self._partition_default_enrollment_intentions_by_enrollment_status( + default_enrollment_intentions_for_customer, + enrolled_non_audit, + enrolled_audit, + ) + ) + return { + 'needs_enrollment': { + 'enrollable': needs_enrollment_enrollable, + 'not_enrollable': needs_enrollment_not_enrollable, + }, + 'already_enrolled': already_enrolled, + } diff --git a/enterprise/api_client/enterprise_catalog.py b/enterprise/api_client/enterprise_catalog.py index a3e41f960..1ac011172 100644 --- a/enterprise/api_client/enterprise_catalog.py +++ b/enterprise/api_client/enterprise_catalog.py @@ -376,11 +376,14 @@ def enterprise_contains_content_items(self, enterprise_uuid, content_ids): The endpoint does not differentiate between course_run_ids and program_uuids so they can be used interchangeably. The two query parameters are left in for backwards compatability with edx-enterprise. """ - query_params = {'course_run_ids': content_ids} + query_params = { + 'course_run_ids': content_ids, + 'get_catalogs_containing_specified_content_ids': True, + } api_url = self.get_api_url(f"{self.ENTERPRISE_CUSTOMER_ENDPOINT}/{enterprise_uuid}/contains_content_items") response = self.client.get(api_url, params=query_params) response.raise_for_status() - return response.json()['contains_content_items'] + return response.json() @UserAPIClient.refresh_token def get_content_metadata_content_identifier(self, enterprise_uuid, content_id): diff --git a/enterprise/cache_utils.py b/enterprise/cache_utils.py index 7e8252460..fae42400b 100644 --- a/enterprise/cache_utils.py +++ b/enterprise/cache_utils.py @@ -19,7 +19,7 @@ def versioned_cache_key(*args): """ components = [str(arg) for arg in args] components.append(code_version) - if stamp_from_settings := getattr(settings, 'CACHE_KEY_VERSION_STAMP', None): + if stamp_from_settings := getattr(settings, 'ENTERPRISE_CACHE_KEY_VERSION_STAMP', None): components.append(stamp_from_settings) decoded_cache_key = CACHE_KEY_SEP.join(components) return hashlib.sha512(decoded_cache_key.encode()).hexdigest() diff --git a/enterprise/constants.py b/enterprise/constants.py index 87d2cec4a..127627303 100644 --- a/enterprise/constants.py +++ b/enterprise/constants.py @@ -150,6 +150,11 @@ class CourseModes: ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE = 'reporting_config_admin' ENTERPRISE_FULFILLMENT_OPERATOR_ROLE = 'fulfillment_operator' ENTERPRISE_SSO_ORCHESTRATOR_OPERATOR_ROLE = 'sso_orchestrator_operator' + +# Default enterprise enrollment roles/permissions +DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE = 'default_enterprise_enrollment_intentions_learner' +DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_PERMISSION = 'enterprise.can_view_default_enterprise_enrollment_intentions' + # Provisioning admins roles: PROVISIONING_ENTERPRISE_CUSTOMER_ADMIN_ROLE = 'provisioning_enterprise_customer_admin' PROVISIONING_PENDING_ENTERPRISE_CUSTOMER_ADMIN_ROLE = 'provisioning_pending_enterprise_customer_users_admin' diff --git a/enterprise/content_metadata/api.py b/enterprise/content_metadata/api.py index 4023c1603..ac109e5ce 100644 --- a/enterprise/content_metadata/api.py +++ b/enterprise/content_metadata/api.py @@ -58,3 +58,41 @@ def get_and_cache_customer_content_metadata(enterprise_customer_uuid, content_ke ) TieredCache.set_all_tiers(cache_key, result, timeout or DEFAULT_CACHE_TIMEOUT) return result + + +def get_and_cache_enterprise_contains_content_items(enterprise_customer_uuid, content_keys, timeout=None): + """ + Returns whether the provided content keys are present in the catalogs + associated with the provided enterprise customer, in addition to a list + of catalog UUIDs containing the content keys. + + The response is cached in a ``TieredCache``. + + Returns: Dict containing `contains_content_items` and `catalog_list` properties. + Raises: An HTTPError if there's a problem checking catalog inclusion + via the enterprise-catalog service. + """ + cache_key = versioned_cache_key('get_enterprise_contains_content_items', enterprise_customer_uuid, content_keys) + cached_response = TieredCache.get_cached_response(cache_key) + if cached_response.is_found: + logger.info(f'cache hit for enterprise customer {enterprise_customer_uuid} and content keys {content_keys}') + return cached_response.value + + try: + result = EnterpriseCatalogApiClient().enterprise_contains_content_items( + enterprise_uuid=enterprise_customer_uuid, + content_ids=content_keys, + ) + except HTTPError as exc: + raise exc + + if not result: + logger.warning('No content items found for customer %s', enterprise_customer_uuid) + return {} + + logger.info( + 'Fetched content catalog inclusion for enterprise customer %s and content keys %s. Result = %s', + enterprise_customer_uuid, content_keys, result, + ) + TieredCache.set_all_tiers(cache_key, result, timeout or DEFAULT_CACHE_TIMEOUT) + return result diff --git a/enterprise/models.py b/enterprise/models.py index ea0619acd..e65186610 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -61,7 +61,10 @@ FulfillmentTypes, json_serialized_course_modes, ) -from enterprise.content_metadata.api import get_and_cache_customer_content_metadata +from enterprise.content_metadata.api import ( + get_and_cache_customer_content_metadata, + get_and_cache_enterprise_contains_content_items, +) from enterprise.errors import LinkUserToEnterpriseError from enterprise.event_bus import send_learner_credit_course_enrollment_revoked_event from enterprise.logging import getEnterpriseLogger @@ -761,10 +764,11 @@ def catalog_contains_course(self, course_run_id): Returns: bool: Whether the enterprise catalog includes the given course run. """ - if EnterpriseCatalogApiClient().enterprise_contains_content_items(self.uuid, [course_run_id]): - return True - - return False + contains_content_items_response = EnterpriseCatalogApiClient().enterprise_contains_content_items( + self.uuid, + [course_run_id], + ) + return contains_content_items_response.get('contains_content_items', False) def enroll_user_pending_registration_with_status(self, email, course_mode, *course_ids, **kwargs): """ @@ -2558,6 +2562,28 @@ def course_run(self): {} ) + @property + def best_mode_for_course_run(self): + """ + Returns the best mode for the course run. + """ + if not self.course_run_key: + return None + return utils.get_best_mode_from_course_key(self.course_run_key) + + @property + def course_run_normalized_metadata(self): + """ + Normalized metadata for the course run. + """ + metadata = self.content_metadata_for_content_key + if not metadata: + return {} + + course_run_key = self.course_run_key + normalized_metadata_by_run = metadata.get('normalized_metadata_by_run', {}) + return normalized_metadata_by_run.get(course_run_key, {}) + @property def course_key(self): """ @@ -2579,7 +2605,7 @@ def is_course_run_enrollable(self): # pragma: no cover """ Whether the course run is enrollable. """ - return False + return self.course_run.get('is_enrollable', False) @property def course_run_enroll_by_date(self): # pragma: no cover @@ -2588,6 +2614,17 @@ def course_run_enroll_by_date(self): # pragma: no cover """ return datetime.datetime.min + @property + def applicable_enterprise_catalog_uuids(self): + """ + Returns a list of UUIDs for applicable enterprise catalogs. + """ + contains_content_items_response = get_and_cache_enterprise_contains_content_items( + enterprise_customer_uuid=self.enterprise_customer.uuid, + content_keys=[self.course_run_key], + ) + return contains_content_items_response.get('catalog_list', []) + def determine_content_type(self): """ Determines the content_type for a given content_key by validating the return value @@ -2636,9 +2673,7 @@ def clean(self): 'content key already exists, but is soft-deleted. Please restore ' 'it here.', ).format(existing_record_admin_url=existing_record_admin_url) - raise ValidationError({ - 'content_key': mark_safe(message) - }) + raise ValidationError({'content_key': mark_safe(message)}) if not self.course_run: # NOTE: This validation check also acts as an inferred check on the derived content_type diff --git a/enterprise/rules.py b/enterprise/rules.py index 7b2a3a695..9cd580baf 100644 --- a/enterprise/rules.py +++ b/enterprise/rules.py @@ -9,6 +9,8 @@ from edx_rest_framework_extensions.auth.jwt.cookies import get_decoded_jwt from enterprise.constants import ( + DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_PERMISSION, + DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE, ENTERPRISE_CATALOG_ADMIN_ROLE, ENTERPRISE_CUSTOMER_PROVISIONING_ADMIN_ACCESS_PERMISSION, ENTERPRISE_DASHBOARD_ADMIN_ROLE, @@ -242,6 +244,27 @@ def has_explicit_access_to_reporting_api(user, obj): ) +@rules.predicate +def has_implicit_access_to_default_enterprise_enrollment_intentions(user, obj): # pylint: disable=unused-argument + """ + Check that if request user has implicit access to `ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE` feature role. + + Params: + user: An ``auth.User`` instance. + obj: The string version of an ``EnterpriseCustomer.uuid``. + + Returns: + boolean: whether the request user has access or not + """ + request = crum.get_current_request() + decoded_jwt = get_decoded_jwt(request) or get_decoded_jwt_from_auth(request) + return request_user_has_implicit_access_via_jwt( + decoded_jwt, + DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE, + str(obj) + ) + + rules.add_perm('enterprise.can_access_admin_dashboard', has_implicit_access_to_dashboard | has_explicit_access_to_dashboard) @@ -270,3 +293,8 @@ def has_explicit_access_to_reporting_api(user, obj): PENDING_ENT_CUSTOMER_ADMIN_PROVISIONING_ADMIN_ACCESS_PERMISSION, has_implicit_access_to_provisioning_pending_enterprise_customer_admin_users, ) + +rules.add_perm( + DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_PERMISSION, + has_implicit_access_to_default_enterprise_enrollment_intentions, +) diff --git a/enterprise/settings/test.py b/enterprise/settings/test.py index 8952ae926..dbc456b5d 100644 --- a/enterprise/settings/test.py +++ b/enterprise/settings/test.py @@ -11,11 +11,13 @@ from celery import Celery from enterprise.constants import ( + DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE, ENTERPRISE_ADMIN_ROLE, ENTERPRISE_CATALOG_ADMIN_ROLE, ENTERPRISE_DASHBOARD_ADMIN_ROLE, ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE, ENTERPRISE_FULFILLMENT_OPERATOR_ROLE, + ENTERPRISE_LEARNER_ROLE, ENTERPRISE_OPERATOR_ROLE, ENTERPRISE_SSO_ORCHESTRATOR_OPERATOR_ROLE, PROVISIONING_ENTERPRISE_CUSTOMER_ADMIN_ROLE, @@ -232,10 +234,14 @@ def root(*args): # For testing edx-rbac rules. This is not the actual value of the setting in prod. SYSTEM_TO_FEATURE_ROLE_MAPPING = { + ENTERPRISE_LEARNER_ROLE: [ + DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE, + ], ENTERPRISE_ADMIN_ROLE: [ ENTERPRISE_DASHBOARD_ADMIN_ROLE, ENTERPRISE_CATALOG_ADMIN_ROLE, - ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE + ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE, + DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE, ], ENTERPRISE_OPERATOR_ROLE: [ ENTERPRISE_DASHBOARD_ADMIN_ROLE, @@ -243,6 +249,7 @@ def root(*args): ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE, ENTERPRISE_FULFILLMENT_OPERATOR_ROLE, ENTERPRISE_SSO_ORCHESTRATOR_OPERATOR_ROLE, + DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE, ], SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE: [ PROVISIONING_ENTERPRISE_CUSTOMER_ADMIN_ROLE, diff --git a/test_utils/factories.py b/test_utils/factories.py index ded8506fa..49f4c0974 100644 --- a/test_utils/factories.py +++ b/test_utils/factories.py @@ -1156,6 +1156,5 @@ class Meta: uuid = factory.LazyAttribute(lambda x: UUID(FAKER.uuid4())) enterprise_customer = factory.SubFactory(EnterpriseCustomerFactory) - content_type = "course" - content_key = "edX+demoX" - factory.SubFactory(EnterpriseCourseEnrollmentFactory) + content_type = DefaultEnterpriseEnrollmentIntention.COURSE + content_key = "edX+DemoX" diff --git a/test_utils/fake_catalog_api.py b/test_utils/fake_catalog_api.py index 44d3f3343..a8ddeb61d 100644 --- a/test_utils/fake_catalog_api.py +++ b/test_utils/fake_catalog_api.py @@ -111,7 +111,7 @@ 'seats': [ { 'type': 'audit', - 'price': '0.00', + 'price': 0, 'currency': 'USD', 'upgrade_deadline': None, 'credit_provider': None, @@ -120,7 +120,7 @@ }, { 'type': 'verified', - 'price': '149.00', + 'price': 149, 'currency': 'USD', 'upgrade_deadline': '2018-08-03T16:44:26.595896Z', 'credit_provider': None, @@ -128,6 +128,7 @@ 'sku': '8CF08E5' } ], + 'first_enrollable_paid_seat_price': 149, 'start': '2013-02-05T05:00:00Z', 'end': '3000-12-31T18:00:00Z', 'enrollment_start': None, @@ -136,6 +137,8 @@ 'pacing_type': 'instructor_paced', 'type': 'verified', 'status': 'published', + "is_enrollable": True, + "is_marketable": True, 'course': 'edX+DemoX', 'full_description': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', 'announcement': None, @@ -265,6 +268,7 @@ FAKE_COURSE = { 'key': 'edX+DemoX', + 'course_type': 'course', 'uuid': 'a9e8bb52-0c8d-4579-8496-1a8becb0a79c', 'title': 'edX Demonstration Course', 'course_runs': [FAKE_COURSE_RUN], @@ -298,7 +302,24 @@ 'content_type': 'course', 'enrollment_url': FAKE_URL, 'programs': [], - 'content_last_modified': '2020-08-18T00:32:33.754662Z' + 'content_last_modified': '2020-08-18T00:32:33.754662Z', + 'advertised_course_run_uuid': FAKE_COURSE_RUN.get('uuid'), + 'normalized_metadata': { + 'start_date': FAKE_COURSE_RUN.get('start'), + 'end_date': FAKE_COURSE_RUN.get('end'), + 'enroll_by_date': FAKE_COURSE_RUN.get('seats')[1].get('upgrade_deadline'), + 'enroll_start_date': FAKE_COURSE_RUN.get('enrollment_start'), + 'content_price': FAKE_COURSE_RUN.get('first_enrollable_paid_seat_price'), + }, + 'normalized_metadata_by_run': { + FAKE_COURSE_RUN.get('key'): { + 'start_date': FAKE_COURSE_RUN.get('start'), + 'end_date': FAKE_COURSE_RUN.get('end'), + 'enroll_by_date': FAKE_COURSE_RUN.get('seats')[1].get('upgrade_deadline'), + 'enroll_start_date': FAKE_COURSE_RUN.get('enrollment_start'), + 'content_price': FAKE_COURSE_RUN.get('first_enrollable_paid_seat_price'), + } + } } FAKE_PROGRAM_RESPONSE1 = { @@ -1272,7 +1293,6 @@ "is_program_eligible_for_one_click_purchase": True } - FAKE_SEARCH_ALL_RESULTS = { "count": 3, "next": None, @@ -1376,6 +1396,11 @@ 'catalog_modified': '2020-07-16T15:11:10.521611Z' } +FAKE_ENTERPRISE_CONTAINS_CONTENT_ITEMS_RESPONSE = { + 'contains_content_items': False, + 'catalog_list': [] +} + def get_catalog_courses(catalog_id): """ @@ -1644,6 +1669,18 @@ def get_fake_content_metadata_no_program(): return list(content_metadata.values()) +def get_fake_enterprise_contains_content_items_response(contains_content_items=False, catalog_list=None): + """ + Returns a fake response from EnterpriseCatalogApiClient.enterprise_contains_content_items. + """ + mock_response = FAKE_ENTERPRISE_CONTAINS_CONTENT_ITEMS_RESPONSE.copy() + if contains_content_items is not None: + mock_response['contains_content_items'] = contains_content_items + if catalog_list: + mock_response['catalog_list'] = catalog_list + return mock_response + + class CourseDiscoveryApiTestMixin: """ Mixin for course discovery API test classes. diff --git a/tests/test_admin/test_forms.py b/tests/test_admin/test_forms.py index 8b7d29f4a..0b731df87 100644 --- a/tests/test_admin/test_forms.py +++ b/tests/test_admin/test_forms.py @@ -210,7 +210,9 @@ def test_clean_course_valid(self, enrollment_client, enterprise_catalog_client): enrollment_instance = enrollment_client.return_value enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } course_id = "course-v1:edX+DemoX+Demo_Course" course_details = fake_enrollment_api.COURSE_DETAILS[course_id] course_mode = "audit" @@ -235,7 +237,9 @@ def test_clean_valid_course_empty_mode(self, enrollment_client, enterprise_catal enrollment_instance = enrollment_client.return_value enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } course_id = "course-v1:edX+DemoX+Demo_Course" form = self._make_bound_form("irrelevant@example.com", course=course_id, course_mode="") assert not form.is_valid() @@ -247,7 +251,9 @@ def test_clean_valid_course_invalid_mode(self, enrollment_client, enterprise_cat enrollment_instance = enrollment_client.return_value enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } course_id = "course-v1:edX+DemoX+Demo_Course" course_mode = "verified" form = self._make_bound_form("irrelevant@example.com", course=course_id, course_mode=course_mode) @@ -352,7 +358,9 @@ def test_validate_bulk_upload_fields( course_id = 'course-v1:edX+DemoX+Demo_Course' enrollment_instance.get_course_details.return_value = fake_enrollment_api.get_course_details(course_id) enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } form = self._make_bound_form( "", course=course, @@ -375,7 +383,9 @@ def test_validate_reason(self, enrollment_client, enterprise_catalog_client): enrollment_instance = enrollment_client.return_value enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } course_id = "course-v1:edX+DemoX+Demo_Course" reason = "" form = self._make_bound_form("irrelevant@example.com", course=course_id, reason=reason, course_mode="audit") diff --git a/tests/test_admin/test_view.py b/tests/test_admin/test_view.py index 7bb7ade0e..7e2ada972 100644 --- a/tests/test_admin/test_view.py +++ b/tests/test_admin/test_view.py @@ -988,7 +988,9 @@ def test_post_enroll_user( enrollment_instance.enroll_user_in_course.side_effect = fake_enrollment_api.enroll_user_in_course enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } user = UserFactory() course_id = "course-v1:HarvardX+CoolScience+2016" @@ -1062,7 +1064,9 @@ def _post_multi_enroll( enrollment_instance.enroll_user_in_course.side_effect = fake_enrollment_api.enroll_user_in_course enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } enrollment_count = 0 user = None @@ -1170,7 +1174,9 @@ def test_post_enroll_no_course_detail( enrollment_instance.enroll_user_in_course.side_effect = fake_enrollment_api.enroll_user_in_course enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } user = UserFactory() course_id = "course-v1:HarvardX+CoolScience+2016" @@ -1221,7 +1227,9 @@ def test_post_enroll_course_when_enrollment_closed( enrollment_instance.get_course_enrollment.side_effect = fake_enrollment_api.get_course_enrollment enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } user = UserFactory() course_id = "course-v1:HarvardX+CoolScience+2016" @@ -1269,7 +1277,9 @@ def test_post_enroll_course_when_enrollment_closed_mode_changed( enrollment_instance.get_course_enrollment.side_effect = fake_enrollment_api.get_course_enrollment enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } user = UserFactory() course_id = "course-v1:HarvardX+CoolScience+2016" @@ -1310,7 +1320,9 @@ def test_post_enroll_course_when_enrollment_closed_no_sce_exists( enrollment_instance.get_course_enrollment.return_value = None enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } user = UserFactory() course_id = "course-v1:HarvardX+CoolScience+2016" @@ -1355,7 +1367,9 @@ def test_post_enroll_with_missing_course_start_date( enrollment_instance.enroll_user_in_course.side_effect = fake_enrollment_api.enroll_user_in_course enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } user = UserFactory() course_id = "course-v1:HarvardX+CoolScience+2016" @@ -1405,7 +1419,9 @@ def test_post_enrollment_error( ) enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } user = UserFactory() course_id = "course-v1:HarvardX+CoolScience+2016" @@ -1438,7 +1454,9 @@ def test_post_enrollment_error_bad_error_string( ) enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } user = UserFactory() course_id = "course-v1:HarvardX+CoolScience+2016" @@ -1637,7 +1655,9 @@ def test_post_create_course_enrollments( enrollment_instance = enrollment_client.return_value course_catalog_instance = course_catalog_client.return_value enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = course_in_catalog + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': course_in_catalog, + } create_enrollment_audit_mock.return_value = True enroll_users_in_course_mock.return_value = [user], [], [] @@ -1822,7 +1842,9 @@ def test_post_link_and_enroll( enrollment_instance.enroll_user_in_course.side_effect = fake_enrollment_api.enroll_user_in_course enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } self._login() user = UserFactory.create() @@ -1878,7 +1900,9 @@ def test_post_link_and_enroll_no_course_details( enrollment_instance.enroll_user_in_course.side_effect = fake_enrollment_api.enroll_user_in_course enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } self._login() user = UserFactory.create() @@ -1927,7 +1951,9 @@ def test_post_link_and_enroll_no_notification( enrollment_instance.enroll_user_in_course.side_effect = fake_enrollment_api.enroll_user_in_course enrollment_instance.get_course_details.side_effect = fake_enrollment_api.get_course_details enterprise_catalog_instance = enterprise_catalog_client.return_value - enterprise_catalog_instance.enterprise_contains_content_items.return_value = True + enterprise_catalog_instance.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } self._login() user = UserFactory.create() diff --git a/tests/test_consent/api/test_views.py b/tests/test_consent/api/test_views.py index aa2cbda4f..8659a26cc 100644 --- a/tests/test_consent/api/test_views.py +++ b/tests/test_consent/api/test_views.py @@ -1098,7 +1098,9 @@ def test_consent_api_post_endpoint_course_not_in_catalog( self.discovery_client.is_course_in_catalog.return_value = False self.discovery_client.get_course_id.return_value = TEST_COURSE_KEY catalog_api_client_mock.return_value.contains_content_items.return_value = False - catalog_api_client_mock.return_value.enterprise_contains_content_items.return_value = False + catalog_api_client_mock.return_value.enterprise_contains_content_items.return_value = { + 'contains_content_items': False + } create_items(factory, items) response = self.client.post(self.path, request_body) self._assert_expectations(response, expected_response_body, expected_status_code) @@ -1519,7 +1521,9 @@ def test_consent_api_delete_endpoint_course_not_in_catalog( catalog_api_client_mock, ): catalog_api_client_mock.return_value.contains_content_items.return_value = False - catalog_api_client_mock.return_value.enterprise_contains_content_items.return_value = False + catalog_api_client_mock.return_value.enterprise_contains_content_items.return_value = { + 'contains_content_items': False + } if factory: create_items(factory, items) response = self.client.delete(self.path, request_body) diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 9ea9d3260..979668415 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -169,7 +169,19 @@ EXPIRED_LICENSED_ENTERPRISE_COURSE_ENROLLMENTS_ENDPOINT = reverse( 'licensed-enterprise-course-enrollment-bulk-licensed-enrollments-expiration' ) -VERIFIED_SUBSCRIPTION_COURSE_MODE = 'verified' +DEFAULT_ENTERPRISE_ENROLLMENT_INTENTION_LIST_ENDPOINT = reverse('default-enterprise-enrollment-intentions-list') +DEFAULT_ENTERPRISE_ENROLLMENT_INTENTION_LEARNER_STATUS_ENDPOINT = reverse( + 'default-enterprise-enrollment-intentions-learner-status' +) +VERIFIED_COURSE_MODE = 'verified' +AUDIT_COURSE_MODE = 'audit' + + +def get_default_enterprise_enrollment_intention_detail_endpoint(enrollment_intention_uuid=None): + return reverse( + 'default-enterprise-enrollment-intentions-detail', + kwargs={'pk': enrollment_intention_uuid if enrollment_intention_uuid else FAKE_UUIDS[0]} + ) def side_effect(url, query_parameters): @@ -3345,7 +3357,7 @@ def test_enterprise_customer_course_enrollments_non_list_request(self): False, True, None, - [{'course_mode': 'audit', 'course_run_id': 'course-v1:edX+DemoX+Demo_Course'}], + [{'course_mode': AUDIT_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course'}], [{ 'non_field_errors': [ 'At least one of the following fields must be specified and map to an EnterpriseCustomerUser: ' @@ -3358,7 +3370,7 @@ def test_enterprise_customer_course_enrollments_non_list_request(self): True, None, [{ - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'lms_user_id': 10, }], @@ -3374,7 +3386,7 @@ def test_enterprise_customer_course_enrollments_non_list_request(self): True, None, [{ - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'tpa_user_id': 'abc', }], @@ -3390,7 +3402,7 @@ def test_enterprise_customer_course_enrollments_non_list_request(self): False, None, [{ - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'lms_user_id': 10, }], @@ -3406,7 +3418,7 @@ def test_enterprise_customer_course_enrollments_non_list_request(self): True, None, [{ - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'lms_user_id': 10, 'tpa_user_id': 'abc', @@ -3421,9 +3433,9 @@ def test_enterprise_customer_course_enrollments_non_list_request(self): ( True, True, - {'is_active': True, 'mode': VERIFIED_SUBSCRIPTION_COURSE_MODE}, + {'is_active': True, 'mode': VERIFIED_COURSE_MODE}, [{ - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'lms_user_id': 10, }], @@ -3437,9 +3449,9 @@ def test_enterprise_customer_course_enrollments_non_list_request(self): ( True, True, - {'is_active': False, 'mode': 'audit'}, + {'is_active': False, 'mode': AUDIT_COURSE_MODE}, [{ - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'lms_user_id': 10, 'cohort': 'masters' @@ -3499,7 +3511,7 @@ def test_enterprise_customer_course_enrollments_detail_errors( False, None, [{ - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'lms_user_id': 10, 'tpa_user_id': 'abc', @@ -3510,7 +3522,7 @@ def test_enterprise_customer_course_enrollments_detail_errors( True, None, [{ - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'lms_user_id': 10, }], @@ -3519,7 +3531,7 @@ def test_enterprise_customer_course_enrollments_detail_errors( True, None, [{ - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'tpa_user_id': 'abc', }], @@ -3528,7 +3540,7 @@ def test_enterprise_customer_course_enrollments_detail_errors( True, None, [{ - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'user_email': 'abc@test.com', }], @@ -3537,7 +3549,7 @@ def test_enterprise_customer_course_enrollments_detail_errors( True, None, [{ - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'lms_user_id': 10, 'email_students': True @@ -3547,7 +3559,7 @@ def test_enterprise_customer_course_enrollments_detail_errors( True, None, [{ - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'lms_user_id': 10, 'email_students': True, @@ -3558,7 +3570,7 @@ def test_enterprise_customer_course_enrollments_detail_errors( True, None, [{ - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'user_email': 'foo@bar.com', 'email_students': True, @@ -3567,18 +3579,18 @@ def test_enterprise_customer_course_enrollments_detail_errors( ), ( True, - {'is_active': True, 'mode': 'audit'}, + {'is_active': True, 'mode': AUDIT_COURSE_MODE}, [{ - 'course_mode': VERIFIED_SUBSCRIPTION_COURSE_MODE, + 'course_mode': VERIFIED_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'lms_user_id': 10, }], ), ( True, - {'is_active': False, 'mode': 'audit'}, + {'is_active': False, 'mode': AUDIT_COURSE_MODE}, [{ - 'course_mode': VERIFIED_SUBSCRIPTION_COURSE_MODE, + 'course_mode': VERIFIED_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'lms_user_id': 10, 'is_active': False, @@ -3588,7 +3600,7 @@ def test_enterprise_customer_course_enrollments_detail_errors( False, None, [{ - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': 'course-v1:edX+DemoX+Demo_Course', 'user_email': 'foo@bar.com', 'is_active': False, @@ -3728,44 +3740,44 @@ def test_enterprise_customer_course_enrollments_detail_multiple( course_run_id = 'course-v1:edX+DemoX+Demo_Course' payload = [ { - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': course_run_id, 'tpa_user_id': tpa_user_id, }, { - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': course_run_id, 'user_email': new_user_email, }, { - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': course_run_id, 'lms_user_id': lms_user_id, }, { - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': course_run_id, }, { - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': course_run_id, 'user_email': pending_email, 'cohort': 'test' }, { - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': course_run_id, 'user_email': pending_email, 'is_active': False, }, { - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': course_run_id, 'user_email': pending_email, 'is_active': True, }, { - 'course_mode': 'audit', + 'course_mode': AUDIT_COURSE_MODE, 'course_run_id': course_run_id, 'user_email': pending_email, 'is_active': False, @@ -3823,7 +3835,7 @@ def test_enterprise_customer_course_enrollments_detail_multiple( # Set up EnrollmentAPI responses mock_enrollment_client.return_value = mock.Mock( get_course_enrollment=mock.Mock( - side_effect=[None, {'is_active': True, 'mode': VERIFIED_SUBSCRIPTION_COURSE_MODE}] + side_effect=[None, {'is_active': True, 'mode': VERIFIED_COURSE_MODE}] ), enroll_user_in_course=mock.Mock() ) @@ -5178,7 +5190,7 @@ def test_bulk_enrollment_in_bulk_courses_pending_licenses( permission = Permission.objects.get(name='Can add Enterprise Customer') self.user.user_permissions.add(permission) - mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + mock_get_course_mode.return_value = VERIFIED_COURSE_MODE self.assertEqual(len(PendingEnrollment.objects.all()), 0) response = self.client.post( @@ -5231,7 +5243,7 @@ def test_bulk_enrollment_in_bulk_courses_existing_users( permission = Permission.objects.get(name='Can add Enterprise Customer') self.user.user_permissions.add(permission) - mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + mock_get_course_mode.return_value = VERIFIED_COURSE_MODE self.assertEqual(len(PendingEnrollment.objects.all()), 0) body = { @@ -5313,7 +5325,7 @@ def test_bulk_enrollment_force_enrollment( permission = Permission.objects.get(name='Can add Enterprise Customer') self.user.user_permissions.add(permission) - mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + mock_get_course_mode.return_value = VERIFIED_COURSE_MODE self.assertEqual(len(PendingEnrollment.objects.all()), 0) body = { @@ -5364,7 +5376,7 @@ def test_bulk_enrollment_in_bulk_courses_nonexisting_user_id( permission = Permission.objects.get(name='Can add Enterprise Customer') self.user.user_permissions.add(permission) - mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + mock_get_course_mode.return_value = VERIFIED_COURSE_MODE self.assertEqual(len(PendingEnrollment.objects.all()), 0) body = { @@ -5431,7 +5443,7 @@ def test_bulk_enrollment_enroll_after_cancel( results in expected state and payload. """ mock_platform_enrollment.return_value = True - mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + mock_get_course_mode.return_value = VERIFIED_COURSE_MODE # Needed for the cancel endpoint: mock_update_or_create_enrollment.update_enrollment.return_value = mock.Mock() @@ -5555,7 +5567,7 @@ def test_bulk_enrollment_includes_fulfillment_source_uuid( permission = Permission.objects.get(name='Can add Enterprise Customer') user.user_permissions.add(permission) - mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + mock_get_course_mode.return_value = VERIFIED_COURSE_MODE enrollment_url = reverse( 'enterprise-customer-enroll-learners-in-courses', @@ -5670,7 +5682,7 @@ def test_bulk_enrollment_with_notification( permission = Permission.objects.get(name='Can add Enterprise Customer') self.user.user_permissions.add(permission) - mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + mock_get_course_mode.return_value = VERIFIED_COURSE_MODE self.assertEqual(len(PendingEnrollment.objects.all()), 0) @@ -5752,7 +5764,7 @@ def test_enroll_learners_in_courses_partial_failure(self, mock_get_course_mode, 'failures': [{'email': 'xyz@test.com', 'course_run_key': course}] } mock_enroll_user.return_value = enrollment_response - mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + mock_get_course_mode.return_value = VERIFIED_COURSE_MODE body = { 'licenses_info': [ @@ -9770,3 +9782,666 @@ def test_get_enterprise_org_members(self): response = self.client.get(settings.TEST_SERVER + url) data = response.json().get('results')[0] assert data['enrollments'] == 0 + + +class TestDefaultEnterpriseEnrollmentIntentionViewSet(BaseTestEnterpriseAPIViews): + """ + Test DefaultEnterpriseEnrollmentIntentionViewSet + """ + + def setUp(self): + super().setUp() + self.enterprise_customer = factories.EnterpriseCustomerFactory() + + username = 'test_user_default_enterprise_enrollment_intentions' + self.user = self.create_user(username=username, is_staff=False) + self.client.login(username=self.user.username, password=TEST_PASSWORD) + + def get_default_enrollment_intention_with_learner_enrollment_state(self, enrollment_intention, **kwargs): + """ + Returns the expected serialized default enrollment intention with learner enrollment state. + + Args: + enrollment_intention: The enrollment intention to serialize. + **kwargs: Additional parameters to customize the response. + - applicable_enterprise_catalog_uuids: List of applicable enterprise catalog UUIDs. + - is_course_run_enrollable: Boolean indicating if the course run is enrollable. + - has_existing_enrollment: Boolean indicating if there is an existing enrollment. + - is_existing_enrollment_active: Boolean indicating if the existing enrollment is + active, or None if no existing enrollment. + - is_existing_enrollment_audit: Boolean indicating if the existing enrollment is + audit, or None if no existing enrollment. + """ + return { + 'uuid': str(enrollment_intention.uuid), + 'content_key': enrollment_intention.content_key, + 'enterprise_customer': str(self.enterprise_customer.uuid), + 'course_key': enrollment_intention.course_key, + 'course_run_key': enrollment_intention.course_run_key, + 'is_course_run_enrollable': kwargs.get('is_course_run_enrollable', True), + 'applicable_enterprise_catalog_uuids': kwargs.get( + 'applicable_enterprise_catalog_uuids', + [fake_catalog_api.FAKE_CATALOG_RESULT.get('uuid')], + ), + 'course_run_normalized_metadata': { + 'start_date': fake_catalog_api.FAKE_COURSE_RUN.get('start'), + 'end_date': fake_catalog_api.FAKE_COURSE_RUN.get('end'), + 'enroll_by_date': fake_catalog_api.FAKE_COURSE_RUN.get('seats')[1].get('upgrade_deadline'), + 'enroll_start_date': fake_catalog_api.FAKE_COURSE_RUN.get('enrollment_start'), + 'content_price': fake_catalog_api.FAKE_COURSE_RUN.get('first_enrollable_paid_seat_price'), + }, + 'created': enrollment_intention.created.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'modified': enrollment_intention.modified.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'has_existing_enrollment': kwargs.get('has_existing_enrollment', False), + 'is_existing_enrollment_active': kwargs.get('is_existing_enrollment_active', None), + 'is_existing_enrollment_audit': kwargs.get('is_existing_enrollment_audit', None), + } + + def create_mock_default_enterprise_enrollment_intention( + self, + mock_catalog_api_client, + content_metadata=None, + contains_content_items=False, + catalog_list=None, + ): + """ + Create a mock default enterprise enrollment intention. + """ + mock_content_metadata = content_metadata or fake_catalog_api.FAKE_COURSE + mock_contains_content_items = contains_content_items + mock_catalog_list = ( + catalog_list + if catalog_list is not None + else [fake_catalog_api.FAKE_CATALOG_RESULT.get('uuid')] + ) + + mock_catalog_api_client.return_value = mock.Mock( + get_content_metadata_content_identifier=mock.Mock( + return_value=mock_content_metadata, + ), + enterprise_contains_content_items=mock.Mock( + return_value=fake_catalog_api.get_fake_enterprise_contains_content_items_response( + contains_content_items=mock_contains_content_items, + catalog_list=mock_catalog_list, + ), + ), + ) + enrollment_intention = factories.DefaultEnterpriseEnrollmentIntentionFactory( + enterprise_customer=self.enterprise_customer, + content_key=mock_content_metadata.get('key', 'edX+DemoX'), + ) + return enrollment_intention + + def test_default_enterprise_enrollment_intentions_missing_enterprise_uuid(self): + """ + Test expected response when successfully listing existing default enterprise enrollment intentions. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + response = self.client.get(f"{settings.TEST_SERVER}{DEFAULT_ENTERPRISE_ENROLLMENT_INTENTION_LIST_ENDPOINT}") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {'detail': 'enterprise_customer_uuid is a required query parameter.'} + + def test_default_enterprise_enrollment_intentions_invalid_enterprise_uuid(self): + """ + Test expected response when successfully listing existing default enterprise enrollment intentions. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + query_params = 'enterprise_customer_uuid=invalid-uuid' + response = self.client.get( + f"{settings.TEST_SERVER}{DEFAULT_ENTERPRISE_ENROLLMENT_INTENTION_LIST_ENDPOINT}?{query_params}" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {'detail': 'enterprise_customer_uuid query parameter is not a valid UUID.'} + + @mock.patch('enterprise.content_metadata.api.EnterpriseCatalogApiClient') + def test_default_enterprise_enrollment_intentions_list(self, mock_catalog_api_client): + """ + Test expected response when successfully listing existing default enterprise enrollment intentions. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + enrollment_intention = self.create_mock_default_enterprise_enrollment_intention(mock_catalog_api_client) + query_params = f'enterprise_customer_uuid={str(self.enterprise_customer.uuid)}' + response = self.client.get( + f"{settings.TEST_SERVER}{DEFAULT_ENTERPRISE_ENROLLMENT_INTENTION_LIST_ENDPOINT}?{query_params}" + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data['count'] == 1 + result = response_data['results'][0] + assert result['content_key'] == enrollment_intention.content_key + assert result['applicable_enterprise_catalog_uuids'] == [fake_catalog_api.FAKE_CATALOG_RESULT.get('uuid')] + + @mock.patch('enterprise.content_metadata.api.EnterpriseCatalogApiClient') + def test_default_enterprise_enrollment_intentions_detail(self, mock_catalog_api_client): + """ + Test expected response when unauthorized user attempts to list default + enterprise enrollment intentions. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + enrollment_intention = self.create_mock_default_enterprise_enrollment_intention(mock_catalog_api_client) + query_params = f'enterprise_customer_uuid={str(self.enterprise_customer.uuid)}' + base_url = get_default_enterprise_enrollment_intention_detail_endpoint(str(enrollment_intention.uuid)) + response = self.client.get(f"{settings.TEST_SERVER}{base_url}?{query_params}") + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data['content_key'] == enrollment_intention.content_key + assert response_data['applicable_enterprise_catalog_uuids'] == \ + [fake_catalog_api.FAKE_CATALOG_RESULT.get('uuid')] + + @mock.patch('enterprise.content_metadata.api.EnterpriseCatalogApiClient') + def test_default_enterprise_enrollment_intentions_list_unauthorized(self, mock_catalog_api_client): + """ + Test expected response when unauthorized user attempts to list default + enterprise enrollment intentions. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + self.create_mock_default_enterprise_enrollment_intention(mock_catalog_api_client) + query_params = f'enterprise_customer_uuid={str(uuid.uuid4())}' + response = self.client.get( + f"{settings.TEST_SERVER}{DEFAULT_ENTERPRISE_ENROLLMENT_INTENTION_LIST_ENDPOINT}?{query_params}" + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data['count'] == 0 + assert response_data['results'] == [] + + @mock.patch('enterprise.content_metadata.api.EnterpriseCatalogApiClient') + def test_default_enterprise_enrollment_intentions_detail_403_forbidden(self, mock_catalog_api_client): + """ + Test expected response when unauthorized user attempts to list default + enterprise enrollment intentions. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + enrollment_intention = self.create_mock_default_enterprise_enrollment_intention(mock_catalog_api_client) + query_params = f'enterprise_customer_uuid={str(uuid.uuid4())}' + base_url = get_default_enterprise_enrollment_intention_detail_endpoint(str(enrollment_intention.uuid)) + response = self.client.get(f"{settings.TEST_SERVER}{base_url}?{query_params}") + assert response.status_code == status.HTTP_403_FORBIDDEN + + @mock.patch('enterprise.content_metadata.api.EnterpriseCatalogApiClient') + def test_default_enterprise_enrollment_intentions_not_in_catalog(self, mock_catalog_api_client): + """ + Test expected response when default enterprise enrollment intention is not in catalog. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + enrollment_intention = self.create_mock_default_enterprise_enrollment_intention( + mock_catalog_api_client, + contains_content_items=False, + catalog_list=[], + ) + query_params = f'enterprise_customer_uuid={str(self.enterprise_customer.uuid)}' + base_url = get_default_enterprise_enrollment_intention_detail_endpoint(str(enrollment_intention.uuid)) + response = self.client.get(f"{settings.TEST_SERVER}{base_url}?{query_params}") + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data['content_key'] == enrollment_intention.content_key + assert response_data['applicable_enterprise_catalog_uuids'] == [] + + def test_default_enterprise_enrollment_intentions_learner_status_not_linked(self): + """ + Test default enterprise enrollment intentions for specific learner not linked to enterprise customer. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + query_params = f'enterprise_customer_uuid={str(self.enterprise_customer.uuid)}' + response = self.client.get( + f"{settings.TEST_SERVER}{DEFAULT_ENTERPRISE_ENROLLMENT_INTENTION_LEARNER_STATUS_ENDPOINT}?{query_params}" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + response_data = response.json() + assert response_data['detail'] == ( + f'User with lms_user_id {self.user.id} is not associated with ' + f'the enterprise customer {str(self.enterprise_customer.uuid)}.' + ) + + @mock.patch('enterprise.content_metadata.api.EnterpriseCatalogApiClient') + @mock.patch('enterprise.models.utils.get_best_mode_from_course_key') + def test_default_enterprise_enrollment_intentions_learner_status_enrollable( + self, + mock_get_best_mode_from_course_key, + mock_catalog_api_client, + ): + """ + Test default enterprise enrollment intentions for specific learner linked to enterprise customer, where + the course run associated with the default enrollment intention is enrollable. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + enrollment_intention = self.create_mock_default_enterprise_enrollment_intention(mock_catalog_api_client) + mock_get_best_mode_from_course_key.return_value = VERIFIED_COURSE_MODE + factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=self.enterprise_customer, + ) + query_params = f'enterprise_customer_uuid={str(self.enterprise_customer.uuid)}' + response = self.client.get( + f"{settings.TEST_SERVER}{DEFAULT_ENTERPRISE_ENROLLMENT_INTENTION_LEARNER_STATUS_ENDPOINT}?{query_params}" + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data['lms_user_id'] == self.user.id + assert response_data['user_email'] == self.user.email + assert response_data['enrollment_statuses'] == { + 'needs_enrollment': { + 'enrollable': [ + self.get_default_enrollment_intention_with_learner_enrollment_state(enrollment_intention) + ], + 'not_enrollable': [], + }, + 'already_enrolled': [], + } + assert response_data['metadata'] == { + 'total_default_enterprise_course_enrollments': 1, + 'total_needs_enrollment': { + 'enrollable': 1, + 'not_enrollable': 0, + }, + 'total_already_enrolled': 0, + } + + @mock.patch('enterprise.content_metadata.api.EnterpriseCatalogApiClient') + @mock.patch('enterprise.models.utils.get_best_mode_from_course_key') + def test_default_enrollment_intentions_learner_status_content_not_enrollable( + self, + mock_get_best_mode_from_course_key, + mock_catalog_api_client, + ): + """ + Test default enterprise enrollment intentions (not enrollable) for + specific learner linked to enterprise customer. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + mock_course_run = fake_catalog_api.FAKE_COURSE_RUN.copy() + mock_course_run.update({'is_enrollable': False}) + mock_course = fake_catalog_api.FAKE_COURSE.copy() + mock_course.update({'course_runs': [mock_course_run]}) + enrollment_intention = self.create_mock_default_enterprise_enrollment_intention( + mock_catalog_api_client, + content_metadata=mock_course, + ) + mock_get_best_mode_from_course_key.return_value = VERIFIED_COURSE_MODE + factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=self.enterprise_customer, + ) + query_params = f'enterprise_customer_uuid={str(self.enterprise_customer.uuid)}' + response = self.client.get( + f"{settings.TEST_SERVER}{DEFAULT_ENTERPRISE_ENROLLMENT_INTENTION_LEARNER_STATUS_ENDPOINT}?{query_params}" + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data['lms_user_id'] == self.user.id + assert response_data['user_email'] == self.user.email + assert response_data['enrollment_statuses'] == { + 'needs_enrollment': { + 'enrollable': [], + 'not_enrollable': [ + self.get_default_enrollment_intention_with_learner_enrollment_state( + enrollment_intention, + is_course_run_enrollable=False, + ) + ], + }, + 'already_enrolled': [], + } + assert response_data['metadata'] == { + 'total_default_enterprise_course_enrollments': 1, + 'total_needs_enrollment': { + 'enrollable': 0, + 'not_enrollable': 1, + }, + 'total_already_enrolled': 0, + } + + @mock.patch('enterprise.content_metadata.api.EnterpriseCatalogApiClient') + @mock.patch('enterprise.models.utils.get_best_mode_from_course_key') + def test_default_enrollment_intentions_learner_status_content_not_in_catalog( + self, + mock_get_best_mode_from_course_key, + mock_catalog_api_client, + ): + """ + Test default enterprise enrollment intentions (not enrollable, no applicable + catalog) for specific learner linked to enterprise customer. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + enrollment_intention = self.create_mock_default_enterprise_enrollment_intention( + mock_catalog_api_client, + contains_content_items=False, + catalog_list=[], + ) + mock_get_best_mode_from_course_key.return_value = VERIFIED_COURSE_MODE + factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=self.enterprise_customer, + ) + query_params = f'enterprise_customer_uuid={str(self.enterprise_customer.uuid)}' + response = self.client.get( + f"{settings.TEST_SERVER}{DEFAULT_ENTERPRISE_ENROLLMENT_INTENTION_LEARNER_STATUS_ENDPOINT}?{query_params}" + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data['lms_user_id'] == self.user.id + assert response_data['user_email'] == self.user.email + assert response_data['enrollment_statuses'] == { + 'needs_enrollment': { + 'enrollable': [], + 'not_enrollable': [ + self.get_default_enrollment_intention_with_learner_enrollment_state( + enrollment_intention, + applicable_enterprise_catalog_uuids=[], + is_course_run_enrollable=True, + has_existing_enrollment=False, + is_existing_enrollment_active=None, + is_existing_enrollment_audit=None, + ) + ], + }, + 'already_enrolled': [], + } + assert response_data['metadata'] == { + 'total_default_enterprise_course_enrollments': 1, + 'total_needs_enrollment': { + 'enrollable': 0, + 'not_enrollable': 1, + }, + 'total_already_enrolled': 0, + } + + @mock.patch('enterprise.content_metadata.api.EnterpriseCatalogApiClient') + @mock.patch('enterprise.models.utils.get_best_mode_from_course_key') + @mock.patch.object(EnterpriseCourseEnrollment, 'course_enrollment', new_callable=mock.PropertyMock) + def test_default_enrollment_intentions_learner_status_already_enrolled_active( + self, + mock_course_enrollment, + mock_get_best_mode_from_course_key, + mock_catalog_api_client, + ): + """ + Test default enterprise enrollment intentions (already enrolled, active + enrollment) for specific learner linked to enterprise customer. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + enrollment_intention = self.create_mock_default_enterprise_enrollment_intention(mock_catalog_api_client) + mock_get_best_mode_from_course_key.return_value = VERIFIED_COURSE_MODE + enterprise_customer_user = factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=self.enterprise_customer, + ) + factories.EnterpriseCourseEnrollmentFactory( + enterprise_customer_user=enterprise_customer_user, + course_id=fake_catalog_api.FAKE_COURSE_RUN.get('key'), + ) + course_enrollment_kwargs = { + 'is_active': True, + 'mode': VERIFIED_COURSE_MODE, + } + mock_course_enrollment.return_value = mock.Mock(**course_enrollment_kwargs) + query_params = f'enterprise_customer_uuid={str(self.enterprise_customer.uuid)}' + response = self.client.get( + f"{settings.TEST_SERVER}{DEFAULT_ENTERPRISE_ENROLLMENT_INTENTION_LEARNER_STATUS_ENDPOINT}?{query_params}" + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data['lms_user_id'] == self.user.id + assert response_data['user_email'] == self.user.email + assert response_data['enrollment_statuses'] == { + 'needs_enrollment': { + 'enrollable': [], + 'not_enrollable': [], + }, + 'already_enrolled': [ + self.get_default_enrollment_intention_with_learner_enrollment_state( + enrollment_intention, + has_existing_enrollment=True, + is_existing_enrollment_active=True, + is_existing_enrollment_audit=False, + ) + ], + } + assert response_data['metadata'] == { + 'total_default_enterprise_course_enrollments': 1, + 'total_needs_enrollment': { + 'enrollable': 0, + 'not_enrollable': 0, + }, + 'total_already_enrolled': 1, + } + + @mock.patch('enterprise.content_metadata.api.EnterpriseCatalogApiClient') + @mock.patch('enterprise.models.utils.get_best_mode_from_course_key') + @mock.patch.object(EnterpriseCourseEnrollment, 'course_enrollment', new_callable=mock.PropertyMock) + def test_default_enrollment_intentions_learner_status_already_enrolled_inactive( + self, + mock_course_enrollment, + mock_get_best_mode_from_course_key, + mock_catalog_api_client, + ): + """ + Test default enterprise enrollment intentions (already enrolled, inactive + enrollment) for specific learner linked to enterprise customer. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + enrollment_intention = self.create_mock_default_enterprise_enrollment_intention(mock_catalog_api_client) + mock_get_best_mode_from_course_key.return_value = VERIFIED_COURSE_MODE + enterprise_customer_user = factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=self.enterprise_customer, + ) + factories.EnterpriseCourseEnrollmentFactory( + enterprise_customer_user=enterprise_customer_user, + course_id=fake_catalog_api.FAKE_COURSE_RUN.get('key'), + ) + course_enrollment_kwargs = { + 'is_active': False, + 'mode': VERIFIED_COURSE_MODE, + } + mock_course_enrollment.return_value = mock.Mock(**course_enrollment_kwargs) + query_params = f'enterprise_customer_uuid={str(self.enterprise_customer.uuid)}' + response = self.client.get( + f"{settings.TEST_SERVER}{DEFAULT_ENTERPRISE_ENROLLMENT_INTENTION_LEARNER_STATUS_ENDPOINT}?{query_params}" + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data['lms_user_id'] == self.user.id + assert response_data['user_email'] == self.user.email + assert response_data['enrollment_statuses'] == { + 'needs_enrollment': { + 'enrollable': [ + self.get_default_enrollment_intention_with_learner_enrollment_state( + enrollment_intention, + has_existing_enrollment=True, + is_existing_enrollment_active=False, + is_existing_enrollment_audit=False, + ) + ], + 'not_enrollable': [], + }, + 'already_enrolled': [], + } + assert response_data['metadata'] == { + 'total_default_enterprise_course_enrollments': 1, + 'total_needs_enrollment': { + 'enrollable': 1, + 'not_enrollable': 0, + }, + 'total_already_enrolled': 0, + } + + @ddt.data( + {'has_audit_mode_only': True}, + {'has_audit_mode_only': False}, + ) + @mock.patch('enterprise.content_metadata.api.EnterpriseCatalogApiClient') + @mock.patch('enterprise.models.utils.get_best_mode_from_course_key') + @mock.patch.object(EnterpriseCourseEnrollment, 'course_enrollment', new_callable=mock.PropertyMock) + @ddt.unpack + def test_default_enrollment_intentions_learner_status_already_enrolled_active_audit( + self, + mock_course_enrollment, + mock_get_best_mode_from_course_key, + mock_catalog_api_client, + has_audit_mode_only, + ): + """ + Test default enterprise enrollment intentions (already enrolled, active + audit enrollment) for specific learner linked to enterprise customer. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + enrollment_intention = self.create_mock_default_enterprise_enrollment_intention(mock_catalog_api_client) + + if has_audit_mode_only: + mock_get_best_mode_from_course_key.return_value = AUDIT_COURSE_MODE + else: + mock_get_best_mode_from_course_key.return_value = VERIFIED_COURSE_MODE + + enterprise_customer_user = factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=self.enterprise_customer, + ) + factories.EnterpriseCourseEnrollmentFactory( + enterprise_customer_user=enterprise_customer_user, + course_id=fake_catalog_api.FAKE_COURSE_RUN.get('key'), + ) + course_enrollment_kwargs = { + 'is_active': True, + 'mode': AUDIT_COURSE_MODE, + } + mock_course_enrollment.return_value = mock.Mock(**course_enrollment_kwargs) + query_params = f'enterprise_customer_uuid={str(self.enterprise_customer.uuid)}' + response = self.client.get( + f"{settings.TEST_SERVER}{DEFAULT_ENTERPRISE_ENROLLMENT_INTENTION_LEARNER_STATUS_ENDPOINT}?{query_params}" + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data['lms_user_id'] == self.user.id + assert response_data['user_email'] == self.user.email + + expected_enrollable = [] + expected_already_enrolled = [] + + expected_serialized_intention = self.get_default_enrollment_intention_with_learner_enrollment_state( + enrollment_intention, + has_existing_enrollment=True, + is_existing_enrollment_active=True, + is_existing_enrollment_audit=True, + ) + + if has_audit_mode_only: + expected_already_enrolled.append(expected_serialized_intention) + else: + expected_enrollable.append(expected_serialized_intention) + + assert response_data['enrollment_statuses'] == { + 'needs_enrollment': { + 'enrollable': expected_enrollable, + 'not_enrollable': [], + }, + 'already_enrolled': expected_already_enrolled, + } + assert response_data['metadata'] == { + 'total_default_enterprise_course_enrollments': 1, + 'total_needs_enrollment': { + 'enrollable': len(expected_enrollable), + 'not_enrollable': 0, + }, + 'total_already_enrolled': len(expected_already_enrolled), + } + + @mock.patch('enterprise.content_metadata.api.EnterpriseCatalogApiClient') + @mock.patch('enterprise.models.utils.get_best_mode_from_course_key') + def test_default_enrollment_intentions_learner_status_staff_lms_user_id_override( + self, + mock_get_best_mode_from_course_key, + mock_catalog_api_client, + ): + """ + Test default enterprise enrollment intentions for staff user, requesting a specific user + linked to enterprise customer via lms_user_id query parameter. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + + # Create and login as a staff user + staff_user = self.create_user(username='staff_username', password=TEST_PASSWORD, is_staff=True) + self.client.login(username=staff_user.username, password=TEST_PASSWORD) + + enrollment_intention = self.create_mock_default_enterprise_enrollment_intention(mock_catalog_api_client) + mock_get_best_mode_from_course_key.return_value = VERIFIED_COURSE_MODE + factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=self.enterprise_customer, + ) + query_params = ( + f'enterprise_customer_uuid={str(self.enterprise_customer.uuid)}' + # Validates staff user can get back data for another user (i.e., request user is `staff_user`) + f'&lms_user_id={self.user.id}' + ) + response = self.client.get( + f"{settings.TEST_SERVER}{DEFAULT_ENTERPRISE_ENROLLMENT_INTENTION_LEARNER_STATUS_ENDPOINT}?{query_params}" + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data['lms_user_id'] == self.user.id + assert response_data['user_email'] == self.user.email + assert response_data['enrollment_statuses'] == { + 'needs_enrollment': { + 'enrollable': [ + self.get_default_enrollment_intention_with_learner_enrollment_state(enrollment_intention) + ], + 'not_enrollable': [], + }, + 'already_enrolled': [], + } + assert response_data['metadata'] == { + 'total_default_enterprise_course_enrollments': 1, + 'total_needs_enrollment': { + 'enrollable': 1, + 'not_enrollable': 0, + }, + 'total_already_enrolled': 0, + } + + @mock.patch('enterprise.content_metadata.api.EnterpriseCatalogApiClient') + @mock.patch('enterprise.models.utils.get_best_mode_from_course_key') + def test_default_enrollment_intentions_learner_status_nonstaff_lms_user_id_override( + self, + mock_get_best_mode_from_course_key, + mock_catalog_api_client + ): + """ + Test default enterprise enrollment intentions for non-staff user linked to enterprise customer. + """ + self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(self.enterprise_customer.uuid)) + enrollment_intention = self.create_mock_default_enterprise_enrollment_intention(mock_catalog_api_client) + mock_get_best_mode_from_course_key.return_value = VERIFIED_COURSE_MODE + factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=self.enterprise_customer, + ) + query_params = ( + f'enterprise_customer_uuid={str(self.enterprise_customer.uuid)}' + f'&lms_user_id={self.user.id + 1}' # Validates non-staff user can't get back data for another user + ) + response = self.client.get( + f"{settings.TEST_SERVER}{DEFAULT_ENTERPRISE_ENROLLMENT_INTENTION_LEARNER_STATUS_ENDPOINT}?{query_params}" + ) + + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data['lms_user_id'] == self.user.id + assert response_data['user_email'] == self.user.email + assert response_data['enrollment_statuses'] == { + 'needs_enrollment': { + 'enrollable': [ + self.get_default_enrollment_intention_with_learner_enrollment_state(enrollment_intention) + ], + 'not_enrollable': [], + }, + 'already_enrolled': [], + } + assert response_data['metadata'] == { + 'total_default_enterprise_course_enrollments': 1, + 'total_needs_enrollment': { + 'enrollable': 1, + 'not_enrollable': 0, + }, + 'total_already_enrolled': 0, + } diff --git a/tests/test_enterprise/api_client/test_enterprise_catalog.py b/tests/test_enterprise/api_client/test_enterprise_catalog.py index a3a830a7b..98be0c5f1 100644 --- a/tests/test_enterprise/api_client/test_enterprise_catalog.py +++ b/tests/test_enterprise/api_client/test_enterprise_catalog.py @@ -416,11 +416,11 @@ def test_contains_content_items(): @responses.activate @mock.patch('enterprise.api_client.client.JwtBuilder', mock.Mock()) def test_enterprise_contains_content_items(): - url = _url("enterprise-customer/{enterprise_uuid}/contains_content_items/?course_run_ids=demoX".format( - enterprise_uuid=TEST_ENTERPRISE_ID - )) + query_params = '?course_run_ids=demoX&get_catalogs_containing_specified_content_ids=True' + url = _url(f"enterprise-customer/{TEST_ENTERPRISE_ID}/contains_content_items/{query_params}") expected_response = { 'contains_content_items': True, + 'catalog_list': [], } responses.add( responses.GET, @@ -429,7 +429,10 @@ def test_enterprise_contains_content_items(): ) client = enterprise_catalog.EnterpriseCatalogApiClient('staff-user-goes-here') actual_response = client.enterprise_contains_content_items(TEST_ENTERPRISE_ID, ['demoX']) - assert actual_response == expected_response['contains_content_items'] + actual_contains_content_items = actual_response['contains_content_items'] + actual_catalog_list = actual_response['catalog_list'] + assert actual_contains_content_items == expected_response['contains_content_items'] + assert actual_catalog_list == expected_response['catalog_list'] @responses.activate diff --git a/tests/test_enterprise/test_rules.py b/tests/test_enterprise/test_rules.py index 313008b17..1f9ec51ec 100644 --- a/tests/test_enterprise/test_rules.py +++ b/tests/test_enterprise/test_rules.py @@ -8,10 +8,12 @@ from pytest import mark from enterprise.constants import ( + DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_PERMISSION, ENTERPRISE_ADMIN_ROLE, ENTERPRISE_CATALOG_ADMIN_ROLE, ENTERPRISE_DASHBOARD_ADMIN_ROLE, ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE, + ENTERPRISE_LEARNER_ROLE, ) from enterprise.models import EnterpriseFeatureRole, EnterpriseFeatureUserRoleAssignment from test_utils import TEST_UUID, APITest, factories @@ -26,12 +28,14 @@ class TestEnterpriseRBACPermissions(APITest): @mock.patch('enterprise.rules.crum.get_current_request') @ddt.data( - 'enterprise.can_access_admin_dashboard', - 'enterprise.can_view_catalog', - 'enterprise.can_enroll_learners', + ('enterprise.can_access_admin_dashboard', ENTERPRISE_ADMIN_ROLE), + ('enterprise.can_view_catalog', ENTERPRISE_ADMIN_ROLE), + ('enterprise.can_enroll_learners', ENTERPRISE_ADMIN_ROLE), + (DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_PERMISSION, ENTERPRISE_LEARNER_ROLE), ) - def test_has_implicit_access(self, permission, get_current_request_mock): - get_current_request_mock.return_value = self.get_request_with_jwt_cookie(ENTERPRISE_ADMIN_ROLE, TEST_UUID) + @ddt.unpack + def test_has_implicit_access(self, permission, enterprise_role, get_current_request_mock): + get_current_request_mock.return_value = self.get_request_with_jwt_cookie(enterprise_role, TEST_UUID) assert self.user.has_perm(permission, TEST_UUID) @mock.patch('enterprise.rules.crum.get_current_request') diff --git a/tests/test_enterprise/views/test_grant_data_sharing_permissions.py b/tests/test_enterprise/views/test_grant_data_sharing_permissions.py index cb97bcab4..286fe2653 100644 --- a/tests/test_enterprise/views/test_grant_data_sharing_permissions.py +++ b/tests/test_enterprise/views/test_grant_data_sharing_permissions.py @@ -125,7 +125,9 @@ def test_get_course_specific_consent( 'title': 'Demo Course' } - enterprise_catalog_client_mock.return_value.enterprise_contains_content_items.return_value = True + enterprise_catalog_client_mock.return_value.enterprise_contains_content_items.return_value = { + 'contains_content_items': True + } mock_discovery_catalog_api_client = course_catalog_api_client_mock.return_value mock_discovery_catalog_api_client.get_course_id.return_value = course_id @@ -516,7 +518,9 @@ def test_get_course_specific_data_sharing_consent_enabled_audit_enrollment_exist course_catalog_api_client_mock.return_value.get_course_id.return_value = course_id mock_enterprise_catalog_client = enterprise_catalog_client_mock.return_value - mock_enterprise_catalog_client.enterprise_contains_content_items.return_value = True + mock_enterprise_catalog_client.return_value.enterprise_contains_content_items.return_value = { + 'contains_content_items': True + } course_mode = 'verified' @@ -684,7 +688,9 @@ def test_post_course_specific_consent( course_catalog_api_client_mock.return_value.get_course_id.return_value = 'edX+DemoX' mock_enterprise_catalog_client = enterprise_catalog_client_mock.return_value - mock_enterprise_catalog_client.enterprise_contains_content_items.return_value = True + mock_enterprise_catalog_client.enterprise_contains_content_items.return_value = { + 'contains_content_items': True + } mock_enrollment_api_client.return_value.get_course_enrollment.return_value = { 'is_active': True, 'mode': 'audit' @@ -782,7 +788,9 @@ def test_retrying_post_consent_when_previously_consented( course_catalog_api_client_mock.return_value.get_course_id.return_value = 'edX+DemoX' mock_enterprise_catalog_client = enterprise_catalog_client_mock.return_value - mock_enterprise_catalog_client.enterprise_contains_content_items.return_value = True + mock_enterprise_catalog_client.enterprise_contains_content_items.return_value = { + 'contains_content_items': True + } reverse_mock.return_value = '/dashboard' post_data = { @@ -947,7 +955,9 @@ def test_get_dsc_verified_mode_unavailable( mock_course_catalog_api_client.return_value.get_course_id.return_value = course_id enterprise_catalog_client = mock_enterprise_catalog_client.return_value - enterprise_catalog_client.enterprise_contains_content_items.return_value = True + enterprise_catalog_client.enterprise_contains_content_items.return_value = { + 'contains_content_items': True + } course_mode = 'verified' mock_get_course_mode.return_value = course_mode diff --git a/tests/test_models.py b/tests/test_models.py index 1e9962327..f8797f851 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -436,11 +436,15 @@ def test_catalog_contains_course_with_enterprise_customer_catalog(self, api_clie factories.EnterpriseCustomerCatalogFactory(enterprise_customer=enterprise_customer) # Test when content is in the enterprise customer's catalog(s) - api_client_mock.return_value.enterprise_contains_content_items.return_value = True + api_client_mock.return_value.enterprise_contains_content_items.return_value = { + 'contains_content_items': True, + } assert enterprise_customer.catalog_contains_course(fake_catalog_api.FAKE_COURSE_RUN['key']) is True # Test when content is NOT in the enterprise customer's catalog(s) - api_client_mock.return_value.enterprise_contains_content_items.return_value = False + api_client_mock.return_value.enterprise_contains_content_items.return_value = { + 'contains_content_items': False, + } assert enterprise_customer.catalog_contains_course(fake_catalog_api.FAKE_COURSE_RUN['key']) is False @mock.patch('enterprise.utils.UserPreference', return_value=mock.MagicMock())