From 98233c0a84c63983e50574bf1ea39d5f4b5569cf Mon Sep 17 00:00:00 2001 From: sandroroux Date: Thu, 19 Oct 2017 10:59:00 -0400 Subject: [PATCH 1/6] New management command for setting up a test environment. The command creates a ScheduleConfig and schedules if they don't exist (the schedule starts three days before the time the command is run). If a schedule already exists, the command updates the schedule's start time and upgrade deadline. The values set by the command will allow send_recurring_nudge to send three-day nudges. --- .../xmodule/modulestore/tests/factories.py | 2 +- lms/envs/devstack.py | 16 +++++ .../setup_models_to_send_test_emails.py | 62 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 openedx/core/djangoapps/schedules/management/commands/setup_models_to_send_test_emails.py 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/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')) From 45bbc2daa3ee51e483bd283c71169760fb7a0195 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 17 Nov 2017 14:38:31 -0500 Subject: [PATCH 2/6] Allow searching by course_id in the schedules django admin page --- openedx/core/djangoapps/schedules/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/schedules/admin.py b/openedx/core/djangoapps/schedules/admin.py index b16e8555f9..aa64248448 100644 --- a/openedx/core/djangoapps/schedules/admin.py +++ b/openedx/core/djangoapps/schedules/admin.py @@ -14,7 +14,7 @@ class ScheduleAdmin(admin.ModelAdmin): list_display = ('username', 'course_id', 'active', 'start', 'upgrade_deadline') 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,) def username(self, obj): From fabad1de4d195be6243efae37d74634940f46368 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 17 Nov 2017 15:04:02 -0500 Subject: [PATCH 3/6] Add actions and filtering to schedules admin page to allow it to function better as a support tool --- openedx/core/djangoapps/schedules/admin.py | 36 +++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/schedules/admin.py b/openedx/core/djangoapps/schedules/admin.py index aa64248448..bd3aedc1e6 100644 --- a/openedx/core/djangoapps/schedules/admin.py +++ b/openedx/core/djangoapps/schedules/admin.py @@ -11,11 +11,45 @@ class ScheduleExperienceAdminInline(admin.StackedInline): @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_filter = ('experience__experience_type', 'active') raw_id_fields = ('enrollment',) readonly_fields = ('modified',) search_fields = ('enrollment__user__username', 'enrollment__course__id',) inlines = (ScheduleExperienceAdminInline,) + actions = ['deactivate_schedules', 'activate_schedules', 'set_experience_to_default', 'set_experience_to_course_updates'] + + 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 set_experience_to_default(self, request, queryset): + rows_updated = models.ScheduleExperience.objects.filter( + schedule__in=list(queryset) + ).update( + experience_type=models.ScheduleExperience.EXPERIENCES.default + ) + self.message_user(request, "{} schedule(s) were changed to use the default experience".format(rows_updated)) + set_experience_to_default.short_description = "Convert the selected schedules to the default experience" + + def set_experience_to_course_updates(self, request, queryset): + rows_updated = models.ScheduleExperience.objects.filter( + schedule__in=list(queryset) + ).update( + experience_type=models.ScheduleExperience.EXPERIENCES.course_updates + ) + self.message_user(request, "{} schedule(s) were changed to use the course update experience".format(rows_updated)) + set_experience_to_course_updates.short_description = "Convert the selected schedules to the course updates experience" + + 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 From 6756f97d0cde1423f2a202df2b0754bf3839cc88 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 17 Nov 2017 15:40:36 -0500 Subject: [PATCH 4/6] Limit schedule resolvers to active schedules --- common/djangoapps/student/models.py | 2 +- openedx/core/djangoapps/schedules/resolvers.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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/openedx/core/djangoapps/schedules/resolvers.py b/openedx/core/djangoapps/schedules/resolvers.py index 61f6a7b911..a262b89c58 100644 --- a/openedx/core/djangoapps/schedules/resolvers.py +++ b/openedx/core/djangoapps/schedules/resolvers.py @@ -123,10 +123,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) From 3ff118eaacaffc539df3f584374fa32046f39a3f Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 21 Nov 2017 11:24:01 -0500 Subject: [PATCH 5/6] Dynamically generate the actions to update to specific experiences --- openedx/core/djangoapps/schedules/admin.py | 52 ++++++++++++++-------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/openedx/core/djangoapps/schedules/admin.py b/openedx/core/djangoapps/schedules/admin.py index bd3aedc1e6..ee61b327c5 100644 --- a/openedx/core/djangoapps/schedules/admin.py +++ b/openedx/core/djangoapps/schedules/admin.py @@ -1,3 +1,5 @@ +import functools + from django.contrib import admin from django import forms from django.utils.translation import ugettext_lazy as _ @@ -9,6 +11,36 @@ 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) + + @admin.register(models.Schedule) class ScheduleAdmin(admin.ModelAdmin): list_display = ('username', 'course_id', 'active', 'start', 'upgrade_deadline', 'experience_display') @@ -17,7 +49,7 @@ class ScheduleAdmin(admin.ModelAdmin): readonly_fields = ('modified',) search_fields = ('enrollment__user__username', 'enrollment__course__id',) inlines = (ScheduleExperienceAdminInline,) - actions = ['deactivate_schedules', 'activate_schedules', 'set_experience_to_default', 'set_experience_to_course_updates'] + actions = ['deactivate_schedules', 'activate_schedules'] + experience_actions def deactivate_schedules(self, request, queryset): rows_updated = queryset.update(active=False) @@ -29,24 +61,6 @@ class ScheduleAdmin(admin.ModelAdmin): self.message_user(request, "{} schedule(s) were activated".format(rows_updated)) activate_schedules.short_description = "Activate selected schedules" - def set_experience_to_default(self, request, queryset): - rows_updated = models.ScheduleExperience.objects.filter( - schedule__in=list(queryset) - ).update( - experience_type=models.ScheduleExperience.EXPERIENCES.default - ) - self.message_user(request, "{} schedule(s) were changed to use the default experience".format(rows_updated)) - set_experience_to_default.short_description = "Convert the selected schedules to the default experience" - - def set_experience_to_course_updates(self, request, queryset): - rows_updated = models.ScheduleExperience.objects.filter( - schedule__in=list(queryset) - ).update( - experience_type=models.ScheduleExperience.EXPERIENCES.course_updates - ) - self.message_user(request, "{} schedule(s) were changed to use the course update experience".format(rows_updated)) - set_experience_to_course_updates.short_description = "Convert the selected schedules to the course updates experience" - def experience_display(self, obj): return obj.experience.get_experience_type_display() experience_display.short_descriptions = _('Experience') From 10970f30347712c84240ab69498449e232a2b978 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 21 Nov 2017 14:56:39 -0500 Subject: [PATCH 6/6] Allow searching for schedules with known errors in the admin site --- openedx/core/djangoapps/schedules/admin.py | 34 ++++++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/schedules/admin.py b/openedx/core/djangoapps/schedules/admin.py index ee61b327c5..2cfbf64903 100644 --- a/openedx/core/djangoapps/schedules/admin.py +++ b/openedx/core/djangoapps/schedules/admin.py @@ -2,6 +2,8 @@ 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 @@ -41,10 +43,26 @@ for (db_name, human_name) in models.ScheduleExperience.EXPERIENCES: 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', 'experience_display') - list_filter = ('experience__experience_type', 'active') + 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',) @@ -66,13 +84,23 @@ class ScheduleAdmin(admin.ModelAdmin): 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):