diff --git a/license_manager/apps/api/serializers.py b/license_manager/apps/api/serializers.py index 0f050696..928136e1 100644 --- a/license_manager/apps/api/serializers.py +++ b/license_manager/apps/api/serializers.py @@ -1,7 +1,12 @@ +from django.core.validators import MinLengthValidator from rest_framework import serializers from rest_framework.fields import SerializerMethodField -from license_manager.apps.subscriptions.constants import ACTIVATED, ASSIGNED +from license_manager.apps.subscriptions.constants import ( + ACTIVATED, + ASSIGNED, + SALESFORCE_ID_LENGTH, +) from license_manager.apps.subscriptions.models import ( CustomerAgreement, License, @@ -361,8 +366,32 @@ class LicenseAdminAssignActionSerializer(CustomTextWithMultipleEmailsSerializer) """ notify_users = serializers.BooleanField(required=False) + user_sfids = serializers.ListField( + child=serializers.CharField( + allow_blank=True, + allow_null=True, + write_only=True, + validators=[MinLengthValidator(SALESFORCE_ID_LENGTH)] + ), + allow_empty=False, + required=False, + error_messages={"empty": "No Salesforce Ids provided."} + ) class Meta: fields = CustomTextWithMultipleEmailsSerializer.Meta.fields + [ 'notify_users', ] + + def validate(self, attrs): + user_emails = attrs.get('user_emails') + user_sfids = attrs.get('user_sfids') + + if user_sfids: + # if saleforce ids list is present then its length must be equal to number of user emails + if len(user_emails) != len(user_sfids): + raise serializers.ValidationError( + 'Number of Salesforce IDs did not match number of provided user emails.' + ) + + return super().validate(attrs) diff --git a/license_manager/apps/api/v1/tests/test_views.py b/license_manager/apps/api/v1/tests/test_views.py index c971cf7c..7bae848d 100644 --- a/license_manager/apps/api/v1/tests/test_views.py +++ b/license_manager/apps/api/v1/tests/test_views.py @@ -41,6 +41,7 @@ from license_manager.apps.subscriptions.exceptions import LicenseRevocationError from license_manager.apps.subscriptions.models import ( License, + SubscriptionLicenseSource, SubscriptionsFeatureRole, SubscriptionsRoleAssignment, ) @@ -1507,6 +1508,71 @@ def test_assign(self, use_superuser, mock_send_assignment_email_task, mock_link_ self.subscription_plan.customer_agreement.enterprise_customer_uuid ) + @mock.patch('license_manager.apps.api.v1.views.link_learners_to_enterprise_task.si') + @mock.patch('license_manager.apps.api.v1.views.send_assignment_email_task.si') + @ddt.data(True, False) + def test_assign_with_salesforce_ids(self, use_superuser, *arge, **kwargs): # pylint: disable=unused-argument + """ + Verify the assign endpoint assigns licenses and correct lincese sources are created. + """ + self._setup_request_jwt(user=self.super_user if use_superuser else self.user) + self._create_available_licenses() + user_emails = ['bb8@mit.edu', self.test_email, 'ama@example.com', 'aaa@example.com'] + user_sfids = ['0000qse56709sdfd08', '0000abc34MS67a8907', '', None] + response = self.api_client.post( + self.assign_url, + {'greeting': self.greeting, 'closing': self.closing, 'user_emails': user_emails, 'user_sfids': user_sfids}, + ) + assert response.status_code == status.HTTP_200_OK + self._assert_licenses_assigned(user_emails) + + # verify that correct salesforce ids are assigned + emails_and_sfids = dict(zip(user_emails, user_sfids)) + for email, salesforce_id in emails_and_sfids.items(): + assigned_license = self.subscription_plan.licenses.get(user_email=email, status=constants.ASSIGNED) + + if salesforce_id: + assert assigned_license.source.source_id == salesforce_id + else: + with self.assertRaises(ObjectDoesNotExist): + SubscriptionLicenseSource.objects.get(license=assigned_license) + + @ddt.data(True, False) + def test_assign_with_less_salesforce_ids(self, use_superuser): + """ + Verify the assign endpoint raises correct error when user_sfids list has less items then user_emails. + """ + self._setup_request_jwt(user=self.super_user if use_superuser else self.user) + user_emails = ['bb8@mit.edu', 'aaa@mit.edu'] + user_sfids = ['0010abc34MS67a8907'] + response = self.api_client.post( + self.assign_url, + {'greeting': self.greeting, 'closing': self.closing, 'user_emails': user_emails, 'user_sfids': user_sfids}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == { + 'non_field_errors': [ + 'Number of Salesforce IDs did not match number of provided user emails.' + ] + } + assert SubscriptionLicenseSource.objects.count() == 0 + + @ddt.data(True, False) + def test_assign_with_empty_salesforce_ids(self, use_superuser): + """ + Verify the assign endpoint raises correct error when user_sfids is present but empty. + """ + self._setup_request_jwt(user=self.super_user if use_superuser else self.user) + user_emails = ['bb8@mit.edu'] + user_sfids = [] + response = self.api_client.post( + self.assign_url, + {'greeting': self.greeting, 'closing': self.closing, 'user_emails': user_emails, 'user_sfids': user_sfids}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {'user_sfids': ['No Salesforce Ids provided.']} + assert SubscriptionLicenseSource.objects.count() == 0 + @mock.patch('license_manager.apps.api.v1.views.link_learners_to_enterprise_task.si') @mock.patch('license_manager.apps.api.v1.views.send_assignment_email_task.si') @ddt.data(True, False) diff --git a/license_manager/apps/api/v1/views.py b/license_manager/apps/api/v1/views.py index 1400ac1c..154369a3 100644 --- a/license_manager/apps/api/v1/views.py +++ b/license_manager/apps/api/v1/views.py @@ -53,6 +53,8 @@ from license_manager.apps.subscriptions.models import ( CustomerAgreement, License, + SubscriptionLicenseSource, + SubscriptionLicenseSourceType, SubscriptionPlan, SubscriptionsRoleAssignment, ) @@ -726,11 +728,36 @@ def _assign_new_licenses(self, subscription_plan, user_emails): ) return licenses + def _set_source_for_assigned_licenses(self, assigned_licenses, emails_and_sfids): + """ + Set source for each assigned license. + """ + license_source = SubscriptionLicenseSourceType.get_source_type(SubscriptionLicenseSourceType.AMT) + source_objects = [] + for assigned_license in assigned_licenses: + sf_opportunity_id = emails_and_sfids.get(assigned_license.user_email) + if sf_opportunity_id: + source = SubscriptionLicenseSource( + license=assigned_license, + source_id=sf_opportunity_id, + source_type=license_source + ) + source_objects.append(source) + + SubscriptionLicenseSource.objects.bulk_create( + source_objects, + batch_size=constants.LICENSE_SOURCE_BULK_OPERATION_BATCH_SIZE + ) + @action(detail=False, methods=['post']) def assign(self, request, subscription_uuid=None): # pylint: disable=unused-argument """ Given a list of emails, assigns a license to those user emails and sends an activation email. + Endpoint will also receive `user_sfids`, an optional list of salesforce ids. If present then + for each assigned license a source object will be created to later identify the source of a + license assignment. + Assignment is intended to be a fully atomic and linearizable operation. We utilize a cache-based lock to ensure that only one assignment operation can be executed at a time for the given subscription plan. @@ -743,6 +770,10 @@ def assign(self, request, subscription_uuid=None): # pylint: disable=unused-arg 'user_emails': [ 'alice@example.com', 'bob@example.com' + ], + 'user_sfids': [ + '001OE000001f26OXZP', + '001OE000001a25WXYZ' ] } """ @@ -769,8 +800,19 @@ def _assign(self, request, subscription_plan): # Validate the user_emails and text sent in the data self._validate_data(request.data) - # Dedupe all lowercase emails before turning back into a list for indexing - user_emails = list({email.lower() for email in request.data.get('user_emails', [])}) + emails_and_sfids = [] + # remove duplicate emails and convert to lowercase + if 'user_sfids' in request.data: + user_emails = map(str.lower, request.data.get('user_emails')) + user_sfids = request.data.get('user_sfids') + # remove whitespaces if there are any + user_sfids = [sfid and sfid.strip() for sfid in user_sfids] + + emails_and_sfids = dict(zip(user_emails, user_sfids)) + user_emails = list(emails_and_sfids.keys()) + else: + # Dedupe all lowercase emails before turning back into a list for indexing + user_emails = list({email.lower() for email in request.data.get('user_emails', [])}) user_emails, already_associated_emails = self._trim_already_associated_emails( subscription_plan, @@ -795,6 +837,8 @@ def _assign(self, request, subscription_plan): assigned_licenses = self._assign_new_licenses( subscription_plan, user_emails, ) + if emails_and_sfids: + self._set_source_for_assigned_licenses(assigned_licenses, emails_and_sfids) except DatabaseError: error_message = 'Database error occurred while assigning licenses, no assignments were completed' logger.exception(error_message) diff --git a/license_manager/apps/subscriptions/admin.py b/license_manager/apps/subscriptions/admin.py index dac23505..a78bdb86 100644 --- a/license_manager/apps/subscriptions/admin.py +++ b/license_manager/apps/subscriptions/admin.py @@ -47,7 +47,9 @@ class LicenseAdmin(DjangoQLSearchMixin, admin.ModelAdmin): 'activation_key', 'get_renewed_to', 'get_renewed_from', - 'auto_applied' + 'auto_applied', + 'source_id', + 'source_type', ] exclude = ['history', 'renewed_to'] list_display = ( @@ -79,8 +81,25 @@ class LicenseAdmin(DjangoQLSearchMixin, admin.ModelAdmin): def get_queryset(self, request): return super().get_queryset(request).select_related( 'subscription_plan', + 'source' ) + @admin.display(description='Source ID') + def source_id(self, instance): + """Return source id of license if a source exists""" + try: + return instance.source.source_id + except License.source.RelatedObjectDoesNotExist: # pylint: disable=no-member + return '' + + @admin.display(description='Source Type') + def source_type(self, instance): + """Return source type of license if a source exists""" + try: + return instance.source.source_type.slug + except License.source.RelatedObjectDoesNotExist: # pylint: disable=no-member + return '' + @admin.display( description='Subscription Plan' ) diff --git a/license_manager/apps/subscriptions/constants.py b/license_manager/apps/subscriptions/constants.py index f896254a..be56b914 100644 --- a/license_manager/apps/subscriptions/constants.py +++ b/license_manager/apps/subscriptions/constants.py @@ -104,6 +104,7 @@ class SegmentEvents: # Bulk operation constants LICENSE_BULK_OPERATION_BATCH_SIZE = 100 PENDING_ACCOUNT_CREATION_BATCH_SIZE = 100 +LICENSE_SOURCE_BULK_OPERATION_BATCH_SIZE = 100 # Num distinct catalog query validation batch size VALIDATE_NUM_CATALOG_QUERIES_BATCH_SIZE = 100