Skip to content

Commit

Permalink
feat: add default enrollment models
Browse files Browse the repository at this point in the history
Adds two models, `DefaultEnterpriseEnrollmentIntention` and
`DefaultEnterpriseEnrollmentRealization`. The former defines, for a customer,
a course/run that associated users should be automatically enrolled into.
The latter represents the relationship between that intention record
and a realized EnterpriseCourseEnrollment; persisting realized enrollments
in this way will help to make read operations related to default enrollments
much more efficient.
ENT-9577
  • Loading branch information
iloveagent57 committed Oct 10, 2024
1 parent ae70aa4 commit 619692e
Show file tree
Hide file tree
Showing 5 changed files with 278 additions and 0 deletions.
54 changes: 54 additions & 0 deletions docs/decisions/0015-default-enrollments.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
==============================
Default Enterprise Enrollments
==============================

Status
======
Proposed - October 2024

Context
=======
Enterprise needs a solution for managing automated enrollments into "default" courses or specific course runs
for enterprise learners. The solution needs to support customer-specific enrollment configurations
without tightly coupling these configurations to a specific subsidy type, i.e. we should be able in the future
to manage default enrollments via both learner credit and subscription subsidy types.

Core requirements
-----------------
1. Internal staff should be able to configure one or more default enrollments with either a course
or a specific course run for automatic enrollment. In the case of specifying a course,
the default enrollment flow should cause the "realization" of default enrollments for learners
into the currently-advertised, enrollable run for a course.
2. Default Enrollments should be loosely coupled to subsidy type.
3. Unenrollment: If a learner chooses to unenroll from a default course, they should not be automatically re-enrolled.
4. Graceful handling of license revocation: Upon license revocation, we currently downgrade the learner’s
enrollment mode to ``audit``. This fact should be visible from any new APIs exposed
in the domain of default enrollments.
5. Non-enrollable courses: If a course becomes unenrollable, default enrollments should fail gracefully,
and in a way that's obvious to the learner.

Decision
========
We will implement two new models, ``DefaultEnterpriseEnrollmentIntention`` to represent the course/runs that
learners should be default-enrolled into for a given enterprise, and ``DefaultEnterpriseEnrollmentRealization``
which represents the mapping between the intention and actual enrollment record(s) for the learner/customer.

Qualities
---------
1. Flexibility: The ``DefaultEnterpriseEnrollmentIntention`` model will allow specification of either a course
or course run.
2. Business logic: The API for this domain (future ADR) will implement the business logic around choosing
the appropriate course run, for enforcing the applicability of a subsidy's specified catalog to the course,
and the enrollability of the course.
3. Non-Tightly Coupled to subsidy type: Nothing in the domain of default enrollments will persist data
related to a subsidy (although a license or transaction identifier will ultimately become associated with
an ``EnterpriseCourseEnrollment`` record during realization).

Consequences
============
1. It's a flexible design.
2. It relies on a network call to enterprise-catalog to fetch content metadata (we can use caching to
make this efficient).
3. We're introducing more complexity in terms of how subsidized enterprise enrollments
can come into existence.
4. The realization model makes the provenance of default enrollments explicit and easy to examine.
31 changes: 31 additions & 0 deletions enterprise/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1316,3 +1316,34 @@ class LearnerCreditEnterpriseCourseEnrollmentAdmin(admin.ModelAdmin):
class Meta:
fields = '__all__'
model = models.LearnerCreditEnterpriseCourseEnrollment


@admin.register(models.DefaultEnterpriseEnrollmentIntention)
class DefaultEnterpriseEnrollmentIntentionAdmin(admin.ModelAdmin):
"""
Django admin model for DefaultEnterpriseEnrollmentIntentions.
"""
list_display = (
'uuid',
'enterprise_customer',
'content_type',
'content_key',
)

readonly_fields = (
'current_course_run_key',
'current_course_run_enrollable',
'current_course_run_enroll_by_date',
)

search_fields = (
'uuid',
'enterprise_customer__uuid',
'content_key',
)

ordering = ('-modified',)

class Meta:
fields = '__all__'
model = models.DefaultEnterpriseEnrollmentIntention
99 changes: 99 additions & 0 deletions enterprise/migrations/0223_default_enrollments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Generated by Django 4.2.15 on 2024-10-10 15:02

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', '0222_alter_enterprisegroup_group_type_and_more'),
]

