AA-137: Support courseware celebrations

- Add a new CourseEnrollmentCelebration model, which ties a
  course enrollment to some booleans about progress celebrations
- Add serialization of the new model to the existing courseware_api
  app's existing course info view
- Add new API in courseware_api to update a celebration model
This commit is contained in:
Michael Terry
2020-06-08 13:59:56 -04:00
parent 26a5f1844c
commit 450072582e
13 changed files with 332 additions and 52 deletions

View File

@@ -31,6 +31,7 @@ from student.models import (
CourseAccessRole,
CourseEnrollment,
CourseEnrollmentAllowed,
CourseEnrollmentCelebration,
DashboardConfiguration,
LinkedInAddToProfileConfiguration,
LoginFailures,
@@ -80,6 +81,44 @@ class _Check(object):
return inner
class DisableEnrollmentAdminMixin:
""" Disables admin access to an admin page that scales with enrollments, as performance is poor at that size. """
@_Check.is_enabled(COURSE_ENROLLMENT_ADMIN_SWITCH.is_enabled)
def has_view_permission(self, request, obj=None):
"""
Returns True if CourseEnrollment objects can be viewed via the admin view.
"""
return super().has_view_permission(request, obj)
@_Check.is_enabled(COURSE_ENROLLMENT_ADMIN_SWITCH.is_enabled)
def has_add_permission(self, request):
"""
Returns True if CourseEnrollment objects can be added via the admin view.
"""
return super().has_add_permission(request)
@_Check.is_enabled(COURSE_ENROLLMENT_ADMIN_SWITCH.is_enabled)
def has_change_permission(self, request, obj=None):
"""
Returns True if CourseEnrollment objects can be modified via the admin view.
"""
return super().has_change_permission(request, obj)
@_Check.is_enabled(COURSE_ENROLLMENT_ADMIN_SWITCH.is_enabled)
def has_delete_permission(self, request, obj=None):
"""
Returns True if CourseEnrollment objects can be deleted via the admin view.
"""
return super().has_delete_permission(request, obj)
@_Check.is_enabled(COURSE_ENROLLMENT_ADMIN_SWITCH.is_enabled)
def has_module_permission(self, request):
"""
Returns True if links to the CourseEnrollment admin view can be displayed.
"""
return super().has_module_permission(request)
class CourseAccessRoleForm(forms.ModelForm):
"""Form for adding new Course Access Roles view the Django Admin Panel."""
@@ -238,7 +277,7 @@ class CourseEnrollmentForm(forms.ModelForm):
@admin.register(CourseEnrollment)
class CourseEnrollmentAdmin(admin.ModelAdmin):
class CourseEnrollmentAdmin(DisableEnrollmentAdminMixin, admin.ModelAdmin):
""" Admin interface for the CourseEnrollment model. """
list_display = ('id', 'course_id', 'mode', 'user', 'is_active',)
list_filter = ('mode', 'is_active',)
@@ -264,41 +303,6 @@ class CourseEnrollmentAdmin(admin.ModelAdmin):
def queryset(self, request):
return super(CourseEnrollmentAdmin, self).queryset(request).select_related('user')
@_Check.is_enabled(COURSE_ENROLLMENT_ADMIN_SWITCH.is_enabled)
def has_view_permission(self, request, obj=None):
"""
Returns True if CourseEnrollment objects can be viewed via the admin view.
"""
return super(CourseEnrollmentAdmin, self).has_view_permission(request, obj)
@_Check.is_enabled(COURSE_ENROLLMENT_ADMIN_SWITCH.is_enabled)
def has_add_permission(self, request):
"""
Returns True if CourseEnrollment objects can be added via the admin view.
"""
return super(CourseEnrollmentAdmin, self).has_add_permission(request)
@_Check.is_enabled(COURSE_ENROLLMENT_ADMIN_SWITCH.is_enabled)
def has_change_permission(self, request, obj=None):
"""
Returns True if CourseEnrollment objects can be modified via the admin view.
"""
return super(CourseEnrollmentAdmin, self).has_change_permission(request, obj)
@_Check.is_enabled(COURSE_ENROLLMENT_ADMIN_SWITCH.is_enabled)
def has_delete_permission(self, request, obj=None):
"""
Returns True if CourseEnrollment objects can be deleted via the admin view.
"""
return super(CourseEnrollmentAdmin, self).has_delete_permission(request, obj)
@_Check.is_enabled(COURSE_ENROLLMENT_ADMIN_SWITCH.is_enabled)
def has_module_permission(self, request):
"""
Returns True if links to the CourseEnrollment admin view can be displayed.
"""
return super(CourseEnrollmentAdmin, self).has_module_permission(request)
class UserProfileInline(admin.StackedInline):
""" Inline admin interface for UserProfile model. """
@@ -511,6 +515,25 @@ class AllowedAuthUserAdmin(admin.ModelAdmin):
model = AllowedAuthUser
@admin.register(CourseEnrollmentCelebration)
class CourseEnrollmentCelebrationAdmin(DisableEnrollmentAdminMixin, admin.ModelAdmin):
"""Admin interface for the CourseEnrollmentCelebration model. """
raw_id_fields = ('enrollment',)
list_display = ('id', 'course', 'user', 'celebrate_first_section')
search_fields = ('enrollment__course__id', 'enrollment__user__username')
class Meta(object):
model = CourseEnrollmentCelebration
def course(self, obj):
return obj.enrollment.course.id
course.short_description = 'Course'
def user(self, obj):
return obj.enrollment.user.username
user.short_description = 'User'
admin.site.register(UserTestGroup)
admin.site.register(Registration)
admin.site.register(PendingNameChange)

View File

@@ -6,8 +6,6 @@ Configuration for the ``student`` Django application.
import os
from django.apps import AppConfig
from django.contrib.auth.signals import user_logged_in
from django.db.models.signals import pre_save
class StudentConfig(AppConfig):
@@ -17,10 +15,8 @@ class StudentConfig(AppConfig):
name = 'student'
def ready(self):
from django.contrib.auth.models import User
from .signals.receivers import on_user_updated
pre_save.connect(on_user_updated, sender=User)
# Connect signal handlers.
from .signals import receivers # pylint: disable=unused-import
# The django-simple-history model on CourseEnrollment creates performance
# problems in testing, we mock it here so that the mock impacts all tests.

View File

@@ -61,6 +61,7 @@ DISABLE_UNENROLL_CERT_STATES = [
'generating',
'downloadable',
]
EMAIL_EXISTS_MSG_FMT = _("An account with the Email '{email}' already exists.")
USERNAME_EXISTS_MSG_FMT = _("An account with the Public Username '{username}' already exists.")

View File

@@ -0,0 +1,29 @@
# Generated by Django 2.2.12 on 2020-06-08 15:12
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
class Migration(migrations.Migration):
dependencies = [
('student', '0033_userprofile_state'),
]
operations = [
migrations.CreateModel(
name='CourseEnrollmentCelebration',
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')),
('celebrate_first_section', models.BooleanField(default=False)),
('enrollment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='celebration', to='student.CourseEnrollment')),
],
options={
'abstract': False,
},
),
]

View File

@@ -13,7 +13,6 @@ file and check it in at the same time as your model changes. To do that,
import hashlib
import inspect
import json
import logging
import uuid
@@ -3048,3 +3047,39 @@ class AccountRecoveryConfiguration(ConfigurationModel):
first row being the header and columns will be as follows: \
username, email, new_email")
)
class CourseEnrollmentCelebration(TimeStampedModel):
"""
Keeps track of how we've celebrated a user's course progress.
An example of a celebration is a dialog that pops up after you complete your first section
in a course saying "good job!". Just some positive feedback like that. (This specific example is
controlled by the celebrated_first_section field below.)
In general, if a row does not exist for an enrollment, we don't want to show any celebrations.
We don't want to suddenly inject celebrations in the middle of a course, because they
might not make contextual sense and it's an inconsistent experience. The helper methods below
(starting with "should_") can help by looking up values with appropriate fallbacks.
See the create_course_enrollment_celebration signal handler for how these get created.
.. no_pii:
"""
enrollment = models.OneToOneField(CourseEnrollment, models.CASCADE, related_name='celebration')
celebrate_first_section = models.BooleanField(default=False)
def __str__(self):
return (
"[CourseEnrollmentCelebration] course: {}; user: {}; first_section: {}"
).format(self.enrollment.course.id, self.enrollment.user.username, self.celebrate_first_section)
@staticmethod
def should_celebrate_first_section(enrollment):
""" Returns the celebration value for first_section with appropriate fallback if it doesn't exist """
if not enrollment:
return False
try:
return enrollment.celebration.celebrate_first_section
except CourseEnrollmentCelebration.DoesNotExist:
return False

