AA-67: Adds in new resolver logic for Weekly Highlights to learners

This commit is contained in:
Jeff LaJoie
2020-04-02 10:54:48 -04:00
parent 0d9b2dd4ed
commit 542905bee4
7 changed files with 331 additions and 22 deletions

View File

@@ -6,6 +6,8 @@ schedule experience built on the Schedules app.
import logging
from datetime import datetime, timedelta
from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_WAFFLE_FLAG
from openedx.core.djangoapps.schedules.exceptions import CourseUpdateDoesNotExist
from openedx.core.lib.request_utils import get_request_or_stub
@@ -62,6 +64,24 @@ def get_week_highlights(user, course_key, week_num):
return highlights
def get_next_section_highlights(user, course_key, target_date):
"""
Get highlights (list of unicode strings) for a week, based upon the current date.
Raises:
CourseUpdateDoeNotExist: if highlights do not exist for the requested date
"""
course_descriptor = _get_course_with_highlights(course_key)
course_module = _get_course_module(course_descriptor, user)
sections_with_highlights = _get_sections_with_highlights(course_module)
highlights = _get_highlights_for_next_section(
sections_with_highlights,
course_key,
target_date
)
return highlights
def _get_course_with_highlights(course_key):
# pylint: disable=missing-docstring
if not COURSE_UPDATE_WAFFLE_FLAG.is_enabled(course_key):
@@ -128,3 +148,17 @@ def _get_highlights_for_week(sections, week_num, course_key):
section = sections[week_num - 1]
return section.highlights
def _get_highlights_for_next_section(sections, course_key, target_date):
sorted_sections = sorted(sections, key=lambda section: section.due)
for index, sorted_section in enumerate(sorted_sections):
if sorted_section.due.date() == target_date and index + 1 < len(sorted_sections):
# Return index + 2 for "week_num", since weeks start at 1 as opposed to indexes,
# and we want the next week, so +1 for index and +1 for next
return sections[index + 1].highlights, index + 2
raise CourseUpdateDoesNotExist(
u"No section found ending on {} for {}".format(
target_date, course_key
)
)

View File

@@ -0,0 +1,33 @@
"""
Management command to send Schedule course updates
"""
import datetime
import pytz
from textwrap import dedent
from django.contrib.sites.models import Site
from openedx.core.djangoapps.schedules.management.commands import SendEmailBaseCommand
from openedx.core.djangoapps.schedules.tasks import ScheduleCourseNextSectionUpdate
class Command(SendEmailBaseCommand):
"""
Command to send Schedule course updates
"""
help = dedent(__doc__).strip()
async_send_task = ScheduleCourseNextSectionUpdate
log_prefix = 'Course Update'
def handle(self, *args, ** options):
current_date = datetime.datetime(
*[int(x) for x in options['date'].split('-')],
tzinfo=pytz.UTC
)
site = Site.objects.get(domain__iexact=options['site_domain_name'])
override_recipient_email = options.get('override_recipient_email')
# day_offset set to 1 as we'll always be looking for yesterday
self.async_send_task.enqueue(site, current_date, 1, override_recipient_email)

View File