operations = [
migrations.CreateModel(
name='DefaultEnterpriseEnrollmentIntention',
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')),
('is_removed', models.BooleanField(default=False)),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('content_type', models.CharField(choices=[('course', 'Course'), ('course_run', 'Course Run')], help_text='The type of content (e.g. a course vs. a course run).', max_length=127)),
('content_key', models.CharField(help_text='A course or course run that related users should be automatically enrolled into.', max_length=255)),
('enterprise_customer', models.ForeignKey(help_text='The customer for which this default enrollment will be realized.', on_delete=django.db.models.deletion.CASCADE, related_name='default_course_enrollments', to='enterprise.enterprisecustomer')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='HistoricalDefaultEnterpriseEnrollmentRealization',
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')),
('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)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('intended_enrollment', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='enterprise.defaultenterpriseenrollmentintention')),
('realized_enrollment', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='enterprise.enterprisecourseenrollment')),
],
options={
'verbose_name': 'historical default enterprise enrollment realization',
'verbose_name_plural': 'historical default enterprise enrollment realizations',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalDefaultEnterpriseEnrollmentIntention',
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')),
('is_removed', models.BooleanField(default=False)),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)),
('content_type', models.CharField(choices=[('course', 'Course'), ('course_run', 'Course Run')], help_text='The type of content (e.g. a course vs. a course run).', max_length=127)),
('content_key', models.CharField(help_text='A course or course run that related users should be automatically enrolled into.', 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', models.ForeignKey(blank=True, db_constraint=False, help_text='The customer for which this default enrollment will be realized.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='enterprise.enterprisecustomer')),
('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 default enterprise enrollment intention',
'verbose_name_plural': 'historical default enterprise enrollment intentions',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='DefaultEnterpriseEnrollmentRealization',
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')),
('intended_enrollment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='enterprise.defaultenterpriseenrollmentintention')),
('realized_enrollment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='enterprise.enterprisecourseenrollment')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='defaultenterpriseenrollmentintention',
name='realized_enrollments',
field=models.ManyToManyField(through='enterprise.DefaultEnterpriseEnrollmentRealization', to='enterprise.enterprisecourseenrollment'),
),
]
93 changes: 93 additions & 0 deletions enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2461,6 +2461,99 @@ class LicensedEnterpriseCourseEnrollment(EnterpriseFulfillmentSource):
)


class DefaultEnterpriseEnrollmentIntention(TimeStampedModel, SoftDeletableModel):
"""
Specific to an enterprise customer, this model defines a course or course run
that should be auto-enrolled for any enterprise customer user linked to the customer.
"""
DEFAULT_ENROLLMENT_CONTENT_TYPE_CHOICES = [
('course', 'Course'),
('course_run', 'Course Run'),
]
uuid = models.UUIDField(
primary_key=True,
default=uuid4,
editable=False,
)
enterprise_customer = models.ForeignKey(
EnterpriseCustomer,
blank=False,
null=False,
related_name="default_course_enrollments",
on_delete=models.deletion.CASCADE,
help_text=_(
"The customer for which this default enrollment will be realized.",
)
)
content_type = models.CharField(
max_length=127,
blank=False,
null=False,
choices=DEFAULT_ENROLLMENT_CONTENT_TYPE_CHOICES,
help_text=_(
"The type of content (e.g. a course vs. a course run)."
),
)
content_key = models.CharField(
max_length=255,
blank=False,
null=False,
help_text=_(
"A course or course run that related users should be automatically enrolled into."
),
)
realized_enrollments = models.ManyToManyField(
EnterpriseCourseEnrollment,
through='DefaultEnterpriseEnrollmentRealization',
through_fields=("intended_enrollment", "realized_enrollment"),
)
history = HistoricalRecords()

@cached_property
def current_course_run(self): # pragma: no cover
"""
Metadata describing the current course run for this default enrollment intention.
"""
return {}

@property
def current_course_run_key(self): # pragma: no cover
"""
The current course run key to use for realized course enrollments.
"""
return self.current_course_run.get('key')

@property
def current_course_run_enrollable(self): # pragma: no cover
"""
Whether the current course run is enrollable.
"""
return False

@property
def current_course_run_enroll_by_date(self): # pragma: no cover
"""
The enrollment deadline for this course.
"""
return datetime.datetime.min


class DefaultEnterpriseEnrollmentRealization(TimeStampedModel):
"""
Represents the relationship between a `DefaultEnterpriseEnrollmentIntention`
and a realized course enrollment that exists because of that intention record.
"""
intended_enrollment = models.ForeignKey(
DefaultEnterpriseEnrollmentIntention,
on_delete=models.CASCADE,
)
realized_enrollment = models.ForeignKey(
EnterpriseCourseEnrollment,
on_delete=models.CASCADE,
)
history = HistoricalRecords()


class EnterpriseCatalogQuery(TimeStampedModel):
"""
Stores a re-usable catalog query.
Expand Down
1 change: 1 addition & 0 deletions tests/test_utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ def setUp(self):
"enable_one_academy",
"show_videos_in_learner_portal_search_results",
"groups",
"default_course_enrollments",
]
),
(
Expand Down

0 comments on commit 619692e

Please sign in to comment.