diff --git a/openedx/core/djangoapps/schedules/management/commands/__init__.py b/openedx/core/djangoapps/schedules/management/commands/__init__.py index 8c71e049d6..ed7b737386 100644 --- a/openedx/core/djangoapps/schedules/management/commands/__init__.py +++ b/openedx/core/djangoapps/schedules/management/commands/__init__.py @@ -9,6 +9,7 @@ from openedx.core.djangoapps.schedules.utils import PrefixedDebugLoggerMixin class SendEmailBaseCommand(PrefixedDebugLoggerMixin, BaseCommand): async_send_task = None # define in subclass + offsets = () # define in subclass def add_arguments(self, parser): parser.add_argument( @@ -37,9 +38,6 @@ class SendEmailBaseCommand(PrefixedDebugLoggerMixin, BaseCommand): override_recipient_email = options.get('override_recipient_email') self.send_emails(site, current_date, override_recipient_email) - def send_emails(self, *args, **kwargs): - raise NotImplementedError - def enqueue(self, day_offset, site, current_date, override_recipient_email=None): self.async_send_task.enqueue( site, @@ -47,3 +45,7 @@ class SendEmailBaseCommand(PrefixedDebugLoggerMixin, BaseCommand): day_offset, override_recipient_email, ) + + def send_emails(self, *args, **kwargs): + for offset in self.offsets: + self.enqueue(offset, *args, **kwargs) diff --git a/openedx/core/djangoapps/schedules/management/commands/send_course_update.py b/openedx/core/djangoapps/schedules/management/commands/send_course_update.py index 2bfa393e29..87da49ba36 100644 --- a/openedx/core/djangoapps/schedules/management/commands/send_course_update.py +++ b/openedx/core/djangoapps/schedules/management/commands/send_course_update.py @@ -4,11 +4,5 @@ from openedx.core.djangoapps.schedules.tasks import ScheduleCourseUpdate class Command(SendEmailBaseCommand): async_send_task = ScheduleCourseUpdate - - def __init__(self, *args, **kwargs): - super(Command, self).__init__(*args, **kwargs) - self.log_prefix = 'Upgrade Reminder' - - def send_emails(self, *args, **kwargs): - for day_offset in xrange(-7, -77, -7): - self.enqueue(day_offset, *args, **kwargs) + log_prefix = 'Course Update' + offsets = xrange(-7, -77, -7) diff --git a/openedx/core/djangoapps/schedules/management/commands/send_recurring_nudge.py b/openedx/core/djangoapps/schedules/management/commands/send_recurring_nudge.py index b5732e20b9..c1c9cf521d 100644 --- a/openedx/core/djangoapps/schedules/management/commands/send_recurring_nudge.py +++ b/openedx/core/djangoapps/schedules/management/commands/send_recurring_nudge.py @@ -4,11 +4,5 @@ from openedx.core.djangoapps.schedules.tasks import ScheduleRecurringNudge class Command(SendEmailBaseCommand): async_send_task = ScheduleRecurringNudge - - def __init__(self, *args, **kwargs): - super(Command, self).__init__(*args, **kwargs) - self.log_prefix = 'Scheduled Nudge' - - def send_emails(self, *args, **kwargs): - for day_offset in (-3, -10): - self.enqueue(day_offset, *args, **kwargs) + log_prefix = 'Scheduled Nudge' + offsets = (-3, -10) diff --git a/openedx/core/djangoapps/schedules/management/commands/send_upgrade_reminder.py b/openedx/core/djangoapps/schedules/management/commands/send_upgrade_reminder.py index 58a6b46a96..d8d0a6156d 100644 --- a/openedx/core/djangoapps/schedules/management/commands/send_upgrade_reminder.py +++ b/openedx/core/djangoapps/schedules/management/commands/send_upgrade_reminder.py @@ -4,10 +4,5 @@ from openedx.core.djangoapps.schedules.tasks import ScheduleUpgradeReminder class Command(SendEmailBaseCommand): async_send_task = ScheduleUpgradeReminder - - def __init__(self, *args, **kwargs): - super(Command, self).__init__(*args, **kwargs) - self.log_prefix = 'Upgrade Reminder' - - def send_emails(self, *args, **kwargs): - self.enqueue(2, *args, **kwargs) + log_prefix = 'Upgrade Reminder' + offsets = (2,) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py b/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py new file mode 100644 index 0000000000..718a80b8e3 --- /dev/null +++ b/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py @@ -0,0 +1,377 @@ +from copy import deepcopy +import datetime +import ddt +import logging + +import attr +from django.conf import settings +from freezegun import freeze_time +from mock import Mock, patch +import pytz + +from courseware.models import DynamicUpgradeDeadlineConfiguration +from edx_ace.channel import ChannelType +from edx_ace.utils.date import serialize +from edx_ace.test_utils import StubPolicy, patch_channels, patch_policies +from opaque_keys.edx.keys import CourseKey +from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory, SiteFactory +from openedx.core.djangoapps.schedules import resolvers, tasks +from openedx.core.djangoapps.schedules.resolvers import _get_datetime_beginning_of_day +from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory, ScheduleFactory +from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase + + +SITE_QUERY = 1 +ORG_DEADLINE_QUERY = 1 +SCHEDULES_QUERY = 1 +COURSE_MODES_QUERY = 1 +GLOBAL_DEADLINE_SWITCH_QUERY = 1 +COMMERCE_CONFIG_QUERY = 1 +NUM_QUERIES_NO_ORG_LIST = 1 + +NUM_QUERIES_NO_MATCHING_SCHEDULES = SITE_QUERY + SCHEDULES_QUERY + +NUM_QUERIES_WITH_MATCHES = ( + NUM_QUERIES_NO_MATCHING_SCHEDULES + + COURSE_MODES_QUERY +) + +NUM_QUERIES_FIRST_MATCH = ( + NUM_QUERIES_WITH_MATCHES + + GLOBAL_DEADLINE_SWITCH_QUERY + + ORG_DEADLINE_QUERY + + COMMERCE_CONFIG_QUERY +) + +LOG = logging.getLogger(__name__) + + +@ddt.ddt +@freeze_time('2017-08-01 00:00:00', tz_offset=0, tick=True) +class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): + + __test__ = False + + ENABLED_CACHES = ['default'] + + has_course_queries = False + + def setUp(self): + super(ScheduleSendEmailTestBase, self).setUp() + + site = SiteFactory.create() + self.site_config = SiteConfigurationFactory.create(site=site) + ScheduleConfigFactory.create(site=self.site_config.site) + + DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True) + + def _calculate_bin_for_user(self, user): + return user.id % self.tested_task.num_bins + + def _get_dates(self, offset=None): + current_day = _get_datetime_beginning_of_day(datetime.datetime.now(pytz.UTC)) + offset = offset or self.expected_offsets[0] + target_day = current_day + datetime.timedelta(days=offset) + return current_day, offset, target_day + + def _get_template_overrides(self): + templates_override = deepcopy(settings.TEMPLATES) + templates_override[0]['OPTIONS']['string_if_invalid'] = "TEMPLATE WARNING - MISSING VARIABLE [%s]" + return templates_override + + def test_command_task_binding(self): + self.assertEqual(self.tested_command.async_send_task, self.tested_task) + + def test_handle(self): + with patch.object(self.tested_command, 'async_send_task') as mock_send: + test_day = datetime.datetime(2017, 8, 1, tzinfo=pytz.UTC) + self.tested_command().handle(date='2017-08-01', site_domain_name=self.site_config.site.domain) + + for offset in self.expected_offsets: + mock_send.enqueue.assert_any_call( + self.site_config.site, + test_day, + offset, + None + ) + + @patch.object(tasks, 'ace') + def test_resolver_send(self, mock_ace): + current_day, offset, target_day = self._get_dates() + with patch.object(self.tested_task, 'apply_async') as mock_apply_async: + self.tested_task.enqueue(self.site_config.site, current_day, offset) + mock_apply_async.assert_any_call( + (self.site_config.site.id, serialize(target_day), offset, 0, None), + retry=False, + ) + mock_apply_async.assert_any_call( + (self.site_config.site.id, serialize(target_day), offset, self.tested_task.num_bins - 1, None), + retry=False, + ) + self.assertFalse(mock_ace.send.called) + + @ddt.data(1, 10, 100) + @patch.object(tasks, 'ace') + @patch.object(resolvers, 'set_custom_metric') + def test_schedule_bin(self, schedule_count, mock_metric, mock_ace): + with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: + current_day, offset, target_day = self._get_dates() + schedules = [ + ScheduleFactory.create( + start=target_day, + upgrade_deadline=target_day, + enrollment__course__self_paced=True, + ) for _ in range(schedule_count) + ] + + bins_in_use = frozenset((self._calculate_bin_for_user(s.enrollment.user)) for s in schedules) + is_first_match = True + course_queries = len(set(s.enrollment.course.id for s in schedules)) if self.has_course_queries else 0 + target_day_str = serialize(target_day) + + for b in range(self.tested_task.num_bins): + LOG.debug('Running bin %d', b) + expected_queries = NUM_QUERIES_NO_MATCHING_SCHEDULES + if b in bins_in_use: + if is_first_match: + expected_queries = ( + # Since this is the first match, we need to cache all of the config models, so we run a + # query for each of those... + NUM_QUERIES_FIRST_MATCH + course_queries + ) + is_first_match = False + else: + expected_queries = NUM_QUERIES_WITH_MATCHES + + expected_queries += NUM_QUERIES_NO_ORG_LIST + + with self.assertNumQueries(expected_queries, table_blacklist=WAFFLE_TABLES): + self.tested_task.apply(kwargs=dict( + site_id=self.site_config.site.id, target_day_str=target_day_str, day_offset=offset, bin_num=b, + )) + + num_schedules = mock_metric.call_args[0][1] + if b in bins_in_use: + self.assertGreater(num_schedules, 0) + else: + self.assertEqual(num_schedules, 0) + + self.assertEqual(mock_schedule_send.apply_async.call_count, schedule_count) + self.assertFalse(mock_ace.send.called) + + def test_no_course_overview(self): + current_day, offset, target_day = self._get_dates() + schedule = ScheduleFactory.create( + start=target_day, + upgrade_deadline=target_day, + enrollment__course__self_paced=True, + ) + schedule.enrollment.course_id = CourseKey.from_string('edX/toy/Not_2012_Fall') + schedule.enrollment.save() + + with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: + for b in range(self.tested_task.num_bins): + self.tested_task.apply(kwargs=dict( + site_id=self.site_config.site.id, + target_day_str=serialize(target_day), + day_offset=offset, + bin_num=b, + )) + + # There is no database constraint that enforces that enrollment.course_id points + # to a valid CourseOverview object. However, in that case, schedules isn't going + # to attempt to address it, and will instead simply skip those users. + # This happens 'transparently' because django generates an inner-join between + # enrollment and course_overview, and thus will skip any rows where course_overview + # is null. + self.assertEqual(mock_schedule_send.apply_async.call_count, 0) + + @ddt.data(True, False) + @patch.object(tasks, 'ace') + @patch.object(tasks, 'Message') + def test_deliver_config(self, is_enabled, mock_message, mock_ace): + schedule_config_kwargs = { + 'site': self.site_config.site, + self.deliver_config: is_enabled, + } + ScheduleConfigFactory.create(**schedule_config_kwargs) + + mock_msg = Mock() + self.deliver_task(self.site_config.site.id, mock_msg) + if is_enabled: + self.assertTrue(mock_ace.send.called) + else: + self.assertFalse(mock_ace.send.called) + + @ddt.data(True, False) + def test_enqueue_config(self, is_enabled): + schedule_config_kwargs = { + 'site': self.site_config.site, + self.enqueue_config: is_enabled, + } + ScheduleConfigFactory.create(**schedule_config_kwargs) + + current_datetime = datetime.datetime(2017, 8, 1, tzinfo=pytz.UTC) + with patch.object(self.tested_task, 'apply_async') as mock_apply_async: + self.tested_task.enqueue(self.site_config.site, current_datetime, 3) + + if is_enabled: + self.assertTrue(mock_apply_async.called) + else: + self.assertFalse(mock_apply_async.called) + + @patch.object(tasks, 'ace') + @ddt.data( + ((['filtered_org'], [], 1)), + (([], ['filtered_org'], 2)) + ) + @ddt.unpack + def test_site_config(self, this_org_list, other_org_list, expected_message_count, mock_ace): + filtered_org = 'filtered_org' + unfiltered_org = 'unfiltered_org' + this_config = SiteConfigurationFactory.create(values={'course_org_filter': this_org_list}) + other_config = SiteConfigurationFactory.create(values={'course_org_filter': other_org_list}) + + for config in (this_config, other_config): + ScheduleConfigFactory.create(site=config.site) + + user1 = UserFactory.create(id=self.tested_task.num_bins) + user2 = UserFactory.create(id=self.tested_task.num_bins * 2) + current_day, offset, target_day = self._get_dates() + + ScheduleFactory.create( + upgrade_deadline=target_day, + start=target_day, + enrollment__course__org=filtered_org, + enrollment__course__self_paced=True, + enrollment__user=user1, + ) + ScheduleFactory.create( + upgrade_deadline=target_day, + start=target_day, + enrollment__course__org=unfiltered_org, + enrollment__course__self_paced=True, + enrollment__user=user1, + ) + ScheduleFactory.create( + upgrade_deadline=target_day, + start=target_day, + enrollment__course__org=unfiltered_org, + enrollment__course__self_paced=True, + enrollment__user=user2, + ) + + with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: + self.tested_task.apply(kwargs=dict( + site_id=this_config.site.id, target_day_str=serialize(target_day), day_offset=offset, bin_num=0 + )) + + self.assertEqual(mock_schedule_send.apply_async.call_count, expected_message_count) + self.assertFalse(mock_ace.send.called) + + @ddt.data(True, False) + def test_course_end(self, has_course_ended): + user1 = UserFactory.create(id=self.tested_task.num_bins) + current_day, offset, target_day = self._get_dates() + + schedule = ScheduleFactory.create( + start=target_day, + upgrade_deadline=target_day, + enrollment__course__self_paced=True, + enrollment__user=user1, + ) + + schedule.enrollment.course.start = current_day - datetime.timedelta(days=30) + end_date_offset = -2 if has_course_ended else 2 + schedule.enrollment.course.end = current_day + datetime.timedelta(days=end_date_offset) + schedule.enrollment.course.save() + + with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: + self.tested_task.apply(kwargs=dict( + site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, bin_num=0, + )) + + if has_course_ended: + self.assertFalse(mock_schedule_send.apply_async.called) + else: + self.assertTrue(mock_schedule_send.apply_async.called) + + @patch.object(tasks, 'ace') + def test_multiple_enrollments(self, mock_ace): + user = UserFactory.create() + current_day, offset, target_day = self._get_dates() + num_courses = 3 + for course_index in range(num_courses): + ScheduleFactory.create( + start=target_day, + upgrade_deadline=target_day, + enrollment__course__self_paced=True, + enrollment__user=user, + enrollment__course__id=CourseKey.from_string('edX/toy/course{}'.format(course_index)) + ) + + course_queries = num_courses if self.has_course_queries else 0 + expected_query_count = NUM_QUERIES_FIRST_MATCH + course_queries + NUM_QUERIES_NO_ORG_LIST + with self.assertNumQueries(expected_query_count, table_blacklist=WAFFLE_TABLES): + with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: + self.tested_task.apply(kwargs=dict( + site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, + bin_num=self._calculate_bin_for_user(user), + )) + self.assertEqual(mock_schedule_send.apply_async.call_count, 1) + self.assertFalse(mock_ace.send.called) + + @ddt.data(1, 10, 100) + def test_templates(self, message_count): + for offset in self.expected_offsets: + self._assert_template_for_offset(offset, message_count) + self.clear_caches() + + def _assert_template_for_offset(self, offset, message_count): + current_day, offset, target_day = self._get_dates(offset) + + user = UserFactory.create() + for course_index in range(message_count): + ScheduleFactory.create( + start=target_day, + upgrade_deadline=target_day, + enrollment__course__self_paced=True, + enrollment__user=user, + enrollment__course__id=CourseKey.from_string('edX/toy/course{}'.format(course_index)) + ) + + patch_policies(self, [StubPolicy([ChannelType.PUSH])]) + mock_channel = Mock( + name='test_channel', + channel_type=ChannelType.EMAIL + ) + patch_channels(self, [mock_channel]) + + sent_messages = [] + with self.settings(TEMPLATES=self._get_template_overrides()): + with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: + mock_schedule_send.apply_async = lambda args, *_a, **_kw: sent_messages.append(args) + + num_expected_queries = NUM_QUERIES_NO_ORG_LIST + NUM_QUERIES_FIRST_MATCH + if self.has_course_queries: + num_expected_queries += message_count + + with self.assertNumQueries(num_expected_queries, table_blacklist=WAFFLE_TABLES): + self.tested_task.apply(kwargs=dict( + site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, + bin_num=self._calculate_bin_for_user(user), + )) + self.assertEqual(len(sent_messages), 1) + + with self.assertNumQueries(2): + for args in sent_messages: + self.deliver_task(*args) + + self.assertEqual(mock_channel.deliver.call_count, 1) + for (_name, (_msg, email), _kwargs) in mock_channel.deliver.mock_calls: + for template in attr.astuple(email): + self.assertNotIn("TEMPLATE WARNING", template) + self.assertNotIn("{{", template) + self.assertNotIn("}}", template) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_base.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_email_base_command.py similarity index 68% rename from openedx/core/djangoapps/schedules/management/commands/tests/test_base.py rename to openedx/core/djangoapps/schedules/management/commands/tests/test_send_email_base_command.py index 3a53dbbd8a..ff60ed1fd5 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/test_base.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_email_base_command.py @@ -4,7 +4,7 @@ from unittest import skipUnless import ddt import pytz from django.conf import settings -from mock import patch +from mock import patch, DEFAULT, Mock from openedx.core.djangoapps.schedules.management.commands import SendEmailBaseCommand from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory, SiteConfigurationFactory @@ -29,3 +29,18 @@ class TestSendEmailBaseCommand(CacheIsolationTestCase): datetime.datetime(2017, 9, 29, tzinfo=pytz.UTC), None ) + + def test_send_emails(self): + with patch.multiple( + self.command, + offsets=(1, 3, 5), + enqueue=DEFAULT, + ): + arg = Mock(name='arg') + kwarg = Mock(name='kwarg') + self.command.send_emails(arg, kwarg=kwarg) + self.assertFalse(arg.called) + self.assertFalse(kwarg.called) + + for offset in self.command.offsets: + self.command.enqueue.assert_any_call(offset, arg, kwarg=kwarg) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py index aadaf75916..b3bfa73175 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py @@ -1,319 +1,39 @@ import datetime -import itertools -from copy import deepcopy from unittest import skipUnless -import attr import ddt -import pytz from django.conf import settings -from edx_ace.channel import ChannelType -from edx_ace.test_utils import StubPolicy, patch_channels, patch_policies from edx_ace.utils.date import serialize from edx_ace.message import Message -from mock import Mock, patch -from opaque_keys.edx.keys import CourseKey +from mock import patch from opaque_keys.edx.locator import CourseLocator +import pytz from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory from courseware.models import DynamicUpgradeDeadlineConfiguration -from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory -from openedx.core.djangoapps.schedules import resolvers, tasks +from openedx.core.djangoapps.schedules import tasks from openedx.core.djangoapps.schedules.management.commands import send_recurring_nudge as nudge -from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory, ScheduleFactory -from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory, SiteFactory -from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES -from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms, FilteredQueryCountMixin +from openedx.core.djangoapps.schedules.management.commands.tests.send_email_base import ScheduleSendEmailTestBase +from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms from student.tests.factories import UserFactory -# 1) Load the current django site -# 2) Query the schedules to find all of the template context information -NUM_QUERIES_NO_MATCHING_SCHEDULES = 2 - -# 3) Query all course modes for all courses in returned schedules -NUM_QUERIES_WITH_MATCHES = NUM_QUERIES_NO_MATCHING_SCHEDULES + 1 - -# 4) Load the non-matching site configurations -NUM_QUERIES_NO_ORG_LIST = 1 - -NUM_COURSE_MODES_QUERIES = 1 - - @ddt.ddt @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 TestSendRecurringNudge(FilteredQueryCountMixin, CacheIsolationTestCase): +class TestSendRecurringNudge(ScheduleSendEmailTestBase): + __test__ = True + # pylint: disable=protected-access - - ENABLED_CACHES = ['default'] - - def setUp(self): - super(TestSendRecurringNudge, self).setUp() - - ScheduleFactory.create(start=datetime.datetime(2017, 8, 1, 15, 44, 30, tzinfo=pytz.UTC)) - ScheduleFactory.create(start=datetime.datetime(2017, 8, 1, 17, 34, 30, tzinfo=pytz.UTC)) - ScheduleFactory.create(start=datetime.datetime(2017, 8, 2, 15, 34, 30, tzinfo=pytz.UTC)) - - site = SiteFactory.create() - self.site_config = SiteConfigurationFactory.create(site=site) - ScheduleConfigFactory.create(site=self.site_config.site) - - DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True) - - @patch.object(nudge.Command, 'async_send_task') - def test_handle(self, mock_send): - test_day = datetime.datetime(2017, 8, 1, tzinfo=pytz.UTC) - nudge.Command().handle(date='2017-08-01', site_domain_name=self.site_config.site.domain) - for day in (-3, -10): - mock_send.enqueue.assert_any_call( - self.site_config.site, - test_day, - day, - None - ) - - @patch.object(tasks, 'ace') - def test_resolver_send(self, mock_ace): - current_day = datetime.datetime(2017, 8, 1, tzinfo=pytz.UTC) - with patch.object(tasks.ScheduleRecurringNudge, 'apply_async') as mock_apply_async: - tasks.ScheduleRecurringNudge.enqueue(self.site_config.site, current_day, -3) - test_day = current_day + datetime.timedelta(days=-3) - mock_apply_async.assert_any_call( - (self.site_config.site.id, serialize(test_day), -3, 0, None), - retry=False, - ) - mock_apply_async.assert_any_call( - (self.site_config.site.id, serialize(test_day), -3, resolvers.RECURRING_NUDGE_NUM_BINS - 1, None), - retry=False, - ) - self.assertFalse(mock_ace.send.called) - - @ddt.data(1, 10, 100) - @patch.object(tasks, 'ace') - @patch.object(tasks.ScheduleRecurringNudge, 'async_send_task') - def test_schedule_bin(self, schedule_count, mock_schedule_send, mock_ace): - schedules = [ - ScheduleFactory.create( - start=datetime.datetime(2017, 8, 3, 18, 44, 30, tzinfo=pytz.UTC), - enrollment__course__id=CourseLocator('edX', 'toy', 'Bin') - ) for i in range(schedule_count) - ] - - bins_in_use = frozenset((s.enrollment.user.id % resolvers.RECURRING_NUDGE_NUM_BINS) for s in schedules) - - test_datetime = datetime.datetime(2017, 8, 3, 18, tzinfo=pytz.UTC) - test_datetime_str = serialize(test_datetime) - for b in range(resolvers.RECURRING_NUDGE_NUM_BINS): - expected_queries = NUM_QUERIES_NO_MATCHING_SCHEDULES + NUM_QUERIES_NO_ORG_LIST - if b in bins_in_use: - # to fetch course modes for valid schedules - expected_queries += NUM_COURSE_MODES_QUERIES - - with self.assertNumQueries(expected_queries, table_blacklist=WAFFLE_TABLES): - - tasks.ScheduleRecurringNudge.apply(kwargs=dict( - site_id=self.site_config.site.id, target_day_str=test_datetime_str, day_offset=-3, bin_num=b, - )) - self.assertEqual(mock_schedule_send.apply_async.call_count, schedule_count) - self.assertFalse(mock_ace.send.called) - - @patch.object(tasks.ScheduleRecurringNudge, 'async_send_task') - def test_no_course_overview(self, mock_schedule_send): - schedule = ScheduleFactory.create( - start=datetime.datetime(2017, 8, 3, 20, 34, 30, tzinfo=pytz.UTC), - enrollment__user=UserFactory.create(), - ) - schedule.enrollment.course_id = CourseKey.from_string('edX/toy/Not_2012_Fall') - schedule.enrollment.save() - - test_datetime = datetime.datetime(2017, 8, 3, 20, tzinfo=pytz.UTC) - test_datetime_str = serialize(test_datetime) - for b in range(resolvers.RECURRING_NUDGE_NUM_BINS): - with self.assertNumQueries(NUM_QUERIES_NO_MATCHING_SCHEDULES + NUM_QUERIES_NO_ORG_LIST, table_blacklist=WAFFLE_TABLES): - tasks.ScheduleRecurringNudge.apply(kwargs=dict( - site_id=self.site_config.site.id, target_day_str=test_datetime_str, day_offset=-3, bin_num=b - )) - - # There is no database constraint that enforces that enrollment.course_id points - # to a valid CourseOverview object. However, in that case, schedules isn't going - # to attempt to address it, and will instead simply skip those users. - # This happens 'transparently' because django generates an inner-join between - # enrollment and course_overview, and thus will skip any rows where course_overview - # is null. - self.assertEqual(mock_schedule_send.apply_async.call_count, 0) - - @patch.object(tasks.ScheduleRecurringNudge, 'async_send_task') - def test_send_after_course_end(self, mock_schedule_send): - user1 = UserFactory.create(id=resolvers.RECURRING_NUDGE_NUM_BINS) - - schedule_start = datetime.datetime(2017, 8, 3, 20, 34, 30, tzinfo=pytz.UTC) - day_command_is_run = schedule_start + datetime.timedelta(days=3) - schedule = ScheduleFactory.create( - start=schedule_start, - enrollment__user=user1, - ) - - schedule.enrollment.course.start = schedule_start - datetime.timedelta(days=30) - schedule.enrollment.course.end = day_command_is_run - datetime.timedelta(days=1) - schedule.enrollment.course.save() - - test_datetime = datetime.datetime(2017, 8, 3, 20, tzinfo=pytz.UTC) - test_datetime_str = serialize(test_datetime) - - tasks.ScheduleRecurringNudge.apply(kwargs=dict( - site_id=self.site_config.site.id, target_day_str=test_datetime_str, day_offset=-3, bin_num=0, - )) - - self.assertFalse(mock_schedule_send.apply_async.called) - - @patch.object(tasks, 'ace') - def test_delivery_disabled(self, mock_ace): - ScheduleConfigFactory.create(site=self.site_config.site, deliver_recurring_nudge=False) - - mock_msg = Mock() - tasks._recurring_nudge_schedule_send(self.site_config.site.id, mock_msg) - self.assertFalse(mock_ace.send.called) - - @patch.object(tasks, 'ace') - @patch.object(tasks.ScheduleUpgradeReminder, 'apply_async') - def test_enqueue_disabled(self, mock_ace, mock_apply_async): - ScheduleConfigFactory.create(site=self.site_config.site, enqueue_recurring_nudge=False) - - current_datetime = datetime.datetime(2017, 8, 1, tzinfo=pytz.UTC) - tasks.ScheduleRecurringNudge.enqueue( - self.site_config.site, - current_datetime, - 3 - ) - self.assertFalse(mock_apply_async.called) - self.assertFalse(mock_ace.send.called) - - @patch.object(tasks, 'ace') - @patch.object(tasks.ScheduleRecurringNudge, 'async_send_task') - @ddt.data( - ((['filtered_org'], [], 1)), - (([], ['filtered_org'], 2)) - ) - @ddt.unpack - def test_site_config(self, this_org_list, other_org_list, expected_message_count, mock_schedule_send, mock_ace): - filtered_org = 'filtered_org' - unfiltered_org = 'unfiltered_org' - this_config = SiteConfigurationFactory.create(values={'course_org_filter': this_org_list}) - other_config = SiteConfigurationFactory.create(values={'course_org_filter': other_org_list}) - - for config in (this_config, other_config): - ScheduleConfigFactory.create(site=config.site) - - user1 = UserFactory.create(id=resolvers.RECURRING_NUDGE_NUM_BINS) - user2 = UserFactory.create(id=resolvers.RECURRING_NUDGE_NUM_BINS * 2) - - ScheduleFactory.create( - start=datetime.datetime(2017, 8, 3, 17, 44, 30, tzinfo=pytz.UTC), - enrollment__course__org=filtered_org, - enrollment__user=user1, - ) - ScheduleFactory.create( - start=datetime.datetime(2017, 8, 3, 17, 44, 30, tzinfo=pytz.UTC), - enrollment__course__org=unfiltered_org, - enrollment__user=user1, - ) - ScheduleFactory.create( - start=datetime.datetime(2017, 8, 3, 17, 44, 30, tzinfo=pytz.UTC), - enrollment__course__org=unfiltered_org, - enrollment__user=user2, - ) - - test_datetime = datetime.datetime(2017, 8, 3, 17, tzinfo=pytz.UTC) - test_datetime_str = serialize(test_datetime) - - expected_queries = NUM_QUERIES_WITH_MATCHES - if not this_org_list: - expected_queries += NUM_QUERIES_NO_ORG_LIST - - with self.assertNumQueries(expected_queries, table_blacklist=WAFFLE_TABLES): - tasks.ScheduleRecurringNudge.apply(kwargs=dict( - site_id=this_config.site.id, target_day_str=test_datetime_str, day_offset=-3, bin_num=0 - )) - - self.assertEqual(mock_schedule_send.apply_async.call_count, expected_message_count) - self.assertFalse(mock_ace.send.called) - - @patch.object(tasks, 'ace') - @patch.object(tasks.ScheduleRecurringNudge, 'async_send_task') - def test_multiple_enrollments(self, mock_schedule_send, mock_ace): - user = UserFactory.create() - schedules = [ - ScheduleFactory.create( - start=datetime.datetime(2017, 8, 3, 19, 44, 30, tzinfo=pytz.UTC), - enrollment__user=user, - enrollment__course__id=CourseLocator('edX', 'toy', 'Course{}'.format(course_num)) - ) - for course_num in (1, 2, 3) - ] - - test_datetime = datetime.datetime(2017, 8, 3, 19, 44, 30, tzinfo=pytz.UTC) - test_datetime_str = serialize(test_datetime) - with self.assertNumQueries(NUM_QUERIES_WITH_MATCHES + NUM_QUERIES_NO_ORG_LIST, table_blacklist=WAFFLE_TABLES): - tasks.ScheduleRecurringNudge.apply(kwargs=dict( - site_id=self.site_config.site.id, target_day_str=test_datetime_str, day_offset=-3, - bin_num=user.id % resolvers.RECURRING_NUDGE_NUM_BINS, - )) - self.assertEqual(mock_schedule_send.apply_async.call_count, 1) - self.assertFalse(mock_ace.send.called) - - @ddt.data(*itertools.product((1, 10, 100), (-3, -10))) - @ddt.unpack - def test_templates(self, message_count, day): - - user = UserFactory.create() - schedules = [ - ScheduleFactory.create( - start=datetime.datetime(2017, 8, 3, 19, 44, 30, tzinfo=pytz.UTC), - enrollment__user=user, - enrollment__course__id=CourseLocator('edX', 'toy', 'Course{}'.format(course_num)) - ) - for course_num in range(message_count) - ] - - test_datetime = datetime.datetime(2017, 8, 3, 19, tzinfo=pytz.UTC) - test_datetime_str = serialize(test_datetime) - - patch_policies(self, [StubPolicy([ChannelType.PUSH])]) - mock_channel = Mock( - name='test_channel', - channel_type=ChannelType.EMAIL - ) - patch_channels(self, [mock_channel]) - - sent_messages = [] - - with self.settings(TEMPLATES=self._get_template_overrides()): - with patch.object(tasks.ScheduleRecurringNudge, 'async_send_task') as mock_schedule_send: - mock_schedule_send.apply_async = lambda args, *_a, **_kw: sent_messages.append(args) - - with self.assertNumQueries(NUM_QUERIES_WITH_MATCHES + NUM_QUERIES_NO_ORG_LIST, table_blacklist=WAFFLE_TABLES): - tasks.ScheduleRecurringNudge.apply(kwargs=dict( - site_id=self.site_config.site.id, target_day_str=test_datetime_str, day_offset=day, - bin_num=self._calculate_bin_for_user(user), - )) - - self.assertEqual(len(sent_messages), 1) - - # Load the site - # Check the schedule config - with self.assertNumQueries(2): - for args in sent_messages: - tasks._recurring_nudge_schedule_send(*args) - - self.assertEqual(mock_channel.deliver.call_count, 1) - for (_name, (_msg, email), _kwargs) in mock_channel.deliver.mock_calls: - for template in attr.astuple(email): - self.assertNotIn("TEMPLATE WARNING", template) - self.assertNotIn("{{", template) - self.assertNotIn("}}", template) + tested_task = tasks.ScheduleRecurringNudge + deliver_task = tasks._recurring_nudge_schedule_send + tested_command = nudge.Command + deliver_config = 'deliver_recurring_nudge' + enqueue_config = 'enqueue_recurring_nudge' + expected_offsets = (-3, -10) def test_user_in_course_with_verified_coursemode_receives_upsell(self): user = UserFactory.create() @@ -344,8 +64,8 @@ class TestSendRecurringNudge(FilteredQueryCountMixin, CacheIsolationTestCase): user, schedule.enrollment.course.org ] - sent_messages = self._stub_sender_and_collect_sent_messages(bin_task=tasks.ScheduleRecurringNudge, - stubbed_send_task=patch.object(tasks.ScheduleRecurringNudge, 'async_send_task'), + sent_messages = self._stub_sender_and_collect_sent_messages(bin_task=self.tested_task, + stubbed_send_task=patch.object(self.tested_task, 'async_send_task'), bin_task_params=bin_task_parameters) self.assertEqual(len(sent_messages), 1) @@ -376,8 +96,8 @@ class TestSendRecurringNudge(FilteredQueryCountMixin, CacheIsolationTestCase): user, schedule.enrollment.course.org ] - sent_messages = self._stub_sender_and_collect_sent_messages(bin_task=tasks.ScheduleRecurringNudge, - stubbed_send_task=patch.object(tasks.ScheduleRecurringNudge, 'async_send_task'), + sent_messages = self._stub_sender_and_collect_sent_messages(bin_task=self.tested_task, + stubbed_send_task=patch.object(self.tested_task, 'async_send_task'), bin_task_params=bin_task_parameters) self.assertEqual(len(sent_messages), 1) @@ -415,8 +135,8 @@ class TestSendRecurringNudge(FilteredQueryCountMixin, CacheIsolationTestCase): user, schedule.enrollment.course.org ] - sent_messages = self._stub_sender_and_collect_sent_messages(bin_task=tasks.ScheduleRecurringNudge, - stubbed_send_task=patch.object(tasks.ScheduleRecurringNudge, 'async_send_task'), + sent_messages = self._stub_sender_and_collect_sent_messages(bin_task=self.tested_task, + stubbed_send_task=patch.object(self.tested_task, 'async_send_task'), bin_task_params=bin_task_parameters) self.assertEqual(len(sent_messages), 1) @@ -440,15 +160,6 @@ class TestSendRecurringNudge(FilteredQueryCountMixin, CacheIsolationTestCase): return sent_messages - def _get_template_overrides(self): - templates_override = deepcopy(settings.TEMPLATES) - templates_override[0]['OPTIONS']['string_if_invalid'] = "TEMPLATE WARNING - MISSING VARIABLE [%s]" - return templates_override - - def _calculate_bin_for_user(self, user): - return user.id % resolvers.RECURRING_NUDGE_NUM_BINS - def _contains_upsell_attribute(self, msg_attr): msg = Message.from_string(msg_attr) - tmp = msg.context["show_upsell"] return msg.context["show_upsell"] diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py index 6fb9963c84..4993dbe18c 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py @@ -1,56 +1,22 @@ -import datetime -from copy import deepcopy import logging from unittest import skipUnless -import attr import ddt -import pytz from django.conf import settings from edx_ace import Message -from freezegun import freeze_time -from edx_ace.channel import ChannelType -from edx_ace.test_utils import StubPolicy, patch_channels, patch_policies from edx_ace.utils.date import serialize from mock import Mock, patch -from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator from course_modes.models import CourseMode -from course_modes.tests.factories import CourseModeFactory -from courseware.models import DynamicUpgradeDeadlineConfiguration -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.core.djangoapps.schedules import resolvers, tasks +from openedx.core.djangoapps.schedules import tasks from openedx.core.djangoapps.schedules.management.commands import send_upgrade_reminder as reminder -from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory, ScheduleFactory -from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory, SiteFactory -from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES +from openedx.core.djangoapps.schedules.management.commands.tests.send_email_base import ScheduleSendEmailTestBase +from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory from openedx.core.djangolib.testing.utils import skip_unless_lms from student.tests.factories import UserFactory -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory -SITE_QUERY = 1 -SCHEDULES_QUERY = 1 -COURSE_MODES_QUERY = 1 -GLOBAL_DEADLINE_SWITCH_QUERY = 1 -COMMERCE_CONFIG_QUERY = 1 -NUM_QUERIES_NO_ORG_LIST = 1 - -NUM_QUERIES_NO_MATCHING_SCHEDULES = SITE_QUERY + SCHEDULES_QUERY - -NUM_QUERIES_WITH_MATCHES = ( - NUM_QUERIES_NO_MATCHING_SCHEDULES + - COURSE_MODES_QUERY -) - -NUM_QUERIES_FIRST_MATCH = ( - NUM_QUERIES_WITH_MATCHES - + GLOBAL_DEADLINE_SWITCH_QUERY - + COMMERCE_CONFIG_QUERY -) - LOG = logging.getLogger(__name__) @@ -58,378 +24,58 @@ LOG = logging.getLogger(__name__) @skip_unless_lms @skipUnless('openedx.core.djangoapps.schedules.apps.SchedulesConfig' in settings.INSTALLED_APPS, "Can't test schedules if the app isn't installed") -@freeze_time('2017-08-01 00:00:00', tz_offset=0, tick=True) -class TestUpgradeReminder(SharedModuleStoreTestCase): +class TestUpgradeReminder(ScheduleSendEmailTestBase): + __test__ = True - ENABLED_CACHES = ['default'] + tested_task = tasks.ScheduleUpgradeReminder + deliver_task = tasks._upgrade_reminder_schedule_send + tested_command = reminder.Command + deliver_config = 'deliver_upgrade_reminder' + enqueue_config = 'enqueue_upgrade_reminder' + expected_offsets = (2,) - @classmethod - def setUpClass(cls): - super(TestUpgradeReminder, cls).setUpClass() - - cls.course = CourseFactory.create( - org='edX', - number='test', - display_name='Test Course', - self_paced=True, - start=datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=30), - ) - cls.course_overview = CourseOverview.get_from_id(cls.course.id) - - def setUp(self): - super(TestUpgradeReminder, self).setUp() - - CourseModeFactory( - course_id=self.course.id, - mode_slug=CourseMode.VERIFIED, - expiration_datetime=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=30), - ) - - ScheduleFactory.create(upgrade_deadline=datetime.datetime(2017, 8, 1, 15, 44, 30, tzinfo=pytz.UTC)) - ScheduleFactory.create(upgrade_deadline=datetime.datetime(2017, 8, 1, 17, 34, 30, tzinfo=pytz.UTC)) - ScheduleFactory.create(upgrade_deadline=datetime.datetime(2017, 8, 2, 15, 34, 30, tzinfo=pytz.UTC)) - - site = SiteFactory.create() - self.site_config = SiteConfigurationFactory.create(site=site) - ScheduleConfigFactory.create(site=self.site_config.site) - - DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True) - - @patch.object(reminder.Command, 'async_send_task') - def test_handle(self, mock_send): - test_day = datetime.datetime(2017, 8, 1, tzinfo=pytz.UTC) - reminder.Command().handle(date='2017-08-01', site_domain_name=self.site_config.site.domain) - mock_send.enqueue.assert_called_with( - self.site_config.site, - test_day, - 2, - None - ) + has_course_queries = True + @ddt.data(True, False) @patch.object(tasks, 'ace') - def test_resolver_send(self, mock_ace): - current_day = datetime.datetime(2017, 8, 1, tzinfo=pytz.UTC) - test_day = current_day + datetime.timedelta(days=2) - ScheduleFactory.create(upgrade_deadline=datetime.datetime(2017, 8, 3, 15, 34, 30, tzinfo=pytz.UTC)) - - with patch.object(tasks.ScheduleUpgradeReminder, 'apply_async') as mock_apply_async: - tasks.ScheduleUpgradeReminder.enqueue(self.site_config.site, current_day, 2) - mock_apply_async.assert_any_call( - (self.site_config.site.id, serialize(test_day), 2, 0, None), - retry=False, - ) - mock_apply_async.assert_any_call( - (self.site_config.site.id, serialize(test_day), 2, resolvers.UPGRADE_REMINDER_NUM_BINS - 1, None), - retry=False, - ) - self.assertFalse(mock_ace.send.called) - - @ddt.data(1, 10, 100) - @patch.object(tasks, 'ace') - @patch.object(tasks.ScheduleUpgradeReminder, 'async_send_task') - def test_schedule_bin(self, schedule_count, mock_schedule_send, mock_ace): - upgrade_deadline = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=2) - schedules = [ - ScheduleFactory.create( - upgrade_deadline=upgrade_deadline, - enrollment__course=self.course_overview, - ) for i in range(schedule_count) - ] - - bins_in_use = frozenset((self._calculate_bin_for_user(s.enrollment.user)) for s in schedules) - is_first_match = True - course_switch_queries = len(set(s.enrollment.course.id for s in schedules)) - org_switch_queries = len(set(s.enrollment.course.id.org for s in schedules)) - test_datetime = upgrade_deadline - test_datetime_str = serialize(test_datetime) - - for b in range(resolvers.UPGRADE_REMINDER_NUM_BINS): - LOG.debug('Running bin %d', b) - expected_queries = NUM_QUERIES_NO_MATCHING_SCHEDULES - if b in bins_in_use: - if is_first_match: - expected_queries = ( - # Since this is the first match, we need to cache all of the config models, so we run a query - # for each of those... - NUM_QUERIES_FIRST_MATCH - + course_switch_queries + org_switch_queries - ) - is_first_match = False - else: - expected_queries = NUM_QUERIES_WITH_MATCHES - - expected_queries += NUM_QUERIES_NO_ORG_LIST - - with self.assertNumQueries(expected_queries, table_blacklist=WAFFLE_TABLES): - tasks.ScheduleUpgradeReminder.apply(kwargs=dict( - site_id=self.site_config.site.id, target_day_str=test_datetime_str, day_offset=2, bin_num=b, - )) - - self.assertEqual(mock_schedule_send.apply_async.call_count, schedule_count) - self.assertFalse(mock_ace.send.called) - - @patch.object(tasks.ScheduleUpgradeReminder, 'async_send_task') - def test_no_course_overview(self, mock_schedule_send): - - schedule = ScheduleFactory.create( - upgrade_deadline=datetime.datetime(2017, 8, 3, 20, 34, 30, tzinfo=pytz.UTC), - ) - schedule.enrollment.course_id = CourseKey.from_string('edX/toy/Not_2012_Fall') - schedule.enrollment.save() - - test_datetime = datetime.datetime(2017, 8, 3, 20, tzinfo=pytz.UTC) - test_datetime_str = serialize(test_datetime) - for b in range(resolvers.UPGRADE_REMINDER_NUM_BINS): - - with self.assertNumQueries(NUM_QUERIES_NO_MATCHING_SCHEDULES + NUM_QUERIES_NO_ORG_LIST, table_blacklist=WAFFLE_TABLES): - tasks.ScheduleUpgradeReminder.apply(kwargs=dict( - site_id=self.site_config.site.id, target_day_str=test_datetime_str, day_offset=2, bin_num=b, - )) - - # There is no database constraint that enforces that enrollment.course_id points - # to a valid CourseOverview object. However, in that case, schedules isn't going - # to attempt to address it, and will instead simply skip those users. - # This happens 'transparently' because django generates an inner-join between - # enrollment and course_overview, and thus will skip any rows where course_overview - # is null. - self.assertEqual(mock_schedule_send.apply_async.call_count, 0) - - @patch.object(tasks, 'ace') - def test_delivery_disabled(self, mock_ace): - ScheduleConfigFactory.create(site=self.site_config.site, deliver_upgrade_reminder=False) - - mock_msg = Mock() - tasks._upgrade_reminder_schedule_send(self.site_config.site.id, mock_msg) - self.assertFalse(mock_ace.send.called) - - @patch.object(tasks, 'ace') - @patch.object(tasks.ScheduleUpgradeReminder, 'apply_async') - def test_enqueue_disabled(self, mock_ace, mock_apply_async): - ScheduleConfigFactory.create(site=self.site_config.site, enqueue_upgrade_reminder=False) - - current_day = datetime.datetime(2017, 8, 1, tzinfo=pytz.UTC) - tasks.ScheduleUpgradeReminder.enqueue( - self.site_config.site, - current_day, - day_offset=3, - ) - self.assertFalse(mock_apply_async.called) - self.assertFalse(mock_ace.send.called) - - @patch.object(tasks, 'ace') - @patch.object(tasks.ScheduleUpgradeReminder, 'async_send_task') - @ddt.data( - ((['filtered_org'], [], 1)), - (([], ['filtered_org'], 2)) - ) - @ddt.unpack - def test_site_config(self, this_org_list, other_org_list, expected_message_count, mock_schedule_send, mock_ace): - filtered_org = 'filtered_org' - unfiltered_org = 'unfiltered_org' - this_config = SiteConfigurationFactory.create(values={'course_org_filter': this_org_list}) - other_config = SiteConfigurationFactory.create(values={'course_org_filter': other_org_list}) - - for config in (this_config, other_config): - ScheduleConfigFactory.create(site=config.site) - - user1 = UserFactory.create(id=resolvers.UPGRADE_REMINDER_NUM_BINS) - user2 = UserFactory.create(id=resolvers.UPGRADE_REMINDER_NUM_BINS * 2) - + def test_verified_learner(self, is_verified, mock_ace): + user = UserFactory.create(id=self.tested_task.num_bins) + current_day, offset, target_day = self._get_dates() ScheduleFactory.create( - upgrade_deadline=datetime.datetime(2017, 8, 3, 17, 44, 30, tzinfo=pytz.UTC), - enrollment__course__org=filtered_org, + upgrade_deadline=target_day, enrollment__course__self_paced=True, - enrollment__user=user1, - ) - ScheduleFactory.create( - upgrade_deadline=datetime.datetime(2017, 8, 3, 17, 44, 30, tzinfo=pytz.UTC), - enrollment__course__org=unfiltered_org, - enrollment__course__self_paced=True, - enrollment__user=user1, - ) - ScheduleFactory.create( - upgrade_deadline=datetime.datetime(2017, 8, 3, 17, 44, 30, tzinfo=pytz.UTC), - enrollment__course__org=unfiltered_org, - enrollment__course__self_paced=True, - enrollment__user=user2, + enrollment__user=user, + enrollment__mode=CourseMode.VERIFIED if is_verified else CourseMode.AUDIT, ) - test_datetime = datetime.datetime(2017, 8, 3, 17, tzinfo=pytz.UTC) - test_datetime_str = serialize(test_datetime) + self.tested_task.apply(kwargs=dict( + site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, + bin_num=self._calculate_bin_for_user(user), + )) - course_switch_queries = 1 - org_switch_queries = 1 - expected_queries = NUM_QUERIES_FIRST_MATCH + course_switch_queries + org_switch_queries - if not this_org_list: - expected_queries += NUM_QUERIES_NO_ORG_LIST - - with self.assertNumQueries(expected_queries, table_blacklist=WAFFLE_TABLES): - tasks.ScheduleUpgradeReminder.apply(kwargs=dict( - site_id=this_config.site.id, target_day_str=test_datetime_str, day_offset=-3, bin_num=0 - )) - - self.assertEqual(mock_schedule_send.apply_async.call_count, expected_message_count) - self.assertFalse(mock_ace.send.called) - - @patch.object(tasks, 'ace') - @patch.object(tasks.ScheduleUpgradeReminder, 'async_send_task') - def test_multiple_enrollments(self, mock_schedule_send, mock_ace): - user = UserFactory.create() - schedules = [ - ScheduleFactory.create( - upgrade_deadline=datetime.datetime(2017, 8, 3, 19, 44, 30, tzinfo=pytz.UTC), - enrollment__user=user, - enrollment__course__self_paced=True, - enrollment__course__id=CourseLocator('edX', 'toy', 'Course{}'.format(course_num)) - ) - for course_num in (1, 2, 3) - ] - - course_switch_queries = len(set(s.enrollment.course.id for s in schedules)) - org_switch_queries = len(set(s.enrollment.course.id.org for s in schedules)) - - test_datetime = datetime.datetime(2017, 8, 3, 19, 44, 30, tzinfo=pytz.UTC) - test_datetime_str = serialize(test_datetime) - expected_query_count = ( - NUM_QUERIES_FIRST_MATCH + course_switch_queries + org_switch_queries + NUM_QUERIES_NO_ORG_LIST - ) - with self.assertNumQueries(expected_query_count, table_blacklist=WAFFLE_TABLES): - tasks.ScheduleUpgradeReminder.apply(kwargs=dict( - site_id=self.site_config.site.id, target_day_str=test_datetime_str, day_offset=2, - bin_num=self._calculate_bin_for_user(user), - )) - self.assertEqual(mock_schedule_send.apply_async.call_count, 1) - self.assertFalse(mock_ace.send.called) - - @ddt.data(1, 10, 100) - def test_templates(self, message_count): - now = datetime.datetime.now(pytz.UTC) - future_datetime = now + datetime.timedelta(days=21) - - user = UserFactory.create() - schedules = [ - ScheduleFactory.create( - upgrade_deadline=future_datetime, - enrollment__user=user, - enrollment__course__self_paced=True, - enrollment__course__end=future_datetime + datetime.timedelta(days=30), - enrollment__course__id=CourseLocator('edX', 'toy', 'Course{}'.format(course_num)) - ) - for course_num in range(message_count) - ] - - for schedule in schedules: - CourseModeFactory( - course_id=schedule.enrollment.course.id, - mode_slug=CourseMode.VERIFIED, - expiration_datetime=future_datetime - ) - - course_switch_queries = len(set(s.enrollment.course.id for s in schedules)) - org_switch_queries = len(set(s.enrollment.course.id.org for s in schedules)) - - test_datetime = future_datetime - test_datetime_str = serialize(test_datetime) - - patch_policies(self, [StubPolicy([ChannelType.PUSH])]) - mock_channel = Mock( - name='test_channel', - channel_type=ChannelType.EMAIL - ) - patch_channels(self, [mock_channel]) - - sent_messages = [] - - with self.settings(TEMPLATES=self._get_template_overrides()): - with patch.object(tasks.ScheduleUpgradeReminder, 'async_send_task') as mock_schedule_send: - mock_schedule_send.apply_async = lambda args, *_a, **_kw: sent_messages.append(args) - - # we execute one query per course to see if it's opted out of dynamic upgrade deadlines - num_expected_queries = ( - NUM_QUERIES_FIRST_MATCH + NUM_QUERIES_NO_ORG_LIST + course_switch_queries + org_switch_queries - ) - - with self.assertNumQueries(num_expected_queries, table_blacklist=WAFFLE_TABLES): - tasks.ScheduleUpgradeReminder.apply(kwargs=dict( - site_id=self.site_config.site.id, target_day_str=test_datetime_str, day_offset=2, - bin_num=self._calculate_bin_for_user(user), - )) - - self.assertEqual(len(sent_messages), 1) - - # Load the site (which we query per message sent) - # Check the schedule config - with self.assertNumQueries(2): - for args in sent_messages: - tasks._upgrade_reminder_schedule_send(*args) - - self.assertEqual(mock_channel.deliver.call_count, 1) - for (_name, (_msg, email), _kwargs) in mock_channel.deliver.mock_calls: - for template in attr.astuple(email): - self.assertNotIn("TEMPLATE WARNING", template) - self.assertNotIn("{{", template) - self.assertNotIn("}}", template) - - def _get_template_overrides(self): - templates_override = deepcopy(settings.TEMPLATES) - templates_override[0]['OPTIONS']['string_if_invalid'] = "TEMPLATE WARNING - MISSING VARIABLE [%s]" - return templates_override - - def _calculate_bin_for_user(self, user): - return user.id % resolvers.UPGRADE_REMINDER_NUM_BINS - - @patch.object(tasks, '_upgrade_reminder_schedule_send') - def test_dont_send_to_verified_learner(self, mock_schedule_send): - upgrade_deadline = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=2) - ScheduleFactory.create( - upgrade_deadline=upgrade_deadline, - enrollment__user=UserFactory.create(id=resolvers.UPGRADE_REMINDER_NUM_BINS), - enrollment__course=self.course_overview, - enrollment__mode=CourseMode.VERIFIED, - ) - test_datetime_str = serialize(datetime.datetime.now(pytz.UTC)) - - tasks.ScheduleUpgradeReminder.delay( - self.site_config.site.id, target_day_str=test_datetime_str, day_offset=2, bin_num=0, - org_list=[self.course.org], - ) - - self.assertFalse(mock_schedule_send.called) - self.assertFalse(mock_schedule_send.apply_async.called) + self.assertEqual(mock_ace.send.called, not is_verified) def test_filter_out_verified_schedules(self): - now = datetime.datetime.now(pytz.UTC) - future_datetime = now + datetime.timedelta(days=21) + current_day, offset, target_day = self._get_dates() user = UserFactory.create() schedules = [ ScheduleFactory.create( - upgrade_deadline=future_datetime, + upgrade_deadline=target_day, enrollment__user=user, enrollment__course__self_paced=True, - enrollment__course__end=future_datetime + datetime.timedelta(days=30), enrollment__course__id=CourseLocator('edX', 'toy', 'Course{}'.format(i)), enrollment__mode=CourseMode.VERIFIED if i in (0, 3) else CourseMode.AUDIT, ) for i in range(5) ] - for schedule in schedules: - CourseModeFactory( - course_id=schedule.enrollment.course.id, - mode_slug=CourseMode.VERIFIED, - expiration_datetime=future_datetime - ) - - test_datetime = future_datetime - test_datetime_str = serialize(test_datetime) - sent_messages = [] - with patch.object(tasks.ScheduleUpgradeReminder, 'async_send_task') as mock_schedule_send: + with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: mock_schedule_send.apply_async = lambda args, *_a, **_kw: sent_messages.append(args[1]) - tasks.ScheduleUpgradeReminder.apply(kwargs=dict( - site_id=self.site_config.site.id, target_day_str=test_datetime_str, day_offset=2, + self.tested_task.apply(kwargs=dict( + site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, bin_num=self._calculate_bin_for_user(user), ))