diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 75304e3f3d..ef9e04a890 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -21,5 +21,6 @@ - Each step in the release build has a condition flag that checks if the rest of the steps are done and if so will deploy to PyPi. (so basically once your build finishes, after maybe a minute you should see the new version in PyPi automatically (on refresh)) - [ ] PR created in [edx-platform](https://github.com/openedx/edx-platform) to upgrade dependencies (including edx-enterprise) + - Trigger the '[Upgrade one Python dependency](https://github.com/openedx/edx-platform/actions/workflows/upgrade-one-python-dependency.yml)' action against master in edx-platform with new version number to generate version bump PR - This **must** be done after the version is visible in PyPi as `make upgrade` in edx-platform will look for the latest version in PyPi. - Note: the edx-enterprise constraint in edx-platform **must** also be bumped to the latest version in PyPi. diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6723a4619d..dac8d10c77 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,9 +15,34 @@ Change Log Unreleased ---------- +[4.15.9] +-------- +* fix: return a 404 response for inactive CSOD customers while fetching courses + + +[4.15.8] +-------- +* fix: SSO self-serve tool invalid entityId parsing + +[4.15.7] +-------- +* feat: add send group membership invite and removal braze emails + +[4.15.6] +-------- +* perf: update user preferences inside an async task to void request timeout + +[4.15.5] +-------- +* fix: Improved the query to only fetch active configs for CSOD customers. + +[4.15.4] +-------- +* fix: allowing for existing pecus to be added to enterprise groups + [4.15.3] -------- -* feat: replacing non encrypted fields of degreed config model with encrypted ones +* feat: replacing non encrypted fields of degreed config model with encrypted ones [4.15.2] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 0612e907e0..98b31ee5eb 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.15.3" +__version__ = "4.15.9" diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index a212b2278c..b17a6864c2 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -1692,6 +1692,17 @@ class Meta: updated = serializers.DateTimeField(required=False, read_only=True) +class EnterpriseGroupRequestDataSerializer(serializers.Serializer): + """ + Serializer for the Enterprise Group Assign Learners endpoint query params + """ + catalog_uuid = serializers.UUIDField(required=False, allow_null=True) + act_by_date = serializers.DateTimeField(required=False, allow_null=True) + learner_emails = serializers.ListField( + child=serializers.EmailField(required=True), + allow_empty=False) + + class EnterpriseGroupLearnersRequestQuerySerializer(serializers.Serializer): """ Serializer for the Enterprise Group Learners endpoint query filter @@ -1705,3 +1716,4 @@ class EnterpriseGroupLearnersRequestQuerySerializer(serializers.Serializer): ], required=False, ) + pending_users_only = serializers.BooleanField(required=False, default=False) diff --git a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py index 4d5e5e41b5..be76cb3c5d 100644 --- a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py +++ b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py @@ -2,6 +2,7 @@ Views for the ``enterprise-customer-sso-configuration`` API endpoint. """ +import re from xml.etree.ElementTree import fromstring import requests @@ -25,6 +26,7 @@ from enterprise.api.utils import get_enterprise_customer_from_user_id from enterprise.api.v1 import serializers from enterprise.api_client.sso_orchestrator import SsoOrchestratorClientError +from enterprise.constants import ENTITY_ID_REGEX from enterprise.logging import getEnterpriseLogger from enterprise.models import EnterpriseCustomer, EnterpriseCustomerSsoConfiguration, EnterpriseCustomerUser from enterprise.tasks import send_sso_configured_email @@ -101,8 +103,13 @@ def fetch_entity_id_from_metadata_xml(metadata_xml): root = fromstring(metadata_xml) if entity_id := root.get('entityID'): return entity_id - if entity_descriptor_child := root.find('EntityDescriptor'): + elif entity_descriptor_child := root.find('EntityDescriptor'): return entity_descriptor_child.get('entityID') + else: + # find and parse URL from it + match = re.search(ENTITY_ID_REGEX, metadata_xml, re.DOTALL) + if match: + return match.group(2) raise EntityIdNotFoundError('Could not find entity ID in metadata xml') diff --git a/enterprise/api/v1/views/enterprise_group.py b/enterprise/api/v1/views/enterprise_group.py index e867c19ff5..f15b48c229 100644 --- a/enterprise/api/v1/views/enterprise_group.py +++ b/enterprise/api/v1/views/enterprise_group.py @@ -17,6 +17,7 @@ from enterprise.api.v1 import serializers from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet from enterprise.logging import getEnterpriseLogger +from enterprise.tasks import send_group_membership_invitation_notification, send_group_membership_removal_notification from enterprise.utils import localized_utcnow LOGGER = getEnterpriseLogger(__name__) @@ -94,14 +95,18 @@ def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) @action(detail=True, methods=['get']) - def get_learners(self, *args, **kwargs): + def get_learners(self, request, *args, **kwargs): """ - Endpoint Location: GET api/v1/enterprise-group//learners/ + Endpoint Location to return all learners: GET api/v1/enterprise-group//learners/ + + Endpoint Location to return pending learners: + GET api/v1/enterprise-group//learners/?pending_users_only=true Request Arguments: - ``group_uuid`` (URL location, required): The uuid of the group from which learners should be listed. Optional query params: + - ``pending_users_only`` (string, optional): Specify that results should only contain pending learners - ``q`` (string, optional): Filter the returned members by user email and name with a provided sub-string - ``sort_by`` (string, optional): Specify how the returned members should be ordered. Supported sorting values are `memberDetails`, `memberStatus`, and `recentAction`. Ordering can be reversed by supplying a `-` at the @@ -116,9 +121,15 @@ def get_learners(self, *args, **kwargs): 'previous': null, 'results': [ { - 'learner_uuid': 'enterprise_customer_user_id', - 'pending_learner_id': 'pending_enterprise_customer_user_id', - 'enterprise_group_membership_uuid': 'enterprise_group_membership_uuid', + 'learner_id': integer or None, + 'pending_learner_id': integer or None, + 'enterprise_group_membership_uuid': UUID, + 'member_details': { + 'user_email': string, + 'user_name': string, + }, + 'recent_action': string, + 'status': string, }, ], } @@ -136,11 +147,16 @@ def get_learners(self, *args, **kwargs): user_query = param_serializers.validated_data.get('user_query') sort_by = param_serializers.validated_data.get('sort_by') + pending_users_only = param_serializers.validated_data.get('pending_users_only') group_uuid = kwargs.get('group_uuid') try: group_object = self.get_queryset().get(uuid=group_uuid) - members = group_object.get_all_learners(user_query, sort_by, desc_order=is_reversed) + members = group_object.get_all_learners(user_query, + sort_by, + desc_order=is_reversed, + pending_users_only=pending_users_only) + page = self.paginate_queryset(members) serializer = serializers.EnterpriseGroupMembershipSerializer(page, many=True) response = self.get_paginated_response(serializer.data) @@ -159,10 +175,14 @@ def assign_learners(self, request, group_uuid): """ POST /enterprise/api/v1/enterprise-group//assign_learners - Required Arguments: + Request Arguments: - ``learner_emails``: List of learner emails to associate with the group. Note: only processes the first 1000 records provided. + Optional request data: + - ``act_by_date`` (datetime, optional): The expiration date for the subsidy. + - ``catalog_uuid`` (string, optional): The uuid of the catalog that is part of the subsidy. + Returns: - ``records_processed``: Total number of group membership records processed. - ``new_learners``: Total number of group membership records associated with new pending enterprise learners @@ -176,91 +196,102 @@ def assign_learners(self, request, group_uuid): customer = group.enterprise_customer except models.EnterpriseGroup.DoesNotExist as exc: raise Http404 from exc - if requested_emails := request.POST.dict().get('learner_emails'): - total_records_processed = 0 - total_existing_users_processed = 0 - total_new_users_processed = 0 - for user_email_batch in utils.batch(requested_emails.rstrip(',').split(',')[: 1000], batch_size=200): - user_emails_to_create = [] - memberships_to_create = [] - # ecus: enterprise customer users - ecus = [] - # Gather all existing User objects associated with the email batch - existing_users = User.objects.filter(email__in=user_email_batch) - - # Build and create a list of EnterpriseCustomerUser objects for the emails of existing Users - # Ignore conflicts in case any of the ent customer user objects already exist - ecu_by_email = { - user.email: models.EnterpriseCustomerUser( - enterprise_customer=customer, user_id=user.id, active=True - ) for user in existing_users - } - models.EnterpriseCustomerUser.objects.bulk_create( - ecu_by_email.values(), - ignore_conflicts=True, - ) + param_serializer = serializers.EnterpriseGroupRequestDataSerializer(data=request.data) + param_serializer.is_valid(raise_exception=True) + # act_by_date and catalog_uuid values are needed for Braze email trigger properties + act_by_date = param_serializer.validated_data.get('act_by_date') + catalog_uuid = param_serializer.validated_data.get('catalog_uuid') + learner_emails = param_serializer.validated_data.get('learner_emails') + total_records_processed = 0 + total_existing_users_processed = 0 + total_new_users_processed = 0 + for user_email_batch in utils.batch(learner_emails[: 1000], batch_size=200): + user_emails_to_create = [] + memberships_to_create = [] + # ecus: enterprise customer users + ecus = [] + # Gather all existing User objects associated with the email batch + existing_users = User.objects.filter(email__in=user_email_batch) + # Build and create a list of EnterpriseCustomerUser objects for the emails of existing Users + # Ignore conflicts in case any of the ent customer user objects already exist + ecu_by_email = { + user.email: models.EnterpriseCustomerUser( + enterprise_customer=customer, user_id=user.id, active=True + ) for user in existing_users + } + models.EnterpriseCustomerUser.objects.bulk_create( + ecu_by_email.values(), + ignore_conflicts=True, + ) - # Fetch all ent customer users related to existing users provided by requester - # whether they were created above or already existed - ecus.extend( - models.EnterpriseCustomerUser.objects.filter( - user_id__in=existing_users.values_list('id', flat=True) - ) + # Fetch all ent customer users related to existing users provided by requester + # whether they were created above or already existed + ecus.extend( + models.EnterpriseCustomerUser.objects.filter( + user_id__in=existing_users.values_list('id', flat=True) ) + ) - # Extend the list of emails that don't have User objects associated and need to be turned into - # new PendingEnterpriseCustomerUser objects - user_emails_to_create.extend(set(user_email_batch).difference(set(ecu_by_email.keys()))) - - # Extend the list of memberships that need to be created associated with existing Users - ent_customer_users = [ - models.EnterpriseGroupMembership( - activated_at=localized_utcnow(), - status=constants.GROUP_MEMBERSHIP_ACCEPTED_STATUS, - enterprise_customer_user=ecu, - group=group - ) - for ecu in ecus - ] - total_existing_users_processed += len(ent_customer_users) - memberships_to_create.extend(ent_customer_users) - - # Go over (in batches) all emails that don't have User objects - for emails_to_create_batch in utils.batch(user_emails_to_create, batch_size=200): - # Create the PendingEnterpriseCustomerUser objects - pecu_records = [ - models.PendingEnterpriseCustomerUser( - enterprise_customer=customer, user_email=user_email - ) for user_email in emails_to_create_batch - ] - # According to Django docs, bulk created objects can't be used in future bulk creates as the in memory - # objects returned by bulk_create won't have PK's assigned. - models.PendingEnterpriseCustomerUser.objects.bulk_create(pecu_records) - pecus = models.PendingEnterpriseCustomerUser.objects.filter( - user_email__in=emails_to_create_batch, - enterprise_customer=customer, + # Extend the list of emails that don't have User objects associated and need to be turned into + # new PendingEnterpriseCustomerUser objects + user_emails_to_create.extend(set(user_email_batch).difference(set(ecu_by_email.keys()))) + + # Extend the list of memberships that need to be created associated with existing Users + ent_customer_users = [ + models.EnterpriseGroupMembership( + activated_at=localized_utcnow(), + enterprise_customer_user=ecu, + group=group ) - total_new_users_processed += len(pecus) - # Extend the list of memberships that need to be created associated with the new pending users - memberships_to_create.extend([ - models.EnterpriseGroupMembership( - pending_enterprise_customer_user=pecu, - group=group - ) for pecu in pecus - ]) - - # Create all our memberships, bulk_create will batch for us. - memberships = models.EnterpriseGroupMembership.objects.bulk_create( - memberships_to_create, ignore_conflicts=True + for ecu in ecus + ] + total_existing_users_processed += len(ent_customer_users) + memberships_to_create.extend(ent_customer_users) + + # Go over (in batches) all emails that don't have User objects + for emails_to_create_batch in utils.batch(user_emails_to_create, batch_size=200): + # Create the PendingEnterpriseCustomerUser objects + pecu_records = [ + models.PendingEnterpriseCustomerUser( + enterprise_customer=customer, user_email=user_email + ) for user_email in emails_to_create_batch + ] + # According to Django docs, bulk created objects can't be used in future bulk creates as the in memory + # objects returned by bulk_create won't have PK's assigned. + models.PendingEnterpriseCustomerUser.objects.bulk_create(pecu_records, ignore_conflicts=True) + pecus = models.PendingEnterpriseCustomerUser.objects.filter( + user_email__in=emails_to_create_batch, + enterprise_customer=customer, ) - total_records_processed += len(memberships) - data = { - 'records_processed': total_records_processed, - 'new_learners': total_new_users_processed, - 'existing_learners': total_existing_users_processed, - } - return Response(data, status=201) - return Response(data="Error: missing request data: `learner_emails`.", status=400) + total_new_users_processed += len(pecus) + # Extend the list of memberships that need to be created associated with the new pending users + memberships_to_create.extend([ + models.EnterpriseGroupMembership( + pending_enterprise_customer_user=pecu, + group=group + ) for pecu in pecus + ]) + + # Create all our memberships, bulk_create will batch for us. + memberships = models.EnterpriseGroupMembership.objects.bulk_create( + memberships_to_create, ignore_conflicts=True + ) + total_records_processed += len(memberships) + data = { + 'records_processed': total_records_processed, + 'new_learners': total_new_users_processed, + 'existing_learners': total_existing_users_processed, + } + membership_uuids = [membership.uuid for membership in memberships] + if act_by_date and catalog_uuid: + for membership_uuid_batch in utils.batch(membership_uuids, batch_size=200): + send_group_membership_invitation_notification.delay( + customer.uuid, + membership_uuid_batch, + act_by_date, + catalog_uuid + ) + return Response(data, status=201) @action(methods=['post'], detail=False, permission_classes=[permissions.IsAuthenticated]) @permission_required( @@ -275,35 +306,50 @@ def remove_learners(self, request, group_uuid): - ``learner_emails``: List of learner emails to associate with the group. + Optional request data: + - ``catalog_uuid`` (string, optional): The uuid of the catalog that is part of the subsidy. + Returns: - ``records_deleted``: Number of membership records removed """ try: group = self.get_queryset().get(uuid=group_uuid) + customer = group.enterprise_customer except models.EnterpriseGroup.DoesNotExist as exc: raise Http404 from exc - if requested_emails := request.POST.dict().get('learner_emails'): - records_deleted = 0 - for user_email_batch in utils.batch(requested_emails.split(','), batch_size=200): - existing_users = User.objects.filter(email__in=user_email_batch).values_list("id", flat=True) - group_q = Q(group=group) - ecu_in_q = Q(enterprise_customer_user__user_id__in=existing_users) - pecu_in_q = Q(pending_enterprise_customer_user__user_email__in=user_email_batch) - records_to_delete = models.EnterpriseGroupMembership.objects.filter( - group_q & (ecu_in_q | pecu_in_q), - ) - records_deleted += len(records_to_delete) - records_to_delete.delete() - - # Woohoo! Records removed! Now to update the soft deleted records - deleted_records = models.EnterpriseGroupMembership.all_objects.filter( - group_q & (ecu_in_q | pecu_in_q), - ) - deleted_records.update( - status=constants.GROUP_MEMBERSHIP_REMOVED_STATUS, - removed_at=localized_utcnow() - ) - data = {'records_deleted': records_deleted} - return Response(data, status=200) - return Response(data="Error: missing request data: `learner_emails`.", status=400) + param_serializer = serializers.EnterpriseGroupRequestDataSerializer(data=request.data) + param_serializer.is_valid(raise_exception=True) + + catalog_uuid = param_serializer.validated_data.get('catalog_uuid') + learner_emails = param_serializer.validated_data.get('learner_emails') + + records_deleted = 0 + for user_email_batch in utils.batch(learner_emails[: 1000], batch_size=200): + existing_users = User.objects.filter(email__in=user_email_batch).values_list("id", flat=True) + group_q = Q(group=group) + ecu_in_q = Q(enterprise_customer_user__user_id__in=existing_users) + pecu_in_q = Q(pending_enterprise_customer_user__user_email__in=user_email_batch) + records_to_delete = models.EnterpriseGroupMembership.objects.filter( + group_q & (ecu_in_q | pecu_in_q), + ) + records_deleted += len(records_to_delete) + records_to_delete_uuids = [record.uuid for record in records_to_delete] + records_to_delete.delete() + for records_to_delete_uuids_batch in utils.batch(records_to_delete_uuids, batch_size=200): + send_group_membership_removal_notification.delay( + customer.uuid, + records_to_delete_uuids_batch, + catalog_uuid) + # Woohoo! Records removed! Now to update the soft deleted records + deleted_records = models.EnterpriseGroupMembership.all_objects.filter( + uuid__in=records_to_delete_uuids, + ) + deleted_records.update( + status=constants.GROUP_MEMBERSHIP_REMOVED_STATUS, + removed_at=localized_utcnow() + ) + data = { + 'records_deleted': records_deleted, + } + return Response(data, status=200) diff --git a/enterprise/api_client/braze.py b/enterprise/api_client/braze.py index c8a05e53dc..3549743a35 100644 --- a/enterprise/api_client/braze.py +++ b/enterprise/api_client/braze.py @@ -9,26 +9,88 @@ logger = logging.getLogger(__name__) +ENTERPRISE_BRAZE_ALIAS_LABEL = 'Enterprise' # Do Not change this, this is consistent with other uses across edX repos. -class BrazeAPIClient: + +class BrazeAPIClient(BrazeClient): """ API client for calls to Braze. """ - @classmethod - def get_braze_client(cls): - """ Returns a Braze client. """ - if not BrazeClient: - return None - - # fetching them from edx-platform settings + def __init__(self): braze_api_key = getattr(settings, 'EDX_BRAZE_API_KEY', None) braze_api_url = getattr(settings, 'EDX_BRAZE_API_SERVER', None) + required_settings = ['EDX_BRAZE_API_KEY', 'EDX_BRAZE_API_SERVER'] + for setting in required_settings: + if not getattr(settings, setting, None): + msg = f'Missing {setting} in settings required for Braze API Client.' + logger.error(msg) + raise ValueError(msg) - if not braze_api_key or not braze_api_url: - return None - - return BrazeClient( + super().__init__( api_key=braze_api_key, api_url=braze_api_url, app_id='', ) + + def generate_mailto_link(self, emails): + """ + Generate a mailto link for the given emails. + """ + if emails: + return f'mailto:{",".join(emails)}' + + return None + + def create_recipient( + self, + user_email, + lms_user_id, + trigger_properties=None, + ): + """ + Create a recipient object using the given user_email and lms_user_id. + Identifies the given email address with any existing Braze alias records + via the provided ``lms_user_id``. + """ + + user_alias = { + 'alias_label': ENTERPRISE_BRAZE_ALIAS_LABEL, + 'alias_name': user_email, + } + + # Identify the user alias in case it already exists. This is necessary so + # we don't accidentally create a duplicate Braze profile. + self.identify_users([{ + 'external_id': lms_user_id, + 'user_alias': user_alias + }]) + + attributes = { + "user_alias": user_alias, + "email": user_email, + "is_enterprise_learner": True, + "_update_existing_only": False, + } + + return { + 'external_user_id': lms_user_id, + 'attributes': attributes, + # If a profile does not already exist, Braze will create a new profile before sending a message. + 'send_to_existing_only': False, + 'trigger_properties': trigger_properties or {}, + } + + def create_recipient_no_external_id(self, user_email): + """ + Create a Braze recipient dict identified only by an alias based on their email. + """ + return { + 'attributes': { + 'email': user_email, + 'is_enterprise_learner': True, + }, + 'user_alias': { + 'alias_label': ENTERPRISE_BRAZE_ALIAS_LABEL, + 'alias_name': user_email, + }, + } diff --git a/enterprise/constants.py b/enterprise/constants.py index 4d20fff79a..15f4ac8ba4 100644 --- a/enterprise/constants.py +++ b/enterprise/constants.py @@ -250,3 +250,5 @@ class FulfillmentTypes: (GROUP_MEMBERSHIP_ACCEPTED_STATUS, 'Accepted'), (GROUP_MEMBERSHIP_PENDING_STATUS, 'Pending'), ) + +ENTITY_ID_REGEX = r"<(\w+:)?EntityDescriptor.*?entityID=['\"](.*?)['\"].*?>" diff --git a/enterprise/models.py b/enterprise/models.py index 75157378ca..3963d13115 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -4292,11 +4292,13 @@ def _get_filtered_ecu_ids(self, user_query): ecus = EnterpriseCustomerUser.objects.raw(sql_string, (customer_id, var_q)) return [ecu.id for ecu in ecus] - def _get_implicit_group_members(self, user_query=None): + def _get_implicit_group_members(self, user_query=None, pending_users_only=False): """ Fetches all implicit members of a group, indicated by a (pending) enterprise customer user records. """ members = [] + customer_users = [] + # Regardless of user_query, we will need all pecus related to the group's customer pending_customer_users = PendingEnterpriseCustomerUser.objects.filter( enterprise_customer=self.enterprise_customer, @@ -4304,17 +4306,19 @@ def _get_implicit_group_members(self, user_query=None): if user_query: # Get all ecus relevant to the user query - customer_users = EnterpriseCustomerUser.objects.filter( - id__in=self._get_filtered_ecu_ids(user_query) - ) + if not pending_users_only: + customer_users = EnterpriseCustomerUser.objects.filter( + id__in=self._get_filtered_ecu_ids(user_query) + ) # pecu has user_email as a field, so we can filter directly pending_customer_users = pending_customer_users.filter(user_email__icontains=user_query) else: - # No filtering query so get all ecus related to the group's customer - customer_users = EnterpriseCustomerUser.objects.filter( - enterprise_customer=self.enterprise_customer, - active=True, - ) + if not pending_users_only: + # No filtering query so get all ecus related to the group's customer + customer_users = EnterpriseCustomerUser.objects.filter( + enterprise_customer=self.enterprise_customer, + active=True, + ) # Build an in memory array of all the implicit memberships for ent_user in customer_users: members.append(EnterpriseGroupMembership( @@ -4330,7 +4334,7 @@ def _get_implicit_group_members(self, user_query=None): )) return members - def _get_explicit_group_members(self, user_query=None, fetch_removed=False): + def _get_explicit_group_members(self, user_query=None, fetch_removed=False, pending_users_only=False,): """ Fetch explicitly defined members of a group, indicated by an existing membership record """ @@ -4345,9 +4349,16 @@ def _get_explicit_group_members(self, user_query=None, fetch_removed=False): # pecu has user_email as a field, so we can filter directly through the ORM with the user_query pecu_filter = Q(pending_enterprise_customer_user__user_email__icontains=user_query) members = members.filter(ecu_filter | pecu_filter) + if pending_users_only: + members = members.filter(is_removed=False, enterprise_customer_user_id__isnull=True) return members - def get_all_learners(self, user_query=None, sort_by=None, desc_order=False, fetch_removed=False): + def get_all_learners(self, + user_query=None, + sort_by=None, + desc_order=False, + fetch_removed=False, + pending_users_only=False): """ Returns all users associated with the group, whether the group specifies the entire org else all associated membership records. @@ -4359,9 +4370,9 @@ def get_all_learners(self, user_query=None, sort_by=None, desc_order=False, fetc beginning of the sorting value ie `-memberStatus`. """ if self.applies_to_all_contexts: - members = self._get_implicit_group_members(user_query) + members = self._get_implicit_group_members(user_query, pending_users_only) else: - members = self._get_explicit_group_members(user_query, fetch_removed) + members = self._get_explicit_group_members(user_query, fetch_removed, pending_users_only) if sort_by: lambda_keys = { 'member_details': lambda t: t.member_email, diff --git a/enterprise/settings/test.py b/enterprise/settings/test.py index e95dcfad49..f6dae73273 100644 --- a/enterprise/settings/test.py +++ b/enterprise/settings/test.py @@ -364,3 +364,8 @@ def root(*args): ENTERPRISE_SSO_ORCHESTRATOR_BASE_URL = 'https://foobar.com' ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_PATH = 'configure' ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH = 'configure-edx-oauth' + +EDX_BRAZE_API_KEY='test-api-key' +EDX_BRAZE_API_SERVER='test-api-server' +BRAZE_GROUPS_INVITATION_EMAIL_CAMPAIGN_ID = 'test-invitation-campaign-id' +BRAZE_GROUPS_REMOVAL_EMAIL_CAMPAIGN_ID = 'test-removal-campaign-id' diff --git a/enterprise/signals.py b/enterprise/signals.py index 52ec0adf72..24f19003c8 100644 --- a/enterprise/signals.py +++ b/enterprise/signals.py @@ -11,13 +11,12 @@ from enterprise.api import activate_admin_permissions from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient from enterprise.decorators import disable_for_loaddata -from enterprise.tasks import create_enterprise_enrollment +from enterprise.tasks import create_enterprise_enrollment, update_enterprise_learners_user_preference from enterprise.utils import ( NotConnectedToOpenEdX, get_default_catalog_content_filter, localized_utcnow, unset_enterprise_learner_language, - unset_language_of_all_enterprise_learners, ) from integrated_channels.blackboard.models import BlackboardEnterpriseCustomerConfiguration from integrated_channels.canvas.models import CanvasEnterpriseCustomerConfiguration @@ -104,10 +103,11 @@ def update_lang_pref_of_all_learners(sender, instance, **kwargs): # pylint: dis # The middleware in the enterprise will handle the cases for setting a proper language for the learner. if instance.default_language: prev_state = models.EnterpriseCustomer.objects.filter(uuid=instance.uuid).first() - if prev_state is None or prev_state.default_language != instance.default_language: + if prev_state and prev_state.default_language != instance.default_language: # Unset the language preference of all the learners linked with the enterprise customer. # The middleware in the enterprise will handle the cases for setting a proper language for the learner. - unset_language_of_all_enterprise_learners(instance) + logger.info('Task triggered to update user preference for learners. Enterprise: [%s]', instance.uuid) + update_enterprise_learners_user_preference.delay(instance.uuid) @receiver(pre_save, sender=models.EnterpriseCustomerBrandingConfiguration) diff --git a/enterprise/tasks.py b/enterprise/tasks.py index c829965257..f6646ad1ec 100644 --- a/enterprise/tasks.py +++ b/enterprise/tasks.py @@ -9,12 +9,18 @@ from edx_django_utils.monitoring import set_code_owner_attribute from django.apps import apps +from django.conf import settings from django.core import mail from django.db import IntegrityError -from enterprise.api_client.braze import BrazeAPIClient +from enterprise.api_client.braze import ENTERPRISE_BRAZE_ALIAS_LABEL, BrazeAPIClient +from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient from enterprise.constants import SSO_BRAZE_CAMPAIGN_ID -from enterprise.utils import get_enterprise_customer, send_email_notification_message +from enterprise.utils import ( + get_enterprise_customer, + send_email_notification_message, + unset_language_of_all_enterprise_learners, +) LOGGER = getLogger(__name__) @@ -127,6 +133,22 @@ def enterprise_customer_user_model(): return apps.get_model('enterprise', 'EnterpriseCustomerUser') +def pending_enterprise_customer_user_model(): + """ + Returns the ``PendingEnterpriseCustomerUser`` class. + This function is needed to avoid circular ref issues when model classes call tasks in this module. + """ + return apps.get_model('enterprise', 'PendingEnterpriseCustomerUser') + + +def enterprise_group_membership_model(): + """ + Returns the ``EnterpriseGroupMembership`` class. + This function is needed to avoid circular ref issues when model classes call tasks in this module. + """ + return apps.get_model('enterprise', 'EnterpriseGroupMembership') + + def enterprise_course_enrollment_model(): """ Returns the ``EnterpriseCourseEnrollment`` class. @@ -169,10 +191,17 @@ def send_sso_configured_email( } try: - braze_client_instance = BrazeAPIClient.get_braze_client() + braze_client_instance = BrazeAPIClient() + recipient = braze_client_instance.create_recipient_no_external_id( + contact_email, + ) + braze_client_instance.create_braze_alias( + [contact_email], + ENTERPRISE_BRAZE_ALIAS_LABEL, + ) braze_client_instance.send_campaign_message( braze_campaign_id, - recipients=[contact_email], + recipients=[recipient], trigger_properties=braze_trigger_properties, ) except BrazeClientError as exc: @@ -182,3 +211,142 @@ def send_sso_configured_email( ) LOGGER.exception(message) raise exc + + +@shared_task +@set_code_owner_attribute +def send_group_membership_invitation_notification( + enterprise_customer_uuid, + membership_uuids, + act_by_date, + catalog_uuid +): + """ + Send braze email notification when member is invited to a group. + + Arguments: + * enterprise_customer_uuid (string) + * memberships (list) + * act_by_date (datetime) + * catalog_uuid (string) + """ + enterprise_customer = get_enterprise_customer(enterprise_customer_uuid) + braze_client_instance = BrazeAPIClient() + enterprise_catalog_client = EnterpriseCatalogApiClient() + braze_trigger_properties = {} + contact_email = enterprise_customer.contact_email + enterprise_customer_name = enterprise_customer.name + braze_trigger_properties['contact_admin_link'] = braze_client_instance.generate_mailto_link(contact_email) + braze_trigger_properties['enterprise_customer_name'] = enterprise_customer_name + braze_trigger_properties[ + 'catalog_content_count' + ] = enterprise_catalog_client.get_catalog_content_count(catalog_uuid) + + braze_trigger_properties['act_by_date'] = act_by_date.strftime('%B %d, %Y') + pecu_emails = [] + ecus = [] + membership_records = enterprise_group_membership_model().objects.filter(uuid__in=membership_uuids) + for group_membership in membership_records: + if group_membership.pending_enterprise_customer_user is not None: + pecu_emails.append(group_membership.pending_enterprise_customer_user.user_email) + else: + ecus.append({ + 'user_email': group_membership.enterprise_customer_user.user_email, + 'user_id': group_membership.enterprise_customer_user.user_id + }) + recipients = [] + for pecu_email in pecu_emails: + recipients.append(braze_client_instance.create_recipient_no_external_id(pecu_email)) + braze_client_instance.create_braze_alias( + [pecu_emails], + ENTERPRISE_BRAZE_ALIAS_LABEL, + ) + for ecu in ecus: + recipients.append(braze_client_instance.create_recipient( + user_email=ecu['user_email'], + lms_user_id=ecu['user_id'])) + try: + braze_client_instance.send_campaign_message( + settings.BRAZE_GROUPS_INVITATION_EMAIL_CAMPAIGN_ID, + recipients=recipients, + trigger_properties=braze_trigger_properties, + ) + except BrazeClientError as exc: + message = ( + "Groups learner invitation email could not be sent " + f"to {recipients} for enterprise {enterprise_customer_name}." + ) + LOGGER.exception(message) + raise exc + + +@shared_task +@set_code_owner_attribute +def send_group_membership_removal_notification(enterprise_customer_uuid, membership_uuids, catalog_uuid): + """ + Send braze email notification when learner is removed from a group. + + Arguments: + * enterprise_customer_uuid (string) + * group_membership_uuid (string) + """ + enterprise_customer = get_enterprise_customer(enterprise_customer_uuid) + braze_client_instance = BrazeAPIClient() + enterprise_catalog_client = EnterpriseCatalogApiClient() + braze_trigger_properties = {} + contact_email = enterprise_customer.contact_email + enterprise_customer_name = enterprise_customer.name + braze_trigger_properties['contact_admin_link'] = braze_client_instance.generate_mailto_link(contact_email) + braze_trigger_properties['enterprise_customer_name'] = enterprise_customer_name + braze_trigger_properties[ + 'catalog_content_count' + ] = enterprise_catalog_client.get_catalog_content_count(catalog_uuid) + pecu_emails = [] + ecus = [] + membership_records = enterprise_group_membership_model().objects.filter(uuid__in=membership_uuids) + for group_membership in membership_records: + if group_membership.pending_enterprise_customer_user is not None: + pecu_emails.append(group_membership.pending_enterprise_customer_user.user_email) + else: + ecus.append({ + 'user_email': group_membership.enterprise_customer_user.user_email, + 'user_id': group_membership.enterprise_customer_user.user_id + }) + + recipients = [] + for pecu_email in pecu_emails: + recipients.append(braze_client_instance.create_recipient_no_external_id(pecu_email)) + braze_client_instance.create_braze_alias( + [pecu_emails], + ENTERPRISE_BRAZE_ALIAS_LABEL, + ) + for ecu in ecus: + recipients.append(braze_client_instance.create_recipient( + user_email=ecu['user_email'], + lms_user_id=ecu['user_id'] + )) + try: + braze_client_instance.send_campaign_message( + settings.BRAZE_GROUPS_REMOVAL_EMAIL_CAMPAIGN_ID, + recipients=recipients, + trigger_properties=braze_trigger_properties, + ) + except BrazeClientError as exc: + message = ( + "Groups learner invitation email could not be sent " + f"to {recipients} for enterprise {enterprise_customer_name}." + ) + LOGGER.exception(message) + raise exc + + +@shared_task +@set_code_owner_attribute +def update_enterprise_learners_user_preference(enterprise_customer_uuid): + """ + Update the user preference `pref-lang` attribute for all enterprise learners linked with an enterprise. + + Arguments: + * enterprise_customer_uuid (UUID): uuid of an enterprise customer + """ + unset_language_of_all_enterprise_learners(enterprise_customer_uuid) diff --git a/enterprise/utils.py b/enterprise/utils.py index 04334ad323..f5e722120e 100644 --- a/enterprise/utils.py +++ b/enterprise/utils.py @@ -2218,22 +2218,29 @@ def get_platform_logo_url(): return urljoin(settings.LMS_ROOT_URL, logo_url) -def unset_language_of_all_enterprise_learners(enterprise_customer): +def unset_language_of_all_enterprise_learners(enterprise_customer_uuid): """ Unset the language preference of all the learners belonging to the given enterprise customer. Arguments: - enterprise_customer (EnterpriseCustomer): Instance of the enterprise customer. + enterprise_customer_uuid (UUI): uuid of an enterprise customer """ if UserPreference: + enterprise_customer = get_enterprise_customer(enterprise_customer_uuid) user_ids = list(enterprise_customer.enterprise_customer_users.values_list('user_id', flat=True)) - UserPreference.objects.filter( - key=LANGUAGE_KEY, - user_id__in=user_ids - ).update( - value='' - ) + LOGGER.info('Update user preference started for learners. Enterprise: [%s]', enterprise_customer_uuid) + + for chunk in batch(user_ids, batch_size=10000): + UserPreference.objects.filter( + key=LANGUAGE_KEY, + user_id__in=chunk + ).update( + value='' + ) + LOGGER.info('Updated user preference for learners. Batch Size: [%s]', len(chunk)) + + LOGGER.info('Update user preference completed for learners. Enterprise: [%s]', enterprise_customer_uuid) def unset_enterprise_learner_language(enterprise_customer_user): diff --git a/integrated_channels/cornerstone/views.py b/integrated_channels/cornerstone/views.py index 457cbdd2d3..7ad228ce36 100644 --- a/integrated_channels/cornerstone/views.py +++ b/integrated_channels/cornerstone/views.py @@ -14,6 +14,7 @@ from rest_framework.response import Response from django.apps import apps +from django.core.exceptions import ObjectDoesNotExist from django.utils.http import parse_http_date_safe from enterprise.api.throttles import ServiceUserThrottle @@ -126,9 +127,20 @@ def get(self, request, *args, **kwargs): }) worker_user = get_enterprise_worker_user() - enterprise_config = CornerstoneEnterpriseCustomerConfiguration.objects.get( - enterprise_customer=enterprise_customer - ) + try: + enterprise_config = CornerstoneEnterpriseCustomerConfiguration.objects.get( + enterprise_customer=enterprise_customer, + active=True + ) + except ObjectDoesNotExist: + return Response( + status=status.HTTP_404_NOT_FOUND, + data={ + "message": ( + "No active Cornerstone configuration found for given ciid." + ) + }) + exporter = enterprise_config.get_content_metadata_exporter(worker_user) transmitter = enterprise_config.get_content_metadata_transmitter() diff --git a/tests/test_enterprise/api/constants.py b/tests/test_enterprise/api/constants.py new file mode 100644 index 0000000000..e7cf071dc0 --- /dev/null +++ b/tests/test_enterprise/api/constants.py @@ -0,0 +1,13 @@ +""" +Constants for Enterprise API Tests. +Attributes: + FAKE_SSO_METADATA_XML_WITH_ENTITY_ID (list of tuples): Contains Fake SSO metadata XML file content and the expected + URL(entityId). +""" + +FAKE_SSO_METADATA_XML_WITH_ENTITY_ID = [ + ('', "https://example.com"), # pylint: disable=line-too-long + (' oVw/6lXDTOS8zt0kz/6qfB+3CoFu77QqVp7X0rYEPZ4= CTFf9mVJQhMOb5jF8Kx3kbT0eVleDRe6UZB8mG2MqHekboXSzzl26mEvEq/e8io5J/v0VP+zkAav 2S58JzN538VW5Vr/vCIuSQZWnkGF1CowK+1UBx08JnuHYJOAYqgoVGceT+U2LS2BpejghHAq5I/p XGP3ibGazqvFmewvB/5uO9hWIV+STEqfdqiZuas2j+tJKeLPcAumHu1wNcY+f/zX4qF7FtN2Dds= AQAB MIIH5TCCBc2gAwIBAgIMKFc71tGDqscdVu3zMA0GCSqGSIb3DQEBCwUAMIGVMQswCQYDVQQGEwJE R0l0kZAQRU5zI4nw2eaGhLgw69rIm1IDCXDgqCTP http://aai.dfn.de/category/idm.nrw-member https://refeds.org/sirtfi l urn:oasis:names:tc:SAML:2.0:nameid-format:transient l sso.l CN=sso.l,OU=IT Center,O=RWTH Aachen,L=Aachen,ST=Nordrhein-Westfalen,C=DE M sso.l CN=sso.l,O=RWTH Aachen,L=Aachen,ST=Nordrhein-Westfalen,C=DE M= sso.l CN=sso.l,O=RWTH Aachen,L=Aachen,ST=Nordrhein-Westfalen,C=DE M= urn:oasis:names:tc:SAML:2.0:nameid-format:transient e9 e9 http://www.l http://www.l ', "https://login.rz.de"), # pylint: disable=line-too-long + (' ', "https://idp1.example.com"), # pylint: disable=line-too-long + ('', "https://example.com") # pylint: disable=line-too-long +] diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index b868d6d4c5..0c9d5695b1 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -14,6 +14,7 @@ from urllib.parse import parse_qs, urlencode, urljoin, urlsplit, urlunsplit import ddt +import pytz import responses from edx_toggles.toggles.testutils import override_waffle_flag from faker import Faker @@ -31,6 +32,7 @@ from django.utils import timezone from enterprise.api.v1 import serializers +from enterprise.api.v1.views.enterprise_customer_sso_configuration import fetch_entity_id_from_metadata_xml from enterprise.api.v1.views.enterprise_subsidy_fulfillment import LicensedEnterpriseCourseEnrollmentViewSet from enterprise.constants import ( ALL_ACCESS_CONTEXT, @@ -41,7 +43,6 @@ ENTERPRISE_OPERATOR_ROLE, ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE, PATHWAY_CUSTOMER_ADMIN_ENROLLMENT, - SSO_BRAZE_CAMPAIGN_ID, ) from enterprise.models import ( ChatGPTResponse, @@ -100,6 +101,8 @@ ) from test_utils.fake_enterprise_api import get_default_branding_object +from .constants import FAKE_SSO_METADATA_XML_WITH_ENTITY_ID + Application = get_application_model() fake = Faker() @@ -7301,6 +7304,7 @@ def setUp(self): is_active=True, is_staff=False, ) + self.pending_enterprise_customer_user = PendingEnterpriseCustomerUserFactory() self.enterprise_customer_user = EnterpriseCustomerUserFactory( user_id=self.user.id, enterprise_customer=self.enterprise_customer ) @@ -7560,7 +7564,13 @@ def test_successful_list_learners(self): 'results': results_list, } response = self.client.get(url) - assert response.json() == expected_response + for i in range(10): + assert response.json()['results'][i]['learner_id'] == expected_response['results'][i]['learner_id'] + assert response.json()['results'][i]['pending_learner_id'] == ( + expected_response['results'][i]['pending_learner_id']) + assert (response.json()['results'][i]['enterprise_group_membership_uuid'] + == expected_response['results'][i]['enterprise_group_membership_uuid']) + # verify page 2 of paginated response url_page_2 = settings.TEST_SERVER + reverse( 'enterprise-group-learners', @@ -7586,12 +7596,47 @@ def test_successful_list_learners(self): } ], } - assert page_2_response.json() == expected_response_page_2 - + assert page_2_response.json()['count'] == expected_response_page_2['count'] + assert page_2_response.json()['previous'] == expected_response_page_2['previous'] + assert page_2_response.json()['results'][0]['learner_id'] == ( + expected_response_page_2['results'][0]['learner_id']) + assert page_2_response.json()['results'][0]['pending_learner_id'] == ( + expected_response_page_2['results'][0]['pending_learner_id']) + assert (page_2_response.json()['results'][0]['enterprise_group_membership_uuid'] + == expected_response_page_2['results'][0]['enterprise_group_membership_uuid']) self.enterprise_group_memberships[0].delete() response = self.client.get(url) assert response.json()['count'] == 10 + # url: 'http://testserver/enterprise/api/v1/enterprise_group//learners/?pending_users_only=true' + # verify filtered response for only pending users + self.enterprise_group_memberships.append(EnterpriseGroupMembershipFactory( + group=self.group_1, + pending_enterprise_customer_user=self.pending_enterprise_customer_user, + enterprise_customer_user=None + )) + pending_users_only_url = settings.TEST_SERVER + reverse( + 'enterprise-group-learners', + kwargs={'group_uuid': self.group_1.uuid}, + ) + '/?pending_users_only=true' + pending_users_only_response = self.client.get(pending_users_only_url) + expected_pending_users_only_response = { + 'count': 1, + 'next': None, + 'previous': None, + 'results': [ + { + 'learner_id': self.enterprise_group_memberships[0].enterprise_customer_user.id, + 'pending_learner_id': self.pending_enterprise_customer_user, + 'enterprise_group_membership_uuid': str(self.enterprise_group_memberships[0].uuid), + 'enterprise_customer': { + 'name': self.enterprise_customer.name, + } + }, + ], + } + assert pending_users_only_response.json()['count'] == expected_pending_users_only_response['count'] + def test_group_uuid_not_found(self): """ Verify that the endpoint api/v1/enterprise_group//learners/ @@ -7738,23 +7783,43 @@ def test_assign_learners_requires_learner_emails(self): ) response = self.client.post(url) assert response.status_code == 400 - assert response.data == "Error: missing request data: `learner_emails`." - def test_successful_assign_learners_to_group(self): + assert response.json() == {'learner_emails': ['This field is required.']} + + def test_assign_learners_to_group_with_existing_pecu(self): """ - Test that both existing and new learners assigned to groups properly creates membership records + Test that we can add existing pending ecus to groups """ url = settings.TEST_SERVER + reverse( 'enterprise-group-assign-learners', kwargs={'group_uuid': self.group_2.uuid}, ) - - existing_emails = ",".join([(UserFactory().email) for _ in range(10)]) - new_emails = ",".join([(f"email_{x}@example.com") for x in range(10)]) - - request_data = {'learner_emails': f"{new_emails},{existing_emails}"} + pcu = PendingEnterpriseCustomerUserFactory(enterprise_customer=self.enterprise_customer) + existing_email = pcu.user_email + request_data = {'learner_emails': existing_email} response = self.client.post(url, data=request_data) + assert response.status_code == 201 + assert response.json() == {'records_processed': 1, 'new_learners': 1, 'existing_learners': 0} + @mock.patch('enterprise.tasks.send_group_membership_invitation_notification.delay', return_value=mock.MagicMock()) + def test_successful_assign_learners_to_group(self, mock_send_group_membership_invitation_notification): + """ + Test that both existing and new learners assigned to groups properly creates membership records + """ + url = settings.TEST_SERVER + reverse( + 'enterprise-group-assign-learners', + kwargs={'group_uuid': self.group_2.uuid}, + ) + existing_emails = [UserFactory().email for _ in range(10)] + new_emails = [f"email_{x}@example.com" for x in range(10)] + act_by_date = datetime.now(pytz.UTC) + catalog_uuid = uuid.uuid4() + request_data = { + 'learner_emails': existing_emails + new_emails, + 'act_by_date': act_by_date, + 'catalog_uuid': catalog_uuid, + } + response = self.client.post(url, data=request_data) assert response.status_code == 201 assert response.data == {'records_processed': 20, 'new_learners': 10, 'existing_learners': 10} assert len( @@ -7769,6 +7834,11 @@ def test_successful_assign_learners_to_group(self): enterprise_customer_user__isnull=True ) ) == 10 + assert mock_send_group_membership_invitation_notification.call_count == 1 + group_uuids = list(reversed(list( + EnterpriseGroupMembership.objects.filter(group=self.group_2).values_list('uuid', flat=True)))) + mock_send_group_membership_invitation_notification.assert_has_calls([ + mock.call(self.enterprise_customer.uuid, group_uuids, act_by_date, catalog_uuid)], any_order=True) def test_remove_learners_404(self): """ @@ -7790,7 +7860,7 @@ def test_remove_learners_requires_learner_emails(self): ) response = self.client.post(url) assert response.status_code == 400 - assert response.data == "Error: missing request data: `learner_emails`." + assert response.json() == {'learner_emails': ['This field is required.']} def test_patch_with_bad_request_customer_to_change_to(self): """ @@ -7821,7 +7891,8 @@ def test_patch_with_bad_request_customer_to_change_to(self): response = self.client.patch(url, data=request_data) assert response.status_code == 401 - def test_successful_remove_learners_from_group(self): + @mock.patch('enterprise.tasks.send_group_membership_removal_notification.delay', return_value=mock.MagicMock()) + def test_successful_remove_learners_from_group(self, mock_send_group_membership_removal_notification): """ Test that both existing and new learners in groups are properly removed by the remove_learners endpoint """ @@ -7829,17 +7900,18 @@ def test_successful_remove_learners_from_group(self): 'enterprise-group-remove-learners', kwargs={'group_uuid': self.group_2.uuid}, ) - existing_emails = "" + existing_emails = [] memberships_to_delete = [] for _ in range(10): membership = EnterpriseGroupMembershipFactory(group=self.group_2) memberships_to_delete.append(membership) - existing_emails += membership.enterprise_customer_user.user.email + ',' + existing_emails.append(membership.enterprise_customer_user.user.email) request_data = {'learner_emails': existing_emails} response = self.client.post(url, data=request_data) assert response.status_code == 200 assert response.data == {'records_deleted': 10} + assert mock_send_group_membership_removal_notification.call_count == 1 for membership in memberships_to_delete: assert EnterpriseGroupMembership.all_objects.get(pk=membership.pk).status == 'removed' assert EnterpriseGroupMembership.all_objects.get(pk=membership.pk).removed_at @@ -7907,7 +7979,7 @@ def test_group_assign_realized_learner_adds_activated_at(self): 'enterprise-group-assign-learners', kwargs={'group_uuid': self.group_2.uuid}, ) - request_data = {'learner_emails': f"{UserFactory().email},email@example.com"} + request_data = {'learner_emails': [UserFactory().email, 'email@example.com']} self.client.post(url, data=request_data) membership = EnterpriseGroupMembership.objects.filter( group=self.group_2, @@ -7921,6 +7993,7 @@ def test_group_assign_realized_learner_adds_activated_at(self): assert not pending_membership.activated_at +@ddt.ddt @mark.django_db class TestEnterpriseCustomerSsoConfigurationViewSet(APITest): """ @@ -8009,7 +8082,7 @@ def test_sso_configuration_oauth_orchestration_complete_not_found(self): response = self.post_sso_configuration_complete(config_pk) assert response.status_code == 404 - @mock.patch("enterprise.api_client.braze.BrazeAPIClient.get_braze_client") + @mock.patch("enterprise.api_client.braze.BrazeAPIClient") def test_sso_configuration_oauth_orchestration_complete_error(self, mock_braze_client): """ Verify that the endpoint is able to mark an sso config as errored. @@ -8030,7 +8103,7 @@ def test_sso_configuration_oauth_orchestration_complete_error(self, mock_braze_c assert enterprise_sso_orchestration_config.errored_at is not None assert response.status_code == status.HTTP_200_OK - @mock.patch("enterprise.api_client.braze.BrazeAPIClient.get_braze_client") + @mock.patch("enterprise.api_client.braze.BrazeAPIClient") def test_sso_configuration_oauth_orchestration_complete(self, mock_braze_client): """ Verify that the endpoint returns the correct response when the oauth orchestration is complete. @@ -8051,46 +8124,6 @@ def test_sso_configuration_oauth_orchestration_complete(self, mock_braze_client) assert enterprise_sso_orchestration_config.is_pending_configuration() is False assert response.status_code == status.HTTP_200_OK - @mock.patch("enterprise.api_client.braze.BrazeAPIClient.get_braze_client") - def test_sso_configuration_oauth_orchestration_email(self, mock_braze_client): - """ - Assert sso configuration calls Braze API with the correct arguments. - """ - mock_braze_client.return_value.get_braze_client.return_value = mock.MagicMock() - mock_send_campaign_message = mock_braze_client.return_value.send_campaign_message - - self.set_jwt_cookie(ENTERPRISE_OPERATOR_ROLE, "*") - config_pk = uuid.uuid4() - enterprise_sso_orchestration_config = EnterpriseCustomerSsoConfigurationFactory( - uuid=config_pk, - enterprise_customer=self.enterprise_customer, - configured_at=None, - submitted_at=localized_utcnow(), - ) - url = settings.TEST_SERVER + reverse( - self.SSO_CONFIGURATION_COMPLETE_ENDPOINT, - kwargs={'configuration_uuid': config_pk} - ) - assert enterprise_sso_orchestration_config.is_pending_configuration() - self.client.post(url) - - expected_trigger_properties = { - 'enterprise_customer_slug': self.enterprise_customer.slug, - 'enterprise_customer_name': self.enterprise_customer.name, - 'enterprise_sender_alias': self.enterprise_customer.sender_alias, - 'enterprise_contact_email': self.enterprise_customer.contact_email, - } - - mock_send_campaign_message.assert_any_call( - SSO_BRAZE_CAMPAIGN_ID, - recipients=[self.enterprise_customer.contact_email], - trigger_properties=expected_trigger_properties, - ) - enterprise_sso_orchestration_config.refresh_from_db() - assert enterprise_sso_orchestration_config.configured_at is not None - - # -------------------------- retrieve test suite -------------------------- - def test_sso_configuration_retrieve(self): """ Test expected response when successfully retrieving an existing sso configuration. @@ -8619,3 +8652,12 @@ def test_sso_configuration_delete_not_found(self): self.set_jwt_cookie(ENTERPRISE_ADMIN_ROLE, self.enterprise_customer.uuid) response = self.delete_sso_configuration(uuid.uuid4()) assert response.status_code == 404 + + @ddt.data(*FAKE_SSO_METADATA_XML_WITH_ENTITY_ID) + @ddt.unpack + def test_fetch_entity_id_from_metadata_xml(self, metadata_xml, expected_entity_id): + """ + Test expected entityId after parsing metadata xml file. + """ + actual_entity_id = fetch_entity_id_from_metadata_xml(metadata_xml) + assert actual_entity_id == expected_entity_id diff --git a/tests/test_enterprise/test_tasks.py b/tests/test_enterprise/test_tasks.py index eba0f8b6ca..eaa460b706 100644 --- a/tests/test_enterprise/test_tasks.py +++ b/tests/test_enterprise/test_tasks.py @@ -4,16 +4,28 @@ import copy import unittest +import uuid +from datetime import datetime from unittest import mock from pytest import mark -from enterprise.models import EnterpriseCourseEnrollment, EnterpriseEnrollmentSource -from enterprise.tasks import create_enterprise_enrollment, send_enterprise_email_notification +from enterprise.constants import SSO_BRAZE_CAMPAIGN_ID +from enterprise.models import EnterpriseCourseEnrollment, EnterpriseEnrollmentSource, EnterpriseGroupMembership +from enterprise.settings.test import BRAZE_GROUPS_INVITATION_EMAIL_CAMPAIGN_ID, BRAZE_GROUPS_REMOVAL_EMAIL_CAMPAIGN_ID +from enterprise.tasks import ( + create_enterprise_enrollment, + send_enterprise_email_notification, + send_group_membership_invitation_notification, + send_group_membership_removal_notification, + send_sso_configured_email, +) from enterprise.utils import serialize_notification_content from test_utils.factories import ( EnterpriseCustomerFactory, EnterpriseCustomerUserFactory, + EnterpriseGroupFactory, + EnterpriseGroupMembershipFactory, PendingEnterpriseCustomerUserFactory, UserFactory, ) @@ -30,10 +42,12 @@ def setUp(self): """ Setup for `TestEnterpriseTasks` test. """ - self.user = UserFactory(id=2, email='user@example.com') self.enterprise_customer = EnterpriseCustomerFactory( name='Team Titans', ) + self.user = UserFactory(email='user@example.com') + self.pending_enterprise_customer_user = PendingEnterpriseCustomerUserFactory() + self.enterprise_group = EnterpriseGroupFactory(enterprise_customer=self.enterprise_customer) self.enterprise_customer_user = EnterpriseCustomerUserFactory( user_id=self.user.id, enterprise_customer=self.enterprise_customer, @@ -180,3 +194,117 @@ def test_send_enterprise_email_notification(self, mock_send_notification, mock_e admin_enrollment=admin_enrollment, ) for item in email_items] mock_send_notification.assert_has_calls(calls) + + @mock.patch('enterprise.tasks.EnterpriseCatalogApiClient', return_value=mock.MagicMock()) + @mock.patch('enterprise.tasks.BrazeAPIClient', return_value=mock.MagicMock()) + def test_send_group_membership_invitation_notification(self, mock_braze_api_client, mock_enterprise_catalog_client): + """ + Verify send_group_membership_invitation_notification hits braze client with expected args + """ + EnterpriseGroupMembershipFactory( + group=self.enterprise_group, + pending_enterprise_customer_user=PendingEnterpriseCustomerUserFactory(), + enterprise_customer_user=None, + ) + EnterpriseGroupMembershipFactory( + group=self.enterprise_group, + pending_enterprise_customer_user=None, + enterprise_customer_user__enterprise_customer=self.enterprise_customer, + activated_at=datetime.now() + ) + admin_email = 'edx@example.org' + mock_recipients = [self.pending_enterprise_customer_user.user_email, + mock_braze_api_client().create_recipient.return_value] + mock_catalog_content_count = 5 + mock_admin_mailto = f'mailto:{admin_email}' + mock_braze_api_client().generate_mailto_link.return_value = mock_admin_mailto + mock_braze_api_client().create_recipient_no_external_id.return_value = ( + self.pending_enterprise_customer_user.user_email) + mock_enterprise_catalog_client().get_catalog_content_count.return_value = ( + mock_catalog_content_count) + act_by_date = datetime.today() + catalog_uuid = uuid.uuid4() + membership_uuids = EnterpriseGroupMembership.objects.values_list('uuid', flat=True) + send_group_membership_invitation_notification( + self.enterprise_customer.uuid, + membership_uuids, + act_by_date, + catalog_uuid, + ) + calls = [mock.call( + BRAZE_GROUPS_INVITATION_EMAIL_CAMPAIGN_ID, + recipients=mock_recipients, + trigger_properties={ + 'contact_admin_link': mock_admin_mailto, + 'enterprise_customer_name': self.enterprise_customer.name, + 'catalog_content_count': mock_catalog_content_count, + 'act_by_date': act_by_date.strftime('%B %d, %Y'), + }, + )] + mock_braze_api_client().send_campaign_message.assert_has_calls(calls) + + @mock.patch('enterprise.tasks.EnterpriseCatalogApiClient', return_value=mock.MagicMock()) + @mock.patch('enterprise.tasks.BrazeAPIClient', return_value=mock.MagicMock()) + def test_send_group_membership_removal_notification(self, mock_braze_api_client, mock_enterprise_catalog_client): + """ + Verify send_group_membership_removal_notification hits braze client with expected args + """ + EnterpriseGroupMembershipFactory( + group=self.enterprise_group, + pending_enterprise_customer_user=PendingEnterpriseCustomerUserFactory(), + enterprise_customer_user=None, + ) + EnterpriseGroupMembershipFactory( + group=self.enterprise_group, + pending_enterprise_customer_user=None, + enterprise_customer_user__enterprise_customer=self.enterprise_customer, + activated_at=datetime.now() + ) + admin_email = 'edx@example.org' + mock_recipients = [self.pending_enterprise_customer_user.user_email, + mock_braze_api_client().create_recipient.return_value] + mock_admin_mailto = f'mailto:{admin_email}' + mock_braze_api_client().generate_mailto_link.return_value = mock_admin_mailto + mock_braze_api_client().create_recipient_no_external_id.return_value = ( + self.pending_enterprise_customer_user.user_email) + mock_catalog_content_count = 5 + mock_enterprise_catalog_client().get_catalog_content_count.return_value = ( + mock_catalog_content_count) + membership_uuids = EnterpriseGroupMembership.objects.values_list('uuid', flat=True) + catalog_uuid = uuid.uuid4() + send_group_membership_removal_notification( + self.enterprise_customer.uuid, + membership_uuids, + catalog_uuid, + ) + calls = [mock.call( + BRAZE_GROUPS_REMOVAL_EMAIL_CAMPAIGN_ID, + recipients=mock_recipients, + trigger_properties={ + 'contact_admin_link': mock_admin_mailto, + 'enterprise_customer_name': self.enterprise_customer.name, + 'catalog_content_count': mock_catalog_content_count, + }, + )] + mock_braze_api_client().send_campaign_message.assert_has_calls(calls) + + @mock.patch('enterprise.tasks.BrazeAPIClient', return_value=mock.MagicMock()) + def test_sso_configuration_oauth_orchestration_email(self, mock_braze_client): + """ + Assert sso configuration calls Braze API with the correct arguments. + """ + mock_braze_client().create_recipient_no_external_id.return_value = ( + self.enterprise_customer.contact_email) + expected_trigger_properties = { + 'enterprise_customer_slug': self.enterprise_customer.slug, + 'enterprise_customer_name': self.enterprise_customer.name, + 'enterprise_sender_alias': self.enterprise_customer.sender_alias, + 'enterprise_contact_email': self.enterprise_customer.contact_email, + } + send_sso_configured_email(self.enterprise_customer.uuid) + call = [mock.call( + SSO_BRAZE_CAMPAIGN_ID, + recipients=[self.enterprise_customer.contact_email], + trigger_properties=expected_trigger_properties + )] + mock_braze_client().send_campaign_message.assert_has_calls(call) diff --git a/tests/test_integrated_channels/test_cornerstone/test_views.py b/tests/test_integrated_channels/test_cornerstone/test_views.py index b39d55cb32..865ae5bdf2 100644 --- a/tests/test_integrated_channels/test_cornerstone/test_views.py +++ b/tests/test_integrated_channels/test_cornerstone/test_views.py @@ -69,6 +69,20 @@ def test_course_list_invalid_ciid(self): response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + @responses.activate + def test_course_list_invalid_ciid(self): + """ + Test courses list with ciid of existing customer but with a non-active config + """ + self.config.active = False + self.config.save() + url = '{path}?ciid={customer_uuid}'.format( + path=self.course_list_url, + customer_uuid=self.enterprise_customer_catalog.enterprise_customer.uuid + ) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + def test_course_list_with_skip_key_if_none_false(self): """ Test courses list view produces desired json when SKIP_KEY_IF_NONE is set to False diff --git a/tests/test_models.py b/tests/test_models.py index 42d8131631..558652b289 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -61,6 +61,7 @@ SystemWideEnterpriseUserRoleAssignment, logo_path, ) +from enterprise.signals import update_enterprise_learners_user_preference from enterprise.utils import ( CourseEnrollmentDowngradeError, get_default_catalog_content_filter, @@ -326,31 +327,54 @@ def test_catalog_contains_course_with_enterprise_customer_catalog(self, api_clie api_client_mock.return_value.enterprise_contains_content_items.return_value = False assert enterprise_customer.catalog_contains_course(fake_catalog_api.FAKE_COURSE_RUN['key']) is False + @mock.patch( + 'enterprise.signals.update_enterprise_learners_user_preference.delay', + wraps=update_enterprise_learners_user_preference + ) @mock.patch('enterprise.utils.UserPreference', return_value=mock.MagicMock()) - def test_unset_language_of_all_enterprise_learners(self, user_preference_mock): + def test_unset_language_of_all_enterprise_learners(self, user_preference_mock, mock_task): """ Validate that unset_language_of_all_enterprise_learners is called whenever default_language changes. """ + user = factories.UserFactory(email='user123@example.com') enterprise_customer = factories.EnterpriseCustomerFactory() + factories.EnterpriseCustomerUserFactory( + enterprise_customer=enterprise_customer, + user_id=user.id + ) + enterprise_customer.default_language = 'es-419' + enterprise_customer.save() + mock_task.assert_called_once() user_preference_mock.objects.filter.assert_called_once() # Make sure `unset_language_of_all_enterprise_learners` is called each time `default_language` changes. enterprise_customer.default_language = 'es-417' enterprise_customer.save() + assert mock_task.call_count == 2 assert user_preference_mock.objects.filter.call_count == 2 # make sure `unset_language_of_all_enterprise_learners` is not called if `default_language` is # not changed. enterprise_customer.default_language = 'es-417' enterprise_customer.save() + assert mock_task.call_count == 2 assert user_preference_mock.objects.filter.call_count == 2 # Make sure `unset_language_of_all_enterprise_learners` is not called if `default_language` is # set to `None`. enterprise_customer.default_language = None enterprise_customer.save() + assert mock_task.call_count == 2 assert user_preference_mock.objects.filter.call_count == 2 + @mock.patch('enterprise.signals.update_enterprise_learners_user_preference.delay') + def test_async_task_not_called_on_enterprise_customer_creation(self, mock_task): + """ + Validate that upon creation of new enterprise customer, async task is not called. + """ + factories.EnterpriseCustomerFactory() + mock_task.assert_not_called() + def test_enterprise_customer_user_toggle_universal_link(self): enterprise_customer = factories.EnterpriseCustomerFactory() # Toggle to True creates with no date, does not create a new link