Merge pull request #22198 from edx/REV-693
[REV-693] Create table to keep track of FBE holdback experiment enrollments
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.25 on 2019-11-01 15:56
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('student', '0023_bulkunenrollconfiguration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='FBEEnrollmentExclusion',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('enrollment', models.OneToOneField(on_delete=django.db.models.deletion.DO_NOTHING, to='student.CourseEnrollment')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -2045,6 +2045,22 @@ class CourseEnrollment(models.Model):
|
||||
cache[(user_id, course_key)] = enrollment_state
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class FBEEnrollmentExclusion(models.Model):
|
||||
"""
|
||||
Disable FBE for enrollments in this table.
|
||||
|
||||
.. no_pii:
|
||||
"""
|
||||
enrollment = models.OneToOneField(
|
||||
CourseEnrollment,
|
||||
on_delete=models.DO_NOTHING,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return "[FBEEnrollmentExclusion] %s" % (self.enrollment,)
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=CourseEnrollment)
|
||||
@receiver(models.signals.post_delete, sender=CourseEnrollment)
|
||||
def invalidate_enrollment_mode_cache(sender, instance, **kwargs): # pylint: disable=unused-argument, invalid-name
|
||||
|
||||
@@ -3,9 +3,10 @@ from __future__ import absolute_import
|
||||
|
||||
from experiments.models import ExperimentData
|
||||
from openedx.features.course_duration_limits.config import EXPERIMENT_DATA_HOLDBACK_KEY, EXPERIMENT_ID
|
||||
from student.models import FBEEnrollmentExclusion
|
||||
|
||||
|
||||
def is_in_holdback(user):
|
||||
def is_in_holdback(user, enrollment):
|
||||
"""
|
||||
Return true if given user is in holdback expermiment
|
||||
"""
|
||||
@@ -21,4 +22,11 @@ def is_in_holdback(user):
|
||||
except ExperimentData.DoesNotExist:
|
||||
pass
|
||||
|
||||
if enrollment is not None:
|
||||
try:
|
||||
if enrollment.fbeenrollmentexclusion:
|
||||
return True
|
||||
except FBEEnrollmentExclusion.DoesNotExist:
|
||||
pass
|
||||
|
||||
return in_holdback
|
||||
|
||||
@@ -121,6 +121,7 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver):
|
||||
schedules = Schedule.objects.select_related(
|
||||
'enrollment__user__profile',
|
||||
'enrollment__course',
|
||||
'enrollment__fbeenrollmentexclusion',
|
||||
).filter(
|
||||
Q(enrollment__course__end__isnull=True) | Q(
|
||||
enrollment__course__end__gte=self.current_datetime
|
||||
|
||||
@@ -86,7 +86,7 @@ class ContentTypeGatingConfig(StackedConfigurationModel):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def enabled_for_enrollment(cls, enrollment=None, user=None, course_key=None, user_partition=None):
|
||||
def enabled_for_enrollment(cls, user=None, course_key=None, user_partition=None):
|
||||
"""
|
||||
Return whether Content Type Gating is enabled for this enrollment.
|
||||
|
||||
@@ -95,27 +95,15 @@ class ContentTypeGatingConfig(StackedConfigurationModel):
|
||||
such as the org, site, or globally), and if the configuration is specified to be
|
||||
``enabled_as_of`` before the enrollment was created.
|
||||
|
||||
Only one of enrollment and (user, course_key) may be specified at a time.
|
||||
|
||||
Arguments:
|
||||
enrollment: The enrollment being queried.
|
||||
user: The user being queried.
|
||||
course_key: The CourseKey of the course being queried.
|
||||
"""
|
||||
if enrollment is not None and (user is not None or course_key is not None):
|
||||
raise ValueError('Specify enrollment or user/course_key, but not both')
|
||||
|
||||
if enrollment is None and (user is None or course_key is None):
|
||||
if user is None or course_key is None:
|
||||
raise ValueError('Both user and course_key must be specified if no enrollment is provided')
|
||||
|
||||
if enrollment is None and user is None and course_key is None:
|
||||
raise ValueError('At least one of enrollment or user and course_key must be specified')
|
||||
|
||||
if course_key is None:
|
||||
course_key = enrollment.course_id
|
||||
|
||||
if enrollment is None:
|
||||
enrollment = CourseEnrollment.get_enrollment(user, course_key)
|
||||
enrollment = CourseEnrollment.get_enrollment(user, course_key, ['fbeenrollmentexclusion'])
|
||||
|
||||
if user is None and enrollment is not None:
|
||||
user = enrollment.user
|
||||
@@ -134,7 +122,7 @@ class ContentTypeGatingConfig(StackedConfigurationModel):
|
||||
return False
|
||||
|
||||
# check if user is in holdback
|
||||
if user_variable_represents_correct_user and is_in_holdback(user):
|
||||
if user_variable_represents_correct_user and is_in_holdback(user, enrollment):
|
||||
return False
|
||||
|
||||
if not correct_modes_for_fbe(course_key, enrollment, user):
|
||||
|
||||
@@ -33,18 +33,15 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase):
|
||||
super(TestContentTypeGatingConfig, self).setUp()
|
||||
|
||||
@ddt.data(
|
||||
(True, True, True),
|
||||
(True, True, False),
|
||||
(True, False, True),
|
||||
(True, False, False),
|
||||
(False, False, True),
|
||||
(False, False, False),
|
||||
(True, True),
|
||||
(True, False),
|
||||
(False, True),
|
||||
(False, False),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_enabled_for_enrollment(
|
||||
self,
|
||||
already_enrolled,
|
||||
pass_enrollment,
|
||||
enrolled_before_enabled,
|
||||
):
|
||||
|
||||
@@ -69,22 +66,14 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase):
|
||||
else:
|
||||
existing_enrollment = None
|
||||
|
||||
if pass_enrollment:
|
||||
enrollment = existing_enrollment
|
||||
user = None
|
||||
course_key = None
|
||||
else:
|
||||
enrollment = None
|
||||
user = self.user
|
||||
course_key = self.course_overview.id
|
||||
enrollment = None
|
||||
user = self.user
|
||||
course_key = self.course_overview.id
|
||||
|
||||
query_count = 7
|
||||
if not already_enrolled or not pass_enrollment and already_enrolled:
|
||||
query_count = 8
|
||||
query_count = 8
|
||||
|
||||
with self.assertNumQueries(query_count):
|
||||
enabled = ContentTypeGatingConfig.enabled_for_enrollment(
|
||||
enrollment=enrollment,
|
||||
user=user,
|
||||
course_key=course_key,
|
||||
)
|
||||
@@ -92,11 +81,11 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase):
|
||||
|
||||
def test_enabled_for_enrollment_failure(self):
|
||||
with self.assertRaises(ValueError):
|
||||
ContentTypeGatingConfig.enabled_for_enrollment(None, None, None)
|
||||
ContentTypeGatingConfig.enabled_for_enrollment(None, None)
|
||||
with self.assertRaises(ValueError):
|
||||
ContentTypeGatingConfig.enabled_for_enrollment(Mock(name='enrollment'), Mock(name='user'), None)
|
||||
ContentTypeGatingConfig.enabled_for_enrollment(Mock(name='user'), None)
|
||||
with self.assertRaises(ValueError):
|
||||
ContentTypeGatingConfig.enabled_for_enrollment(Mock(name='enrollment'), None, Mock(name='course_key'))
|
||||
ContentTypeGatingConfig.enabled_for_enrollment(None, Mock(name='course_key'))
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_enabled_for_course(
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"""
|
||||
A managment command that can be used to set up Schedules with various configurations for testing.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import datetime
|
||||
from textwrap import dedent
|
||||
|
||||
import factory
|
||||
import pytz
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from course_modes.tests.factories import CourseModeFactory
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory, ScheduleFactory
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.factories import XMODULE_FACTORY_LOCK, CourseFactory
|
||||
|
||||
|
||||
class CourseDurationLimitExpirySchedule(ScheduleFactory):
|
||||
"""
|
||||
A ScheduleFactory that creates a Schedule set up for Course Duration Limit expiry
|
||||
"""
|
||||
start = factory.Faker('date_time_between', start_date='-21d', end_date='-21d', tzinfo=pytz.UTC)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
A management command that generates schedule objects for all expected course duration limit
|
||||
email types, so that it is easy to generate test emails of all available types.
|
||||
"""
|
||||
help = dedent(__doc__).strip()
|
||||
|
||||
def handle(self, *args, **options):
|
||||
courses = modulestore().get_courses()
|
||||
|
||||
# Find the largest auto-generated course, and pick the next sequence id to generate the next
|
||||
# course with.
|
||||
max_org_sequence_id = max([0] + [int(course.org[4:]) for course in courses if course.org.startswith('org.')])
|
||||
|
||||
XMODULE_FACTORY_LOCK.enable()
|
||||
CourseFactory.reset_sequence(max_org_sequence_id + 1, force=True)
|
||||
course = CourseFactory.create(
|
||||
start=datetime.datetime.today() - datetime.timedelta(days=30),
|
||||
end=datetime.datetime.today() + datetime.timedelta(days=30),
|
||||
number=factory.Sequence('schedules_test_course_{}'.format),
|
||||
display_name=factory.Sequence(u'Schedules Test Course {}'.format),
|
||||
)
|
||||
XMODULE_FACTORY_LOCK.disable()
|
||||
course_overview = CourseOverview.load_from_module_store(course.id)
|
||||
CourseModeFactory.create(course_id=course_overview.id, mode_slug=CourseMode.AUDIT)
|
||||
CourseModeFactory.create(course_id=course_overview.id, mode_slug=CourseMode.VERIFIED)
|
||||
CourseDurationLimitExpirySchedule.create_batch(20, enrollment__course=course_overview)
|
||||
ScheduleConfigFactory.create(site=Site.objects.get(name='example.com'))
|
||||
@@ -1,26 +0,0 @@
|
||||
"""
|
||||
./manage.py lms send_access_expiry_reminder <domain>
|
||||
|
||||
Send out reminder emails for any students who will lose access to course content in 7 days.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
from openedx.core.djangoapps.schedules.management.commands import SendEmailBaseCommand
|
||||
from openedx.features.course_duration_limits.tasks import CourseDurationLimitExpiryReminder
|
||||
|
||||
from ... import resolvers
|
||||
|
||||
|
||||
class Command(SendEmailBaseCommand):
|
||||
"""
|
||||
Send out reminder emails for any students who will lose access
|
||||
to course content in 7 days.
|
||||
|
||||
./manage.py lms send_access_expiry_reminder <domain>
|
||||
"""
|
||||
help = dedent(__doc__).strip()
|
||||
async_send_task = CourseDurationLimitExpiryReminder
|
||||
log_prefix = resolvers.EXPIRY_REMINDER_LOG_PREFIX
|
||||
offsets = (7,) # Days until Course Duration Limit expiry
|
||||
@@ -1,15 +0,0 @@
|
||||
"""
|
||||
Message types used for ACE communication by the course_duration_limits app.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
import logging
|
||||
|
||||
from openedx.core.djangoapps.ace_common.message import BaseMessageType
|
||||
from openedx.core.djangoapps.schedules.config import DEBUG_MESSAGE_WAFFLE_FLAG
|
||||
|
||||
|
||||
class ExpiryReminder(BaseMessageType):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ExpiryReminder, self).__init__(*args, **kwargs)
|
||||
self.log_level = logging.DEBUG if DEBUG_MESSAGE_WAFFLE_FLAG.is_enabled() else None
|
||||
@@ -80,7 +80,7 @@ class CourseDurationLimitConfig(StackedConfigurationModel):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def enabled_for_enrollment(cls, enrollment=None, user=None, course_key=None):
|
||||
def enabled_for_enrollment(cls, user=None, course_key=None):
|
||||
"""
|
||||
Return whether Course Duration Limits are enabled for this enrollment.
|
||||
|
||||
@@ -97,20 +97,10 @@ class CourseDurationLimitConfig(StackedConfigurationModel):
|
||||
course_key: The CourseKey of the course being queried.
|
||||
"""
|
||||
|
||||
if enrollment is not None and (user is not None or course_key is not None):
|
||||
raise ValueError('Specify enrollment or user/course_key, but not both')
|
||||
|
||||
if enrollment is None and (user is None or course_key is None):
|
||||
if user is None or course_key is None:
|
||||
raise ValueError('Both user and course_key must be specified if no enrollment is provided')
|
||||
|
||||
if enrollment is None and user is None and course_key is None:
|
||||
raise ValueError('At least one of enrollment or user and course_key must be specified')
|
||||
|
||||
if course_key is None:
|
||||
course_key = enrollment.course_id
|
||||
|
||||
if enrollment is None:
|
||||
enrollment = CourseEnrollment.get_enrollment(user, course_key)
|
||||
enrollment = CourseEnrollment.get_enrollment(user, course_key, ['fbeenrollmentexclusion'])
|
||||
|
||||
if user is None and enrollment is not None:
|
||||
user = enrollment.user
|
||||
@@ -128,7 +118,7 @@ class CourseDurationLimitConfig(StackedConfigurationModel):
|
||||
student_masquerade = is_masquerading_as_specific_student(user, course_key)
|
||||
|
||||
# check if user is in holdback
|
||||
if (no_masquerade or student_masquerade) and is_in_holdback(user):
|
||||
if (no_masquerade or student_masquerade) and is_in_holdback(user, enrollment):
|
||||
return False
|
||||
|
||||
not_student_masquerade = is_masquerading and not student_masquerade
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
"""
|
||||
Resolvers used to find users for course_duration_limit message
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.contrib.staticfiles.templatetags.staticfiles import static
|
||||
from django.db.models import Q
|
||||
from django.utils.timesince import timeuntil
|
||||
from django.utils.translation import ugettext as _
|
||||
from eventtracking import tracker
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from lms.djangoapps.courseware.date_summary import verified_upgrade_deadline_link
|
||||
from lms.djangoapps.experiments.stable_bucketing import stable_bucketing_hash_group
|
||||
from openedx.core.djangoapps.catalog.utils import get_course_run_details
|
||||
from openedx.core.djangoapps.schedules.resolvers import (
|
||||
BinnedSchedulesBaseResolver,
|
||||
InvalidContextError,
|
||||
_get_trackable_course_home_url
|
||||
)
|
||||
from track import segment
|
||||
|
||||
from .access import MAX_DURATION, MIN_DURATION, get_user_course_expiration_date
|
||||
from .models import CourseDurationLimitConfig
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NUM_BINS = 24
|
||||
EXPIRY_REMINDER_NUM_BINS = 1
|
||||
EXPIRY_REMINDER_LOG_PREFIX = 'FBE Expiry Reminder'
|
||||
|
||||
|
||||
class ExpiryReminderResolver(BinnedSchedulesBaseResolver):
|
||||
"""
|
||||
Send a message to all users whose course duration limit expiration date
|
||||
is at ``self.current_date`` + ``day_offset``.
|
||||
"""
|
||||
log_prefix = EXPIRY_REMINDER_LOG_PREFIX
|
||||
schedule_date_field = 'start'
|
||||
num_bins = EXPIRY_REMINDER_NUM_BINS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
async_send_task,
|
||||
site,
|
||||
course_key,
|
||||
target_datetime,
|
||||
day_offset,
|
||||
override_recipient_email=None
|
||||
):
|
||||
access_duration = MIN_DURATION
|
||||
discovery_course_details = get_course_run_details(course_key, ['weeks_to_complete'])
|
||||
expected_weeks = discovery_course_details.get('weeks_to_complete')
|
||||
if expected_weeks:
|
||||
access_duration = timedelta(weeks=expected_weeks)
|
||||
|
||||
access_duration = max(MIN_DURATION, min(MAX_DURATION, access_duration))
|
||||
|
||||
self.course_key = course_key
|
||||
|
||||
super(ExpiryReminderResolver, self).__init__(
|
||||
async_send_task,
|
||||
site,
|
||||
target_datetime - access_duration,
|
||||
day_offset - access_duration.days,
|
||||
0,
|
||||
override_recipient_email,
|
||||
)
|
||||
|
||||
# TODO: This isn't named well, given the purpose we're using it for. That's ok for now,
|
||||
# this is just a test.
|
||||
@property
|
||||
def experience_filter(self):
|
||||
return Q(enrollment__course_id=self.course_key, enrollment__mode=CourseMode.AUDIT)
|
||||
|
||||
def get_template_context(self, user, user_schedules):
|
||||
course_id_strs = []
|
||||
course_links = []
|
||||
first_valid_upsell_context = None
|
||||
first_schedule = None
|
||||
first_expiration_date = None
|
||||
|
||||
self.log_info(u"Found %s schedules for %s", len(user_schedules), user.username)
|
||||
|
||||
for schedule in user_schedules:
|
||||
upsell_context = _get_upsell_information_for_schedule(user, schedule)
|
||||
if not upsell_context['show_upsell']:
|
||||
self.log_info(u"No upsell available for %r", schedule.enrollment)
|
||||
continue
|
||||
|
||||
if not CourseDurationLimitConfig.enabled_for_enrollment(enrollment=schedule.enrollment):
|
||||
self.log_info(u"course duration limits not enabled for %r", schedule.enrollment)
|
||||
continue
|
||||
|
||||
expiration_date = get_user_course_expiration_date(user, schedule.enrollment.course)
|
||||
if expiration_date is None:
|
||||
self.log_info(u"No course expiration date for %r", schedule.enrollment.course)
|
||||
continue
|
||||
|
||||
if first_valid_upsell_context is None:
|
||||
first_schedule = schedule
|
||||
first_valid_upsell_context = upsell_context
|
||||
first_expiration_date = expiration_date
|
||||
course_id_str = str(schedule.enrollment.course_id)
|
||||
course_id_strs.append(course_id_str)
|
||||
course_links.append({
|
||||
'url': _get_trackable_course_home_url(schedule.enrollment.course_id),
|
||||
'name': schedule.enrollment.course.display_name
|
||||
})
|
||||
|
||||
if first_schedule is None:
|
||||
self.log_info(u'No courses eligible for upgrade for user %s.', user.username)
|
||||
raise InvalidContextError()
|
||||
|
||||
# Experiment code: Skip users who are in the control bucket
|
||||
hash_bucket = stable_bucketing_hash_group('fbe_access_expiry_reminder', 2, user.username)
|
||||
properties = {
|
||||
'site': self.site.domain, # pylint: disable=no-member
|
||||
'app_label': 'course_duration_limits',
|
||||
'nonInteraction': 1,
|
||||
'bucket': hash_bucket,
|
||||
'experiment': 'REVMI-95',
|
||||
}
|
||||
course_ids = course_id_strs
|
||||
properties['num_courses'] = len(course_ids)
|
||||
if course_ids:
|
||||
properties['course_ids'] = course_ids[:10]
|
||||
properties['primary_course_id'] = course_ids[0]
|
||||
|
||||
tracking_context = {
|
||||
'host': self.site.domain, # pylint: disable=no-member
|
||||
'path': '/', # make up a value, in order to allow the host to be passed along.
|
||||
}
|
||||
# I wonder if the user of this event should be the recipient, as they are not the ones
|
||||
# who took an action. Rather, the system is acting, and they are the object.
|
||||
# Admittedly that may be what 'nonInteraction' is meant to address. But sessionization may
|
||||
# get confused by these events if they're attributed in this way, because there's no way for
|
||||
# this event to get context that would match with what the user might be doing at the moment.
|
||||
# But the events do show up in GA being joined up with existing sessions (i.e. within a half
|
||||
# hour in the past), so they don't always break sessions. Not sure what happens after these.
|
||||
# We can put the recipient_user_id into the properties, and then export as a custom dimension.
|
||||
with tracker.get_tracker().context('course_duration_limits', tracking_context):
|
||||
segment.track(
|
||||
user_id=user.id,
|
||||
event_name='edx.bi.experiment.user.bucketed',
|
||||
properties=properties,
|
||||
)
|
||||
if hash_bucket == 0:
|
||||
raise InvalidContextError()
|
||||
|
||||
context = {
|
||||
'course_links': course_links,
|
||||
'first_course_name': first_schedule.enrollment.course.display_name,
|
||||
'cert_image': static('course_experience/images/verified-cert.png'),
|
||||
'course_ids': course_id_strs,
|
||||
'first_course_expiration_date': first_expiration_date.strftime(_(u"%b. %d, %Y")),
|
||||
'time_until_expiration': timeuntil(first_expiration_date.date(), now=datetime.utcnow().date())
|
||||
}
|
||||
context.update(first_valid_upsell_context)
|
||||
return context
|
||||
|
||||
|
||||
def _get_verified_upgrade_link(user, schedule):
|
||||
enrollment = schedule.enrollment
|
||||
if enrollment.is_active:
|
||||
return verified_upgrade_deadline_link(user, enrollment.course)
|
||||
|
||||
|
||||
def _get_upsell_information_for_schedule(user, schedule):
|
||||
"""
|
||||
Return upsell variables for inclusion in a message template being sent to this user.
|
||||
"""
|
||||
template_context = {}
|
||||
|
||||
verified_upgrade_link = _get_verified_upgrade_link(user, schedule)
|
||||
has_verified_upgrade_link = verified_upgrade_link is not None
|
||||
|
||||
if has_verified_upgrade_link:
|
||||
template_context['upsell_link'] = verified_upgrade_link
|
||||
|
||||
template_context['show_upsell'] = has_verified_upgrade_link
|
||||
return template_context
|
||||
@@ -1,174 +0,0 @@
|
||||
"""
|
||||
Tasks requiring asynchronous handling for course_duration_limits
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
import six
|
||||
import waffle
|
||||
from celery import task
|
||||
from celery_utils.logged_task import LoggedTask
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.sites.models import Site
|
||||
from edx_ace import ace
|
||||
from edx_ace.message import Message
|
||||
from edx_ace.utils.date import deserialize, serialize
|
||||
from edx_django_utils.monitoring import set_custom_metric
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from openedx.core.djangoapps.schedules.resolvers import _get_datetime_beginning_of_day
|
||||
from openedx.core.djangoapps.schedules.tasks import _annonate_send_task_for_monitoring, _track_message_sent
|
||||
from openedx.core.lib.celery.task_utils import emulate_http_request
|
||||
|
||||
from . import message_types, resolvers
|
||||
from .models import CourseDurationLimitConfig
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
ROUTING_KEY = getattr(settings, 'ACE_ROUTING_KEY', None)
|
||||
|
||||
|
||||
class CourseDurationLimitMessageBaseTask(LoggedTask):
|
||||
"""
|
||||
Base class for top-level Schedule tasks that create subtasks
|
||||
for each Bin.
|
||||
"""
|
||||
ignore_result = True
|
||||
routing_key = ROUTING_KEY
|
||||
num_bins = resolvers.DEFAULT_NUM_BINS
|
||||
enqueue_config_var = None # define in subclass
|
||||
log_prefix = None
|
||||
resolver = None # define in subclass
|
||||
async_send_task = None # define in subclass
|
||||
|
||||
@classmethod
|
||||
def log_debug(cls, message, *args, **kwargs):
|
||||
"""
|
||||
Wrapper around LOG.debug that prefixes the message.
|
||||
"""
|
||||
LOG.debug(cls.log_prefix + ': ' + message, *args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def log_info(cls, message, *args, **kwargs):
|
||||
"""
|
||||
Wrapper around LOG.info that prefixes the message.
|
||||
"""
|
||||
LOG.info(cls.log_prefix + ': ' + message, *args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def enqueue(cls, site, current_date, day_offset, override_recipient_email=None):
|
||||
current_date = _get_datetime_beginning_of_day(current_date)
|
||||
|
||||
for course_key, config in CourseDurationLimitConfig.all_current_course_configs().items():
|
||||
if not config['enabled'][0]:
|
||||
cls.log_info(u'Course duration limits disabled for course_key %s, skipping', course_key)
|
||||
continue
|
||||
|
||||
# enqueue_enabled, _ = config[cls.enqueue_config_var]
|
||||
# TODO: Switch over to a model where enqueing is based in CourseDurationLimitConfig
|
||||
enqueue_enabled = waffle.switch_is_active('course_duration_limits.enqueue_enabled')
|
||||
|
||||
if not enqueue_enabled:
|
||||
cls.log_info(u'Message queuing disabled for course_key %s', course_key)
|
||||
continue
|
||||
|
||||
target_date = current_date + datetime.timedelta(days=day_offset)
|
||||
task_args = (
|
||||
site.id,
|
||||
six.text_type(course_key),
|
||||
serialize(target_date),
|
||||
day_offset,
|
||||
override_recipient_email,
|
||||
)
|
||||
cls().apply_async(
|
||||
task_args,
|
||||
retry=False,
|
||||
)
|
||||
|
||||
def run( # pylint: disable=arguments-differ
|
||||
self, site_id, course_key_str, target_day_str, day_offset, override_recipient_email=None,
|
||||
):
|
||||
try:
|
||||
site = Site.objects.select_related('configuration').get(id=site_id)
|
||||
with emulate_http_request(site=site):
|
||||
msg_type = self.make_message_type(day_offset)
|
||||
_annotate_for_monitoring(msg_type, course_key_str, target_day_str, day_offset)
|
||||
return self.resolver( # pylint: disable=not-callable
|
||||
self.async_send_task,
|
||||
site,
|
||||
CourseKey.from_string(course_key_str),
|
||||
deserialize(target_day_str),
|
||||
day_offset,
|
||||
override_recipient_email=override_recipient_email,
|
||||
).send(msg_type)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
LOG.exception("Task failed")
|
||||
|
||||
def make_message_type(self, day_offset):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@task(base=LoggedTask, ignore_result=True, routing_key=ROUTING_KEY)
|
||||
def _expiry_reminder_schedule_send(site_id, msg_str):
|
||||
_schedule_send(
|
||||
msg_str,
|
||||
site_id,
|
||||
'deliver_expiry_reminder',
|
||||
resolvers.EXPIRY_REMINDER_LOG_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
class CourseDurationLimitExpiryReminder(CourseDurationLimitMessageBaseTask):
|
||||
"""
|
||||
Task to send out a reminder that a users access to course content is expiring soon.
|
||||
"""
|
||||
num_bins = resolvers.EXPIRY_REMINDER_NUM_BINS
|
||||
enqueue_config_var = 'enqueue_expiry_reminder'
|
||||
log_prefix = resolvers.EXPIRY_REMINDER_LOG_PREFIX
|
||||
resolver = resolvers.ExpiryReminderResolver
|
||||
async_send_task = _expiry_reminder_schedule_send
|
||||
|
||||
def make_message_type(self, day_offset):
|
||||
return message_types.ExpiryReminder()
|
||||
|
||||
|
||||
def _schedule_send(msg_str, site_id, delivery_config_var, log_prefix):
|
||||
site = Site.objects.select_related('configuration').get(pk=site_id)
|
||||
if _is_delivery_enabled(site, delivery_config_var, log_prefix):
|
||||
msg = Message.from_string(msg_str)
|
||||
|
||||
user = User.objects.get(username=msg.recipient.username) # pylint: disable=no-member
|
||||
with emulate_http_request(site=site, user=user):
|
||||
_annonate_send_task_for_monitoring(msg)
|
||||
LOG.debug(u'%s: Sending message = %s', log_prefix, msg_str)
|
||||
ace.send(msg)
|
||||
_track_message_sent(site, user, msg)
|
||||
|
||||
|
||||
def _is_delivery_enabled(site, delivery_config_var, log_prefix): # pylint: disable=unused-argument
|
||||
# Experiment TODO: when this is going to prod, switch over to a config-model backed solution
|
||||
#if getattr(CourseDurationLimitConfig.current(site=site), delivery_config_var, False):
|
||||
if waffle.switch_is_active('course_duration_limits.delivery_enabled'):
|
||||
return True
|
||||
else:
|
||||
LOG.info(u'%s: Message delivery disabled for site %s', log_prefix, site.domain)
|
||||
return False
|
||||
|
||||
|
||||
def _annotate_for_monitoring(message_type, course_key, target_day_str, day_offset):
|
||||
"""
|
||||
Set custom metrics in monitoring to make it easier to identify what messages are being sent and why.
|
||||
"""
|
||||
# This identifies the type of message being sent, for example: schedules.recurring_nudge3.
|
||||
set_custom_metric('message_name', '{0}.{1}'.format(message_type.app_label, message_type.name))
|
||||
# The domain name of the site we are sending the message for.
|
||||
set_custom_metric('course_key', course_key)
|
||||
# The date we are processing data for.
|
||||
set_custom_metric('target_day', target_day_str)
|
||||
# The number of days relative to the current date to process data for.
|
||||
set_custom_metric('day_offset', day_offset)
|
||||
# A unique identifier for this batch of messages being sent.
|
||||
set_custom_metric('send_uuid', message_type.uuid)
|
||||
@@ -1,47 +0,0 @@
|
||||
{% extends 'ace_common/edx_ace/common/base_body.html' %}
|
||||
{% load i18n %}
|
||||
{% load ace %}
|
||||
|
||||
{% block preview_text %}
|
||||
{% blocktrans trimmed %}
|
||||
We hope you have enjoyed {{first_course_name}}! You lose all access to this course in {{time_until_expiration}}.
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<table width="100%" align="left" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
We hope you have enjoyed {{first_course_name}}! You lose all access to this course, including your progress, on
|
||||
{{ first_course_expiration_date }} ({{time_until_expiration}}).
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Upgrade now to get unlimited access and for the chance to earn a verified certificate.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
{% if show_upsell %}
|
||||
<p>
|
||||
<a href="{% with_link_tracking upsell_link %}" style="
|
||||
color: #1e8142;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
-webkit-border-radius: 4px;
|
||||
-moz-border-radius: 4px;
|
||||
background-color: #FFFFFF;
|
||||
border: 3px solid #1e8142;
|
||||
display: inline-block;
|
||||
padding: 8px 65px;
|
||||
">
|
||||
<font color="#1e8142"><b>{% trans "Upgrade Now" %}</b></font>
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -1,13 +0,0 @@
|
||||
{% autoescape off %}
|
||||
{% load i18n %}
|
||||
{% load ace %}
|
||||
|
||||
{% blocktrans trimmed %}
|
||||
We hope you have enjoyed {{first_course_name}}! You lose all access to this course, including your progress, on {{ first_course_expiration_date }} ({{time_until_expiration}}).
|
||||
|
||||
Upgrade now to get unlimited access and for the chance to earn a verified certificate.
|
||||
{% endblocktrans %}
|
||||
{% if show_upsell %}
|
||||
{% trans "Upgrade Now" %} <{% with_link_tracking upsell_link %}>
|
||||
{% endif %}
|
||||
{% endautoescape %}
|
||||
@@ -1,3 +0,0 @@
|
||||
{% autoescape off %}
|
||||
{{ first_course_name }}
|
||||
{% endautoescape %}
|
||||
@@ -1 +0,0 @@
|
||||
{% extends 'ace_common/edx_ace/common/base_head.html' %}
|
||||
@@ -1,5 +0,0 @@
|
||||
{% autoescape off %}
|
||||
{% load i18n %}
|
||||
|
||||
{% blocktrans trimmed %}Upgrade to keep your access to {{first_course_name}}{% endblocktrans %}
|
||||
{% endautoescape %}
|
||||
@@ -39,18 +39,15 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase):
|
||||
super(TestCourseDurationLimitConfig, self).setUp()
|
||||
|
||||
@ddt.data(
|
||||
(True, True, True),
|
||||
(True, True, False),
|
||||
(True, False, True),
|
||||
(True, False, False),
|
||||
(False, False, True),
|
||||
(False, False, False),
|
||||
(True, True),
|
||||
(True, False),
|
||||
(False, True),
|
||||
(False, False),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_enabled_for_enrollment(
|
||||
self,
|
||||
already_enrolled,
|
||||
pass_enrollment,
|
||||
enrolled_before_enabled,
|
||||
):
|
||||
|
||||
@@ -75,22 +72,13 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase):
|
||||
else:
|
||||
existing_enrollment = None
|
||||
|
||||
if pass_enrollment:
|
||||
enrollment = existing_enrollment
|
||||
user = None
|
||||
course_key = None
|
||||
else:
|
||||
enrollment = None
|
||||
user = self.user
|
||||
course_key = self.course_overview.id
|
||||
user = self.user
|
||||
course_key = self.course_overview.id
|
||||
|
||||
query_count = 7
|
||||
if pass_enrollment and already_enrolled:
|
||||
query_count = 6
|
||||
|
||||
with self.assertNumQueries(query_count):
|
||||
enabled = CourseDurationLimitConfig.enabled_for_enrollment(
|
||||
enrollment=enrollment,
|
||||
user=user,
|
||||
course_key=course_key,
|
||||
)
|
||||
@@ -98,16 +86,14 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase):
|
||||
|
||||
def test_enabled_for_enrollment_failure(self):
|
||||
with self.assertRaises(ValueError):
|
||||
CourseDurationLimitConfig.enabled_for_enrollment(None, None, None)
|
||||
CourseDurationLimitConfig.enabled_for_enrollment(None, None)
|
||||
with self.assertRaises(ValueError):
|
||||
CourseDurationLimitConfig.enabled_for_enrollment(
|
||||
Mock(name='enrollment'),
|
||||
Mock(name='user'),
|
||||
None
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
CourseDurationLimitConfig.enabled_for_enrollment(
|
||||
Mock(name='enrollment'),
|
||||
None,
|
||||
Mock(name='course_key')
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user