diff --git a/openedx/core/djangoapps/schedules/content_highlights.py b/openedx/core/djangoapps/schedules/content_highlights.py index 424af077c5..dd005382dd 100644 --- a/openedx/core/djangoapps/schedules/content_highlights.py +++ b/openedx/core/djangoapps/schedules/content_highlights.py @@ -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 + ) + ) diff --git a/openedx/core/djangoapps/schedules/management/commands/send_course_next_section_update.py b/openedx/core/djangoapps/schedules/management/commands/send_course_next_section_update.py new file mode 100644 index 0000000000..8805ebbcaf --- /dev/null +++ b/openedx/core/djangoapps/schedules/management/commands/send_course_next_section_update.py @@ -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) diff --git a/openedx/core/djangoapps/schedules/resolvers.py b/openedx/core/djangoapps/schedules/resolvers.py index 6437e28238..1b9fe79fed 100644 --- a/openedx/core/djangoapps/schedules/resolvers.py +++ b/openedx/core/djangoapps/schedules/resolvers.py @@ -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. diff --git a/openedx/core/djangoapps/schedules/tasks.py b/openedx/core/djangoapps/schedules/tasks.py index e3ff5631f0..0c636b75b5 100644 --- a/openedx/core/djangoapps/schedules/tasks.py +++ b/openedx/core/djangoapps/schedules/tasks.py @@ -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) diff --git a/openedx/core/djangoapps/schedules/tests/test_content_highlights.py b/openedx/core/djangoapps/schedules/tests/test_content_highlights.py index 7cc9bc119f..5519125408 100644 --- a/openedx/core/djangoapps/schedules/tests/test_content_highlights.py +++ b/openedx/core/djangoapps/schedules/tests/test_content_highlights.py @@ -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()) diff --git a/openedx/core/djangoapps/schedules/tests/test_resolvers.py b/openedx/core/djangoapps/schedules/tests/test_resolvers.py index aa60ec7cab..b38982a492 100644 --- a/openedx/core/djangoapps/schedules/tests/test_resolvers.py +++ b/openedx/core/djangoapps/schedules/tests/test_resolvers.py @@ -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']) diff --git a/openedx/core/djangoapps/schedules/tests/test_tasks.py b/openedx/core/djangoapps/schedules/tests/test_tasks.py index 38a9b8e655..a4e9c6e7b2 100644 --- a/openedx/core/djangoapps/schedules/tests/test_tasks.py +++ b/openedx/core/djangoapps/schedules/tests/test_tasks.py @@ -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')