diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index fdfca2721c..a32a2a3306 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -1760,7 +1760,7 @@ class CourseEnrollment(models.Model):
return None
try:
- if not self.schedule:
+ if not self.schedule or not self.schedule.active:
return None
log.debug(
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
index da8af6cecf..fa91d765fb 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
@@ -12,7 +12,7 @@ from collections import defaultdict
from contextlib import contextmanager
from uuid import uuid4
-from factory import Factory, Sequence, lazy_attribute_sequence, lazy_attribute
+from factory import Factory, Sequence, lazy_attribute_sequence, lazy_attribute, Faker
from factory.errors import CyclicDefinitionError
from mock import patch
from nose.tools import assert_less_equal, assert_greater_equal
diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py
index fb21fed0f1..74aa6a8e64 100644
--- a/lms/envs/devstack.py
+++ b/lms/envs/devstack.py
@@ -268,6 +268,22 @@ JWT_AUTH.update({
'JWT_AUDIENCE': 'lms-key',
})
+
+############## Settings for ACE ####################################
+ACE_ENABLED_CHANNELS = [
+ 'file_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
+
+
#####################################################################
# See if the developer has any local overrides.
if os.path.isfile(join(dirname(abspath(__file__)), 'private.py')):
diff --git a/openedx/core/djangoapps/schedules/admin.py b/openedx/core/djangoapps/schedules/admin.py
index b16e8555f9..2cfbf64903 100644
--- a/openedx/core/djangoapps/schedules/admin.py
+++ b/openedx/core/djangoapps/schedules/admin.py
@@ -1,5 +1,9 @@
+import functools
+
from django.contrib import admin
from django import forms
+from django.db.models import F
+from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from . import models
@@ -9,22 +13,94 @@ class ScheduleExperienceAdminInline(admin.StackedInline):
model = models.ScheduleExperience
+def _set_experience(db_name, human_name, modeladmin, request, queryset):
+ """
+ A django action which will set all selected schedules to the supplied experience.
+ The intended usage is with functools.partial to generate the action for each experience type
+ dynamically.
+
+ Arguments:
+ db_name: the database name of the experience being selected
+ human_name: the human name of the experience being selected
+ modeladmin: The ModelAdmin subclass, passed by django as part of the standard Action interface
+ request: The current request, passed by django as part of the standard Action interface
+ queryset: The queryset selecting schedules, passed by django as part of the standard Action interface
+ """
+ rows_updated = models.ScheduleExperience.objects.filter(
+ schedule__in=list(queryset)
+ ).update(
+ experience_type=db_name
+ )
+ modeladmin.message_user(request, "{} schedule(s) were changed to use the {} experience".format(rows_updated, human_name))
+
+
+# Generate a list of all "set_experience_to_X" actions
+experience_actions = []
+for (db_name, human_name) in models.ScheduleExperience.EXPERIENCES:
+ partial = functools.partial(_set_experience, db_name, human_name)
+ partial.short_description = "Convert the selected schedules to the {} experience".format(human_name)
+ partial.__name__ = "set_experience_to_{}".format(db_name)
+ experience_actions.append(partial)
+
+
+class KnownErrorCases(admin.SimpleListFilter):
+ title = _('KnownErrorCases')
+
+ parameter_name = 'error'
+
+ def lookups(self, request, model_admin):
+ return (
+ ('schedule_start', _('Schedule start < course start')),
+ )
+
+ def queryset(self, request, queryset):
+ if self.value() == 'schedule_start':
+ return queryset.filter(start__lt=F('enrollment__course__start'))
+
+
@admin.register(models.Schedule)
class ScheduleAdmin(admin.ModelAdmin):
- list_display = ('username', 'course_id', 'active', 'start', 'upgrade_deadline')
+ list_display = ('username', 'course_id', 'active', 'start', 'upgrade_deadline', 'experience_display')
+ list_display_links = ('start', 'upgrade_deadline', 'experience_display')
+ list_filter = ('experience__experience_type', 'active', KnownErrorCases)
raw_id_fields = ('enrollment',)
readonly_fields = ('modified',)
- search_fields = ('enrollment__user__username', 'enrollment__course_id',)
+ search_fields = ('enrollment__user__username', 'enrollment__course__id',)
inlines = (ScheduleExperienceAdminInline,)
+ actions = ['deactivate_schedules', 'activate_schedules'] + experience_actions
+
+ def deactivate_schedules(self, request, queryset):
+ rows_updated = queryset.update(active=False)
+ self.message_user(request, "{} schedule(s) were deactivated".format(rows_updated))
+ deactivate_schedules.short_description = "Deactivate selected schedules"
+
+ def activate_schedules(self, request, queryset):
+ rows_updated = queryset.update(active=True)
+ self.message_user(request, "{} schedule(s) were activated".format(rows_updated))
+ activate_schedules.short_description = "Activate selected schedules"
+
+ def experience_display(self, obj):
+ return obj.experience.get_experience_type_display()
+ experience_display.short_descriptions = _('Experience')
def username(self, obj):
- return obj.enrollment.user.username
+ return '{}'.format(
+ reverse("admin:auth_user_change", args=(obj.enrollment.user.id,)),
+ obj.enrollment.user.username
+ )
+ username.allow_tags = True
username.short_description = _('Username')
def course_id(self, obj):
- return obj.enrollment.course_id
+ return '{}'.format(
+ reverse("admin:course_overviews_courseoverview_change", args=(
+ obj.enrollment.course_id,
+ )),
+ obj.enrollment.course_id
+ )
+ course_id.allow_tags = True
course_id.short_description = _('Course ID')
def get_queryset(self, request):
diff --git a/openedx/core/djangoapps/schedules/management/commands/setup_models_to_send_test_emails.py b/openedx/core/djangoapps/schedules/management/commands/setup_models_to_send_test_emails.py
new file mode 100644
index 0000000000..bc7c1813cb
--- /dev/null
+++ b/openedx/core/djangoapps/schedules/management/commands/setup_models_to_send_test_emails.py
@@ -0,0 +1,62 @@
+import datetime
+import pytz
+import factory
+
+from django.core.management.base import BaseCommand
+from student.models import CourseEnrollment
+from django.contrib.sites.models import Site
+from openedx.core.djangoapps.schedules.models import Schedule, ScheduleConfig, ScheduleExperience
+from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory, ScheduleConfigFactory, ScheduleExperienceFactory
+from student.tests.factories import CourseEnrollmentFactory
+from xmodule.modulestore.tests.factories import CourseFactory, XMODULE_FACTORY_LOCK
+from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
+from xmodule.modulestore.django import modulestore
+
+
+class ThreeDayNudgeSchedule(ScheduleFactory):
+ start = factory.Faker('date_time_between', start_date='-3d', end_date='-3d', tzinfo=pytz.UTC)
+
+
+class TenDayNudgeSchedule(ScheduleFactory):
+ start = factory.Faker('date_time_between', start_date='-10d', end_date='-10d', tzinfo=pytz.UTC)
+
+
+class UpgradeReminderSchedule(ScheduleFactory):
+ start = factory.Faker('past_datetime', tzinfo=pytz.UTC)
+ upgrade_deadline = factory.Faker('date_time_between', start_date='+2d', end_date='+2d', tzinfo=pytz.UTC)
+
+
+class ContentHighlightSchedule(ScheduleFactory):
+ start = factory.Faker('date_time_between', start_date='-7d', end_date='-7d', tzinfo=pytz.UTC)
+ experience = factory.RelatedFactory(ScheduleExperienceFactory, 'schedule', experience_type=ScheduleExperience.EXPERIENCES.course_updates)
+
+
+class Command(BaseCommand):
+ """
+ A management command that generates schedule objects for all expected schedule email types, so that it is easy to
+ generate test emails of all available types.
+ """
+
+ def handle(self, *args, **options):
+ courses = modulestore().get_courses()
+
+ # Find the largest auto-generated course, and pick the next sequence id to generate the next
+ # course with.
+ max_org_sequence_id = max(int(course.org[4:]) for course in courses if course.org.startswith('org.'))
+
+ XMODULE_FACTORY_LOCK.enable()
+ CourseFactory.reset_sequence(max_org_sequence_id + 1, force=True)
+ course = CourseFactory(
+ start=datetime.datetime.today() - datetime.timedelta(days=30),
+ end=datetime.datetime.today() + datetime.timedelta(days=30),
+ number=factory.Sequence('schedules_test_course_{}'.format),
+ display_name=factory.Sequence('Schedules Test Course {}'.format),
+ )
+ XMODULE_FACTORY_LOCK.disable()
+ course_overview = CourseOverview.load_from_module_store(course.id)
+ ThreeDayNudgeSchedule.create(enrollment__course=course_overview)
+ TenDayNudgeSchedule.create(enrollment__course=course_overview)
+ UpgradeReminderSchedule.create(enrollment__course=course_overview)
+ ContentHighlightSchedule.create(enrollment__course=course_overview)
+
+ ScheduleConfigFactory.create(site=Site.objects.get(name='example.com'))
diff --git a/openedx/core/djangoapps/schedules/resolvers.py b/openedx/core/djangoapps/schedules/resolvers.py
index e5b94dbc5a..19003f0216 100644
--- a/openedx/core/djangoapps/schedules/resolvers.py
+++ b/openedx/core/djangoapps/schedules/resolvers.py
@@ -122,10 +122,12 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver):
'enrollment__course',
).filter(
Q(enrollment__course__end__isnull=True) | Q(
- enrollment__course__end__gte=self.current_datetime),
+ enrollment__course__end__gte=self.current_datetime
+ ),
self.experience_filter,
enrollment__user__in=users,
enrollment__is_active=True,
+ active=True,
**schedule_day_equals_target_day_filter
).order_by(order_by)