Skip to content

Commit

Permalink
feat: modifying license enrollment endpoint to support other subsidies
Browse files Browse the repository at this point in the history
  • Loading branch information
alex-sheehan-edx committed Jan 27, 2023
1 parent d3eb78a commit 3855bdd
Show file tree
Hide file tree
Showing 7 changed files with 325 additions and 59 deletions.
38 changes: 24 additions & 14 deletions enterprise/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1220,26 +1220,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 @@ -1248,7 +1251,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 @@ -1258,9 +1262,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
40 changes: 24 additions & 16 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 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),
),
]
34 changes: 34 additions & 0 deletions enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1906,6 +1906,40 @@ def __repr__(self):
return self.__str__()


class SubsidizedEnterpriseCourseEnrollment(TimeStampedModel):
"""
An Enterprise Course Enrollment that is enrolled via a transaction ID.
.. no_pii:
"""

transaction_id = models.UUIDField(
primary_key=False,
editable=False,
null=False
)

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."
)
)

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 = HistoricalRecords()


class LicensedEnterpriseCourseEnrollment(TimeStampedModel):
"""
An Enterprise Course Enrollment that is enrolled via a license.
Expand Down
55 changes: 41 additions & 14 deletions enterprise/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,13 @@ def licensed_enterprise_course_enrollment_model():
return apps.get_model('enterprise', 'LicensedEnterpriseCourseEnrollment')


def subsidized_enterprise_course_enrollment_model():
"""
returns the ``SubsidizedEnterpriseCourseEnrollment`` class.
"""
return apps.get_model('enterprise', 'SubsidizedEnterpriseCourseEnrollment')


def enterprise_customer_invite_key_model():
"""
Returns the ``EnterpriseCustomerInviteKey`` class.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: [
Expand All @@ -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
Expand All @@ -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')
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({
Expand Down
Loading

0 comments on commit 3855bdd

Please sign in to comment.