diff --git a/docs/decisions/0011-enterprise-subsidy-enrollments-and-entitlements.rst b/docs/decisions/0011-enterprise-subsidy-enrollments-and-entitlements.rst new file mode 100644 index 0000000000..adf9ec06e7 --- /dev/null +++ b/docs/decisions/0011-enterprise-subsidy-enrollments-and-entitlements.rst @@ -0,0 +1,143 @@ +## Enterprise subsidy enrollments and entitlements + +# Status + +Draft + +# Context + +Enterprise course enrollment and course entitlement records can come into existence from different types of subsidies: + +- Subscription licenses - we’d want to track the license uuid at time of enrollment creation. + +- Learner credit subsidy transactions - we’d want to track the transaction uuid at time of enrollment or entitlement creation. + +- Coupon codes - not yet tracked in the context of this ADR. + +On top of architecting the tables to support sub licenses and credit transactions we want to build a system that can support other, not yet known types of subsidies as there is the chance that new ones will be added in the future. + +We also will very likely need to create a new EnterpriseCourseEntitlement model, which would mirror the purpose of the EnterpriseCourseEnrollment model but instead track a subsidy that is not yet connected to a course enrollment and will support upgrading to said enrollment. + +**Why implement table inheritance and what is `EnterpriseFulfillmentSource`?** + +- Having concrete table inheritance (`EnterpriseFulfillmentSource` being the parent model of all enterprise related subsidy based enrollments) means that we have a single entry point table that every kind of transaction/enrollment/entitlement/etc will extend from. + +- Transaction and fulfillment type are encapsulated by a singular model (or the model name). + +- We can more easily track the entitlement to enrollment lifecycle. + +- The null/not-null data integrity constraints are easy to grok and helps keep our data valid. + +- Table inheritance means that things are easily extendable. As we onboard new types of fulfillments, we can simply add new child models. + +**Why have `EnterpriseCourseEntitlement`s?** + +There are situations where enterprise admins give a learner a subsidy for a specific course, but that course does not yet have a valid run for the learner to enroll in. Entitlements allow us to track the subsidy for the specific user before the enrollment can be created so that when the time comes for the learner to start/create the enrollment, the entitlment can be easily converted and used. These entitlements can also be easily gathered, tracked and provided as metric data for the admins of any enterprise customer. + +It is also important that these models link to and mirror the state of the B2C enrollment and entitlement models, such that there is a 1:1 relationship between B2C and B2B rows for enterprise related enrollments and entitlements. Meaning that when we write an enterprise enrollment or entitlement, we should also create the B2C counterpart for that record. + +**Personas for whom this is relevant** + +Anyone who wants to enroll in Exec Ed courses through the edX Enterprise system + anyone who wants reporting on Exec Ed enrollments via edX Enterprise system, e.g. + +Learning and Development managers at BigTech, Inc. + +Libby Learner, an aspiring executive at Any Co. + +This will apply not only to enterprise customers with learners consuming Executive Education content (the catalyst for this change), but to all subsidy based consumptions for B2B customers. + +**Benefits of this rework** + +- It’ll make Executive Education (and lay the ground work for future external content integrations) subsidy consumption “standardized” with subsidy consumptions related to `edx.org`. + +- We can do this in a de-coupled way with Event-bus and/or polling. + +- We can easily support new subsidy types + +# Decisions + +**A rework of the enterprise subsidy enrollment models and creation of enterprise entitlements** + +The new enterprise entitlement table: + +``` +EnterpriseCourseEntitlement +--------------------------- +- uuid, created, modified, history (boilerplate) +- enterprise_customer_user_id (NOT NULL, FK to EnterpriseCustomerUser) +- enterprise_course_enrollment_id (NULL, FK to EnterpriseCourseEnrollment) +- converted_at (NULL DateTime). +- (cached property) course_entitlement_id (query look up of related CourseEntitlement) + +TBD: +-- A built in method of entitlement conversion to enrollment +``` + +Reworked and added table inheritance to all subsidy based enrollment tables. As such all subsidy based fulfillment records will have access to these fields: + +``` +EnterpriseFulfillmentSource +--------------------------- +- uuid, created, modified, history (boilerplate) +- fulfillment_ty (NOT NULL, char selection: (`license`, `learner_credit`, `coupon_code`)) +- enterprise_course_entitlement (NOT NULL, FK to EnterpriseCourseEntitlement) +- enterprise_course_enrollment (NOT NULL, FK to EnterpriseCourseEnrollment) +- is_revoked (Default False, Bool) +``` + +Models inheriting `EnterpriseFulfillmentSource`: + +``` +LicensedEnterpriseCourseEnrollment (inherited from EnterpriseFulfillmentSource) +------------------------------------------------------------------------------- +- license_uuid (NOT NULL, UUID field) +``` + +``` +LearnerCreditEnterpriseCourseEnrollment (inherited from EnterpriseFulfillmentSource) +------------------------------------------------------------------------------------ +- transaction_id (NOT NULL, UUID field) +``` + +[NOTE] Even though these models are labeled as `...Enrollment`s, they can reference entitlements as well as enrollments. In fact, despite both `enterprise_course_entitlement` `enterprise_course_enrollment` both being nullable, there is validation on the `EnterpriseFulfillmentSource` which will guarantees one of these values must exist. + +To support interactions with these reworked and new models, we've buffed out the bulk enrollment (`enroll_learners_in_courses`) EnterpriseCustomerViewSet view to support subsidy enrollments. `enrollment_info` parameters supplied to the endpoint can now include transaction ID's that will detected and realized into a `LearnerCreditEnterpriseCourseEnrollment` record. + +**How we'd use this in code** + +``` +# In the parent class... +@classmethod +def get_fulfillment_source(cls, enrollment_id, entitlement_id=None): + return cls.objects.select_related( + # all child tables joined here + ).filter( + cls.enterprise_course_enrollment=enrollment_id + ) + # do kwargs stuff here to optionally pass in a non-null + # entitlement id to filter by... + +@property +def fulfillment_status(self): + if not self.enterprise_course_enrollment: + return 'entitled' + return 'enrolled' +``` + +# Consequences + +- Table inheritance means that we’ll most likely have to do JOINs in our code and in our analytics/reporting. + +- There exists a subsidy based enrollment table already (`LicensedEnterpriseCourseEnrollment`), this table and it's records will need to be migrated to the table inheritance structure which would complicate our django migration hierarchy. + +# Further Improvements + +- Verify transaction ID's are real on creation through the bulk enrollment endpoint +- Add a programatic way to turn entitlements into enrollments +- Continue extending the `enroll_learners_in_courses` endpoint to support bulk entitlement creation of entitlements. (suggestion here is that if course run keys are supplied for enrollments, if course uuid's are supplied then we generate entitlements instead) + +# Alternatives Considered + +- `One big table`: Jam everything into one big table; almost every field is optional - might do code-level validation in the model’s save() method to ensure the presence of non-null fields depending on type of fulfillment. + +- `Table-hierarchy based on FK relationships`: Instead of strict inheritance, we could implement subsidy based tables that rely on foreign keys instead. 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/constants.py b/enterprise/constants.py index b452ce53df..3e5c0aa425 100644 --- a/enterprise/constants.py +++ b/enterprise/constants.py @@ -200,3 +200,10 @@ def json_serialized_course_modes(): 500: 'An internal problem on our side interfered.', 503: 'The server is temporarily unavailable.', } + + +class FulfillmentTypes: + LICENSE = 'license' + LEARNER_CREDIT = 'learner_credit' + COUPON_CODE = 'coupon_code' + CHOICES = [(choice, choice.capitalize().replace('_', ' ')) for choice in (LICENSE, LEARNER_CREDIT, COUPON_CODE)] 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_20230209_2108.py b/enterprise/migrations/0167_auto_20230209_2108.py new file mode 100644 index 0000000000..d6f97c5427 --- /dev/null +++ b/enterprise/migrations/0167_auto_20230209_2108.py @@ -0,0 +1,115 @@ +# Generated by Django 3.2.17 on 2023-02-09 21:08 + +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=[ + ('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)), + ('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=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, 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(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..74ba82ac71 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -48,6 +48,7 @@ AVAILABLE_LANGUAGES, ENTERPRISE_OPERATOR_ROLE, DefaultColors, + FulfillmentTypes, json_serialized_course_modes, ) from enterprise.errors import LinkUserToEnterpriseError @@ -81,6 +82,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 +1692,75 @@ 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( + unique=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() + + # TODO we probably want to verify that this exists on save? + @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 +1869,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 +1981,53 @@ 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, + uuid = models.UUIDField( + unique=True, editable=False, - null=False + null=False, + default=uuid4, + ) + + fulfillment_type = models.CharField( + max_length=128, + choices=FulfillmentTypes.CHOICES, + default=FulfillmentTypes.LICENSE, + help_text=f"Subsidy fulfillment type, can be one of: {[choice[0] for choice in FulfillmentTypes.CHOICES]}" + ) + + 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 +2036,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 @@ -1953,6 +2047,11 @@ def enrollments_for_user(cls, enterprise_customer_user): 'enterprise_course_enrollment__enterprise_customer_user__enterprise_customer', ) + @property + def enterprise_customer_user(self): + user_source = self.enterprise_course_entitlement or self.enterprise_course_enrollment + return user_source.enterprise_customer_user # may be null + def revoke(self): """ Marks this object as revoked and marks the associated EnterpriseCourseEnrollment @@ -1961,8 +2060,49 @@ 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 __str__(self): + """ + Return human-readable string representation. + """ + return f"<{self.__class__.__name__} for Enterprise user {self.enterprise_customer_user}>" + + def save(self, *args, **kwargs): + if not self.enterprise_course_enrollment and not self.enterprise_course_entitlement: + raise IntegrityError + super().save(*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 + ) + + +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, + ) + 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. """ diff --git a/tests/test_utilities.py b/tests/test_utilities.py index cd4802bfc8..4c1a13a22f 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -172,6 +172,7 @@ def setUp(self): ( EnterpriseCustomerUser, [ + 'enterprise_entitlements', "adminnotificationread", "enterprise_enrollments", "id",