Merge pull request #16339 from edx/cale/dry-schedule-tests
Dry schedule tests
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
|
||||
@@ -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),
|
||||
))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user