diff --git a/enterprise/api/v1/views.py b/enterprise/api/v1/views.py index b1b89fe9e5..46ee421dfd 100644 --- a/enterprise/api/v1/views.py +++ b/enterprise/api/v1/views.py @@ -866,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..239666032a 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, + enterprise_licensedenterprisecourseenrollment_enrollment_related__is_revoked=True, + enterprise_licensedenterprisecourseenrollment_enrollment_related__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.enterprise_licensedenterprisecourseenrollment_enrollment_related 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_20230206_1532.py b/enterprise/migrations/0167_auto_20230206_1532.py new file mode 100644 index 0000000000..9d68134d83 --- /dev/null +++ b/enterprise/migrations/0167_auto_20230206_1532.py @@ -0,0 +1,103 @@ +# Generated by Django 3.2.16 on 2023-02-06 15:32 + +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 + + +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='SubsidizedEnterpriseCourseEnrollment', + 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)), + ('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='enterprise_subsidizedenterprisecourseenrollment_enrollment_related', 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='enterprise_subsidizedenterprisecourseenrollment_entitlement_related', to='enterprise.enterprisecourseentitlement')), + ], + options={ + 'abstract': False, + }, + ), + migrations.RemoveField( + model_name='licensedenterprisecourseenrollment', + name='id', + ), + 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(null=True, editable=False, serialize=False), + ), + 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='enterprise_licensedenterprisecourseenrollment_enrollment_related', 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='enterprise_licensedenterprisecourseenrollment_entitlement_related', to='enterprise.enterprisecourseentitlement'), + ), + ] diff --git a/enterprise/migrations/0167_historicalsubsidizedenterprisecourseenrollment_subsidizedenterprisecourseenrollment.py b/enterprise/migrations/0167_historicalsubsidizedenterprisecourseenrollment_subsidizedenterprisecourseenrollment.py deleted file mode 100644 index 447ec580d2..0000000000 --- a/enterprise/migrations/0167_historicalsubsidizedenterprisecourseenrollment_subsidizedenterprisecourseenrollment.py +++ /dev/null @@ -1,55 +0,0 @@ -# Generated by Django 3.2.16 on 2023-01-27 14:54 - -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 - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('enterprise', '0166_auto_20221209_0819'), - ] - - operations = [ - migrations.CreateModel( - name='SubsidizedEnterpriseCourseEnrollment', - 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')), - ('transaction_id', models.UUIDField(editable=False)), - ('is_revoked', models.BooleanField(default=False, help_text="Whether the subsidized enterprise course enrollment is revoked, e.g., when a user's subsidy is revoked.")), - ('enterprise_course_enrollment', models.OneToOneField(help_text='The course enrollment the associated license is for.', on_delete=django.db.models.deletion.CASCADE, related_name='subsidized_with', to='enterprise.enterprisecourseenrollment')), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='HistoricalSubsidizedEnterpriseCourseEnrollment', - 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')), - ('transaction_id', models.UUIDField(editable=False)), - ('is_revoked', models.BooleanField(default=False, help_text="Whether the subsidized enterprise course enrollment is revoked, e.g., when a user's subsidy is revoked.")), - ('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_course_enrollment', models.ForeignKey(blank=True, db_constraint=False, help_text='The course enrollment the associated license is for.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='enterprise.enterprisecourseenrollment')), - ('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 subsidized enterprise course enrollment', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', - }, - bases=(simple_history.models.HistoricalChanges, models.Model), - ), - ] diff --git a/enterprise/migrations/0168_auto_20230206_1655.py b/enterprise/migrations/0168_auto_20230206_1655.py new file mode 100644 index 0000000000..f55b7ca99c --- /dev/null +++ b/enterprise/migrations/0168_auto_20230206_1655.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.16 on 2023-02-06 16:55 + +from django.db import migrations +import uuid + + +def gen_uuid(apps, schema_editor): + LicensedEnterpriseCourseEnrollment = apps.get_model('enterprise', 'LicensedEnterpriseCourseEnrollment') + for row in LicensedEnterpriseCourseEnrollment.objects.all(): + row.uuid = uuid.uuid4() + row.save(update_fields=['uuid']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0167_auto_20230206_1532'), + ] + + operations = [ + migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop), + ] diff --git a/enterprise/migrations/0169_auto_20230206_1655.py b/enterprise/migrations/0169_auto_20230206_1655.py new file mode 100644 index 0000000000..80222e1a0c --- /dev/null +++ b/enterprise/migrations/0169_auto_20230206_1655.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.16 on 2023-02-06 16:55 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0168_auto_20230206_1655'), + ] + + operations = [ + migrations.AlterField( + model_name='licensedenterprisecourseenrollment', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index a0849fd92e..570f758323 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_enrollment(self): + """ + Returns the ``student.CourseEnrollment`` associated with this enterprise course enrollment record. + """ + if not CourseEntitlement: + return None + try: + return CourseEntitlement.objects.get( + user=self.enterprise_customer_user.user, + course_uuid=self.course_uuid, + ) + except CourseEnrollment.DoesNotExist: + LOGGER.error('{} does not have a matching student.CourseEnrollment'.format(self)) + 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.enterprise_licensedenterprisecourseenrollment_enrollment_related # pylint: disable=no-member except LicensedEnterpriseCourseEnrollment.DoesNotExist: associated_license = None return associated_license @@ -1906,68 +1979,64 @@ def __repr__(self): return self.__str__() -class SubsidizedEnterpriseCourseEnrollment(TimeStampedModel): +class EnterpriseFulfillmentSource(TimeStampedModel): """ - An Enterprise Course Enrollment that is enrolled via a transaction ID. - - .. no_pii: + Base class for enterprise subsidy fulfillments """ + class Meta: + abstract = True - transaction_id = 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( + primary_key=True, editable=False, - null=False + null=False, + default=uuid4, ) - enterprise_course_enrollment = models.OneToOneField( - EnterpriseCourseEnrollment, - blank=False, - null=False, - related_name='subsidized_with', - on_delete=models.deletion.CASCADE, - help_text=_( - "The course enrollment the associated license is for." - ) + 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)}" ) - is_revoked = models.BooleanField( - default=False, + enterprise_course_entitlement = models.OneToOneField( + EnterpriseCourseEntitlement, + blank=True, + null=True, + related_name="%(app_label)s_%(class)s_entitlement_related", + on_delete=models.deletion.CASCADE, help_text=_( - "Whether the subsidized enterprise course enrollment is revoked, e.g., when a user's subsidy is revoked." + "The course entitlement the associated subsidy is for." ) ) - history = HistoricalRecords() - - -class LicensedEnterpriseCourseEnrollment(TimeStampedModel): - """ - An Enterprise Course Enrollment that is enrolled via a license. - - .. no_pii: - """ - - license_uuid = models.UUIDField( - primary_key=False, - editable=False, - null=False - ) - enterprise_course_enrollment = models.OneToOneField( EnterpriseCourseEnrollment, - blank=False, - null=False, - related_name='licensed_with', + blank=True, + null=True, + related_name="%(app_label)s_%(class)s_enrollment_related", 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." ) ) @@ -1976,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 @@ -1995,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(EnterpriseFulfillmentSource, self).save(*args, **kwargs) + + +class SubsidizedEnterpriseCourseEnrollment(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/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 05f3010e13..db60b74d75 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -3275,6 +3275,8 @@ def test_post_license_revoke_all_successes( 'user_id': self.user.id, 'enterprise_id': enterprise_customer_user.enterprise_customer.uuid, } + # import pdb + # pdb.set_trace() response = self.client.post( settings.TEST_SERVER + LICENSED_ENTERPISE_COURSE_ENROLLMENTS_REVOKE_ENDPOINT, data=post_data, 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. """