View File

@@ -2,13 +2,20 @@
Signal receivers for the "student" application.
"""
# pylint: disable=unused-argument
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import IntegrityError
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from student.helpers import USERNAME_EXISTS_MSG_FMT, AccountValidationError
from student.models import is_email_retired, is_username_retired
from lms.djangoapps.courseware.toggles import REDIRECT_TO_COURSEWARE_MICROFRONTEND
from student.helpers import EMAIL_EXISTS_MSG_FMT, USERNAME_EXISTS_MSG_FMT, AccountValidationError
from student.models import CourseEnrollment, CourseEnrollmentCelebration, is_email_retired, is_username_retired
@receiver(pre_save, sender=get_user_model())
def on_user_updated(sender, instance, **kwargs):
"""
Check for retired usernames.
@@ -34,6 +41,32 @@ def on_user_updated(sender, instance, **kwargs):
# Check for a retired email.
if is_email_retired(instance.email):
raise AccountValidationError(
EMAIL_EXISTS_MSG_FMT.format(username=instance.email),
EMAIL_EXISTS_MSG_FMT.format(email=instance.email),
field="email"
)
@receiver(post_save, sender=CourseEnrollment)
def create_course_enrollment_celebration(sender, instance, created, **kwargs):
"""
Creates celebration rows when enrollments are created
This is how we distinguish between new enrollments that we want to celebrate and old ones
that existed before we introduced a given celebration.
"""
if not created:
return
# The UI for celebrations is only supported on the MFE right now, so don't turn on
# celebrations unless this enrollment's course is MFE-enabled.
if not REDIRECT_TO_COURSEWARE_MICROFRONTEND.is_enabled(instance.course_id):
return
try:
CourseEnrollmentCelebration.objects.create(
enrollment=instance,
celebrate_first_section=True,
)
except IntegrityError:
# A celebration object was already created. Shouldn't happen, but ignore it if it does.
pass