@@ -13,12 +13,13 @@ from django.urls import reverse
from edx_ace.recipient import Recipient
from edx_ace.recipient_resolver import RecipientResolver
from edx_django_utils.monitoring import function_trace, set_custom_metric
from edx_when.api import get_schedules_with_due_date
from lms.djangoapps.courseware.utils import verified_upgrade_deadline_link, verified_upgrade_link_is_valid
from lms.djangoapps.discussion.notification_prefs.views import UsernameCipher
from openedx.core.djangoapps.ace_common.template_context import get_base_template_context
from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_SHOW_UNSUBSCRIBE_WAFFLE_SWITCH
from openedx.core.djangoapps.schedules.content_highlights import get_week_highlights
from openedx.core.djangoapps.schedules.content_highlights import get_week_highlights, get_next_section_highlights
from openedx.core.djangoapps.schedules.exceptions import CourseUpdateDoesNotExist
from openedx.core.djangoapps.schedules.message_types import CourseUpdate, InstructorLedCourseUpdate
from openedx.core.djangoapps.schedules.models import Schedule, ScheduleExperience
@@ -417,6 +418,93 @@ class CourseUpdateResolver(BinnedSchedulesBaseResolver):
yield (user, schedule.enrollment.course.closest_released_language, template_context, course.self_paced)
@attr.s
class CourseNextSectionUpdate(PrefixedDebugLoggerMixin, RecipientResolver):
"""
Send a message to all users whose schedule gives them a due date of yesterday.
"""
async_send_task = attr.ib()
site = attr.ib()
target_datetime = attr.ib()
course_key = attr.ib()
override_recipient_email = attr.ib(default=None)
log_prefix = 'Next Section Course Update'
experience_filter = Q(experience__experience_type=ScheduleExperience.EXPERIENCES.course_updates)
def send(self):
schedules = self.get_schedules()
LOG.info(
u'Found {} emails to send for course-key: {}'.format(
len(schedules), self.course_key
)
)
for (user, language, context, is_self_paced) in schedules:
msg_type = CourseUpdate() if is_self_paced else InstructorLedCourseUpdate()
msg_type.personalize(
Recipient(
user.username,
self.override_recipient_email or user.email,
),
language,
context,
)
LOG.info(
u'Sending email to user: {} for course-key: {}'.format(
user.username,
self.course_key
)
)
# TODO: Uncomment below when going live
# with function_trace('enqueue_send_task'):
# self.async_send_task.apply_async((self.site.id, str(msg)), retry=False)
def get_schedules(self):
target_date = self.target_datetime.date()
schedules = get_schedules_with_due_date(self.course_key, target_date).filter(
self.experience_filter,
active=True,
enrollment__user__is_active=True,
)
template_context = get_base_template_context(self.site)
for schedule in schedules:
enrollment = schedule.enrollment
course = schedule.enrollment.course
user = enrollment.user
try:
week_highlights, week_num = get_next_section_highlights(user, course.id, target_date)
except CourseUpdateDoesNotExist:
LOG.warning(
u'Weekly highlights for user {} of course {} does not exist or is disabled'.format(
user, course.id
)
)
# continue to the next schedule, don't yield an email for this one
continue
unsubscribe_url = None
if (COURSE_UPDATE_SHOW_UNSUBSCRIBE_WAFFLE_SWITCH.is_enabled() and
'bulk_email_optout' in settings.ACE_ENABLED_POLICIES):
unsubscribe_url = reverse('bulk_email_opt_out', kwargs={
'token': UsernameCipher.encrypt(user.username),
'course_id': str(enrollment.course_id),
})
template_context.update({
'course_name': course.display_name,
'course_url': _get_trackable_course_home_url(enrollment.course_id),
'week_num': week_num,
'week_highlights': week_highlights,
# This is used by the bulk email optout policy
'course_ids': [str(enrollment.course_id)],
'unsubscribe_url': unsubscribe_url,
})
template_context.update(_get_upsell_information_for_schedule(user, schedule))
yield (user, enrollment.course.closest_released_language, template_context, course.self_paced)
def _get_trackable_course_home_url(course_id):
"""
Get the home page URL for the course.

View File

@@ -20,6 +20,7 @@ from edx_django_utils.monitoring import set_custom_metric
from eventtracking import tracker
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.schedules import message_types, resolvers
from openedx.core.djangoapps.schedules.models import Schedule, ScheduleConfig
from openedx.core.lib.celery.task_utils import emulate_http_request
@@ -38,6 +39,7 @@ KNOWN_RETRY_ERRORS = ( # Errors we expect occasionally that could resolve on re
RECURRING_NUDGE_LOG_PREFIX = 'Recurring Nudge'
UPGRADE_REMINDER_LOG_PREFIX = 'Upgrade Reminder'
COURSE_UPDATE_LOG_PREFIX = 'Course Update'
COURSE_NEXT_SECTION_UPDATE_LOG_PREFIX = 'Course Next Section Update'
@task(base=LoggedPersistOnFailureTask, bind=True, default_retry_delay=30)
@@ -59,12 +61,10 @@ def update_course_schedules(self, **kwargs):
class ScheduleMessageBaseTask(LoggedTask):
"""
Base class for top-level Schedule tasks that create subtasks
for each Bin.
Base class for top-level Schedule tasks that create subtasks.
"""
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
@@ -84,6 +84,20 @@ class ScheduleMessageBaseTask(LoggedTask):
"""
LOG.info(cls.log_prefix + ': ' + message, *args, **kwargs)
@classmethod
def is_enqueue_enabled(cls, site):
if cls.enqueue_config_var:
return getattr(ScheduleConfig.current(site), cls.enqueue_config_var)
return False
class BinnedScheduleMessageBaseTask(ScheduleMessageBaseTask):
"""
Base class for top-level Schedule tasks that create subtasks
for each Bin.
"""
num_bins = resolvers.DEFAULT_NUM_BINS
@classmethod
def enqueue(cls, site, current_date, day_offset, override_recipient_email=None):
current_date = resolvers._get_datetime_beginning_of_day(current_date)
@@ -108,12 +122,6 @@ class ScheduleMessageBaseTask(LoggedTask):
retry=False,
)
@classmethod
def is_enqueue_enabled(cls, site):
if cls.enqueue_config_var:
return getattr(ScheduleConfig.current(site), cls.enqueue_config_var)
return False
def run(
self, site_id, target_day_str, day_offset, bin_num, override_recipient_email=None,
):
@@ -164,7 +172,7 @@ def _course_update_schedule_send(site_id, msg_str):
)
class ScheduleRecurringNudge(ScheduleMessageBaseTask):
class ScheduleRecurringNudge(BinnedScheduleMessageBaseTask):
num_bins = resolvers.RECURRING_NUDGE_NUM_BINS
enqueue_config_var = 'enqueue_recurring_nudge'
log_prefix = RECURRING_NUDGE_LOG_PREFIX
@@ -175,7 +183,7 @@ class ScheduleRecurringNudge(ScheduleMessageBaseTask):
return message_types.RecurringNudge(abs(day_offset))
class ScheduleUpgradeReminder(ScheduleMessageBaseTask):
class ScheduleUpgradeReminder(BinnedScheduleMessageBaseTask):
num_bins = resolvers.UPGRADE_REMINDER_NUM_BINS
enqueue_config_var = 'enqueue_upgrade_reminder'
log_prefix = UPGRADE_REMINDER_LOG_PREFIX
@@ -186,7 +194,7 @@ class ScheduleUpgradeReminder(ScheduleMessageBaseTask):
return message_types.UpgradeReminder()
class ScheduleCourseUpdate(ScheduleMessageBaseTask):
class ScheduleCourseUpdate(BinnedScheduleMessageBaseTask):
num_bins = resolvers.COURSE_UPDATE_NUM_BINS
enqueue_config_var = 'enqueue_course_update'
log_prefix = COURSE_UPDATE_LOG_PREFIX
@@ -197,6 +205,47 @@ class ScheduleCourseUpdate(ScheduleMessageBaseTask):
return message_types.CourseUpdate()
class ScheduleCourseNextSectionUpdate(ScheduleMessageBaseTask):
enqueue_config_var = 'enqueue_course_update'
log_prefix = COURSE_NEXT_SECTION_UPDATE_LOG_PREFIX
resolver = resolvers.CourseNextSectionUpdate
async_send_task = _course_update_schedule_send
@classmethod
def enqueue(cls, site, current_date, day_offset, override_recipient_email=None):
target_date = (current_date - datetime.timedelta(days=day_offset)).date()
if not cls.is_enqueue_enabled(site):
cls.log_info(u'Message queuing disabled for site %s', site.domain)
return
cls.log_info(u'Target date = %s', target_date.isoformat())
for course_key in CourseOverview.get_all_course_keys():
task_args = (
site.id,
serialize(target_date),
course_key,
override_recipient_email,
)
cls.log_info(u'Launching task with args = %r', task_args)
cls().apply_async(
task_args,
retry=False,
)
def run(self, site_id, target_day_str, course_key, override_recipient_email=None):
site = Site.objects.select_related('configuration').get(id=site_id)
with emulate_http_request(site=site):
_annotate_for_monitoring(message_types.CourseUpdate(), site, 0, target_day_str, -1)
return self.resolver(
self.async_send_task,
site,
deserialize(target_day_str),
course_key,
override_recipient_email,
).send()
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):
@@ -253,18 +302,24 @@ def _is_delivery_enabled(site, delivery_config_var, log_prefix):
LOG.info(u'%s: Message delivery disabled for site %s', log_prefix, site.domain)
def _annotate_for_monitoring(message_type, site, bin_num, target_day_str, day_offset):
def _annotate_for_monitoring(message_type, site, bin_num=None, target_day_str=None, day_offset=None, course_key=None):
# 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('site', site.domain)
# This is the "bin" of data being processed. We divide up the work into chunks so that we don't tie up celery
# workers for too long. This could help us identify particular bins that are problematic.
set_custom_metric('bin', bin_num)
if bin_num:
set_custom_metric('bin', bin_num)
# The date we are processing data for.
set_custom_metric('target_day', target_day_str)
if target_day_str:
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)
if day_offset:
set_custom_metric('day_offset', day_offset)
# If we're processing these according to a course_key rather than bin we can use this to identify problematic keys.
if course_key:
set_custom_metric('course_key', course_key)
# A unique identifier for this batch of messages being sent.
set_custom_metric('send_uuid', message_type.uuid)

