Commit cd4ebaf2 by Calen Pennington Committed by Clinton Blackburn

Only send one email even if there are multiple enrollments by the same user on the same day

parent 8c37dff1
......@@ -9,12 +9,12 @@ from bulk_email.models import Optout
class CourseEmailOptout(Policy):
def check(self, message):
course_id = message.context.get('course_id')
if not course_id:
course_ids = message.context.get('course_ids')
if not course_ids:
return PolicyResult(deny=frozenset())
course_key = CourseKey.from_string(course_id)
if Optout.objects.filter(user__username=message.recipient.username, course_id=course_key).exists():
course_keys = [CourseKey.from_string(course_id) for course_id in course_ids]
if Optout.objects.filter(user__username=message.recipient.username, course_id__in=course_keys).count() == len(course_keys):
return PolicyResult(deny={ChannelType.EMAIL})
return PolicyResult(deny=frozenset())
......@@ -166,7 +166,7 @@ class TestACEOptoutCourseEmails(ModuleStoreTestCase):
email_address=self.student.email,
),
context={
'course_id': str(self.course.id)
'course_ids': [str(self.course.id)]
},
)
......
......@@ -28,3 +28,7 @@ class CourseOverviewFactory(DjangoModelFactory):
@factory.lazy_attribute
def id(self):
return CourseLocator(self.org, 'toy', '2012_Fall')
@factory.lazy_attribute
def display_name(self):
return "{} Course".format(self.id)
import datetime
from mock import patch, Mock
import itertools
from unittest import skipUnless
import pytz
import attr
import ddt
import pytz
from django.conf import settings
from django.test import override_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 mock import Mock, patch
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import CourseLocator
from openedx.core.djangoapps.schedules import tasks
from openedx.core.djangoapps.schedules.management.commands import send_recurring_nudge as nudge
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory, ScheduleFactory
from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory, SiteFactory
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory, ScheduleConfigFactory
from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory
from student.tests.factories import UserFactory
@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")
@skipUnless('openedx.core.djangoapps.schedules.apps.SchedulesConfig' in settings.INSTALLED_APPS,
"Can't test schedules if the app isn't installed")
class TestSendRecurringNudge(CacheIsolationTestCase):
# pylint: disable=protected-access
def setUp(self):
......@@ -71,7 +76,7 @@ class TestSendRecurringNudge(CacheIsolationTestCase):
test_time_str = serialize(datetime.datetime(2017, 8, 1, 18, tzinfo=pytz.UTC))
with self.assertNumQueries(1):
tasks.recurring_nudge_schedule_hour(
self.site_config.site, 3, test_time_str, [schedules[0].enrollment.course.org],
self.site_config.site.id, 3, test_time_str, [schedules[0].enrollment.course.org],
)
self.assertEqual(mock_schedule_send.apply_async.call_count, schedule_count)
self.assertFalse(mock_ace.send.called)
......@@ -88,7 +93,7 @@ class TestSendRecurringNudge(CacheIsolationTestCase):
test_time_str = serialize(datetime.datetime(2017, 8, 1, 20, tzinfo=pytz.UTC))
with self.assertNumQueries(1):
tasks.recurring_nudge_schedule_hour(
self.site_config.site, 3, test_time_str, [schedule.enrollment.course.org],
self.site_config.site.id, 3, test_time_str, [schedule.enrollment.course.org],
)
# There is no database constraint that enforces that enrollment.course_id points
......@@ -136,27 +141,15 @@ class TestSendRecurringNudge(CacheIsolationTestCase):
for config in (limited_config, unlimited_config):
ScheduleConfigFactory.create(site=config.site)
filtered_sched = ScheduleFactory.create(
ScheduleFactory.create(
start=datetime.datetime(2017, 8, 2, 17, 44, 30, tzinfo=pytz.UTC),
enrollment__course__org=filtered_org,
)
unfiltered_scheds = [
for _ in range(2):
ScheduleFactory.create(
start=datetime.datetime(2017, 8, 2, 17, 44, 30, tzinfo=pytz.UTC),
enrollment__course__org=unfiltered_org,
)
for _ in range(2)
]
print(filtered_sched.enrollment)
print(filtered_sched.enrollment.course)
print(filtered_sched.enrollment.course.org)
print(unfiltered_scheds[0].enrollment)
print(unfiltered_scheds[0].enrollment.course)
print(unfiltered_scheds[0].enrollment.course.org)
print(unfiltered_scheds[1].enrollment)
print(unfiltered_scheds[1].enrollment.course)
print(unfiltered_scheds[1].enrollment.course.org)
test_time_str = serialize(datetime.datetime(2017, 8, 2, 17, tzinfo=pytz.UTC))
with self.assertNumQueries(1):
......@@ -164,6 +157,77 @@ class TestSendRecurringNudge(CacheIsolationTestCase):
limited_config.site.id, 3, test_time_str, org_list=org_list, exclude_orgs=exclude_orgs,
)
print(mock_schedule_send.mock_calls)
self.assertEqual(mock_schedule_send.apply_async.call_count, expected_message_count)
self.assertFalse(mock_ace.send.called)
@ddt.data(
(19, 1),
(20, 0),
(21, 0)
)
@ddt.unpack
@patch.object(tasks, 'ace')
@patch.object(tasks, '_recurring_nudge_schedule_send')
def test_multiple_enrollments(self, test_hour, messages_sent, mock_schedule_send, mock_ace):
user = UserFactory.create()
schedules = [
ScheduleFactory.create(
start=datetime.datetime(2017, 8, 1, hour, 44, 30, tzinfo=pytz.UTC),
enrollment__user=user,
enrollment__course__id=CourseLocator('edX', 'toy', 'Hour{}'.format(hour))
)
for hour in (19, 20, 21)
]
test_time_str = serialize(datetime.datetime(2017, 8, 1, test_hour, tzinfo=pytz.UTC))
with self.assertNumQueries(1):
tasks.recurring_nudge_schedule_hour(
self.site_config.site.id, 3, test_time_str, [schedules[0].enrollment.course.org],
)
self.assertEqual(mock_schedule_send.apply_async.call_count, messages_sent)
self.assertFalse(mock_ace.send.called)
@ddt.data(*itertools.product((1, 10, 100), (3, 10)))
@ddt.unpack
@override_settings()
def test_templates(self, message_count, day):
settings.TEMPLATES[0]['OPTIONS']['string_if_invalid'] = "TEMPLATE WARNING - MISSING VARIABLE [%s]"
user = UserFactory.create()
schedules = [
ScheduleFactory.create(
start=datetime.datetime(2017, 8, 1, 19, 44, 30, tzinfo=pytz.UTC),
enrollment__user=user,
enrollment__course__id=CourseLocator('edX', 'toy', 'Hour{}'.format(idx))
)
for idx in range(message_count)
]
test_time_str = serialize(datetime.datetime(2017, 8, 1, 19, tzinfo=pytz.UTC))
patch_policies(self, [StubPolicy([ChannelType.PUSH])])
mock_channel = Mock(
name='test_channel',
channel_type=ChannelType.EMAIL
)
patch_channels(self, [mock_channel])
sent_messages = []
with patch.object(tasks, '_recurring_nudge_schedule_send') as mock_schedule_send:
mock_schedule_send.apply_async = lambda args, *_a, **_kw: sent_messages.append(args)
with self.assertNumQueries(1):
tasks.recurring_nudge_schedule_hour(
self.site_config.site.id, day, test_time_str, [schedules[0].enrollment.course.org],
)
self.assertEqual(len(sent_messages), 1)
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)
import datetime
from subprocess import check_output, CalledProcessError
from itertools import groupby
from logging import getLogger
from urlparse import urlparse
from celery.task import task
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.db.models import Min
from django.db.utils import DatabaseError
from django.utils.http import urlquote
from logging import getLogger
from edx_ace import ace
from edx_ace.message import MessageType, Message
from edx_ace.message import Message, MessageType
from edx_ace.recipient import Recipient
from edx_ace.utils.date import deserialize
from edxmako.shortcuts import marketing_link
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.schedules.models import Schedule, ScheduleConfig
from edxmako.shortcuts import marketing_link
from openedx.core.djangoapps.schedules.models import Schedule, ScheduleConfig
log = getLogger(__name__)
......@@ -84,14 +84,33 @@ def _recurring_nudge_schedule_send(site_id, msg_str):
def _recurring_nudge_schedules_for_hour(target_hour, org_list, exclude_orgs=False):
beginning_of_day = target_hour.replace(hour=0, minute=0, second=0)
users = User.objects.filter(
courseenrollment__schedule__start__gte=beginning_of_day,
courseenrollment__schedule__start__lt=beginning_of_day + datetime.timedelta(days=1),
courseenrollment__is_active=True,
).annotate(
first_schedule=Min('courseenrollment__schedule__start')
).filter(
first_schedule__gte=target_hour,
first_schedule__lt=target_hour + datetime.timedelta(minutes=60)
)
if org_list is not None:
if exclude_orgs:
users = users.exclude(courseenrollment__course__org__in=org_list)
else:
users = users.filter(courseenrollment__course__org__in=org_list)
schedules = Schedule.objects.select_related(
'enrollment__user__profile',
'enrollment__course',
).filter(
start__gte=target_hour,
start__lt=target_hour + datetime.timedelta(minutes=60),
enrollment__user__in=users,
start__gte=beginning_of_day,
start__lt=beginning_of_day + datetime.timedelta(days=1),
enrollment__is_active=True,
)
).order_by('enrollment__user__id')
if org_list is not None:
if exclude_orgs:
......@@ -102,20 +121,24 @@ def _recurring_nudge_schedules_for_hour(target_hour, org_list, exclude_orgs=Fals
if "read_replica" in settings.DATABASES:
schedules = schedules.using("read_replica")
for schedule in schedules:
enrollment = schedule.enrollment
user = enrollment.user
dashboard_relative_url = reverse('dashboard')
course_id_str = str(enrollment.course_id)
course = enrollment.course
for (user, user_schedules) in groupby(schedules, lambda s: s.enrollment.user):
user_schedules = list(user_schedules)
course_id_strs = [str(schedule.enrollment.course_id) for schedule in user_schedules]
course_root_relative_url = reverse('course_root', args=[course_id_str])
dashboard_relative_url = reverse('dashboard')
def absolute_url(relative_path):
return u'{}{}'.format(settings.LMS_ROOT_URL, urlquote(relative_path))
first_schedule = user_schedules[0]
template_context = {
'student_name': user.profile.name,
'course_name': course.display_name,
'course_url': absolute_url(course_root_relative_url),
'course_name': first_schedule.enrollment.course.display_name,
'course_url': absolute_url(reverse('course_root', args=[str(first_schedule.enrollment.course_id)])),
# This is used by the bulk email optout policy
'course_ids': course_id_strs,
# Platform information
'homepage_url': encode_url(marketing_link('ROOT')),
......@@ -125,12 +148,8 @@ def _recurring_nudge_schedules_for_hour(target_hour, org_list, exclude_orgs=Fals
'contact_mailing_address': settings.CONTACT_MAILING_ADDRESS,
'social_media_urls': encode_urls_in_dict(getattr(settings, 'SOCIAL_MEDIA_FOOTER_URLS', {})),
'mobile_store_urls': encode_urls_in_dict(getattr(settings, 'MOBILE_STORE_URLS', {})),
# This is used by the bulk email optout policy
'course_id': course_id_str,
}
yield (user, course.language, template_context)
yield (user, first_schedule.enrollment.course.language, template_context)
def encode_url(url):
......
......@@ -2,10 +2,17 @@
{% load i18n %}
{% block preview_text %}
{% blocktrans trimmed %}
Keep up the momentum! Many edX learners in {{course_name}} are completing more problems every week, and
participating in the discussion forums. What do you want to do to keep learning?
{% endblocktrans %}
{% if courses|length > 1 %}
{% blocktrans trimmed %}
Many {{ platform_name }} learners are completing more problems every week, and
participating in the discussion forums. What do you want to do to keep learning?
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
Many {{ platform_name }} learners in {{course_name}} are completing more problems every week, and
participating in the discussion forums. What do you want to do to keep learning?
{% endblocktrans %}
{% endif %}
{% endblock %}
{% block content %}
......@@ -15,14 +22,27 @@
<h1>{% trans "Keep up the momentum!" %}</h1>
<p>
{% blocktrans trimmed %}
Many edX learners in <strong>{{course_name}}</strong> are completing more problems every week, and
participating in the discussion forums. What do you want to do to keep learning?
{% endblocktrans %}
{% if courses|length > 1 %}
{% blocktrans trimmed %}
Many edX learners are completing more problems every week, and
participating in the discussion forums. What do you want to do to keep learning?
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
Many edX learners in <strong>{{course_name}}</strong> are completing more problems every week, and
participating in the discussion forums. What do you want to do to keep learning?
{% endblocktrans %}
{% endif %}
</p>
<p>
<!-- email client support for style sheets is pretty spotty, so we have to inline all of these styles -->
<a href="{{ course_url }}" style="
<a
{% if courses|length > 1 %}
href="{{ dashboard_url }}"
{% else %}
href="{{ course_url }}"
{% endif %}
style="
color: #ffffff;
text-decoration: none;
border-radius: 4px;
......@@ -42,4 +62,4 @@
</td>
</tr>
</table>
{% endblock %}
\ No newline at end of file
{% endblock %}
{% load i18n %}
{% blocktrans trimmed %}
Keep up the momentum! Many edX learners in {{course_name}} are completing more problems every week, and
participating in the discussion forums. What do you want to do to keep learning?
{% endblocktrans %}
{% trans "Keep learning" %} <{{course_url}}>
\ No newline at end of file
{% if courses|length > 1 %}
{% blocktrans trimmed %}
Many edX learners are completing more problems every week, and
participating in the discussion forums. What do you want to do to keep learning?
{% endblocktrans %}
{% trans "Keep learning" %} <{{dashboard_url}}>
{% else %}
{% blocktrans trimmed %}
Many edX learners in {{course_name}} are completing more problems every week, and
participating in the discussion forums. What do you want to do to keep learning?
{% endblocktrans %}
{% trans "Keep learning" %} <{{course_url}}>
{% endif %}
{{ course_name }}
\ No newline at end of file
{% if courses|length > 1 %}
{{ platform_name }}
{% else %}
{{ course_name }}
{% endif %}
......@@ -2,10 +2,17 @@
{% load i18n %}
{% block preview_text %}
{% blocktrans trimmed %}
Keep learning today. Remember when you enrolled in {{course_name}} on edX.org? We do, and we’re glad
to have you! Come see what everyone is learning.
{% endblocktrans %}
{% if courses|length > 1 %}
{% blocktrans trimmed %}
Remember when you enrolled in {{ course_name }}, and other courses on edX.org? We do, and we’re glad
to have you! Come see what everyone is learning.
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
Remember when you enrolled in {{ course_name }} on edX.org? We do, and we’re glad
to have you! Come see what everyone is learning.
{% endblocktrans %}
{% endif %}
{% endblock %}
{% block content %}
......@@ -15,15 +22,28 @@
<h1>{% trans "Keep learning today" %}.</h1>
<p>
{% blocktrans trimmed %}
Remember when you enrolled in <strong>{{ course_name }}</strong> on edX.org? We do, and we’re glad
to have you! Come see what everyone is learning.
{% endblocktrans %}
{% if courses|length > 1 %}
{% blocktrans trimmed %}
Remember when you enrolled in <strong>{{ course_name }}</strong>, and other courses on edX.org? We do, and we’re glad
to have you! Come see what everyone is learning.
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
Remember when you enrolled in <strong>{{ course_name }}</strong> on edX.org? We do, and we’re glad
to have you! Come see what everyone is learning.
{% endblocktrans %}
{% endif %}
</p>
<p>
<!-- email client support for style sheets is pretty spotty, so we have to inline all of these styles -->
<a href="{{ course_url }}" style="
<a
{% if courses|length > 1 %}
href="{{ dashboard_url }}"
{% else %}
href="{{ course_url }}"
{% endif %}
style="
color: #ffffff;
text-decoration: none;
border-radius: 4px;
......@@ -37,10 +57,10 @@
display: inline-block;
">
<!-- old email clients require the use of the font tag :( -->
<font color="#ffffff"><b>{% trans "Keep learning" %}</b></font>
<font color="#ffffff"><b>{% trans "Start learning now" %}</b></font>
</a>
</p>
</td>
</tr>
</table>
{% endblock %}
\ No newline at end of file
{% endblock %}
{% load i18n %}
{% if courses|length > 1 %}
{% blocktrans trimmed %}
Remember when you enrolled in {{ course_name }}, and other courses on edX.org? We do, and we’re glad
to have you! Come see what everyone is learning.
{% endblocktrans %}
{% trans "Start learning now" %} <{{ dashboard_url }}>
{% else %}
{% blocktrans trimmed %}
Keep learning today. Remember when you enrolled in {{course_name}} on edX.org? We do, and we’re glad
Remember when you enrolled in {{ course_name }} on edX.org? We do, and we’re glad
to have you! Come see what everyone is learning.
{% endblocktrans %}
{% trans "Keep learning" %} <{{course_url}}>
\ No newline at end of file
{% trans "Start learning now" %} <{{ course_url }}>
{% endif %}
{{ course_name }}
\ No newline at end of file
{% if courses|length > 1 %}
{{ platform_name }}
{% else %}
{{ course_name }}
{% endif %}
{% load i18n %}
{% blocktrans %}Keep learning in {{course_name}}!{% endblocktrans %}
\ No newline at end of file
{% if courses|length > 1 %}
{% blocktrans %}Keep learning on {{ platform_name }}!{% endblocktrans %}
{% else %}
{% blocktrans %}Keep learning in {{course_name}} !{% endblocktrans %}
{% endif %}
......@@ -38,6 +38,7 @@ django==1.8.18
django-waffle==0.12.0
djangorestframework-jwt==1.11.0
enum34==1.1.6
edx-ace==0.1.2
edx-ccx-keys==0.2.1
edx-celeryutils==0.2.6
edx-drf-extensions==1.2.3
......
......@@ -97,7 +97,6 @@ git+https://github.com/edx/xblock-utils.git@v1.0.5#egg=xblock-utils==1.0.5
git+https://github.com/edx/edx-user-state-client.git@1.0.1#egg=edx-user-state-client==1.0.1
git+https://github.com/edx/xblock-lti-consumer.git@v1.1.5#egg=lti_consumer-xblock==1.1.5
git+https://github.com/edx/edx-proctoring.git@1.2.0#egg=edx-proctoring==1.2.0
git+https://github.com/edx/edx-ace.git@v0.1.1#egg=edx-ace==0.1.1
# Third Party XBlocks
git+https://github.com/open-craft/xblock-poll@7ba819b968fe8faddb78bb22e1fe7637005eb414#egg=xblock-poll==1.2.7
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment