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 10, 2023
1 parent 734c9e7 commit a5fda34
Show file tree
Hide file tree
Showing 12 changed files with 670 additions and 86 deletions.
143 changes: 143 additions & 0 deletions docs/decisions/0011-enterprise-subsidy-enrollments-and-entitlements
Original file line number Diff line number Diff line change
@@ -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.
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
7 changes: 7 additions & 0 deletions enterprise/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
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
Loading

0 comments on commit a5fda34

Please sign in to comment.