Skip to content

Commit

Permalink
feat: enterprise entitlements and subsidy based fulfillment models he…
Browse files Browse the repository at this point in the history
…irarchy rework
  • Loading branch information
alex-sheehan-edx committed Feb 8, 2023
1 parent 734c9e7 commit 2b711de
Show file tree
Hide file tree
Showing 9 changed files with 533 additions and 86 deletions.
38 changes: 24 additions & 14 deletions enterprise/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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)
Expand All @@ -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

Expand Down
57 changes: 33 additions & 24 deletions enterprise/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
},
...
]
Expand Down Expand Up @@ -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:
Expand All @@ -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 = {}
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions enterprise/management/commands/revert_enrollment_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
113 changes: 113 additions & 0 deletions enterprise/migrations/0167_auto_20230208_2151.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
Loading

0 comments on commit 2b711de

Please sign in to comment.