View File

@@ -1,8 +1,11 @@
# -*- coding: utf-8 -*-
import datetime
from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_WAFFLE_FLAG
from openedx.core.djangoapps.schedules.content_highlights import course_has_highlights, get_week_highlights
from openedx.core.djangoapps.schedules.content_highlights import (
course_has_highlights, get_week_highlights, get_next_section_highlights,
)
from openedx.core.djangoapps.schedules.exceptions import CourseUpdateDoesNotExist
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.core.djangolib.testing.utils import skip_unless_lms
@@ -130,3 +133,25 @@ class TestContentHighlights(ModuleStoreTestCase):
self.assertTrue(course_has_highlights(self.course_key))
with self.assertRaises(CourseUpdateDoesNotExist):
get_week_highlights(self.user, self.course_key, week_num=1)
@override_waffle_flag(COURSE_UPDATE_WAFFLE_FLAG, True)
def test_get_next_section_highlights(self):
yesterday = datetime.datetime.utcnow() - datetime.timedelta(days=1)
today = datetime.datetime.utcnow()
tomorrow = datetime.datetime.utcnow() + datetime.timedelta(days=1)
with self.store.bulk_operations(self.course_key):
self._create_chapter( # Week 1
highlights=[u'a', u'b', u'á'],
due=yesterday,
)
self._create_chapter( # Week 2
highlights=[u'skipped a week'],
due=today,
)
self.assertEqual(
get_next_section_highlights(self.user, self.course_key, yesterday.date()),
([u'skipped a week'], 2),
)
with self.assertRaises(CourseUpdateDoesNotExist):
get_next_section_highlights(self.user, self.course_key, tomorrow.date())

