Merge pull request #19941 from open-craft/jill/opt-out-weekly-highlight-messages
Add api support to let users opt out of email updates
This commit is contained in:
82
lms/djangoapps/bulk_email/tests/test_views.py
Normal file
82
lms/djangoapps/bulk_email/tests/test_views.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Test the bulk email opt out view.
|
||||
"""
|
||||
from six import text_type
|
||||
|
||||
import ddt
|
||||
from django.http import Http404
|
||||
from django.test.client import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from bulk_email.models import Optout
|
||||
from bulk_email.views import opt_out_email_updates
|
||||
from lms.djangoapps.discussion.notification_prefs.views import UsernameCipher
|
||||
from openedx.core.lib.tests import attr
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
|
||||
@attr(shard=1)
|
||||
@ddt.ddt
|
||||
@override_settings(SECRET_KEY="test secret key")
|
||||
class OptOutEmailUpdatesViewTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Check the opt out email functionality.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(OptOutEmailUpdatesViewTest, self).setUp()
|
||||
self.user = UserFactory.create(username="testuser1")
|
||||
self.token = UsernameCipher.encrypt('testuser1')
|
||||
self.request_factory = RequestFactory()
|
||||
self.course = CourseFactory.create(run='testcourse1', display_name='Test Course Title')
|
||||
self.url = reverse('bulk_email_opt_out', args=[self.token, text_type(self.course.id)])
|
||||
|
||||
# Ensure we start with no opt-out records
|
||||
self.assertEqual(Optout.objects.count(), 0)
|
||||
|
||||
def test_opt_out_email_confirm(self):
|
||||
"""
|
||||
Ensure that the default GET view asks for confirmation.
|
||||
"""
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "Do you want to unsubscribe from emails for Test Course Title?")
|
||||
self.assertEqual(Optout.objects.count(), 0)
|
||||
|
||||
def test_opt_out_email_unsubscribe(self):
|
||||
"""
|
||||
Ensure that the POSTing "confirm" creates the opt-out record.
|
||||
"""
|
||||
response = self.client.post(self.url, {'submit': 'confirm'})
|
||||
self.assertContains(response, "You have been unsubscribed from emails for Test Course Title.")
|
||||
self.assertEqual(Optout.objects.count(), 1)
|
||||
|
||||
def test_opt_out_email_cancel(self):
|
||||
"""
|
||||
Ensure that the POSTing "cancel" does not create the opt-out record
|
||||
"""
|
||||
response = self.client.post(self.url, {'submit': 'cancel'})
|
||||
self.assertContains(response, "You have not been unsubscribed from emails for Test Course Title.")
|
||||
self.assertEqual(Optout.objects.count(), 0)
|
||||
|
||||
@ddt.data(
|
||||
("ZOMG INVALID BASE64 CHARS!!!", "base64url", False),
|
||||
("Non-ASCII\xff", "base64url", False),
|
||||
("D6L8Q01ztywqnr3coMOlq0C3DG05686lXX_1ArEd0ok", "base64url", False),
|
||||
("AAAAAAAAAAA=", "initialization_vector", False),
|
||||
("nMXVK7PdSlKPOovci-M7iqS09Ux8VoCNDJixLBmj", "aes", False),
|
||||
("AAAAAAAAAAAAAAAAAAAAAMoazRI7ePLjEWXN1N7keLw=", "padding", False),
|
||||
("AAAAAAAAAAAAAAAAAAAAACpyUxTGIrUjnpuUsNi7mAY=", "username", False),
|
||||
("_KHGdCAUIToc4iaRGy7K57mNZiiXxO61qfKT08ExlY8=", "course", 'course-v1:testcourse'),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_unsubscribe_invalid_token(self, token, message, course):
|
||||
"""
|
||||
Make sure that view returns 404 in case token is not valid
|
||||
"""
|
||||
request = self.request_factory.get("dummy")
|
||||
with self.assertRaises(Http404) as err:
|
||||
opt_out_email_updates(request, token, course)
|
||||
self.assertIn(message, err)
|
||||
18
lms/djangoapps/bulk_email/urls.py
Normal file
18
lms/djangoapps/bulk_email/urls.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
URLs for bulk_email app
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
|
||||
from bulk_email import views
|
||||
|
||||
urlpatterns = [
|
||||
url(
|
||||
r'^email/optout/(?P<token>[a-zA-Z0-9-_=]+)/{}/$'.format(
|
||||
settings.COURSE_ID_PATTERN,
|
||||
),
|
||||
views.opt_out_email_updates,
|
||||
name='bulk_email_opt_out',
|
||||
),
|
||||
]
|
||||
72
lms/djangoapps/bulk_email/views.py
Normal file
72
lms/djangoapps/bulk_email/views.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Views to support bulk email functionalities like opt-out.
|
||||
"""
|
||||
|
||||
from __future__ import division
|
||||
|
||||
import logging
|
||||
|
||||
from six import text_type
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import Http404
|
||||
|
||||
from bulk_email.models import Optout
|
||||
from courseware.courses import get_course_by_id
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from lms.djangoapps.discussion.notification_prefs.views import (
|
||||
UsernameCipher,
|
||||
UsernameDecryptionException,
|
||||
)
|
||||
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def opt_out_email_updates(request, token, course_id):
|
||||
"""
|
||||
A view that let users opt out of any email updates.
|
||||
|
||||
This meant is meant to be the target of an opt-out link or button.
|
||||
The `token` parameter must decrypt to a valid username.
|
||||
The `course_id` is the string course key of any course.
|
||||
|
||||
Raises a 404 if there are any errors parsing the input.
|
||||
"""
|
||||
try:
|
||||
username = UsernameCipher().decrypt(token.encode())
|
||||
user = User.objects.get(username=username)
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
course = get_course_by_id(course_key, depth=0)
|
||||
except UnicodeDecodeError:
|
||||
raise Http404("base64url")
|
||||
except UsernameDecryptionException as exn:
|
||||
raise Http404(text_type(exn))
|
||||
except User.DoesNotExist:
|
||||
raise Http404("username")
|
||||
except InvalidKeyError:
|
||||
raise Http404("course")
|
||||
|
||||
context = {
|
||||
'course': course,
|
||||
'cancelled': False,
|
||||
'confirmed': False,
|
||||
}
|
||||
|
||||
if request.method == 'POST':
|
||||
if request.POST.get('submit') == 'confirm':
|
||||
Optout.objects.get_or_create(user=user, course_id=course.id)
|
||||
log.info(
|
||||
u"User %s (%s) opted out of receiving emails from course %s",
|
||||
user.username,
|
||||
user.email,
|
||||
course_id,
|
||||
)
|
||||
context['confirmed'] = True
|
||||
else:
|
||||
context['cancelled'] = True
|
||||
|
||||
return render_to_response('bulk_email/unsubscribe.html', context)
|
||||
48
lms/templates/bulk_email/unsubscribe.html
Normal file
48
lms/templates/bulk_email/unsubscribe.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<%page expression_filter="h" />
|
||||
<%inherit file="../main.html" />
|
||||
<%!
|
||||
from openedx.core.djangolib.markup import Text
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
<%def name="header()">
|
||||
%if confirmed:
|
||||
${Text(_("Unsubscribe Successful"))}
|
||||
%elif cancelled:
|
||||
${Text(_("Unsubscribe Cancelled"))}
|
||||
%else:
|
||||
${Text(_("Confirm Unsubscribe"))}
|
||||
%endif
|
||||
</%def>
|
||||
|
||||
<%block name="pagetitle">${header()}</%block>
|
||||
<section class="container unsubscribe">
|
||||
|
||||
<section class="message">
|
||||
<h1>
|
||||
<%block name="pageheader">${header()}</%block>
|
||||
</h1>
|
||||
<p>
|
||||
<%block name="pagecontent">
|
||||
%if confirmed:
|
||||
${Text(_("You have been unsubscribed from emails for {course}.")).format(
|
||||
course=course.display_name_with_default
|
||||
)}
|
||||
%elif cancelled:
|
||||
${Text(_("You have not been unsubscribed from emails for {course}.")).format(
|
||||
course=course.display_name_with_default
|
||||
)}
|
||||
%else:
|
||||
${Text(_("Do you want to unsubscribe from emails for {course}?")).format(
|
||||
course=course.display_name_with_default
|
||||
)}
|
||||
<br /><br />
|
||||
<form method="post">
|
||||
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
|
||||
<button name="submit" value="confirm" type="submit">${Text(_('Unsubscribe'))}</button>
|
||||
<button name="submit" value="cancel" type="submit">${Text(_('Cancel'))}</button>
|
||||
</form>
|
||||
%endif
|
||||
</%block>
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
@@ -722,6 +722,10 @@ if settings.FEATURES.get('ENABLE_DISCUSSION_SERVICE'):
|
||||
),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
url(r'^bulk_email/', include('bulk_email.urls')),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
url(
|
||||
r'^courses/{}/tab/(?P<tab_type>[^/]+)/$'.format(
|
||||
|
||||
@@ -177,6 +177,13 @@
|
||||
{{ contact_mailing_address }}
|
||||
</td>
|
||||
</tr>
|
||||
{% if unsubscribe_url %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% with_link_tracking unsubscribe_url %}">{% trans "Unsubscribe from these emails." as tmsg %}{{ tmsg | force_escape }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -3,9 +3,13 @@ Contains configuration for schedules app
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlag, WaffleFlagNamespace
|
||||
from openedx.core.djangoapps.waffle_utils import (
|
||||
WaffleFlagNamespace, CourseWaffleFlag, WaffleFlag,
|
||||
WaffleSwitch, WaffleSwitchNamespace,
|
||||
)
|
||||
|
||||
WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(name=u'schedules')
|
||||
WAFFLE_SWITCH_NAMESPACE = WaffleSwitchNamespace(name=u'schedules')
|
||||
|
||||
CREATE_SCHEDULE_WAFFLE_FLAG = CourseWaffleFlag(
|
||||
waffle_namespace=WAFFLE_FLAG_NAMESPACE,
|
||||
@@ -20,3 +24,5 @@ COURSE_UPDATE_WAFFLE_FLAG = CourseWaffleFlag(
|
||||
)
|
||||
|
||||
DEBUG_MESSAGE_WAFFLE_FLAG = WaffleFlag(WAFFLE_FLAG_NAMESPACE, u'enable_debugging')
|
||||
|
||||
COURSE_UPDATE_SHOW_UNSUBSCRIBE_WAFFLE_SWITCH = WaffleSwitch(WAFFLE_SWITCH_NAMESPACE, u'course_update_show_unsubscribe')
|
||||
|
||||
@@ -15,7 +15,9 @@ from edx_ace.recipient_resolver import RecipientResolver
|
||||
from edx_django_utils.monitoring import function_trace, set_custom_metric
|
||||
|
||||
from lms.djangoapps.courseware.date_summary import verified_upgrade_deadline_link, verified_upgrade_link_is_valid
|
||||
from lms.djangoapps.discussion.notification_prefs.views import UsernameCipher
|
||||
from openedx.core.djangoapps.ace_common.template_context import get_base_template_context
|
||||
from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_SHOW_UNSUBSCRIBE_WAFFLE_SWITCH
|
||||
from openedx.core.djangoapps.schedules.content_highlights import get_week_highlights
|
||||
from openedx.core.djangoapps.schedules.exceptions import CourseUpdateDoesNotExist
|
||||
from openedx.core.djangoapps.schedules.models import Schedule, ScheduleExperience
|
||||
@@ -91,6 +93,13 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver):
|
||||
with function_trace('enqueue_send_task'):
|
||||
self.async_send_task.apply_async((self.site.id, str(msg)), retry=False)
|
||||
|
||||
@classmethod
|
||||
def bin_num_for_user_id(cls, user_id):
|
||||
"""
|
||||
Returns the bin number used for the given (numeric) user ID.
|
||||
"""
|
||||
return user_id % cls.num_bins
|
||||
|
||||
def get_schedules_with_target_date_by_bin_and_orgs(
|
||||
self, order_by='enrollment__user__id'
|
||||
):
|
||||
@@ -109,7 +118,7 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver):
|
||||
courseenrollment__is_active=True,
|
||||
**schedule_day_equals_target_day_filter
|
||||
).annotate(
|
||||
id_mod=F('id') % self.num_bins
|
||||
id_mod=self.bin_num_for_user_id(F('id'))
|
||||
).filter(
|
||||
id_mod=self.bin_num
|
||||
)
|
||||
@@ -363,6 +372,14 @@ class CourseUpdateResolver(BinnedSchedulesBaseResolver):
|
||||
)
|
||||
# continue to the next schedule, don't yield an email for this one
|
||||
else:
|
||||
unsubscribe_url = None
|
||||
if (COURSE_UPDATE_SHOW_UNSUBSCRIBE_WAFFLE_SWITCH.is_enabled() and
|
||||
'bulk_email_optout' in settings.ACE_ENABLED_POLICIES):
|
||||
unsubscribe_url = reverse('bulk_email_opt_out', kwargs={
|
||||
'token': UsernameCipher.encrypt(user.username),
|
||||
'course_id': str(enrollment.course_id),
|
||||
})
|
||||
|
||||
template_context.update({
|
||||
'course_name': schedule.enrollment.course.display_name,
|
||||
'course_url': _get_trackable_course_home_url(enrollment.course_id),
|
||||
@@ -372,6 +389,7 @@ class CourseUpdateResolver(BinnedSchedulesBaseResolver):
|
||||
|
||||
# This is used by the bulk email optout policy
|
||||
'course_ids': [str(enrollment.course_id)],
|
||||
'unsubscribe_url': unsubscribe_url,
|
||||
})
|
||||
template_context.update(_get_upsell_information_for_schedule(user, schedule))
|
||||
|
||||
|
||||
@@ -8,25 +8,47 @@ from unittest import skipUnless
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from mock import Mock
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from mock import Mock, patch
|
||||
from waffle.testutils import override_switch
|
||||
|
||||
from openedx.core.djangoapps.schedules.resolvers import BinnedSchedulesBaseResolver
|
||||
from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_WAFFLE_FLAG
|
||||
from openedx.core.djangoapps.schedules.resolvers import (
|
||||
BinnedSchedulesBaseResolver,
|
||||
CourseUpdateResolver,
|
||||
)
|
||||
from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory
|
||||
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.waffle_utils.testutils import override_waffle_flag
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationMixin, skip_unless_lms
|
||||
from student.tests.factories import CourseEnrollmentFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
|
||||
|
||||
class SchedulesResolverTestMixin(CacheIsolationMixin):
|
||||
"""
|
||||
Base class for the resolver tests.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(SchedulesResolverTestMixin, self).setUp()
|
||||
self.site = SiteFactory.create()
|
||||
self.site_config = SiteConfigurationFactory(site=self.site)
|
||||
self.schedule_config = ScheduleConfigFactory.create(site=self.site)
|
||||
|
||||
|
||||
@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 TestBinnedSchedulesBaseResolver(CacheIsolationTestCase):
|
||||
class TestBinnedSchedulesBaseResolver(SchedulesResolverTestMixin, TestCase):
|
||||
"""
|
||||
Tests the BinnedSchedulesBaseResolver.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestBinnedSchedulesBaseResolver, self).setUp()
|
||||
|
||||
self.site = SiteFactory.create()
|
||||
self.site_config = SiteConfigurationFactory(site=self.site)
|
||||
self.schedule_config = ScheduleConfigFactory.create(site=self.site)
|
||||
self.resolver = BinnedSchedulesBaseResolver(
|
||||
async_send_task=Mock(name='async_send_task'),
|
||||
site=self.site,
|
||||
@@ -72,3 +94,64 @@ class TestBinnedSchedulesBaseResolver(CacheIsolationTestCase):
|
||||
result = self.resolver.filter_by_org(mock_query)
|
||||
mock_query.exclude.assert_called_once_with(enrollment__course__org__in=expected_org_list)
|
||||
self.assertEqual(result, mock_query.exclude.return_value)
|
||||
|
||||
|
||||
@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 TestCourseUpdateResolver(SchedulesResolverTestMixin, ModuleStoreTestCase):
|
||||
"""
|
||||
Tests the CourseUpdateResolver.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestCourseUpdateResolver, self).setUp()
|
||||
self.course = CourseFactory(highlights_enabled_for_messaging=True, self_paced=True)
|
||||
with self.store.bulk_operations(self.course.id):
|
||||
ItemFactory.create(parent=self.course, category='chapter', highlights=[u'good stuff'])
|
||||
|
||||
def create_resolver(self):
|
||||
"""
|
||||
Creates a CourseUpdateResolver with an enrollment to schedule.
|
||||
"""
|
||||
with patch('openedx.core.djangoapps.schedules.signals.get_current_site') as mock_get_current_site:
|
||||
mock_get_current_site.return_value = self.site_config.site
|
||||
enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user, mode=u'audit')
|
||||
|
||||
return CourseUpdateResolver(
|
||||
async_send_task=Mock(name='async_send_task'),
|
||||
site=self.site_config.site,
|
||||
target_datetime=enrollment.schedule.start,
|
||||
day_offset=-7,
|
||||
bin_num=CourseUpdateResolver.bin_num_for_user_id(self.user.id),
|
||||
)
|
||||
|
||||
@override_settings(CONTACT_MAILING_ADDRESS='123 Sesame Street')
|
||||
@override_waffle_flag(COURSE_UPDATE_WAFFLE_FLAG, True)
|
||||
def test_schedule_context(self):
|
||||
resolver = self.create_resolver()
|
||||
schedules = list(resolver.schedules_for_bin())
|
||||
expected_context = {
|
||||
'contact_email': 'info@example.com',
|
||||
'contact_mailing_address': '123 Sesame Street',
|
||||
'course_ids': [str(self.course.id)],
|
||||
'course_name': self.course.display_name,
|
||||
'course_url': '/courses/{}/course/'.format(self.course.id),
|
||||
'dashboard_url': '/dashboard',
|
||||
'homepage_url': '/',
|
||||
'mobile_store_urls': {},
|
||||
'platform_name': u'\xe9dX',
|
||||
'show_upsell': False,
|
||||
'social_media_urls': {},
|
||||
'template_revision': 'release',
|
||||
'unsubscribe_url': None,
|
||||
'week_highlights': ['good stuff'],
|
||||
'week_num': 1,
|
||||
}
|
||||
self.assertEqual(schedules, [(self.user, None, expected_context)])
|
||||
|
||||
@override_waffle_flag(COURSE_UPDATE_WAFFLE_FLAG, True)
|
||||
@override_switch('schedules.course_update_show_unsubscribe', True)
|
||||
def test_schedule_context_show_unsubscribe(self):
|
||||
resolver = self.create_resolver()
|
||||
schedules = list(resolver.schedules_for_bin())
|
||||
self.assertIn('optout', schedules[0][2]['unsubscribe_url'])
|
||||
|
||||
@@ -48,6 +48,22 @@ class CacheIsolationMixin(object):
|
||||
__settings_overrides = []
|
||||
__old_settings = []
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CacheIsolationMixin, cls).setUpClass()
|
||||
cls.start_cache_isolation()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.end_cache_isolation()
|
||||
super(CacheIsolationMixin, cls).tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
super(CacheIsolationMixin, self).setUp()
|
||||
|
||||
self.clear_caches()
|
||||
self.addCleanup(self.clear_caches)
|
||||
|
||||
@classmethod
|
||||
def start_cache_isolation(cls):
|
||||
"""
|
||||
@@ -131,21 +147,6 @@ class CacheIsolationTestCase(CacheIsolationMixin, TestCase):
|
||||
:py:class:`CacheIsolationMixin`) at class setup, and flushes the cache
|
||||
between every test.
|
||||
"""
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CacheIsolationTestCase, cls).setUpClass()
|
||||
cls.start_cache_isolation()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.end_cache_isolation()
|
||||
super(CacheIsolationTestCase, cls).tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
super(CacheIsolationTestCase, self).setUp()
|
||||
|
||||
self.clear_caches()
|
||||
self.addCleanup(self.clear_caches)
|
||||
|
||||
|
||||
class _AssertNumQueriesContext(CaptureQueriesContext):
|
||||
|
||||
Reference in New Issue
Block a user