Adds optional "unsubscribe" link and api support to let users opt out of email updates.
Scheduled emails show "unsubscribe" link if waffle switch `schedules.course_update_show_unsubscribe` is enabled, and settings.ACE_ENABLED_POLICIES respects `bulk_email_optout`. API endpoint allows GET/POST requests, which: * GET asks for confirmation of opt-out * POST accepts "unsubscribe" or "cancel", where "unsubscribe" creates the Optout entry, and "cancel" does nothing.
This commit is contained in:
committed by
Jillian Vogel
parent
c0889e16ab
commit
3eb9058dd2
@@ -175,6 +175,13 @@
|
||||
{{ contact_mailing_address }}
|
||||
</td>
|
||||
</tr>
|
||||
{% if unsubscribe_url %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% with_link_tracking unsubscribe_url %}">{% trans "Unsubscribe from these emails." %}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
from openedx.core.djangoapps.waffle_utils import WaffleFlagNamespace, CourseWaffleFlag, WaffleFlag
|
||||
"""
|
||||
Contains waffle flags and switches for use with the Schedules app.
|
||||
"""
|
||||
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,
|
||||
@@ -16,3 +23,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')
|
||||
|
||||
@@ -13,6 +13,8 @@ from edx_ace.recipient import Recipient
|
||||
from edx_django_utils.monitoring import function_trace, set_custom_metric
|
||||
|
||||
from courseware.date_summary import verified_upgrade_deadline_link, verified_upgrade_link_is_valid
|
||||
from lms.djangoapps.notification_prefs.views import UsernameCipher
|
||||
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
|
||||
@@ -358,6 +360,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),
|
||||
@@ -367,6 +377,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))
|
||||
|
||||
|
||||
@@ -1,27 +1,51 @@
|
||||
"""
|
||||
Tests for the Schedules app resolvers.
|
||||
"""
|
||||
import datetime
|
||||
from unittest import skipUnless
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from mock import Mock
|
||||
from freezegun import freeze_time
|
||||
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 SiteFactory, SiteConfigurationFactory
|
||||
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, 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(CacheIsolationTestCase):
|
||||
"""
|
||||
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, CacheIsolationTestCase):
|
||||
"""
|
||||
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,
|
||||
@@ -67,3 +91,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)
|
||||
|
||||
|
||||
@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")
|
||||
@override_waffle_flag(COURSE_UPDATE_WAFFLE_FLAG, True)
|
||||
@freeze_time('2017-08-01 01:00:00', tz_offset=0, tick=False)
|
||||
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=1,
|
||||
)
|
||||
|
||||
def test_schedule_context(self):
|
||||
resolver = self.create_resolver()
|
||||
schedules = list(resolver.schedules_for_bin())
|
||||
expected_context = {
|
||||
'course_name': self.course.display_name,
|
||||
'course_url': '/courses/{}/course/'.format(self.course.id),
|
||||
'week_num': 1,
|
||||
'week_highlights': ['good stuff'],
|
||||
'course_ids': [str(self.course.id)],
|
||||
'platform_name': u'\xe9dX',
|
||||
'mobile_store_urls': {'google': '#', 'apple': '#'},
|
||||
'homepage_url': '/',
|
||||
'template_revision': 'unknown',
|
||||
'contact_email': 'info@example.com',
|
||||
'social_media_urls': {},
|
||||
'dashboard_url': '/dashboard',
|
||||
'contact_mailing_address': '',
|
||||
'show_upsell': False,
|
||||
'unsubscribe_url': None,
|
||||
}
|
||||
self.assertEqual(schedules, [(self.user, None, expected_context)])
|
||||
|
||||
@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'])
|
||||
|
||||
Reference in New Issue
Block a user