View File

@@ -14,9 +14,11 @@ from mock import Mock, patch
from waffle.testutils import override_switch
from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_WAFFLE_FLAG
from openedx.core.djangoapps.schedules.models import Schedule
from openedx.core.djangoapps.schedules.resolvers import (
BinnedSchedulesBaseResolver,
CourseUpdateResolver,
CourseNextSectionUpdate,
)
from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory
from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory, SiteFactory
@@ -167,3 +169,75 @@ class TestCourseUpdateResolver(SchedulesResolverTestMixin, ModuleStoreTestCase):
self.user.save()
schedules = resolver.get_schedules_with_target_date_by_bin_and_orgs()
self.assertEqual(schedules.count(), 0)
@skip_unless_lms
@skipUnless('openedx.core.djangoapps.schedules.apps.SchedulesConfig' in settings.INSTALLED_APPS,
"Can't test schedules if the app isn't installed")
class TestCourseNextSectionUpdateResolver(SchedulesResolverTestMixin, ModuleStoreTestCase):
"""
Tests the TestCourseNextSectionUpdateResolver.
"""
def setUp(self):
super(TestCourseNextSectionUpdateResolver, self).setUp()
self.course = CourseFactory(highlights_enabled_for_messaging=True, self_paced=True)
self.yesterday = datetime.datetime.utcnow() - datetime.timedelta(days=1)
self.today = datetime.datetime.utcnow()
self.tomorrow = datetime.datetime.utcnow() + datetime.timedelta(days=1)
with self.store.bulk_operations(self.course.id):
ItemFactory.create(parent=self.course, category='chapter', highlights=[u'good stuff 1'], due=self.yesterday)
ItemFactory.create(parent=self.course, category='chapter', highlights=[u'good stuff 2'], due=self.today)
ItemFactory.create(parent=self.course, category='chapter', highlights=[u'good stuff 3'], due=self.tomorrow)
def create_resolver(self):
"""
Creates a CourseNextSectionUpdateResolver with an enrollment to schedule.
"""
with patch('openedx.core.djangoapps.schedules.signals.get_current_site') as mock_get_current_site:
mock_get_current_site.return_value = self.site_config.site
CourseEnrollmentFactory(course_id=self.course.id, user=self.user, mode=u'audit')
return CourseNextSectionUpdate(
async_send_task=Mock(name='async_send_task'),
site=self.site_config.site,
target_datetime=self.yesterday,
course_key=self.course.id,
)
@override_settings(CONTACT_MAILING_ADDRESS='123 Sesame Street')
@override_waffle_flag(COURSE_UPDATE_WAFFLE_FLAG, True)
def test_schedule_context(self):
resolver = self.create_resolver()
# Mock the call to edx-when to just return all schedules
with patch('openedx.core.djangoapps.schedules.resolvers.get_schedules_with_due_date') as mock_get_schedules:
mock_get_schedules.return_value = Schedule.objects.all()
schedules = list(resolver.get_schedules())
expected_context = {
'contact_email': 'info@example.com',
'contact_mailing_address': '123 Sesame Street',
'course_ids': [str(self.course.id)],
'course_name': self.course.display_name,
'course_url': '/courses/{}/course/'.format(self.course.id),
'dashboard_url': '/dashboard',
'homepage_url': '/',
'mobile_store_urls': {},
'platform_name': u'\xe9dX',
'show_upsell': False,
'social_media_urls': {},
'template_revision': 'release',
'unsubscribe_url': None,
'week_highlights': ['good stuff 2'],
'week_num': 2,
}
self.assertEqual(schedules, [(self.user, None, expected_context, True)])
@override_waffle_flag(COURSE_UPDATE_WAFFLE_FLAG, True)
@override_switch('schedules.course_update_show_unsubscribe', True)
def test_schedule_context_show_unsubscribe(self):
resolver = self.create_resolver()
# Mock the call to edx-when to just return all schedules
with patch('openedx.core.djangoapps.schedules.resolvers.get_schedules_with_due_date') as mock_get_schedules:
mock_get_schedules.return_value = Schedule.objects.all()
schedules = list(resolver.get_schedules())
self.assertIn('optout', schedules[0][2]['unsubscribe_url'])

View File

@@ -11,7 +11,7 @@ from django.conf import settings
from mock import DEFAULT, Mock, patch
from openedx.core.djangoapps.schedules.resolvers import DEFAULT_NUM_BINS
from openedx.core.djangoapps.schedules.tasks import ScheduleMessageBaseTask
from openedx.core.djangoapps.schedules.tasks import BinnedScheduleMessageBaseTask
from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
@@ -21,13 +21,13 @@ from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_un
@skip_unless_lms
@skipUnless('openedx.core.djangoapps.schedules.apps.SchedulesConfig' in settings.INSTALLED_APPS,
"Can't test schedules if the app isn't installed")
class TestScheduleMessageBaseTask(CacheIsolationTestCase):
class TestBinnedScheduleMessageBaseTask(CacheIsolationTestCase):
def setUp(self):
super(TestScheduleMessageBaseTask, self).setUp()
super(TestBinnedScheduleMessageBaseTask, self).setUp()
self.site = SiteFactory.create()
self.schedule_config = ScheduleConfigFactory.create(site=self.site)
self.basetask = ScheduleMessageBaseTask
self.basetask = BinnedScheduleMessageBaseTask
def test_send_enqueue_disabled(self):
send = Mock(name='async_send_task')