Commit c08060a0 by Nimisha Asthagiri Committed by GitHub

Merge pull request #15745 from edx/ret/triggered-email

RET triggered emails
parents a403fd14 6a36eb01
......@@ -1067,6 +1067,9 @@ INSTALLED_APPS = [
# Waffle related utilities
'openedx.core.djangoapps.waffle_utils',
# Dynamic schedules
'openedx.core.djangoapps.schedules.apps.SchedulesConfig',
# DRF filters
'django_filters',
]
......
......@@ -50,9 +50,12 @@ import lms.lib.comment_client as cc
import request_cache
from certificates.models import GeneratedCertificate
from course_modes.models import CourseMode
from courseware.models import DynamicUpgradeDeadlineConfiguration, CourseDynamicUpgradeDeadlineConfiguration
from enrollment.api import _default_course_mode
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.schedules.models import ScheduleConfig
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming.helpers import get_current_site
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, NoneToEmptyManager
from track import contexts
from util.milestones_helpers import is_entrance_exams_enabled
......@@ -1715,7 +1718,11 @@ class CourseEnrollment(models.Model):
return None
try:
if self.schedule:
schedule_driven_deadlines_enabled = (
DynamicUpgradeDeadlineConfiguration.is_enabled()
or CourseDynamicUpgradeDeadlineConfiguration.is_enabled(self.course_id)
)
if schedule_driven_deadlines_enabled and self.schedule and self.schedule.upgrade_deadline is not None:
log.debug(
'Schedules: Pulling upgrade deadline for CourseEnrollment %d from Schedule %d.',
self.id, self.schedule.id
......
......@@ -126,7 +126,9 @@ class CourseEnrollmentFactory(DjangoModelFactory):
model = CourseEnrollment
user = factory.SubFactory(UserFactory)
course_id = CourseKey.from_string('edX/toy/2012_Fall')
course = factory.SubFactory(
'openedx.core.djangoapps.content.course_overviews.tests.factories.CourseOverviewFactory',
)
class CourseAccessRoleFactory(DjangoModelFactory):
......
......@@ -13,6 +13,7 @@ from django.db.models.functions import Lower
from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory
from courseware.models import DynamicUpgradeDeadlineConfiguration
from openedx.core.djangoapps.schedules.models import Schedule
from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms
......@@ -131,6 +132,7 @@ class CourseEnrollmentTests(SharedModuleStoreTestCase):
self.assertEqual(enrollment.upgrade_deadline, course_mode.expiration_datetime)
# The schedule's upgrade deadline should be used if a schedule exists
DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True)
schedule = ScheduleFactory(enrollment=enrollment)
self.assertEqual(enrollment.upgrade_deadline, schedule.upgrade_deadline)
......
from edx_ace.policy import Policy, PolicyResult
from edx_ace.channel import ChannelType
from opaque_keys.edx.keys import CourseKey
from bulk_email.models import Optout
class CourseEmailOptout(Policy):
def check(self, message):
course_id = message.context.get('course_id')
if not course_id:
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():
return PolicyResult(deny={ChannelType.EMAIL})
return PolicyResult(deny=frozenset())
......@@ -11,6 +11,11 @@ from mock import Mock, patch
from nose.plugins.attrib import attr
from bulk_email.models import BulkEmailFlag
from bulk_email.policies import CourseEmailOptout
from edx_ace.message import Message
from edx_ace.recipient import Recipient
from edx_ace.policy import PolicyResult
from edx_ace.channel import ChannelType
from student.models import CourseEnrollment
from student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
......@@ -27,7 +32,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase):
def setUp(self):
super(TestOptoutCourseEmails, self).setUp()
course_title = u"ẗëṡẗ title イ乇丂イ ᄊ乇丂丂ムg乇 キo尺 ムレレ тэѕт мэѕѕаБэ"
self.course = CourseFactory.create(display_name=course_title)
self.course = CourseFactory.create(run='testcourse1', display_name=course_title)
self.instructor = AdminFactory.create()
self.student = UserFactory.create()
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
......@@ -44,10 +49,6 @@ class TestOptoutCourseEmails(ModuleStoreTestCase):
}
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=False)
def tearDown(self):
super(TestOptoutCourseEmails, self).tearDown()
BulkEmailFlag.objects.all().delete()
def navigate_to_email_view(self):
"""Navigate to the instructor dash's email view"""
# Pull up email view on instructor dashboard
......@@ -114,3 +115,73 @@ class TestOptoutCourseEmails(ModuleStoreTestCase):
sent_addresses = [message.to[0] for message in mail.outbox]
self.assertIn(self.student.email, sent_addresses)
self.assertIn(self.instructor.email, sent_addresses)
@attr(shard=1)
@patch('bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message', autospec=True))
class TestACEOptoutCourseEmails(ModuleStoreTestCase):
"""
Test that optouts are referenced in sending course email.
"""
def setUp(self):
super(TestACEOptoutCourseEmails, self).setUp()
course_title = u"ẗëṡẗ title イ乇丂イ ᄊ乇丂丂ムg乇 キo尺 ムレレ тэѕт мэѕѕаБэ"
self.course = CourseFactory.create(run='testcourse1', display_name=course_title)
self.instructor = AdminFactory.create()
self.student = UserFactory.create()
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
self.client.login(username=self.student.username, password="test")
self._set_email_optout(False)
self.policy = CourseEmailOptout()
def _set_email_optout(self, opted_out):
url = reverse('change_email_settings')
# This is a checkbox, so on the post of opting out (that is, an Un-check of the box),
# the Post that is sent will not contain 'receive_emails'
post_data = {'course_id': self.course.id.to_deprecated_string()}
if not opted_out:
post_data['receive_emails'] = 'on'
response = self.client.post(url, post_data)
self.assertEquals(json.loads(response.content), {'success': True})
def test_policy_optedout(self):
"""
Make sure the policy prevents ACE emails if the user is opted-out.
"""
self._set_email_optout(True)
channel_mods = self.policy.check(self.create_test_message())
self.assertEqual(channel_mods, PolicyResult(deny={ChannelType.EMAIL}))
def create_test_message(self):
return Message(
app_label='foo',
name='bar',
recipient=Recipient(
username=self.student.username,
email_address=self.student.email,
),
context={
'course_id': str(self.course.id)
},
)
def test_policy_optedin(self):
"""
Make sure the policy allows ACE emails if the user is opted-in.
"""
channel_mods = self.policy.check(self.create_test_message())
self.assertEqual(channel_mods, PolicyResult(deny=set()))
def test_policy_no_course_id(self):
"""
Make sure the policy denies ACE emails if there is no course id in the context.
"""
message = self.create_test_message()
message.context = {}
channel_mods = self.policy.check(message)
self.assertEqual(channel_mods, PolicyResult(deny=set()))
......@@ -237,18 +237,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase):
# # of sql queries to default,
# # of mongo queries,
# )
('no_overrides', 1, True, False): (25, 1),
('no_overrides', 2, True, False): (25, 1),
('no_overrides', 3, True, False): (25, 1),
('ccx', 1, True, False): (25, 1),
('ccx', 2, True, False): (25, 1),
('ccx', 3, True, False): (25, 1),
('no_overrides', 1, False, False): (25, 1),
('no_overrides', 2, False, False): (25, 1),
('no_overrides', 3, False, False): (25, 1),
('ccx', 1, False, False): (25, 1),
('ccx', 2, False, False): (25, 1),
('ccx', 3, False, False): (25, 1),
('no_overrides', 1, True, False): (26, 1),
('no_overrides', 2, True, False): (26, 1),
('no_overrides', 3, True, False): (26, 1),
('ccx', 1, True, False): (26, 1),
('ccx', 2, True, False): (26, 1),
('ccx', 3, True, False): (26, 1),
('no_overrides', 1, False, False): (26, 1),
('no_overrides', 2, False, False): (26, 1),
('no_overrides', 3, False, False): (26, 1),
('ccx', 1, False, False): (26, 1),
('ccx', 2, False, False): (26, 1),
('ccx', 3, False, False): (26, 1),
}
......@@ -260,19 +260,19 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase):
__test__ = True
TEST_DATA = {
('no_overrides', 1, True, False): (25, 3),
('no_overrides', 2, True, False): (25, 3),
('no_overrides', 3, True, False): (25, 3),
('ccx', 1, True, False): (25, 3),
('ccx', 2, True, False): (25, 3),
('ccx', 3, True, False): (25, 3),
('ccx', 1, True, True): (26, 3),
('ccx', 2, True, True): (26, 3),
('ccx', 3, True, True): (26, 3),
('no_overrides', 1, False, False): (25, 3),
('no_overrides', 2, False, False): (25, 3),
('no_overrides', 3, False, False): (25, 3),
('ccx', 1, False, False): (25, 3),
('ccx', 2, False, False): (25, 3),
('ccx', 3, False, False): (25, 3),
('no_overrides', 1, True, False): (26, 3),
('no_overrides', 2, True, False): (26, 3),
('no_overrides', 3, True, False): (26, 3),
('ccx', 1, True, False): (26, 3),
('ccx', 2, True, False): (26, 3),
('ccx', 3, True, False): (26, 3),
('ccx', 1, True, True): (27, 3),
('ccx', 2, True, True): (27, 3),
('ccx', 3, True, True): (27, 3),
('no_overrides', 1, False, False): (26, 3),
('no_overrides', 2, False, False): (26, 3),
('no_overrides', 3, False, False): (26, 3),
('ccx', 1, False, False): (26, 3),
('ccx', 2, False, False): (26, 3),
('ccx', 3, False, False): (26, 3),
}
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('courseware', '0002_coursedynamicupgradedeadlineconfiguration_dynamicupgradedeadlineconfiguration'),
]
operations = [
migrations.AlterField(
model_name='coursedynamicupgradedeadlineconfiguration',
name='opt_out',
field=models.BooleanField(default=False, help_text='This does not do anything and is no longer used. Setting enabled=False has the same effect.'),
),
]
......@@ -398,5 +398,5 @@ class CourseDynamicUpgradeDeadlineConfiguration(ConfigurationModel):
)
opt_out = models.BooleanField(
default=False,
help_text=_('Disable the dynamic upgrade deadline for this course run.')
help_text=_('This does not do anything and is no longer used. Setting enabled=False has the same effect.')
)
......@@ -1447,12 +1447,12 @@ class ProgressPageTests(ProgressPageBaseTests):
"""Test that query counts remain the same for self-paced and instructor-paced courses."""
SelfPacedConfiguration(enabled=self_paced_enabled).save()
self.setup_course(self_paced=self_paced)
with self.assertNumQueries(42, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(2):
with self.assertNumQueries(43, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(2):
self._get_progress_page()
@ddt.data(
(False, 42, 28),
(True, 35, 24)
(False, 43, 27),
(True, 36, 23)
)
@ddt.unpack
def test_progress_queries(self, enable_waffle, initial, subsequent):
......
......@@ -1028,3 +1028,12 @@ PARENTAL_CONSENT_AGE_LIMIT = ENV_TOKENS.get(
'PARENTAL_CONSENT_AGE_LIMIT',
PARENTAL_CONSENT_AGE_LIMIT
)
############## Settings for ACE ####################################
ACE_ENABLED_CHANNELS = ENV_TOKENS.get('ACE_ENABLED_CHANNELS', ACE_ENABLED_CHANNELS)
ACE_ENABLED_POLICIES = ENV_TOKENS.get('ACE_ENABLED_POLICIES', ACE_ENABLED_POLICIES)
ACE_CHANNEL_SAILTHRU_DEBUG = ENV_TOKENS.get('ACE_CHANNEL_SAILTHRU_DEBUG', ACE_CHANNEL_SAILTHRU_DEBUG)
ACE_CHANNEL_SAILTHRU_TEMPLATE_NAME = ENV_TOKENS.get('ACE_CHANNEL_SAILTHRU_TEMPLATE_NAME', ACE_CHANNEL_SAILTHRU_TEMPLATE_NAME)
ACE_CHANNEL_SAILTHRU_API_KEY = AUTH_TOKENS.get('ACE_CHANNEL_SAILTHRU_API_KEY', ACE_CHANNEL_SAILTHRU_API_KEY)
ACE_CHANNEL_SAILTHRU_API_SECRET = AUTH_TOKENS.get('ACE_CHANNEL_SAILTHRU_API_SECRET', ACE_CHANNEL_SAILTHRU_API_SECRET)
ACE_ROUTING_KEY = ENV_TOKENS.get('ACE_ROUTING_KEY', ACE_ROUTING_KEY)
......@@ -2241,7 +2241,7 @@ INSTALLED_APPS = [
'database_fixups',
'openedx.core.djangoapps.waffle_utils',
'openedx.core.djangoapps.schedules',
'openedx.core.djangoapps.schedules.apps.SchedulesConfig',
# Features
'openedx.features.course_bookmarks',
......@@ -3288,3 +3288,18 @@ COURSES_API_CACHE_TIMEOUT = 3600 # Value is in seconds
############## Settings for CourseGraph ############################
COURSEGRAPH_JOB_QUEUE = LOW_PRIORITY_QUEUE
############## Settings for ACE ####################################
ACE_ENABLED_CHANNELS = [
'sailthru_email'
]
ACE_ENABLED_POLICIES = [
'bulk_email_optout'
]
ACE_CHANNEL_SAILTHRU_DEBUG = True
ACE_CHANNEL_SAILTHRU_TEMPLATE_NAME = 'Automated Communication Engine Email'
ACE_CHANNEL_SAILTHRU_API_KEY = None
ACE_CHANNEL_SAILTHRU_API_SECRET = None
ACE_ROUTING_KEY = LOW_PRIORITY_QUEUE
import json
import factory
from factory.django import DjangoModelFactory
from ..models import CourseOverview
from opaque_keys.edx.locator import CourseLocator
class CourseOverviewFactory(DjangoModelFactory):
class Meta(object):
model = CourseOverview
django_get_or_create = ('id', )
version = CourseOverview.VERSION
pre_requisite_courses = []
start = factory.Faker('past_datetime')
org = 'edX'
@factory.lazy_attribute
def _pre_requisite_courses_json(self):
return json.dumps(self.pre_requisite_courses)
@factory.lazy_attribute
def _location(self):
return self.id.make_usage_key('course', 'course')
@factory.lazy_attribute
def id(self):
return CourseLocator(self.org, 'toy', '2012_Fall')
......@@ -35,7 +35,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls, check_mongo_calls_range
from .models import CourseOverview, CourseOverviewImageSet, CourseOverviewImageConfig
from ..models import CourseOverview, CourseOverviewImageSet, CourseOverviewImageConfig
@attr(shard=3)
......
......@@ -25,3 +25,9 @@ class ScheduleAdmin(admin.ModelAdmin):
qs = super(ScheduleAdmin, self).get_queryset(request)
qs = qs.select_related('enrollment', 'enrollment__user')
return qs
@admin.register(models.ScheduleConfig)
class ScheduleConfigAdmin(admin.ModelAdmin):
search_fields = ('site',)
list_display = ('site', 'create_schedules', 'enqueue_recurring_nudge', 'deliver_recurring_nudge')
......@@ -8,4 +8,4 @@ class SchedulesConfig(AppConfig):
def ready(self):
# noinspection PyUnresolvedReferences
from . import signals # pylint: disable=unused-variable
from . import signals, tasks # pylint: disable=unused-variable
from __future__ import print_function
import datetime
import logging
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
import pytz
from edx_ace.utils.date import serialize
from openedx.core.djangoapps.schedules.models import ScheduleConfig
from openedx.core.djangoapps.schedules.tasks import recurring_nudge_schedule_hour
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
from edx_ace.recipient_resolver import RecipientResolver
LOG = logging.getLogger(__name__)
class ScheduleStartResolver(RecipientResolver):
def __init__(self, site, current_date):
self.site = site
self.current_date = current_date.replace(hour=0, minute=0, second=0)
def send(self, day, override_recipient_email=None):
"""
Send a message to all users whose schedule started at ``self.current_date`` - ``day``.
"""
if not ScheduleConfig.current(self.site).enqueue_recurring_nudge:
return
try:
site_config = SiteConfiguration.objects.get(site_id=self.site.id)
org_list = site_config.values.get('course_org_filter', None)
exclude_orgs = False
if not org_list:
not_orgs = set()
for other_site_config in SiteConfiguration.objects.all():
not_orgs.update(other_site_config.values.get('course_org_filter', []))
org_list = list(not_orgs)
exclude_orgs = True
elif not isinstance(org_list, list):
org_list = [org_list]
except SiteConfiguration.DoesNotExist:
org_list = None
exclude_orgs = False
target_date = self.current_date - datetime.timedelta(days=day)
for hour in range(24):
target_hour = target_date + datetime.timedelta(hours=hour)
recurring_nudge_schedule_hour.apply_async(
(self.site.id, day, serialize(target_hour), org_list, exclude_orgs, override_recipient_email),
retry=False,
)
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
'--date',
default=datetime.datetime.utcnow().date().isoformat(),
help='The date to compute weekly messages relative to, in YYYY-MM-DD format',
)
parser.add_argument(
'--override-recipient-email',
help='Send all emails to this address instead of the actual recipient'
)
parser.add_argument('site_domain_name')
def handle(self, *args, **options):
current_date = datetime.datetime(
*[int(x) for x in options['date'].split('-')],
tzinfo=pytz.UTC
)
site = Site.objects.get(domain__iexact=options['site_domain_name'])
resolver = ScheduleStartResolver(site, current_date)
for day in (3, 10):
resolver.send(day, options.get('override_recipient_email'))
from __future__ import print_function
import datetime
from dateutil.tz import tzutc, gettz
from django.core.management.base import BaseCommand
from django.test.utils import CaptureQueriesContext
from django.db.models import Prefetch
from django.conf import settings
from django.core.urlresolvers import reverse
from django.db import DEFAULT_DB_ALIAS, connections
from django.utils.http import urlquote
from openedx.core.djangoapps.schedules.models import Schedule
from openedx.core.djangoapps.user_api.models import UserPreference
from edx_ace.message import MessageType
from edx_ace.recipient_resolver import RecipientResolver
from edx_ace import ace
from edx_ace.recipient import Recipient
from course_modes.models import CourseMode, format_course_price
from lms.djangoapps.experiments.utils import check_and_get_upgrade_link
class VerifiedUpgradeDeadlineReminder(MessageType):
pass
class VerifiedDeadlineResolver(RecipientResolver):
def __init__(self, target_deadline):
self.target_deadline = target_deadline
def send(self, msg_type):
for (user, language, context) in self.build_email_context():
msg = msg_type.personalize(
Recipient(
user.username,
user.email,
),
language,
context
)
ace.send(msg)
def build_email_context(self):
schedules = Schedule.objects.select_related(
'enrollment__user__profile',
'enrollment__course',
).prefetch_related(
Prefetch(
'enrollment__course__modes',
queryset=CourseMode.objects.filter(mode_slug=CourseMode.VERIFIED),
to_attr='verified_modes'
),
Prefetch(
'enrollment__user__preferences',
queryset=UserPreference.objects.filter(key='time_zone'),
to_attr='tzprefs'
),
).filter(
upgrade_deadline__year=self.schedule_deadline.year,
upgrade_deadline__month=self.schedule_deadline.month,
upgrade_deadline__day=self.schedule_deadline.day,
)
if "read_replica" in settings.DATABASES:
schedules = schedules.using("read_replica")
for schedule in schedules:
enrollment = schedule.enrollment
user = enrollment.user
user_time_zone = tzutc()
for preference in user.tzprefs:
user_time_zone = gettz(preference.value)
course_id_str = str(enrollment.course_id)
course = enrollment.course
course_root = reverse('course_root', kwargs={'course_id': urlquote(course_id_str)})
def absolute_url(relative_path):
return u'{}{}'.format(settings.LMS_ROOT_URL, relative_path)
template_context = {
'user_full_name': user.profile.name,
'user_personal_address': user.profile.name if user.profile.name else user.username,
'user_username': user.username,
'user_time_zone': user_time_zone,
'user_schedule_start_time': schedule.start,
'user_schedule_verified_upgrade_deadline_time': schedule.upgrade_deadline,
'course_id': course_id_str,
'course_title': course.display_name,
'course_url': absolute_url(course_root),
'course_image_url': absolute_url(course.course_image_url),
'course_end_time': course.end,
'course_verified_upgrade_url': check_and_get_upgrade_link(course, user),
'course_verified_upgrade_price': format_course_price(course.verified_modes[0].min_price),
}
yield (user, course.language, template_context)
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument('--date', default=datetime.datetime.utcnow().date().isoformat())
def handle(self, *args, **options):
current_date = datetime.date(*[int(x) for x in options['date'].split('-')])
msg_t = VerifiedUpgradeDeadlineReminder()
for offset in (2, 9, 16):
target_date = current_date + datetime.timedelta(days=offset)
VerifiedDeadlineResolver(target_date).send(msg_t)
import datetime
from mock import patch, Mock
from unittest import skipUnless
import pytz
import ddt
from django.conf import settings
from edx_ace.utils.date import serialize
from opaque_keys.edx.keys import CourseKey
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.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
@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(CacheIsolationTestCase):
# pylint: disable=protected-access
def setUp(self):
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)
@patch.object(nudge, 'ScheduleStartResolver')
def test_handle(self, mock_resolver):
test_time = datetime.datetime(2017, 8, 1, tzinfo=pytz.UTC)
nudge.Command().handle(date='2017-08-01', site_domain_name=self.site_config.site.domain)
mock_resolver.assert_called_with(self.site_config.site, test_time)
for day in (3, 10):
mock_resolver().send.assert_any_call(day, None)
@patch.object(tasks, 'ace')
@patch.object(nudge, 'recurring_nudge_schedule_hour')
def test_resolver_send(self, mock_schedule_hour, mock_ace):
current_time = datetime.datetime(2017, 8, 1, tzinfo=pytz.UTC)
nudge.ScheduleStartResolver(self.site_config.site, current_time).send(3)
test_time = current_time - datetime.timedelta(days=3)
self.assertFalse(mock_schedule_hour.called)
mock_schedule_hour.apply_async.assert_any_call(
(self.site_config.site.id, 3, serialize(test_time), [], True, None),
retry=False,
)
mock_schedule_hour.apply_async.assert_any_call(
(self.site_config.site.id, 3, serialize(test_time + datetime.timedelta(hours=23)), [], True, None),
retry=False,
)
self.assertFalse(mock_ace.send.called)
@ddt.data(1, 10, 100)
@patch.object(tasks, 'ace')
@patch.object(tasks, '_recurring_nudge_schedule_send')
def test_schedule_hour(self, schedule_count, mock_schedule_send, mock_ace):
schedules = [
ScheduleFactory.create(start=datetime.datetime(2017, 8, 1, 18, 34, 30, tzinfo=pytz.UTC))
for _ in range(schedule_count)
]
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.assertEqual(mock_schedule_send.apply_async.call_count, schedule_count)
self.assertFalse(mock_ace.send.called)
@patch.object(tasks, '_recurring_nudge_schedule_send')
def test_no_course_overview(self, mock_schedule_send):
schedule = ScheduleFactory.create(
start=datetime.datetime(2017, 8, 1, 20, 34, 30, tzinfo=pytz.UTC),
)
schedule.enrollment.course_id = CourseKey.from_string('edX/toy/Not_2012_Fall')
schedule.enrollment.save()
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],
)
# 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_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(nudge, 'recurring_nudge_schedule_hour')
def test_enqueue_disabled(self, mock_schedule_hour, mock_ace):
ScheduleConfigFactory.create(site=self.site_config.site, enqueue_recurring_nudge=False)
current_time = datetime.datetime(2017, 8, 1, tzinfo=pytz.UTC)
nudge.ScheduleStartResolver(self.site_config.site, current_time).send(3)
self.assertFalse(mock_schedule_hour.called)
self.assertFalse(mock_schedule_hour.apply_async.called)
self.assertFalse(mock_ace.send.called)
@patch.object(tasks, 'ace')
@patch.object(tasks, '_recurring_nudge_schedule_send')
@ddt.data(
((['filtered_org'], False, 1)),
((['filtered_org'], True, 2))
)
@ddt.unpack
def test_site_config(self, org_list, exclude_orgs, expected_message_count, mock_schedule_send, mock_ace):
filtered_org = 'filtered_org'
unfiltered_org = 'unfiltered_org'
site1 = SiteFactory.create(domain='foo1.bar', name='foo1.bar')
limited_config = SiteConfigurationFactory.create(values={'course_org_filter': [filtered_org]}, site=site1)
site2 = SiteFactory.create(domain='foo2.bar', name='foo2.bar')
unlimited_config = SiteConfigurationFactory.create(values={'course_org_filter': []}, site=site2)
for config in (limited_config, unlimited_config):
ScheduleConfigFactory.create(site=config.site)
filtered_sched = ScheduleFactory.create(
start=datetime.datetime(2017, 8, 2, 17, 44, 30, tzinfo=pytz.UTC),
enrollment__course__org=filtered_org,
)
unfiltered_scheds = [
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):
tasks.recurring_nudge_schedule_hour(
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)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
('sites', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('schedules', '0002_auto_20170816_1532'),
]
operations = [
migrations.CreateModel(
name='ScheduleConfig',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('create_schedules', models.BooleanField(default=False)),
('enqueue_recurring_nudge', models.BooleanField(default=False)),
('deliver_recurring_nudge', models.BooleanField(default=False)),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
('site', models.ForeignKey(to='sites.Site')),
],
options={
'ordering': ('-change_date',),
'abstract': False,
},
),
]
from collections import namedtuple
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from django.contrib.sites.models import Site
from config_models.models import ConfigurationModel
class Schedule(TimeStampedModel):
......@@ -23,3 +28,12 @@ class Schedule(TimeStampedModel):
class Meta(object):
verbose_name = _('Schedule')
verbose_name_plural = _('Schedules')
class ScheduleConfig(ConfigurationModel):
KEY_FIELDS = ('site',)
site = models.ForeignKey(Site)
create_schedules = models.BooleanField(default=False)
enqueue_recurring_nudge = models.BooleanField(default=False)
deliver_recurring_nudge = models.BooleanField(default=False)
......@@ -3,68 +3,84 @@ import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from course_modes.models import CourseMode
from courseware.models import DynamicUpgradeDeadlineConfiguration, CourseDynamicUpgradeDeadlineConfiguration
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
from openedx.core.djangoapps.theming.helpers import get_current_site
from openedx.core.djangoapps.waffle_utils import WaffleFlagNamespace, CourseWaffleFlag
from student.models import CourseEnrollment
from .models import Schedule
from .models import Schedule, ScheduleConfig
log = logging.getLogger(__name__)
def _get_upgrade_deadline(enrollment):
""" Returns the upgrade deadline for the given enrollment.
SCHEDULE_WAFFLE_FLAG = CourseWaffleFlag(
waffle_namespace=WaffleFlagNamespace('schedules'),
flag_name='create_schedules_for_course',
flag_undefined_default=False
)
The deadline is determined based on the following data (in priority order):
1. Course run-specific deadline configuration (CourseDynamicUpgradeDeadlineConfiguration)
2. Global deadline configuration (DynamicUpgradeDeadlineConfiguration)
3. Verified course mode expiration
"""
course_key = enrollment.course_id
upgrade_deadline = None
try:
verified_mode = CourseMode.verified_mode_for_course(course_key)
if verified_mode:
upgrade_deadline = verified_mode.expiration_datetime
except CourseMode.DoesNotExist:
pass
global_config = DynamicUpgradeDeadlineConfiguration.current()
if global_config.enabled:
delta = global_config.deadline_days
# Check if the given course has opted out of the feature
course_config = CourseDynamicUpgradeDeadlineConfiguration.current(course_key)
@receiver(post_save, sender=CourseEnrollment, dispatch_uid='create_schedule_for_enrollment')
def create_schedule(sender, **kwargs):
if not kwargs['created']:
# only create schedules when enrollment records are created
return
current_site = get_current_site()
if current_site is None:
log.debug('Schedules: No current site')
return
enrollment = kwargs['instance']
schedule_config = ScheduleConfig.current(current_site)
if (
not schedule_config.create_schedules
and not SCHEDULE_WAFFLE_FLAG.is_enabled(enrollment.course_id)
):
log.debug('Schedules: Creation not enabled for this course or for this site')
return
delta = None
if enrollment.course_overview.self_paced:
global_config = DynamicUpgradeDeadlineConfiguration.current()
if global_config.enabled:
# Use the default from this model whether or not the feature is enabled
delta = global_config.deadline_days
# Check if the course has a deadline override
course_config = CourseDynamicUpgradeDeadlineConfiguration.current(enrollment.course_id)
if course_config.enabled:
if course_config.opt_out:
return upgrade_deadline
delta = course_config.deadline_days
course_overview = CourseOverview.get_from_id(course_key)
# This represents the first date at which the learner can access the content. This will be the latter of
# either the enrollment date or the course's start date.
content_availability_date = max(enrollment.created, course_overview.start)
cav_based_deadline = content_availability_date + datetime.timedelta(days=delta)
# If the deadline from above is None, make sure we have a value for comparison
upgrade_deadline = upgrade_deadline or datetime.date.max
# The content availability-based deadline should never occur after the verified mode's
# expiration date, if one is set.
upgrade_deadline = min(upgrade_deadline, cav_based_deadline)
return upgrade_deadline
upgrade_deadline = None
@receiver(post_save, sender=CourseEnrollment, dispatch_uid='create_schedule_for_enrollment')
def create_schedule(sender, **kwargs):
if WaffleSwitchNamespace('schedules').is_enabled('enable-create-schedule-receiver') and kwargs['created']:
enrollment = kwargs['instance']
upgrade_deadline = _get_upgrade_deadline(enrollment)
Schedule.objects.create(enrollment=enrollment, start=timezone.now(), upgrade_deadline=upgrade_deadline)
# This represents the first date at which the learner can access the content. This will be the latter of
# either the enrollment date or the course's start date.
content_availability_date = max(enrollment.created, enrollment.course_overview.start)
if delta is not None:
upgrade_deadline = content_availability_date + datetime.timedelta(days=delta)
course_upgrade_deadline = None
try:
verified_mode = CourseMode.verified_mode_for_course(enrollment.course_id)
except CourseMode.DoesNotExist:
pass
else:
if verified_mode:
course_upgrade_deadline = verified_mode.expiration_datetime
if course_upgrade_deadline is not None and upgrade_deadline is not None:
# The content availability-based deadline should never occur after the verified mode's
# expiration date, if one is set.
upgrade_deadline = min(upgrade_deadline, course_upgrade_deadline)
Schedule.objects.create(
enrollment=enrollment,
start=content_availability_date,
upgrade_deadline=upgrade_deadline
)
log.debug('Schedules: created a new schedule starting at %s with an upgrade deadline of %s',
content_availability_date, upgrade_deadline)
import datetime
from celery.task import task
from django.conf import settings
from django.contrib.sites.models import Site
from django.core.urlresolvers import reverse
from django.utils.http import urlquote
from edx_ace import ace
from edx_ace.message import MessageType, Message
from edx_ace.recipient import Recipient
from edx_ace.utils.date import deserialize
from openedx.core.djangoapps.schedules.models import Schedule, ScheduleConfig
ROUTING_KEY = getattr(settings, 'ACE_ROUTING_KEY', None)
class RecurringNudge(MessageType):
def __init__(self, day, *args, **kwargs):
super(RecurringNudge, self).__init__(*args, **kwargs)
self.name = "recurringnudge_day{}".format(day)
@task(ignore_result=True, routing_key=ROUTING_KEY)
def recurring_nudge_schedule_hour(
site_id, day, target_hour_str, org_list, exclude_orgs=False, override_recipient_email=None,
):
target_hour = deserialize(target_hour_str)
msg_type = RecurringNudge(day)
for (user, language, context) in _recurring_nudge_schedules_for_hour(target_hour, org_list, exclude_orgs):
msg = msg_type.personalize(
Recipient(
user.username,
override_recipient_email or user.email,
),
language,
context,
)
_recurring_nudge_schedule_send.apply_async((site_id, str(msg)), retry=False)
@task(ignore_result=True, routing_key=ROUTING_KEY)
def _recurring_nudge_schedule_send(site_id, msg_str):
site = Site.objects.get(pk=site_id)
if not ScheduleConfig.current(site).deliver_recurring_nudge:
return
msg = Message.from_string(msg_str)
ace.send(msg)
def _recurring_nudge_schedules_for_hour(target_hour, org_list, exclude_orgs=False):
schedules = Schedule.objects.select_related(
'enrollment__user__profile',
'enrollment__course',
).filter(
start__gte=target_hour,
start__lt=target_hour + datetime.timedelta(minutes=60),
enrollment__is_active=True,
)
if org_list is not None:
if exclude_orgs:
schedules = schedules.exclude(enrollment__course__org__in=org_list)
else:
schedules = schedules.filter(enrollment__course__org__in=org_list)
if "read_replica" in settings.DATABASES:
schedules = schedules.using("read_replica")
for schedule in schedules:
enrollment = schedule.enrollment
user = enrollment.user
course_id_str = str(enrollment.course_id)
course = enrollment.course
course_root = reverse('course_root', args=[course_id_str])
def absolute_url(relative_path):
return u'{}{}'.format(settings.LMS_ROOT_URL, urlquote(relative_path))
template_context = {
'student_name': user.profile.name,
'course_name': course.display_name,
'course_url': absolute_url(course_root),
# This is used by the bulk email optout policy
'course_id': course_id_str,
}
yield (user, course.language, template_context)
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
<div bgcolor="#f5f5f5" lang="{{ LANGUAGE_CODE|default:"en" }}" style="
margin: 0;
padding: 0;
min-width: 100%;
">
<!-- This is preview text that is visible in the inbox view of many email clients but not visible in the actual
email itself. -->
<div style="
display:none;
font-size:1px;
line-height:1px;
max-height:0px;
max-width:0px;
opacity:0;
overflow:hidden;
visibility:hidden;
">
{% block preview_text %}{% endblock %}
</div>
<!-- Hack for outlook 2010, which wants to render everything in Times New Roman -->
<!--[if mso]>
<style type="text/css">
body, table, td {font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif !important;}
</style>
<![endif]-->
<!--[if (gte mso 9)|(IE)]>
<table role="presentation" width="600" align="center" cellpadding="0" cellspacing="0" border="0">
<tr>
<td>
<![endif]-->
<!-- CONTENT -->
<table class="content" role="presentation" align="center" cellpadding="0" cellspacing="0" border="0" bgcolor="#f5f5f5" width="100%" style="
font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 1em;
line-height: 1.5;
max-width: 600px;
padding: 0 20px 0 20px;
">
<tr>
<!-- HEADER -->
<td class="header" style="
padding: 20px;
">
<table role="presentation" width="100%" align="left" border="0" cellpadding="0" cellspacing="0">
<tr>
<td width="70">
<a href="http://www.edx.org"><img
src="https://media.sailthru.com/595/1k1/8/o/599f355101b3f.png" width="70"
height="30" alt="edX Home Page"/></a>
</td>
<td align="right" style="text-align: right;">
<a class="login" href="https://courses.edx.org/dashboard" style="color: #005686;">Sign In</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<!-- MAIN -->
<td class="main" bgcolor="#ffffff" style="
padding: 30px 20px;
box-shadow: 0 1px 5px rgba(0,0,0,0.25);
">
{% block content %}{% endblock %}
</td>
</tr>
<tr>
<!-- FOOTER -->
<td class="footer" style="padding: 20px;">
<table role="presentation" width="100%" align="left" border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="padding-bottom: 20px;">
<!-- LOGO / SOCIAL -->
<table role="presentation" width="100%" align="left" border="0" cellpadding="0" cellspacing="0">
<tr>
<td width="70">
<!-- LOGO -->
<a href="http://www.edx.org"><img
src="https://media.sailthru.com/595/1k1/8/o/599f355101b3f.png"
width="70" height="30" alt="edX Home Page"/></a>
</td>
<td align="right">
<!-- SOCIAL -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="210">
<tr>
<td height="32" width="42" align="right">
<a href="https://www.linkedin.com/company/edx">
<img src="https://media.sailthru.com/595/1k1/8/o/599f354ec70cb.png"
width="32" height="32" alt="edX on LinkedIn"/>
</a>
</td>
<td height="32" width="42" align="right">
<a href="https://www.twitter.com/edXOnline/">
<img src="https://media.sailthru.com/595/1k1/8/o/599f354d9c26e.png"
width="32" height="32" alt="edX on Twitter"/>
</a>
</td>
<td height="32" width="42" align="right">
<a href="http://www.facebook.com/edX">
<img src="https://media.sailthru.com/595/1k1/8/o/599f355052c8e.png"
width="32" height="32" alt="edX on Facebook"/>
</a>
</td>
<td height="32" width="42" align="right">
<a href="https://plus.google.com/%2BedXOnline">
<img src="https://media.sailthru.com/595/1k1/8/o/599f354fc554a.png"
width="32" height="32" alt="edX on Google Plus"/>
</a>
</td>
<td height="32" width="42" align="right">
<a href="https://www.reddit.com/r/edX/">
<img src="https://media.sailthru.com/595/1k1/8/o/599f354e326b9.png"
width="32" height="32" alt="edX on Reddit"/>
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<!-- ACTIONS / APP BUTTONS -->
<td>
<table role="presentation" width="100%" align="left" border="0" cellpadding="0" cellspacing="0">
<tr>
<!-- APP BUTTONS -->
<td class="col" width="148" valign="top" align="right" style="padding-bottom: 20px;">
<a href="https://itunes.apple.com/us/app/edx/id945480667?mt=8" style="text-decoration: none">
<img src="https://media.sailthru.com/595/1k1/6/2/5931cfbba391b.png"
alt="Download the iOS app on the Apple Store" width="136" height="50"/>
</a>
<a href="https://play.google.com/store/apps/details?id=org.edx.mobile" style="text-decoration: none">
<img src="https://media.sailthru.com/595/1k1/6/2/5931cf879a033.png"
alt="Download the Android app on the Google Play Store"
width="136" height="50" style="margin: 10px 0 0 5px"/>
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<!-- COPYRIGHT -->
<td>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td>
<p><small>Copyright &copy; 2017 edX, All rights
reserved.</small></p>
<p>
Our mailing address is:<br/>
141 Portland St. 9th Floor, Cambridge, MA 02139
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</div>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>{% block title %}edX Email{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style type="text/css">
@media only screen and (min-device-width: 601px) {
.content {
width: 600px !important;
}
}
@-ms-viewport{
width: device-width;
}
/* Column Drop Layout Pattern CSS */
@media only screen and (max-width: 450px) {
td[class="col"] {
display: block;
width: 100%;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
float: left;
text-align: left !important;
padding-bottom: 20px;
}
}
</style>
\ No newline at end of file
{% extends 'schedules/edx_ace/common/base_body.html' %}
{% load i18n %}
{% block preview_text %}
{% blocktrans %} Learning isn't easy - but it's worth it! Complete some problems and learn something new in {{course_name}}. {% endblocktrans %}
{% endblock %}
{% block content %}
<table width="100%" align="left" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<h1>{% blocktrans %} Keep up the momentum! {% endblocktrans %}</h1>
<p>
{% blocktrans %} 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 %}
</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="
color: #ffffff;
text-decoration: none;
border-radius: 4px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
background-color: #005686;
border-top: 10px solid #005686;
border-bottom: 10px solid #005686;
border-right: 16px solid #005686;
border-left: 16px solid #005686;
display: inline-block;
">
<font color="#ffffff"><b>{% blocktrans %} Keep learning {% endblocktrans %}</b></font>
</a>
</p>
</td>
</tr>
</table>
{% endblock %}
\ No newline at end of file
{% load i18n %}
{% blocktrans %} 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 %}
{% blocktrans %} Keep learning {% endblocktrans %} <{{course_url}}>
\ No newline at end of file
{% load i18n %}
{% blocktrans %}What do you want to do to keep learning?{% endblocktrans %}
\ No newline at end of file
{% extends 'schedules/edx_ace/common/base_body.html' %}
{% load i18n %}
{% block preview_text %}
{% blocktrans %} Learning isn't easy - but it's worth it! Learn something new in {{course_name}}. {% endblocktrans %}
{% endblock %}
{% block content %}
<table width="100%" align="left" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<h1>{% blocktrans %} Remember when you enrolled in <strong>{{course_name}}</strong> on edX.org? {% endblocktrans %}</h1>
<p>{% blocktrans %} We do! Come see what everyone is learning. {% endblocktrans %}</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="
color: #ffffff;
text-decoration: none;
border-radius: 4px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
background-color: #005686;
border-top: 10px solid #005686;
border-bottom: 10px solid #005686;
border-right: 16px solid #005686;
border-left: 16px solid #005686;
display: inline-block;
">
<font color="#ffffff"><b>{% blocktrans %} Start learning now {% endblocktrans %}</b></font>
</a>
</p>
</td>
</tr>
</table>
{% endblock %}
\ No newline at end of file
{% load i18n %}
{% blocktrans %} Remember when you enrolled in {{course_name}} on edX.org? We do! Come see what everyone is learning. {% endblocktrans %}
{% blocktrans %} Start learning now {% endblocktrans %} <{{course_url}}>
\ No newline at end of file
{% load i18n %}
{% blocktrans %} {{course_name}} has started on edX {% endblocktrans %}
\ No newline at end of file
Dear {{ user_personal_address }},
<br/>
We hope you are enjoying {{ course_title }}.
Upgrade by {{ user_schedule_verified_upgrade_deadline_time|date:"l, F dS, Y" }}
to get a shareable certificate!
<br/>
<a href="{{course_verified_upgrade_url}}">Upgrade now</a>
Dear {{ user_personal_address }},
We hope you are enjoying {{ course_title }}.
Upgrade by {{ user_schedule_verified_upgrade_deadline_time|date:"l, F dS, Y" }} to get a shareable certificate!
Upgrade now at {{course_verified_upgrade_url}}
......@@ -2,6 +2,8 @@ import factory
import pytz
from openedx.core.djangoapps.schedules import models
from student.tests.factories import CourseEnrollmentFactory
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
class ScheduleFactory(factory.DjangoModelFactory):
......@@ -10,3 +12,14 @@ class ScheduleFactory(factory.DjangoModelFactory):
start = factory.Faker('future_datetime', tzinfo=pytz.UTC)
upgrade_deadline = factory.Faker('future_datetime', tzinfo=pytz.UTC)
enrollment = factory.SubFactory(CourseEnrollmentFactory)
class ScheduleConfigFactory(factory.DjangoModelFactory):
class Meta(object):
model = models.ScheduleConfig
site = factory.SubFactory(SiteFactory)
create_schedules = True
enqueue_recurring_nudge = True
deliver_recurring_nudge = True
from django.test import TestCase
import datetime
from mock import patch
from pytz import utc
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory
from courseware.models import DynamicUpgradeDeadlineConfiguration
from openedx.core.djangoapps.schedules.signals import SCHEDULE_WAFFLE_FLAG
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.tests.factories import CourseEnrollmentFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ..models import Schedule
from ..tests.factories import ScheduleConfigFactory
@patch('openedx.core.djangoapps.schedules.signals.get_current_site')
@skip_unless_lms
class CreateScheduleTests(TestCase):
def test_create_schedule(self):
""" A schedule should be created for every new enrollment if the switch is active. """
class CreateScheduleTests(SharedModuleStoreTestCase):
SWITCH_NAME = 'enable-create-schedule-receiver'
switch_namesapce = WaffleSwitchNamespace('schedules')
def assert_schedule_created(self):
enrollment = CourseEnrollmentFactory()
self.assertIsNotNone(enrollment.schedule)
self.assertIsNone(enrollment.schedule.upgrade_deadline)
with switch_namesapce.override(SWITCH_NAME, True):
enrollment = CourseEnrollmentFactory()
self.assertIsNotNone(enrollment.schedule)
def assert_schedule_not_created(self):
enrollment = CourseEnrollmentFactory()
with self.assertRaises(Schedule.DoesNotExist):
enrollment.schedule
with switch_namesapce.override(SWITCH_NAME, False):
enrollment = CourseEnrollmentFactory()
with self.assertRaises(Schedule.DoesNotExist):
enrollment.schedule
@override_waffle_flag(SCHEDULE_WAFFLE_FLAG, True)
def test_create_schedule(self, mock_get_current_site):
site = SiteFactory.create()
mock_get_current_site.return_value = site
ScheduleConfigFactory.create(site=site)
self.assert_schedule_created()
@override_waffle_flag(SCHEDULE_WAFFLE_FLAG, True)
def test_no_current_site(self, mock_get_current_site):
mock_get_current_site.return_value = None
self.assert_schedule_not_created()
@override_waffle_flag(SCHEDULE_WAFFLE_FLAG, True)
def test_schedule_config_disabled_waffle_enabled(self, mock_get_current_site):
site = SiteFactory.create()
mock_get_current_site.return_value = site
ScheduleConfigFactory.create(site=site, create_schedules=False)
self.assert_schedule_created()
@override_waffle_flag(SCHEDULE_WAFFLE_FLAG, False)
def test_schedule_config_enabled_waffle_disabled(self, mock_get_current_site):
site = SiteFactory.create()
mock_get_current_site.return_value = site
ScheduleConfigFactory.create(site=site, create_schedules=True)
self.assert_schedule_created()
@override_waffle_flag(SCHEDULE_WAFFLE_FLAG, False)
def test_schedule_config_disabled_waffle_disabled(self, mock_get_current_site):
site = SiteFactory.create()
mock_get_current_site.return_value = site
ScheduleConfigFactory.create(site=site, create_schedules=False)
self.assert_schedule_not_created()
@override_waffle_flag(SCHEDULE_WAFFLE_FLAG, True)
def test_schedule_config_creation_enabled_instructor_paced(self, mock_get_current_site):
site = SiteFactory.create()
mock_get_current_site.return_value = site
ScheduleConfigFactory.create(site=site, enabled=True, create_schedules=True)
course = create_self_paced_course_run()
DynamicUpgradeDeadlineConfiguration.objects.create(enabled=False)
enrollment = CourseEnrollmentFactory(course_id=course.id, mode=CourseMode.AUDIT)
self.assertEqual(enrollment.schedule.start, enrollment.created)
self.assertIsNone(enrollment.schedule.upgrade_deadline)
@override_waffle_flag(SCHEDULE_WAFFLE_FLAG, True)
def test_schedule_config_creation_enabled_instructor_paced_with_deadline(self, mock_get_current_site):
site = SiteFactory.create()
mock_get_current_site.return_value = site
ScheduleConfigFactory.create(site=site, enabled=True, create_schedules=True)
course = create_self_paced_course_run()
global_config = DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True)
enrollment = CourseEnrollmentFactory(course_id=course.id, mode=CourseMode.AUDIT)
expected_deadline = enrollment.created + datetime.timedelta(days=global_config.deadline_days)
self.assertEqual(enrollment.schedule.start, enrollment.created)
self.assertEqual(enrollment.schedule.upgrade_deadline, expected_deadline)
def create_self_paced_course_run():
""" Create a new course run and course modes.
Both audit and verified `CourseMode` objects will be created for the course run.
"""
now = datetime.datetime.now(utc)
course = CourseFactory.create(start=now + datetime.timedelta(days=-1), self_paced=True)
CourseModeFactory(
course_id=course.id,
mode_slug=CourseMode.AUDIT
)
CourseModeFactory(
course_id=course.id,
mode_slug=CourseMode.VERIFIED,
expiration_datetime=now + datetime.timedelta(days=100)
)
return course
......@@ -166,7 +166,7 @@ class TestCourseHomePage(CourseHomePageTestCase):
course_home_url(self.course)
# Fetch the view and verify the query counts
with self.assertNumQueries(42, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with self.assertNumQueries(41, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with check_mongo_calls(4):
url = course_home_url(self.course)
self.client.get(url)
......
......@@ -127,7 +127,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
course_updates_url(self.course)
# Fetch the view and verify that the query counts haven't changed
with self.assertNumQueries(32, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with self.assertNumQueries(33, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with check_mongo_calls(4):
url = course_updates_url(self.course)
self.client.get(url)
......@@ -97,6 +97,7 @@ 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.0#egg=edx-ace
# Third Party XBlocks
git+https://github.com/open-craft/xblock-poll@7ba819b968fe8faddb78bb22e1fe7637005eb414#egg=xblock-poll==1.2.7
......
......@@ -58,5 +58,8 @@ setup(
"milestones = lms.djangoapps.course_api.blocks.transformers.milestones:MilestonesAndSpecialExamsTransformer",
"grades = lms.djangoapps.grades.transformer:GradesTransformer",
],
"openedx.ace.policy": [
"bulk_email_optout = lms.djangoapps.bulk_email.policies:CourseEmailOptout"
],
}
)
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