View File

@@ -19,6 +19,7 @@ from student.models import (
CourseAccessRole,
CourseEnrollment,
CourseEnrollmentAllowed,
CourseEnrollmentCelebration,
PendingEmailChange,
Registration,
User,
@@ -165,6 +166,13 @@ class CourseEnrollmentFactory(DjangoModelFactory):
return manager.create(*args, **kwargs)
class CourseEnrollmentCelebrationFactory(DjangoModelFactory):
class Meta:
model = CourseEnrollmentCelebration
enrollment = factory.SubFactory(CourseEnrollmentFactory)
class CourseAccessRoleFactory(DjangoModelFactory):
class Meta(object):
model = CourseAccessRole

View File

@@ -0,0 +1,34 @@
""" Tests for student signal receivers. """
from lms.djangoapps.courseware.toggles import REDIRECT_TO_COURSEWARE_MICROFRONTEND
from student.models import CourseEnrollmentCelebration
from student.tests.factories import CourseEnrollmentFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
class ReceiversTest(SharedModuleStoreTestCase):
"""
Tests for dashboard utility functions
"""
@REDIRECT_TO_COURSEWARE_MICROFRONTEND.override(active=True)
def test_celebration_created(self):
""" Test that we make celebration objects when enrollments are created """
self.assertEqual(CourseEnrollmentCelebration.objects.count(), 0)
# Test initial creation upon an enrollment being made
enrollment = CourseEnrollmentFactory()
self.assertEqual(CourseEnrollmentCelebration.objects.count(), 1)
celebration = CourseEnrollmentCelebration.objects.get(enrollment=enrollment, celebrate_first_section=True)
# Test nothing changes if we update that enrollment
celebration.celebrate_first_section = False
celebration.save()
enrollment.mode = 'test-mode'
enrollment.save()
self.assertEqual(CourseEnrollmentCelebration.objects.count(), 1)
CourseEnrollmentCelebration.objects.get(enrollment=enrollment, celebrate_first_section=False)
def test_celebration_gated_by_waffle(self):
""" Test we don't make a celebration if the MFE redirect waffle flag is off """
CourseEnrollmentFactory()
self.assertEqual(CourseEnrollmentCelebration.objects.count(), 0)