diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index 0d9f1497e8..b474373da1 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -1229,26 +1229,29 @@ def validate(self, data): # pylint: disable=arguments-renamed return data -class LicensesInfoSerializer(serializers.Serializer): +class EnrollmentsInfoSerializer(serializers.Serializer): """ Nested serializer class to allow for many license info dictionaries. """ - email = serializers.CharField(required=False) - course_run_key = serializers.CharField(required=False) + email = serializers.CharField(required=True) + course_run_key = serializers.CharField(required=True) license_uuid = serializers.CharField(required=False) + transaction_id = serializers.CharField(required=False) def create(self, validated_data): return validated_data def validate(self, data): # pylint: disable=arguments-renamed - missing_fields = [] - for key in self.fields.keys(): - if not data.get(key): - missing_fields.append(key) - - if missing_fields: - raise serializers.ValidationError('Found missing licenses_info field(s): {}.'.format(missing_fields)) - + license_uuid = data.get('license_uuid') + transaction_id = data.get('transaction_id') + if not license_uuid and not transaction_id: + raise serializers.ValidationError( + "At least one subsidy info field [license_uuid or transaction_id] required." + ) + if license_uuid and transaction_id: + raise serializers.ValidationError( + "Enrollment info contains conflicting subsidy information: `license_uuid` and `transaction_id` found" + ) return data @@ -1257,7 +1260,8 @@ class EnterpriseCustomerBulkSubscriptionEnrollmentsSerializer(serializers.Serial """ Serializes a licenses info field for bulk enrollment requests. """ - licenses_info = LicensesInfoSerializer(many=True, required=False) + licenses_info = EnrollmentsInfoSerializer(many=True, required=False) + enrollments_info = EnrollmentsInfoSerializer(many=True, required=False) reason = serializers.CharField(required=False) salesforce_id = serializers.CharField(required=False) discount = serializers.DecimalField(None, 5, required=False) @@ -1267,9 +1271,15 @@ def create(self, validated_data): return validated_data def validate(self, data): # pylint: disable=arguments-renamed - if data.get('licenses_info') is None: + licenses_info = data.get('licenses_info') + enrollments_info = data.get('enrollments_info') + if bool(licenses_info) == bool(enrollments_info): + if licenses_info: + raise serializers.ValidationError( + '`licenses_info` must be ommitted if `enrollments_info` is present.' + ) raise serializers.ValidationError( - 'Must include the "licenses_info" parameter in request.' + 'Must include the `enrollment_info` parameter in request.' ) return data diff --git a/enterprise/api/v1/views.py b/enterprise/api/v1/views.py index f78a3e56a2..46ee421dfd 100644 --- a/enterprise/api/v1/views.py +++ b/enterprise/api/v1/views.py @@ -69,7 +69,7 @@ ) from enterprise.utils import ( NotConnectedToOpenEdX, - enroll_licensed_users_in_courses, + enroll_subsidy_users_in_courses, get_best_mode_from_course_key, get_enterprise_customer, get_request_value, @@ -245,23 +245,30 @@ def course_enrollments(self, request, pk): # pylint: disable=unused-argument def enroll_learners_in_courses(self, request, pk): """ - Creates a set of licensed enterprise_learners by bulk enrolling them in all specified courses. This endpoint is - not transactional, in that any one or more failures will not affect other successful enrollments made within - the same request. + Creates a set of enterprise enrollments for specified learners by bulk enrolling them in provided courses. + This endpoint is not transactional, in that any one or more failures will not affect other successful + enrollments smade within the same request. Parameters: - licenses_info (list of dicts): an array of dictionaries, each containing the necessary information to - create a licenced enrollment for a user in a specified course. Each dictionary must contain a user - email, a course run key, and a UUID of the license that the learner is using to enroll with. + enrollment_info (list of dicts): an array of dictionaries, each containing the necessary information to + create an enrollment based on a subsidy for a user in a specified course. Each dictionary must contain + a user email, a course run key, and either a UUID of the license that the learner is using to enroll + with or a transaction ID related to Executive Education the enrollment. `licenses_info` is also + accepted as a body param name. Example:: - licenses_info: [ + enrollment_info: [ { 'email': 'newuser@test.com', 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', }, + { + 'email': 'newuser2@test.com', + 'course_run_key': 'course-v2:edX+FunX+Fun_Course', + 'transaction_id': '84kdbdbade7b4fcb838f8asjke8e18ae', + }, ... ] @@ -298,21 +305,22 @@ def enroll_learners_in_courses(self, request, pk): return Response(serializer.errors, status=HTTP_400_BAD_REQUEST) email_errors = [] - licenses_info = serializer.validated_data.get('licenses_info') + serialized_data = serializer.validated_data + enrollments_info = serialized_data.get('licenses_info', serialized_data.get('enrollments_info')) # Default subscription discount is 100% - discount = serializer.validated_data.get('discount', 100.00) + discount = serialized_data.get('discount', 100.00) emails = set() # Retrieve and store course modes for each unique course provided - course_runs_modes = {license_info['course_run_key']: None for license_info in licenses_info} + course_runs_modes = {enrollment_info['course_run_key']: None for enrollment_info in enrollments_info} for course_run in course_runs_modes: course_runs_modes[course_run] = get_best_mode_from_course_key(course_run) - for index, info in enumerate(licenses_info): + for index, info in enumerate(enrollments_info): emails.add(info['email']) - licenses_info[index]['course_mode'] = course_runs_modes[info['course_run_key']] + enrollments_info[index]['course_mode'] = course_runs_modes[info['course_run_key']] for email in emails: try: @@ -326,12 +334,12 @@ def enroll_learners_in_courses(self, request, pk): except LinkUserToEnterpriseError: email_errors.append(email) - # Remove the bad emails from licenses_info and emails, don't attempt to enroll or link bad emails. + # Remove the bad emails from enrollments_info and the emails set, don't attempt to enroll or link bad emails. for errored_user in email_errors: - licenses_info[:] = [info for info in licenses_info if info['email'] != errored_user] + enrollments_info[:] = [info for info in enrollments_info if info['email'] != errored_user] emails.remove(errored_user) - results = enroll_licensed_users_in_courses(enterprise_customer, licenses_info, discount) + results = enroll_subsidy_users_in_courses(enterprise_customer, enrollments_info, discount) # collect the returned activation links for licenses which need activation activation_links = {} @@ -858,17 +866,18 @@ def bulk_licensed_enrollments_expiration(self, request): try: termination_status = self._terminate_enrollment(enterprise_course_enrollment, course_overview) - LOGGER.info(( - "EnterpriseCourseEnrollment record with enterprise license %s " - "unenrolled to status %s." - ), enterprise_course_enrollment.licensed_with.license_uuid, termination_status) + license_uuid = enterprise_course_enrollment.license.license_uuid + LOGGER.info( + f"EnterpriseCourseEnrollment record with enterprise license {license_uuid} " + f"unenrolled to status {termination_status}." + ) if termination_status != self.EnrollmentTerminationStatus.COURSE_COMPLETED: enterprise_course_enrollment.license.revoke() except EnrollmentModificationException as exc: - LOGGER.error(( - "Failed to unenroll EnterpriseCourseEnrollment record for enterprise license %s. " - "error message %s." - ), enterprise_course_enrollment.licensed_with.license_uuid, str(exc)) + LOGGER.error( + f"Failed to unenroll EnterpriseCourseEnrollment record for enterprise license " + f"{enterprise_course_enrollment.license.license_uuid}. error message {str(exc)}." + ) any_failures = True status_code = status.HTTP_200_OK if not any_failures else status.HTTP_422_UNPROCESSABLE_ENTITY diff --git a/enterprise/management/commands/revert_enrollment_objects.py b/enterprise/management/commands/revert_enrollment_objects.py index a89851039c..d86233e672 100644 --- a/enterprise/management/commands/revert_enrollment_objects.py +++ b/enterprise/management/commands/revert_enrollment_objects.py @@ -110,15 +110,15 @@ def revert_enrollment_objects(self, options): for ecu in ecus: eces = EnterpriseCourseEnrollment.objects.filter( enterprise_customer_user=ecu, - licensed_with__is_revoked=True, - licensed_with__modified__gte=time_to_revert_to, + licensedenterprisecourseenrollment_enrollment_fulfillment__is_revoked=True, + licensedenterprisecourseenrollment_enrollment_fulfillment__modified__gte=time_to_revert_to, ) for enrollment in eces: student_course_enrollment = enrollment.course_enrollment student_course_enrollment.history.as_of(time_to_revert_to).save() - licensed_enrollment = enrollment.licensed_with + licensed_enrollment = enrollment.licensedenterprisecourseenrollment_enrollment_fulfillment licensed_enrollment.history.as_of(time_to_revert_to).save() enrollment.history.as_of(time_to_revert_to).save() diff --git a/enterprise/migrations/0167_auto_20230208_2151.py b/enterprise/migrations/0167_auto_20230208_2151.py new file mode 100644 index 0000000000..5d5898bc4e --- /dev/null +++ b/enterprise/migrations/0167_auto_20230208_2151.py @@ -0,0 +1,113 @@ +# Generated by Django 3.2.17 on 2023-02-08 21:51 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +import simple_history.models +import uuid + + +def create_uuid(apps, schema_editor): + Category = apps.get_model('enterprise', 'LicensedEnterpriseCourseEnrollment') + for category in Category.objects.all(): + category.uuid = uuid.uuid4() + category.save() + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('enterprise', '0166_auto_20221209_0819'), + ] + + operations = [ + migrations.CreateModel( + name='EnterpriseCourseEntitlement', + fields=[ + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('course_uuid', models.CharField(help_text='The UUID of the course (not course run) in which the learner is entitled.', max_length=255)), + ('enterprise_customer_user', models.ForeignKey(help_text='The enterprise learner to which this entitlement is attached.', on_delete=django.db.models.deletion.CASCADE, related_name='enterprise_entitlements', to='enterprise.enterprisecustomeruser')), + ], + options={ + 'ordering': ['created'], + 'unique_together': {('enterprise_customer_user', 'course_uuid')}, + }, + ), + migrations.CreateModel( + name='HistoricalEnterpriseCourseEntitlement', + fields=[ + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('course_uuid', models.CharField(help_text='The UUID of the course (not course run) in which the learner is entitled.', max_length=255)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('enterprise_customer_user', models.ForeignKey(blank=True, db_constraint=False, help_text='The enterprise learner to which this entitlement is attached.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='enterprise.enterprisecustomeruser')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical enterprise course entitlement', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='LearnerCreditEnterpriseCourseEnrollment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('fulfillment_type', models.CharField(choices=[('license', 'License'), ('learner_credit', 'Learner Credit'), ('coupon_code', 'Coupon Code')], default='license', help_text="Subsidy fulfillment type, can be one of: ('license', 'learner_credit', 'coupon_code')", max_length=128)), + ('is_revoked', models.BooleanField(default=False, help_text="Whether the enterprise subsidy is revoked, e.g., when a user's license is revoked.")), + ('transaction_id', models.UUIDField(editable=False)), + ('enterprise_course_enrollment', models.OneToOneField(blank=True, help_text='The course enrollment the associated subsidy is for.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='learnercreditenterprisecourseenrollment_enrollment_fulfillment', to='enterprise.enterprisecourseenrollment')), + ('enterprise_course_entitlement', models.OneToOneField(blank=True, help_text='The course entitlement the associated subsidy is for.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='learnercreditenterprisecourseenrollment_entitlement_fulfillment', to='enterprise.enterprisecourseentitlement')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='licensedenterprisecourseenrollment', + name='fulfillment_type', + field=models.CharField(choices=[('license', 'License'), ('learner_credit', 'Learner Credit'), ('coupon_code', 'Coupon Code')], default='license', help_text="Subsidy fulfillment type, can be one of: ('license', 'learner_credit', 'coupon_code')", max_length=128), + ), + migrations.AddField( + model_name='licensedenterprisecourseenrollment', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, null=True), + ), + migrations.RunPython(create_uuid, reverse_code=migrations.RunPython.noop), + migrations.AlterField( + model_name='licensedenterprisecourseenrollment', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + ), + migrations.AlterField( + model_name='licensedenterprisecourseenrollment', + name='enterprise_course_enrollment', + field=models.OneToOneField(blank=True, help_text='The course enrollment the associated subsidy is for.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='licensedenterprisecourseenrollment_enrollment_fulfillment', to='enterprise.enterprisecourseenrollment'), + ), + migrations.AlterField( + model_name='licensedenterprisecourseenrollment', + name='is_revoked', + field=models.BooleanField(default=False, help_text="Whether the enterprise subsidy is revoked, e.g., when a user's license is revoked."), + ), + migrations.DeleteModel( + name='HistoricalLicensedEnterpriseCourseEnrollment', + ), + migrations.AddField( + model_name='licensedenterprisecourseenrollment', + name='enterprise_course_entitlement', + field=models.OneToOneField(blank=True, help_text='The course entitlement the associated subsidy is for.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='licensedenterprisecourseenrollment_entitlement_fulfillment', to='enterprise.enterprisecourseentitlement'), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index 2eb47cdf0c..d48f7e5147 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -81,6 +81,11 @@ except ImportError: CourseEnrollment = None +try: + from common.djangoapps.entitlements.models import CourseEntitlement +except ImportError: + CourseEntitlement = None + LOGGER = getLogger(__name__) User = auth.get_user_model() mark_safe_lazy = lazy(mark_safe, str) @@ -1686,6 +1691,74 @@ def sync_learner_profile_data(self): return identity_provider is not None and identity_provider.sync_learner_profile_data +class EnterpriseCourseEntitlementManager(models.Manager): + """ + Model manager for `EnterpriseCourseEntitlement`. + """ + + def get_queryset(self): + """ + Override to return only those entitlment records for which learner is linked to an enterprise. + """ + return super().get_queryset().select_related('enterprise_customer_user').filter( + enterprise_customer_user__linked=True + ) + + +class EnterpriseCourseEntitlement(TimeStampedModel): + """ + Store the information about the entitlement of an enterprise user for a course + """ + + objects = EnterpriseCourseEntitlementManager() + + class Meta: + unique_together = (('enterprise_customer_user', 'course_uuid',),) + app_label = 'enterprise' + ordering = ['created'] + + uuid = models.UUIDField( + primary_key=True, + editable=False, + null=False, + default=uuid4, + ) + course_uuid = models.CharField( + max_length=255, + blank=False, + help_text=_( + "The UUID of the course (not course run) in which the learner is entitled." + ) + ) + enterprise_customer_user = models.ForeignKey( + EnterpriseCustomerUser, + blank=False, + null=False, + related_name='enterprise_entitlements', + on_delete=models.deletion.CASCADE, + help_text=_( + "The enterprise learner to which this entitlement is attached." + ) + ) + history = HistoricalRecords() + + @cached_property + def course_entitlement(self): + """ + Returns the ``CourseEntitlement`` associated with this enterprise course entitlement record. + """ + if not CourseEntitlement: + return None + try: + return CourseEntitlement.objects.get( + user=self.enterprise_customer_user.user, + course_uuid=self.course_uuid, + ) + except CourseEntitlement.DoesNotExist: + LOGGER.error(f'{self} does not have a matching CourseEntitlement') + return None + + class EnterpriseCourseEnrollmentManager(models.Manager): """ Model manager for `EnterpriseCourseEnrollment`. @@ -1794,7 +1867,7 @@ def license(self): Returns the license associated with this enterprise course enrollment if one exists. """ try: - associated_license = self.licensed_with # pylint: disable=no-member + associated_license = self.licensedenterprisecourseenrollment_enrollment_fulfillment # pylint: disable=no-member except LicensedEnterpriseCourseEnrollment.DoesNotExist: associated_license = None return associated_license @@ -1906,34 +1979,64 @@ def __repr__(self): return self.__str__() -class LicensedEnterpriseCourseEnrollment(TimeStampedModel): +class EnterpriseFulfillmentSource(TimeStampedModel): """ - An Enterprise Course Enrollment that is enrolled via a license. - - .. no_pii: + Base class for enterprise subsidy fulfillments """ + class Meta: + abstract = True - license_uuid = models.UUIDField( - primary_key=False, + LICENSE_FULFILLMENT_TYPE = "license" + LEARNER_CREDIT_FULFILLMENT_TYPE = "learner_credit" + COUPON_CODE_FULFILLMENT_TYPE = "coupon_code" + + fulfillment_type_choices = [ + (LICENSE_FULFILLMENT_TYPE, "License"), + (LEARNER_CREDIT_FULFILLMENT_TYPE, "Learner Credit"), + (COUPON_CODE_FULFILLMENT_TYPE, "Coupon Code"), + ] + + uuid = models.UUIDField( + unique=True, editable=False, - null=False + null=False, + default=uuid4, + ) + + fulfillment_type = models.CharField( + max_length=128, + choices=fulfillment_type_choices, + default=LICENSE_FULFILLMENT_TYPE, + help_text=f"Subsidy fulfillment type, can be one of: " + f"{(LICENSE_FULFILLMENT_TYPE, LEARNER_CREDIT_FULFILLMENT_TYPE, COUPON_CODE_FULFILLMENT_TYPE)}" + ) + + enterprise_course_entitlement = models.OneToOneField( + EnterpriseCourseEntitlement, + blank=True, + null=True, + related_name="%(class)s_entitlement_fulfillment", + on_delete=models.deletion.CASCADE, + help_text=_( + "The course entitlement the associated subsidy is for." + ) ) enterprise_course_enrollment = models.OneToOneField( EnterpriseCourseEnrollment, - blank=False, - null=False, - related_name='licensed_with', + blank=True, + null=True, + related_name="%(class)s_enrollment_fulfillment", on_delete=models.deletion.CASCADE, help_text=_( - "The course enrollment the associated license is for." + "The course enrollment the associated subsidy is for." ) ) is_revoked = models.BooleanField( default=False, help_text=_( - "Whether the licensed enterprise course enrollment is revoked, e.g., when a user's license is revoked." + "Whether the enterprise subsidy is revoked, e.g., when a user's license is revoked." ) ) @@ -1942,8 +2045,8 @@ class LicensedEnterpriseCourseEnrollment(TimeStampedModel): @classmethod def enrollments_for_user(cls, enterprise_customer_user): """ - Returns a QuerySet of LicensedEnterpriseCourseEnrollments, along with their associated (hydrated) - enterprise enrollments, users, and customers. + Returns a QuerySet of subsidy based enrollment records for a particular user, along with their associated + (hydrated) user, enterprise enrollments, and customer object. """ return cls.objects.filter( enterprise_course_enrollment__enterprise_customer_user=enterprise_customer_user @@ -1961,8 +2064,61 @@ def revoke(self): self.is_revoked = True self.enterprise_course_enrollment.saved_for_later = True self.enterprise_course_enrollment.save() + + # TODO revoke entitlements as well? self.save() + def save(self, *args, **kwargs): + if not self.enterprise_course_enrollment and not self.enterprise_course_entitlement: + raise IntegrityError + super().delete(*args, **kwargs) + + +class LearnerCreditEnterpriseCourseEnrollment(EnterpriseFulfillmentSource): + """ + An Enterprise Course Enrollment that is enrolled via a transaction ID. + + .. no_pii: + """ + + transaction_id = models.UUIDField( + primary_key=False, + editable=False, + null=False + ) + + def __str__(self): + """ + Return human-readable string representation. + """ + user_source = self.enterprise_course_entitlement if self.enterprise_course_entitlement else None + user_source = self.enterprise_course_enrollment if self.enterprise_course_enrollment else user_source + enterprise_customer_user = user_source.enterprise_customer_user if user_source else None + return f"" + + +class LicensedEnterpriseCourseEnrollment(EnterpriseFulfillmentSource): + """ + An Enterprise Course Enrollment that is enrolled via a license. + + .. no_pii: + """ + + license_uuid = models.UUIDField( + primary_key=False, + editable=False, + null=False, + ) + + def __str__(self): + """ + Return human-readable string representation. + """ + user_source = self.enterprise_course_entitlement if self.enterprise_course_entitlement else None + user_source = self.enterprise_course_enrollment if self.enterprise_course_enrollment else user_source + enterprise_customer_user = user_source.enterprise_customer_user if user_source else None + return f"" + class EnterpriseCatalogQuery(TimeStampedModel): """ diff --git a/enterprise/utils.py b/enterprise/utils.py index 4b6f21e557..f44217c4bf 100644 --- a/enterprise/utils.py +++ b/enterprise/utils.py @@ -711,6 +711,13 @@ def licensed_enterprise_course_enrollment_model(): return apps.get_model('enterprise', 'LicensedEnterpriseCourseEnrollment') +def subsidized_enterprise_course_enrollment_model(): + """ + returns the ``LearnerCreditEnterpriseCourseEnrollment`` class. + """ + return apps.get_model('enterprise', 'LearnerCreditEnterpriseCourseEnrollment') + + def enterprise_customer_invite_key_model(): """ Returns the ``EnterpriseCustomerInviteKey`` class. @@ -1751,7 +1758,8 @@ def customer_admin_enroll_user_with_status( course_mode, course_id, enrollment_source=None, - license_uuid=None + license_uuid=None, + transaction_id=None, ): """ For use with bulk enrollment, or any use case of admin enrolling a user @@ -1812,6 +1820,11 @@ def customer_admin_enroll_user_with_status( 'source': source } ) + if transaction_id: + subsidized_enterprise_course_enrollment_model().objects.get_or_create( + transaction_id=transaction_id, + enterprise_course_enrollment=obj, + ) if license_uuid: licensed_enterprise_course_enrollment_model().objects.get_or_create( license_uuid=license_uuid, @@ -1897,14 +1910,15 @@ def get_create_ent_enrollment( return enterprise_course_enrollment, created -def enroll_licensed_users_in_courses(enterprise_customer, licensed_users_info, discount=100.00): +def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, discount=100.00): """ Takes a list of licensed learner data and enrolls each learner in the requested courses. Args: enterprise_customer: The EnterpriseCustomer (object) which is sponsoring the enrollment - licensed_users_info: (list) An array of dictionaries, each containing information necessary to create a - licensed enterprise enrollment for a specific learner in a specified course run. + subsidy_users_info: (list) An array of dictionaries, each containing information necessary to create a + enterprise enrollment from a subsidy for a specific learner in a specified course run. Accepted forms of + subsidies are: [`license_uuid` and `transaction_id`] Example:: licensed_users_info: [ @@ -1913,7 +1927,13 @@ def enroll_licensed_users_in_courses(enterprise_customer, licensed_users_info, d 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', 'course_mode': 'verified', 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae' - } + }, + { + 'email': 'newuser2@test.com', + 'course_run_key': 'course-v2:edX+FunX+Fun_Course', + 'course_mode': 'unpaid-executive-education', + 'transaction_id': '84kdbdbade7b4fcb838f8asjke8e18ae', + }, ] discount: (int) the discount offered to the learner for their enrollment. Subscription based enrollments default to 100 @@ -1931,21 +1951,28 @@ def enroll_licensed_users_in_courses(enterprise_customer, licensed_users_info, d 'pending': [], 'failures': [], } - for licensed_user_info in licensed_users_info: - user_email = licensed_user_info.get('email') - course_mode = licensed_user_info.get('course_mode') - course_run_key = licensed_user_info.get('course_run_key') - license_uuid = licensed_user_info.get('license_uuid') - activation_link = licensed_user_info.get('activation_link') - - user = User.objects.filter(email=licensed_user_info['email']).first() + for subsidy_user_info in subsidy_users_info: + user_email = subsidy_user_info.get('email').strip().lower() + course_mode = subsidy_user_info.get('course_mode') + course_run_key = subsidy_user_info.get('course_run_key') + license_uuid = subsidy_user_info.get('license_uuid') + transaction_id = subsidy_user_info.get('transaction_id') + activation_link = subsidy_user_info.get('activation_link') + + user = User.objects.filter(email=subsidy_user_info['email']).first() try: if user: enrollment_source = enterprise_enrollment_source_model().get_source( enterprise_enrollment_source_model().CUSTOMER_ADMIN ) succeeded, created = customer_admin_enroll_user_with_status( - enterprise_customer, user, course_mode, course_run_key, enrollment_source, license_uuid + enterprise_customer, + user, + course_mode, + course_run_key, + enrollment_source, + license_uuid, + transaction_id ) if succeeded: results['successes'].append({ diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 930b24190c..05f3010e13 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -3408,11 +3408,34 @@ class TestBulkEnrollment(BaseTestEnterpriseAPIViews): """ @ddt.data( + # enrollment_info usage + { + 'body': { + 'enrollments_info': [{ + 'email': 'abc@test.com', + 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', + 'license_uuid': '5a88bdcade7c4ecb838f8111b68e18ac' + }] + }, + 'expected_code': 202, + 'expected_response': { + 'successes': [], + 'pending': [{ + 'email': 'abc@test.com', + 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', + 'created': True, + 'activation_link': None, + }], + 'failures': [] + }, + 'expected_num_pending_licenses': 1, + 'expected_events': [mock.call(PATHWAY_CUSTOMER_ADMIN_ENROLLMENT, 1, 'course-v1:edX+DemoX+Demo_Course')], + }, # Validation failure cases { 'body': {}, 'expected_code': 400, - 'expected_response': {'non_field_errors': ['Must include the "licenses_info" parameter in request.']}, + 'expected_response': {'non_field_errors': ['Must include the `enrollment_info` parameter in request.']}, 'expected_num_pending_licenses': 0, 'expected_events': None, }, @@ -3433,7 +3456,54 @@ class TestBulkEnrollment(BaseTestEnterpriseAPIViews): }, 'expected_code': 400, 'expected_response': { - 'licenses_info': [{'non_field_errors': ["Found missing licenses_info field(s): ['license_uuid']."]}] + 'licenses_info': [ + { + 'non_field_errors': [ + "At least one subsidy info field [license_uuid or transaction_id] required." + ], + } + ] + }, + 'expected_num_pending_licenses': 0, + 'expected_events': None, + }, + { + 'body': { + 'licenses_info': [ + { + 'email': 'abc@test.com', + 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', + 'license_uuid': 'foobar', + 'transaction_id': 'ayylmao' + }, + ] + }, + 'expected_code': 400, + 'expected_response': { + 'licenses_info': [ + { + 'non_field_errors': [ + "Enrollment info contains conflicting subsidy information: " + "`license_uuid` and `transaction_id` found", + ] + } + ] + }, + 'expected_num_pending_licenses': 0, + 'expected_events': None, + }, + { + 'body': { + 'licenses_info': [ + { + 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', + 'license_uuid': '5a88bdcade7c4ecb838f8111b68e18ac' + } + ] + }, + 'expected_code': 400, + 'expected_response': { + 'licenses_info': [{'email': ['This field is required.']}] }, 'expected_num_pending_licenses': 0, 'expected_events': None, @@ -3579,6 +3649,68 @@ class TestBulkEnrollment(BaseTestEnterpriseAPIViews): mock.call(PATHWAY_CUSTOMER_ADMIN_ENROLLMENT, 1, 'course-v2:edX+DemoX+Second_Demo_Course') ], }, + { + 'body': { + 'enrollments_info': [ + { + 'email': 'abc@test.com', + 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', + 'license_uuid': '5a88bdcade7c4ecb838f8111b68e18ac' + }, + { + 'email': 'xyz@test.com', + 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', + 'license_uuid': '2c58acdade7c4ede838f7111b42e18ac' + }, + { + 'email': 'abc@test.com', + 'course_run_key': 'course-v2:edX+DemoX+Second_Demo_Course', + 'license_uuid': '5a88bdcade7c4ecb838f8111b68e18ac' + }, + { + 'email': 'xyz@test.com', + 'course_run_key': 'course-v2:edX+DemoX+Second_Demo_Course', + 'license_uuid': '2c58acdade7c4ede838f7111b42e18ac' + }, + ] + }, + 'expected_code': 202, + 'expected_response': { + 'successes': [], + 'pending': [ + { + 'email': 'abc@test.com', + 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', + 'created': True, + 'activation_link': None, + }, + { + 'email': 'xyz@test.com', + 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', + 'created': True, + 'activation_link': None, + }, + { + 'email': 'abc@test.com', + 'course_run_key': 'course-v2:edX+DemoX+Second_Demo_Course', + 'created': True, + 'activation_link': None, + }, + { + 'email': 'xyz@test.com', + 'course_run_key': 'course-v2:edX+DemoX+Second_Demo_Course', + 'created': True, + 'activation_link': None, + } + ], + 'failures': [] + }, + 'expected_num_pending_licenses': 4, + 'expected_events': [ + mock.call(PATHWAY_CUSTOMER_ADMIN_ENROLLMENT, 1, 'course-v1:edX+DemoX+Demo_Course'), + mock.call(PATHWAY_CUSTOMER_ADMIN_ENROLLMENT, 1, 'course-v2:edX+DemoX+Second_Demo_Course') + ], + }, ) @ddt.unpack @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') @@ -3771,7 +3903,7 @@ def _make_call(course_run, enrolled_learners): mock_notify_task.assert_has_calls(mock_calls, any_order=True) - @mock.patch('enterprise.api.v1.views.enroll_licensed_users_in_courses') + @mock.patch('enterprise.api.v1.views.enroll_subsidy_users_in_courses') @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') def test_enroll_learners_in_courses_partial_failure(self, mock_get_course_mode, mock_enroll_user): """ diff --git a/tests/test_enterprise/test_utils.py b/tests/test_enterprise/test_utils.py index ba3b5bb0b6..720c53f9c4 100644 --- a/tests/test_enterprise/test_utils.py +++ b/tests/test_enterprise/test_utils.py @@ -13,7 +13,7 @@ from enterprise.models import EnterpriseCourseEnrollment from enterprise.utils import ( - enroll_licensed_users_in_courses, + enroll_subsidy_users_in_courses, get_idiff_list, get_platform_logo_url, is_pending_user, @@ -97,14 +97,14 @@ def test_get_platform_logo_url(self, logo_url, expected_logo_url, mock_get_logo_ @mock.patch('enterprise.utils.lms_enroll_user_in_course') @mock.patch('enterprise.utils.CourseEnrollmentError', new_callable=lambda: StubException) @mock.patch('enterprise.utils.CourseUserGroup', new_callable=lambda: StubModel) - def test_enroll_licensed_users_in_courses_fails( + def test_enroll_subsidy_users_in_courses_fails( self, mock_model, mock_error, mock_customer_admin_enroll_user_with_status ): """ - Test that `enroll_licensed_users_in_courses` properly handles failure cases where something goes wrong with the + Test that `enroll_subsidy_users_in_courses` properly handles failure cases where something goes wrong with the user enrollment. """ self.create_user() @@ -121,7 +121,7 @@ def test_enroll_licensed_users_in_courses_fails( 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae' }] - result = enroll_licensed_users_in_courses(ent_customer, licensed_users_info) + result = enroll_subsidy_users_in_courses(ent_customer, licensed_users_info) self.assertEqual( { 'successes': [], @@ -134,14 +134,14 @@ def test_enroll_licensed_users_in_courses_fails( @mock.patch('enterprise.utils.lms_enroll_user_in_course') @mock.patch('enterprise.utils.CourseEnrollmentError', new_callable=lambda: StubException) @mock.patch('enterprise.utils.CourseUserGroup', new_callable=lambda: StubModel) - def test_enroll_licensed_users_in_courses_partially_fails( + def test_enroll_subsidy_users_in_courses_partially_fails( self, mock_model, mock_error, mock_customer_admin_enroll_user_with_status ): """ - Test that `enroll_licensed_users_in_courses` properly handles partial failure states and still creates + Test that `enroll_subsidy_users_in_courses` properly handles partial failure states and still creates enrollments for the users that succeed. """ self.create_user() @@ -173,7 +173,7 @@ def test_enroll_licensed_users_in_courses_partially_fails( mock_model.DoesNotExist = Exception mock_customer_admin_enroll_user_with_status.side_effect = [True, mock_error('mocked error')] - result = enroll_licensed_users_in_courses(ent_customer, licensed_users_info) + result = enroll_subsidy_users_in_courses(ent_customer, licensed_users_info) self.assertEqual( { 'pending': [], @@ -191,9 +191,9 @@ def test_enroll_licensed_users_in_courses_partially_fails( self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 1) @mock.patch('enterprise.utils.lms_enroll_user_in_course') - def test_enroll_licensed_users_in_courses_succeeds(self, mock_customer_admin_enroll_user): + def test_enroll_subsidy_users_in_courses_succeeds(self, mock_customer_admin_enroll_user): """ - Test that users that already exist are enrolled by enroll_licensed_users_in_courses and returned under the + Test that users that already exist are enrolled by enroll_subsidy_users_in_courses and returned under the `succeeded` field. """ self.create_user() @@ -215,7 +215,7 @@ def test_enroll_licensed_users_in_courses_succeeds(self, mock_customer_admin_enr mock_customer_admin_enroll_user.return_value = True - result = enroll_licensed_users_in_courses(ent_customer, licensed_users_info) + result = enroll_subsidy_users_in_courses(ent_customer, licensed_users_info) self.assertEqual( { 'pending': [], @@ -234,7 +234,7 @@ def test_enroll_licensed_users_in_courses_succeeds(self, mock_customer_admin_enr def test_enroll_pending_licensed_users_in_courses_succeeds(self): """ - Test that users that do not exist are pre-enrolled by enroll_licensed_users_in_courses and returned under the + Test that users that do not exist are pre-enrolled by enroll_subsidy_users_in_courses and returned under the `pending` field. """ ent_customer = factories.EnterpriseCustomerFactory( @@ -247,7 +247,7 @@ def test_enroll_pending_licensed_users_in_courses_succeeds(self): 'course_mode': 'verified', 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae' }] - result = enroll_licensed_users_in_courses(ent_customer, licensed_users_info) + result = enroll_subsidy_users_in_courses(ent_customer, licensed_users_info) self.assertEqual(result['pending'][0]['email'], 'pending-user-email@example.com') self.assertFalse(result['successes']) diff --git a/tests/test_models.py b/tests/test_models.py index 4074a6d837..0eb775d3df 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -176,7 +176,7 @@ def setUp(self): ) super().setUp() - def test_create_license_enrollment_no_course_enrollment(self): + def test_create_license_enrollment_no_course_enrollment_or_entitlement(self): """ Test creating a LicensedEnterpriseCourseEnrollment without a Course Enrollment. """