Commit 12e1af27 by Calen Pennington Committed by GitHub

Merge pull request #16092 from edx/upsell-nudges-for-unverified-users

Upsell nudges for unverified users
parents 92318ccc 40d3f4f2
...@@ -1696,16 +1696,13 @@ class CourseEnrollment(models.Model): ...@@ -1696,16 +1696,13 @@ class CourseEnrollment(models.Model):
def verified_mode(self): def verified_mode(self):
return CourseMode.verified_mode_for_course(self.course_id) return CourseMode.verified_mode_for_course(self.course_id)
@property @cached_property
def upgrade_deadline(self): def upgrade_deadline(self):
""" """
Returns the upgrade deadline for this enrollment, if it is upgradeable. Returns the upgrade deadline for this enrollment, if it is upgradeable.
If the seat cannot be upgraded, None is returned. If the seat cannot be upgraded, None is returned.
Note: Note:
When loading this model, use `select_related` to retrieve the associated schedule object. When loading this model, use `select_related` to retrieve the associated schedule object.
Returns: Returns:
datetime|None datetime|None
""" """
...@@ -1717,40 +1714,61 @@ class CourseEnrollment(models.Model): ...@@ -1717,40 +1714,61 @@ class CourseEnrollment(models.Model):
) )
return None return None
if self.dynamic_upgrade_deadline is not None:
return self.dynamic_upgrade_deadline
return self.course_upgrade_deadline
@cached_property
def dynamic_upgrade_deadline(self):
try: try:
schedule_driven_deadlines_enabled = ( course_overview = self.course
DynamicUpgradeDeadlineConfiguration.is_enabled() except CourseOverview.DoesNotExist:
or CourseDynamicUpgradeDeadlineConfiguration.is_enabled(self.course_id) course_overview = self.course_overview
if not course_overview.self_paced:
return None
if not DynamicUpgradeDeadlineConfiguration.is_enabled():
return None
course_config = CourseDynamicUpgradeDeadlineConfiguration.current(self.course_id)
if course_config.enabled and course_config.opt_out:
return None
try:
if not self.schedule:
return None
log.debug(
'Schedules: Pulling upgrade deadline for CourseEnrollment %d from Schedule %d.',
self.id, self.schedule.id
) )
if ( upgrade_deadline = self.schedule.upgrade_deadline
schedule_driven_deadlines_enabled
and self.course_overview.self_paced
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
)
return self.schedule.upgrade_deadline
except ObjectDoesNotExist: except ObjectDoesNotExist:
# NOTE: Schedule has a one-to-one mapping with CourseEnrollment. If no schedule is associated # NOTE: Schedule has a one-to-one mapping with CourseEnrollment. If no schedule is associated
# with this enrollment, Django will raise an exception rather than return None. # with this enrollment, Django will raise an exception rather than return None.
log.debug('Schedules: No schedule exists for CourseEnrollment %d.', self.id) log.debug('Schedules: No schedule exists for CourseEnrollment %d.', self.id)
pass return None
if upgrade_deadline is None or datetime.now(UTC) >= upgrade_deadline:
return None
return upgrade_deadline
@cached_property
def course_upgrade_deadline(self):
try: try:
if self.verified_mode: if self.verified_mode:
log.debug('Schedules: Defaulting to verified mode expiration date-time for %s.', self.course_id) log.debug('Schedules: Defaulting to verified mode expiration date-time for %s.', self.course_id)
return self.verified_mode.expiration_datetime return self.verified_mode.expiration_datetime
else: else:
log.debug('Schedules: No verified mode located for %s.', self.course_id) log.debug('Schedules: No verified mode located for %s.', self.course_id)
return None
except CourseMode.DoesNotExist: except CourseMode.DoesNotExist:
log.debug('Schedules: %s has no verified mode.', self.course_id) log.debug('Schedules: %s has no verified mode.', self.course_id)
pass return None
log.debug('Schedules: Returning default of `None`')
return None
def is_verified_enrollment(self): def is_verified_enrollment(self):
""" """
......
...@@ -14,6 +14,7 @@ from django.db.models.functions import Lower ...@@ -14,6 +14,7 @@ from django.db.models.functions import Lower
from course_modes.models import CourseMode from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory from course_modes.tests.factories import CourseModeFactory
from courseware.models import DynamicUpgradeDeadlineConfiguration from courseware.models import DynamicUpgradeDeadlineConfiguration
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.schedules.models import Schedule from openedx.core.djangoapps.schedules.models import Schedule
from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms from openedx.core.djangolib.testing.utils import skip_unless_lms
...@@ -132,6 +133,25 @@ class CourseEnrollmentTests(SharedModuleStoreTestCase): ...@@ -132,6 +133,25 @@ class CourseEnrollmentTests(SharedModuleStoreTestCase):
self.assertEqual(Schedule.objects.all().count(), 0) self.assertEqual(Schedule.objects.all().count(), 0)
self.assertEqual(enrollment.upgrade_deadline, course_mode.expiration_datetime) self.assertEqual(enrollment.upgrade_deadline, course_mode.expiration_datetime)
@skip_unless_lms
# NOTE: We mute the post_save signal to prevent Schedules from being created for new enrollments
@factory.django.mute_signals(signals.post_save)
def test_upgrade_deadline_with_schedule(self):
""" The property should use either the CourseMode or related Schedule to determine the deadline. """
course = CourseFactory(self_paced=True)
CourseModeFactory(
course_id=course.id,
mode_slug=CourseMode.VERIFIED,
# This must be in the future to ensure it is returned by downstream code.
expiration_datetime=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=30),
)
course_overview = CourseOverview.load_from_module_store(course.id)
enrollment = CourseEnrollmentFactory(
course_id=course.id,
mode=CourseMode.AUDIT,
course=course_overview,
)
# The schedule's upgrade deadline should be used if a schedule exists # The schedule's upgrade deadline should be used if a schedule exists
DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True) DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True)
schedule = ScheduleFactory(enrollment=enrollment) schedule = ScheduleFactory(enrollment=enrollment)
......
...@@ -237,18 +237,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase): ...@@ -237,18 +237,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase):
# # of sql queries to default, # # of sql queries to default,
# # of mongo queries, # # of mongo queries,
# ) # )
('no_overrides', 1, True, False): (19, 1), ('no_overrides', 1, True, False): (16, 1),
('no_overrides', 2, True, False): (19, 1), ('no_overrides', 2, True, False): (16, 1),
('no_overrides', 3, True, False): (19, 1), ('no_overrides', 3, True, False): (16, 1),
('ccx', 1, True, False): (19, 1), ('ccx', 1, True, False): (16, 1),
('ccx', 2, True, False): (19, 1), ('ccx', 2, True, False): (16, 1),
('ccx', 3, True, False): (19, 1), ('ccx', 3, True, False): (16, 1),
('no_overrides', 1, False, False): (19, 1), ('no_overrides', 1, False, False): (16, 1),
('no_overrides', 2, False, False): (19, 1), ('no_overrides', 2, False, False): (16, 1),
('no_overrides', 3, False, False): (19, 1), ('no_overrides', 3, False, False): (16, 1),
('ccx', 1, False, False): (19, 1), ('ccx', 1, False, False): (16, 1),
('ccx', 2, False, False): (19, 1), ('ccx', 2, False, False): (16, 1),
('ccx', 3, False, False): (19, 1), ('ccx', 3, False, False): (16, 1),
} }
...@@ -260,19 +260,19 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase): ...@@ -260,19 +260,19 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase):
__test__ = True __test__ = True
TEST_DATA = { TEST_DATA = {
('no_overrides', 1, True, False): (19, 3), ('no_overrides', 1, True, False): (16, 3),
('no_overrides', 2, True, False): (19, 3), ('no_overrides', 2, True, False): (16, 3),
('no_overrides', 3, True, False): (19, 3), ('no_overrides', 3, True, False): (16, 3),
('ccx', 1, True, False): (19, 3), ('ccx', 1, True, False): (16, 3),
('ccx', 2, True, False): (19, 3), ('ccx', 2, True, False): (16, 3),
('ccx', 3, True, False): (19, 3), ('ccx', 3, True, False): (16, 3),
('ccx', 1, True, True): (20, 3), ('ccx', 1, True, True): (17, 3),
('ccx', 2, True, True): (20, 3), ('ccx', 2, True, True): (17, 3),
('ccx', 3, True, True): (20, 3), ('ccx', 3, True, True): (17, 3),
('no_overrides', 1, False, False): (19, 3), ('no_overrides', 1, False, False): (16, 3),
('no_overrides', 2, False, False): (19, 3), ('no_overrides', 2, False, False): (16, 3),
('no_overrides', 3, False, False): (19, 3), ('no_overrides', 3, False, False): (16, 3),
('ccx', 1, False, False): (19, 3), ('ccx', 1, False, False): (16, 3),
('ccx', 2, False, False): (19, 3), ('ccx', 2, False, False): (16, 3),
('ccx', 3, False, False): (19, 3), ('ccx', 3, False, False): (16, 3),
} }
...@@ -36,6 +36,7 @@ from lms.djangoapps.ccx.utils import ccx_course, is_email ...@@ -36,6 +36,7 @@ from lms.djangoapps.ccx.utils import ccx_course, is_email
from lms.djangoapps.ccx.views import get_date from lms.djangoapps.ccx.views import get_date
from lms.djangoapps.grades.tasks import compute_all_grades_for_course from lms.djangoapps.grades.tasks import compute_all_grades_for_course
from lms.djangoapps.instructor.access import allow_access, list_with_level from lms.djangoapps.instructor.access import allow_access, list_with_level
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from request_cache.middleware import RequestCache from request_cache.middleware import RequestCache
from student.models import CourseEnrollment, CourseEnrollmentAllowed from student.models import CourseEnrollment, CourseEnrollmentAllowed
from student.roles import CourseCcxCoachRole, CourseInstructorRole, CourseStaffRole from student.roles import CourseCcxCoachRole, CourseInstructorRole, CourseStaffRole
...@@ -1061,6 +1062,7 @@ class TestCCXGrades(FieldOverrideTestMixin, SharedModuleStoreTestCase, LoginEnro ...@@ -1061,6 +1062,7 @@ class TestCCXGrades(FieldOverrideTestMixin, SharedModuleStoreTestCase, LoginEnro
def setUpClass(cls): def setUpClass(cls):
super(TestCCXGrades, cls).setUpClass() super(TestCCXGrades, cls).setUpClass()
cls._course = course = CourseFactory.create(enable_ccx=True) cls._course = course = CourseFactory.create(enable_ccx=True)
CourseOverview.load_from_module_store(course.id)
# Create a course outline # Create a course outline
cls.mooc_start = start = datetime.datetime( cls.mooc_start = start = datetime.datetime(
...@@ -1122,6 +1124,7 @@ class TestCCXGrades(FieldOverrideTestMixin, SharedModuleStoreTestCase, LoginEnro ...@@ -1122,6 +1124,7 @@ class TestCCXGrades(FieldOverrideTestMixin, SharedModuleStoreTestCase, LoginEnro
# which emulates how a student would get access. # which emulates how a student would get access.
self.ccx_key = CCXLocator.from_course_locator(self._course.id, unicode(ccx.id)) self.ccx_key = CCXLocator.from_course_locator(self._course.id, unicode(ccx.id))
self.course = get_course_by_id(self.ccx_key, depth=None) self.course = get_course_by_id(self.ccx_key, depth=None)
CourseOverview.load_from_module_store(self.course.id)
setup_students_and_grades(self) setup_students_and_grades(self)
self.client.login(username=coach.username, password="test") self.client.login(username=coach.username, password="test")
self.addCleanup(RequestCache.clear_request_cache) self.addCleanup(RequestCache.clear_request_cache)
......
...@@ -20,6 +20,7 @@ from course_modes.models import CourseMode, get_cosmetic_verified_display_price ...@@ -20,6 +20,7 @@ from course_modes.models import CourseMode, get_cosmetic_verified_display_price
from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline
from openedx.core.djangoapps.certificates.api import can_show_certificate_available_date_field from openedx.core.djangoapps.certificates.api import can_show_certificate_available_date_field
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangolib.markup import HTML, Text from openedx.core.djangolib.markup import HTML, Text
from openedx.features.course_experience import CourseHomeMessages, UPGRADE_DEADLINE_MESSAGE from openedx.features.course_experience import CourseHomeMessages, UPGRADE_DEADLINE_MESSAGE
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -380,6 +381,63 @@ class CertificateAvailableDate(DateSummary): ...@@ -380,6 +381,63 @@ class CertificateAvailableDate(DateSummary):
) )
def verified_upgrade_deadline_link(user, course=None, course_id=None):
"""
Format the correct verified upgrade link for the specified ``user``
in a course.
One of ``course`` or ``course_id`` must be supplied. If both are specified,
``course`` will take priority.
Arguments:
user (:class:`~django.contrib.auth.models.User`): The user to display
the link for.
course (:class:`.CourseOverview`): The course to render a link for.
course_id (:class:`.CourseKey`): The course_id of the course to render for.
Returns:
The formatted link that will allow the user to upgrade to verified
in this course.
"""
if course is not None:
course_id = course.id
ecommerce_service = EcommerceService()
if ecommerce_service.is_enabled(user):
if course is not None and isinstance(course, CourseOverview):
course_mode = course.modes.get(mode_slug=CourseMode.VERIFIED)
else:
course_mode = CourseMode.objects.get(
course_id=course_id, mode_slug=CourseMode.VERIFIED
)
return ecommerce_service.get_checkout_page_url(course_mode.sku)
return reverse('verify_student_upgrade_and_verify', args=(course_id,))
def verified_upgrade_link_is_valid(enrollment=None):
"""
Return whether this enrollment can be upgraded.
Arguments:
enrollment (:class:`.CourseEnrollment`): The enrollment under consideration.
If None, then the enrollment is considered to be upgradeable.
"""
# Return `true` if user is not enrolled in course
if enrollment is None:
return False
upgrade_deadline = enrollment.upgrade_deadline
if upgrade_deadline is None:
return False
if datetime.datetime.now(utc).date() > upgrade_deadline.date():
return False
# Show the summary if user enrollment is in which allow user to upsell
return enrollment.is_active and enrollment.mode in CourseMode.UPSELL_TO_VERIFIED_MODES
class VerifiedUpgradeDeadlineDate(DateSummary): class VerifiedUpgradeDeadlineDate(DateSummary):
""" """
Displays the date before which learners must upgrade to the Displays the date before which learners must upgrade to the
...@@ -395,7 +453,7 @@ class VerifiedUpgradeDeadlineDate(DateSummary): ...@@ -395,7 +453,7 @@ class VerifiedUpgradeDeadlineDate(DateSummary):
@property @property
def link(self): def link(self):
return EcommerceService().upgrade_url(self.user, self.course_id) return verified_upgrade_deadline_link(self.user, self.course, self.course_id)
@cached_property @cached_property
def enrollment(self): def enrollment(self):
...@@ -413,19 +471,7 @@ class VerifiedUpgradeDeadlineDate(DateSummary): ...@@ -413,19 +471,7 @@ class VerifiedUpgradeDeadlineDate(DateSummary):
if not is_enabled: if not is_enabled:
return False return False
enrollment_mode = None return verified_upgrade_link_is_valid(self.enrollment)
is_active = None
if self.enrollment:
enrollment_mode = self.enrollment.mode
is_active = self.enrollment.is_active
# Return `true` if user is not enrolled in course
if enrollment_mode is None and is_active is None:
return True
# Show the summary if user enrollment is in which allow user to upsell
return is_active and enrollment_mode in CourseMode.UPSELL_TO_VERIFIED_MODES
@lazy @lazy
def date(self): def date(self):
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('courseware', '0003_auto_20170825_0935'),
]
operations = [
migrations.AlterField(
model_name='coursedynamicupgradedeadlineconfiguration',
name='opt_out',
field=models.BooleanField(default=False, help_text='Disable the dynamic upgrade deadline for this course run.'),
),
]
...@@ -398,5 +398,5 @@ class CourseDynamicUpgradeDeadlineConfiguration(ConfigurationModel): ...@@ -398,5 +398,5 @@ class CourseDynamicUpgradeDeadlineConfiguration(ConfigurationModel):
) )
opt_out = models.BooleanField( opt_out = models.BooleanField(
default=False, default=False,
help_text=_('This does not do anything and is no longer used. Setting enabled=False has the same effect.') help_text=_('Disable the dynamic upgrade deadline for this course run.')
) )
...@@ -618,18 +618,6 @@ class TestScheduleOverrides(SharedModuleStoreTestCase): ...@@ -618,18 +618,6 @@ class TestScheduleOverrides(SharedModuleStoreTestCase):
self.assertEqual(block.date, expected) self.assertEqual(block.date, expected)
@override_waffle_flag(CREATE_SCHEDULE_WAFFLE_FLAG, True) @override_waffle_flag(CREATE_SCHEDULE_WAFFLE_FLAG, True)
def test_date_with_self_paced_with_single_course(self):
""" If the global switch is off, a single course can still be enabled. """
course = create_self_paced_course_run(days_till_start=-1)
DynamicUpgradeDeadlineConfiguration.objects.create(enabled=False)
course_config = CourseDynamicUpgradeDeadlineConfiguration.objects.create(enabled=True, course_id=course.id)
enrollment = CourseEnrollmentFactory(course_id=course.id, mode=CourseMode.AUDIT)
block = VerifiedUpgradeDeadlineDate(course, enrollment.user)
expected = enrollment.created + timedelta(days=course_config.deadline_days)
self.assertEqual(block.date, expected)
@override_waffle_flag(CREATE_SCHEDULE_WAFFLE_FLAG, True)
def test_date_with_existing_schedule(self): def test_date_with_existing_schedule(self):
""" If a schedule is created while deadlines are disabled, they shouldn't magically appear once the feature is """ If a schedule is created while deadlines are disabled, they shouldn't magically appear once the feature is
turned on. """ turned on. """
......
...@@ -213,8 +213,8 @@ class IndexQueryTestCase(ModuleStoreTestCase): ...@@ -213,8 +213,8 @@ class IndexQueryTestCase(ModuleStoreTestCase):
NUM_PROBLEMS = 20 NUM_PROBLEMS = 20
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 10, 147), (ModuleStoreEnum.Type.mongo, 10, 145),
(ModuleStoreEnum.Type.split, 4, 147), (ModuleStoreEnum.Type.split, 4, 145),
) )
@ddt.unpack @ddt.unpack
def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count): def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count):
...@@ -1047,6 +1047,7 @@ class BaseDueDateTests(ModuleStoreTestCase): ...@@ -1047,6 +1047,7 @@ class BaseDueDateTests(ModuleStoreTestCase):
course = modulestore().get_course(course.id) course = modulestore().get_course(course.id)
self.assertIsNotNone(course.get_children()[0].get_children()[0].due) self.assertIsNotNone(course.get_children()[0].get_children()[0].due)
CourseEnrollmentFactory(user=self.user, course_id=course.id) CourseEnrollmentFactory(user=self.user, course_id=course.id)
CourseOverview.load_from_module_store(course.id)
return course return course
def setUp(self): def setUp(self):
...@@ -1456,13 +1457,13 @@ class ProgressPageTests(ProgressPageBaseTests): ...@@ -1456,13 +1457,13 @@ class ProgressPageTests(ProgressPageBaseTests):
"""Test that query counts remain the same for self-paced and instructor-paced courses.""" """Test that query counts remain the same for self-paced and instructor-paced courses."""
SelfPacedConfiguration(enabled=self_paced_enabled).save() SelfPacedConfiguration(enabled=self_paced_enabled).save()
self.setup_course(self_paced=self_paced) self.setup_course(self_paced=self_paced)
with self.assertNumQueries(36, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(1): with self.assertNumQueries(34 if self_paced else 33, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(1):
self._get_progress_page() self._get_progress_page()
@patch.dict(settings.FEATURES, {'ASSUME_ZERO_GRADE_IF_ABSENT_FOR_ALL_TESTS': False}) @patch.dict(settings.FEATURES, {'ASSUME_ZERO_GRADE_IF_ABSENT_FOR_ALL_TESTS': False})
@ddt.data( @ddt.data(
(False, 43, 27), (False, 40, 26),
(True, 36, 23) (True, 33, 22)
) )
@ddt.unpack @ddt.unpack
def test_progress_queries(self, enable_waffle, initial, subsequent): def test_progress_queries(self, enable_waffle, initial, subsequent):
...@@ -2213,6 +2214,7 @@ class TestIndexView(ModuleStoreTestCase): ...@@ -2213,6 +2214,7 @@ class TestIndexView(ModuleStoreTestCase):
state=json.dumps({'state': unicode(item.scope_ids.usage_id)}) state=json.dumps({'state': unicode(item.scope_ids.usage_id)})
) )
CourseOverview.load_from_module_store(course.id)
CourseEnrollmentFactory(user=user, course_id=course.id) CourseEnrollmentFactory(user=user, course_id=course.id)
self.assertTrue(self.client.login(username=user.username, password='test')) self.assertTrue(self.client.login(username=user.username, password='test'))
...@@ -2241,6 +2243,7 @@ class TestIndexView(ModuleStoreTestCase): ...@@ -2241,6 +2243,7 @@ class TestIndexView(ModuleStoreTestCase):
vertical = ItemFactory.create(parent=section, category='vertical', display_name="Vertical") vertical = ItemFactory.create(parent=section, category='vertical', display_name="Vertical")
ItemFactory.create(parent=vertical, category='id_checker', display_name="ID Checker") ItemFactory.create(parent=vertical, category='id_checker', display_name="ID Checker")
CourseOverview.load_from_module_store(course.id)
CourseEnrollmentFactory(user=user, course_id=course.id) CourseEnrollmentFactory(user=user, course_id=course.id)
self.assertTrue(self.client.login(username=user.username, password='test')) self.assertTrue(self.client.login(username=user.username, password='test'))
...@@ -2281,6 +2284,8 @@ class TestIndexViewWithVerticalPositions(ModuleStoreTestCase): ...@@ -2281,6 +2284,8 @@ class TestIndexViewWithVerticalPositions(ModuleStoreTestCase):
ItemFactory.create(parent=self.section, category='vertical', display_name="Vertical2") ItemFactory.create(parent=self.section, category='vertical', display_name="Vertical2")
ItemFactory.create(parent=self.section, category='vertical', display_name="Vertical3") ItemFactory.create(parent=self.section, category='vertical', display_name="Vertical3")
CourseOverview.load_from_module_store(self.course.id)
self.client.login(username=self.user, password='test') self.client.login(username=self.user, password='test')
CourseEnrollmentFactory(user=self.user, course_id=self.course.id) CourseEnrollmentFactory(user=self.user, course_id=self.course.id)
...@@ -2505,6 +2510,7 @@ class EnterpriseConsentTestCase(EnterpriseTestConsentRequired, ModuleStoreTestCa ...@@ -2505,6 +2510,7 @@ class EnterpriseConsentTestCase(EnterpriseTestConsentRequired, ModuleStoreTestCa
self.user = UserFactory.create() self.user = UserFactory.create()
self.assertTrue(self.client.login(username=self.user.username, password='test')) self.assertTrue(self.client.login(username=self.user.username, password='test'))
self.course = CourseFactory.create() self.course = CourseFactory.create()
CourseOverview.load_from_module_store(self.course.id)
CourseEnrollmentFactory(user=self.user, course_id=self.course.id) CourseEnrollmentFactory(user=self.user, course_id=self.course.id)
def test_consent_required(self): def test_consent_required(self):
......
...@@ -12,6 +12,7 @@ from mock import patch ...@@ -12,6 +12,7 @@ from mock import patch
from lms.djangoapps.courseware.field_overrides import OverrideModulestoreFieldData from lms.djangoapps.courseware.field_overrides import OverrideModulestoreFieldData
from lms.djangoapps.courseware.url_helpers import get_redirect_url from lms.djangoapps.courseware.url_helpers import get_redirect_url
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory from student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -104,6 +105,7 @@ class RenderXBlockTestMixin(object): ...@@ -104,6 +105,7 @@ class RenderXBlockTestMixin(object):
category='html', category='html',
data="<p>Test HTML Content<p>" data="<p>Test HTML Content<p>"
) )
CourseOverview.load_from_module_store(self.course.id)
# block_name_to_be_tested can be `html_block` or `vertical_block`. # block_name_to_be_tested can be `html_block` or `vertical_block`.
# These attributes help ensure the positive and negative tests are in sync. # These attributes help ensure the positive and negative tests are in sync.
......
import factory import factory
import factory.fuzzy
from experiments.models import ExperimentData, ExperimentKeyValue from experiments.models import ExperimentData, ExperimentKeyValue
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
......
...@@ -3,21 +3,41 @@ from course_modes.models import ( ...@@ -3,21 +3,41 @@ from course_modes.models import (
get_cosmetic_verified_display_price get_cosmetic_verified_display_price
) )
from courseware.date_summary import ( from courseware.date_summary import (
VerifiedUpgradeDeadlineDate verified_upgrade_deadline_link, verified_upgrade_link_is_valid
) )
def check_and_get_upgrade_link(user, course_id): def check_and_get_upgrade_link_and_date(user, enrollment=None, course=None):
""" """
For an authenticated user, return a link to allow them to upgrade For an authenticated user, return a link to allow them to upgrade
in the specified course. in the specified course.
""" """
if user.is_authenticated(): if enrollment is None and course is None:
upgrade_data = VerifiedUpgradeDeadlineDate(None, user, course_id=course_id) raise ValueError("Must specify either an enrollment or a course")
if upgrade_data.is_enabled:
return upgrade_data
return None if enrollment:
if course is None:
course = enrollment.course
elif enrollment.course_id != course.id:
raise ValueError("{} refers to a different course than {} which was supplied".format(
enrollment, course
))
if enrollment.user_id != user.id:
raise ValueError("{} refers to a different user than {} which was supplied".format(
enrollment, user
))
if enrollment is None:
enrollment = CourseEnrollment.get_enrollment(user, course.id)
if user.is_authenticated() and verified_upgrade_link_is_valid(enrollment):
return (
verified_upgrade_deadline_link(user, course),
enrollment.upgrade_deadline
)
return (None, None)
def get_experiment_user_metadata_context(course, user): def get_experiment_user_metadata_context(course, user):
...@@ -26,23 +46,26 @@ def get_experiment_user_metadata_context(course, user): ...@@ -26,23 +46,26 @@ def get_experiment_user_metadata_context(course, user):
""" """
enrollment_mode = None enrollment_mode = None
enrollment_time = None enrollment_time = None
enrollment = None
try: try:
enrollment = CourseEnrollment.objects.get(user_id=user.id, course_id=course.id) enrollment = CourseEnrollment.objects.select_related(
'course'
).get(user_id=user.id, course_id=course.id)
if enrollment.is_active: if enrollment.is_active:
enrollment_mode = enrollment.mode enrollment_mode = enrollment.mode
enrollment_time = enrollment.created enrollment_time = enrollment.created
except CourseEnrollment.DoesNotExist: except CourseEnrollment.DoesNotExist:
pass # Not enrolled, used the default None values pass # Not enrolled, used the default None values
upgrade_data = check_and_get_upgrade_link(user, course.id) upgrade_link, upgrade_date = check_and_get_upgrade_link_and_date(user, enrollment, course)
return { return {
'upgrade_link': upgrade_data and upgrade_data.link, 'upgrade_link': upgrade_link,
'upgrade_price': unicode(get_cosmetic_verified_display_price(course)), 'upgrade_price': unicode(get_cosmetic_verified_display_price(course)),
'enrollment_mode': enrollment_mode, 'enrollment_mode': enrollment_mode,
'enrollment_time': enrollment_time, 'enrollment_time': enrollment_time,
'pacing_type': 'self_paced' if course.self_paced else 'instructor_paced', 'pacing_type': 'self_paced' if course.self_paced else 'instructor_paced',
'upgrade_deadline': upgrade_data and upgrade_data.date, 'upgrade_deadline': upgrade_date,
'course_key': course.id, 'course_key': course.id,
'course_start': course.start, 'course_start': course.start,
'course_end': course.end, 'course_end': course.end,
......
...@@ -14,21 +14,41 @@ from mock import Mock, patch ...@@ -14,21 +14,41 @@ from mock import Mock, patch
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import CourseLocator from opaque_keys.edx.locator import CourseLocator
from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory
from courseware.models import DynamicUpgradeDeadlineConfiguration
from openedx.core.djangoapps.schedules import resolvers, tasks from openedx.core.djangoapps.schedules import resolvers, tasks
from openedx.core.djangoapps.schedules.management.commands import send_upgrade_reminder as reminder from openedx.core.djangoapps.schedules.management.commands import send_upgrade_reminder as reminder
from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory, ScheduleFactory from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory, ScheduleFactory
from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory, SiteFactory 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 WAFFLE_TABLES
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms, FilteredQueryCountMixin
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
# 1) Load the current django site
# 2) Query the schedules to find all of the template context information
NUM_QUERIES_NO_MATCHING_SCHEDULES = 2
# 3) Query all course modes for all courses in returned schedules
NUM_QUERIES_WITH_MATCHES = NUM_QUERIES_NO_MATCHING_SCHEDULES + 1
# 1) Global dynamic deadline switch
# 2) E-commerce configuration
NUM_QUERIES_WITH_DEADLINE = 2
NUM_COURSE_MODES_QUERIES = 1
@ddt.ddt @ddt.ddt
@skip_unless_lms @skip_unless_lms
@skipUnless('openedx.core.djangoapps.schedules.apps.SchedulesConfig' in settings.INSTALLED_APPS, @skipUnless('openedx.core.djangoapps.schedules.apps.SchedulesConfig' in settings.INSTALLED_APPS,
"Can't test schedules if the app isn't installed") "Can't test schedules if the app isn't installed")
class TestUpgradeReminder(CacheIsolationTestCase): class TestUpgradeReminder(FilteredQueryCountMixin, CacheIsolationTestCase):
# pylint: disable=protected-access # pylint: disable=protected-access
ENABLED_CACHES = ['default']
def setUp(self): def setUp(self):
super(TestUpgradeReminder, self).setUp() super(TestUpgradeReminder, self).setUp()
...@@ -74,20 +94,26 @@ class TestUpgradeReminder(CacheIsolationTestCase): ...@@ -74,20 +94,26 @@ class TestUpgradeReminder(CacheIsolationTestCase):
schedules = [ schedules = [
ScheduleFactory.create( ScheduleFactory.create(
upgrade_deadline=datetime.datetime(2017, 8, 3, 18, 44, 30, tzinfo=pytz.UTC), upgrade_deadline=datetime.datetime(2017, 8, 3, 18, 44, 30, tzinfo=pytz.UTC),
enrollment__user=UserFactory.create(),
enrollment__course__id=CourseLocator('edX', 'toy', 'Bin') enrollment__course__id=CourseLocator('edX', 'toy', 'Bin')
) for _ in range(schedule_count) ) for i in range(schedule_count)
] ]
bins_in_use = frozenset((s.enrollment.user.id % tasks.UPGRADE_REMINDER_NUM_BINS) for s in schedules)
test_time = datetime.datetime(2017, 8, 3, 18, tzinfo=pytz.UTC) test_time = datetime.datetime(2017, 8, 3, 18, tzinfo=pytz.UTC)
test_time_str = serialize(test_time) test_time_str = serialize(test_time)
for b in range(tasks.UPGRADE_REMINDER_NUM_BINS): for b in range(tasks.UPGRADE_REMINDER_NUM_BINS):
# waffle flag takes an extra query before it is cached expected_queries = NUM_QUERIES_NO_MATCHING_SCHEDULES
with self.assertNumQueries(3 if b == 0 else 2): if b in bins_in_use:
# to fetch course modes for valid schedules
expected_queries += NUM_COURSE_MODES_QUERIES
with self.assertNumQueries(expected_queries, table_blacklist=WAFFLE_TABLES):
tasks.upgrade_reminder_schedule_bin( tasks.upgrade_reminder_schedule_bin(
self.site_config.site.id, target_day_str=test_time_str, day_offset=2, bin_num=b, self.site_config.site.id, target_day_str=test_time_str, day_offset=2, bin_num=b,
org_list=[schedules[0].enrollment.course.org], org_list=[schedules[0].enrollment.course.org],
) )
self.assertEqual(mock_schedule_send.apply_async.call_count, schedule_count) self.assertEqual(mock_schedule_send.apply_async.call_count, schedule_count)
self.assertFalse(mock_ace.send.called) self.assertFalse(mock_ace.send.called)
...@@ -103,8 +129,7 @@ class TestUpgradeReminder(CacheIsolationTestCase): ...@@ -103,8 +129,7 @@ class TestUpgradeReminder(CacheIsolationTestCase):
test_time = datetime.datetime(2017, 8, 3, 20, tzinfo=pytz.UTC) test_time = datetime.datetime(2017, 8, 3, 20, tzinfo=pytz.UTC)
test_time_str = serialize(test_time) test_time_str = serialize(test_time)
for b in range(tasks.UPGRADE_REMINDER_NUM_BINS): for b in range(tasks.UPGRADE_REMINDER_NUM_BINS):
# waffle flag takes an extra query before it is cached with self.assertNumQueries(NUM_QUERIES_NO_MATCHING_SCHEDULES, table_blacklist=WAFFLE_TABLES):
with self.assertNumQueries(3 if b == 0 else 2):
tasks.upgrade_reminder_schedule_bin( tasks.upgrade_reminder_schedule_bin(
self.site_config.site.id, target_day_str=test_time_str, day_offset=2, bin_num=b, self.site_config.site.id, target_day_str=test_time_str, day_offset=2, bin_num=b,
org_list=[schedule.enrollment.course.org], org_list=[schedule.enrollment.course.org],
...@@ -176,7 +201,7 @@ class TestUpgradeReminder(CacheIsolationTestCase): ...@@ -176,7 +201,7 @@ class TestUpgradeReminder(CacheIsolationTestCase):
test_time = datetime.datetime(2017, 8, 3, 17, tzinfo=pytz.UTC) test_time = datetime.datetime(2017, 8, 3, 17, tzinfo=pytz.UTC)
test_time_str = serialize(test_time) test_time_str = serialize(test_time)
with self.assertNumQueries(3): with self.assertNumQueries(NUM_QUERIES_WITH_MATCHES, table_blacklist=WAFFLE_TABLES):
tasks.upgrade_reminder_schedule_bin( tasks.upgrade_reminder_schedule_bin(
limited_config.site.id, target_day_str=test_time_str, day_offset=2, bin_num=0, limited_config.site.id, target_day_str=test_time_str, day_offset=2, bin_num=0,
org_list=org_list, exclude_orgs=exclude_orgs, org_list=org_list, exclude_orgs=exclude_orgs,
...@@ -200,7 +225,7 @@ class TestUpgradeReminder(CacheIsolationTestCase): ...@@ -200,7 +225,7 @@ class TestUpgradeReminder(CacheIsolationTestCase):
test_time = datetime.datetime(2017, 8, 3, 19, 44, 30, tzinfo=pytz.UTC) test_time = datetime.datetime(2017, 8, 3, 19, 44, 30, tzinfo=pytz.UTC)
test_time_str = serialize(test_time) test_time_str = serialize(test_time)
with self.assertNumQueries(3): with self.assertNumQueries(NUM_QUERIES_WITH_MATCHES, table_blacklist=WAFFLE_TABLES):
tasks.upgrade_reminder_schedule_bin( tasks.upgrade_reminder_schedule_bin(
self.site_config.site.id, target_day_str=test_time_str, day_offset=2, self.site_config.site.id, target_day_str=test_time_str, day_offset=2,
bin_num=user.id % tasks.UPGRADE_REMINDER_NUM_BINS, bin_num=user.id % tasks.UPGRADE_REMINDER_NUM_BINS,
...@@ -212,18 +237,31 @@ class TestUpgradeReminder(CacheIsolationTestCase): ...@@ -212,18 +237,31 @@ class TestUpgradeReminder(CacheIsolationTestCase):
@ddt.data(*itertools.product((1, 10, 100), (2, 10))) @ddt.data(*itertools.product((1, 10, 100), (2, 10)))
@ddt.unpack @ddt.unpack
def test_templates(self, message_count, day): def test_templates(self, message_count, day):
DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True)
now = datetime.datetime.now(pytz.UTC)
future_date = now + datetime.timedelta(days=21)
user = UserFactory.create() user = UserFactory.create()
schedules = [ schedules = [
ScheduleFactory.create( ScheduleFactory.create(
upgrade_deadline=datetime.datetime(2017, 8, 3, 19, 44, 30, tzinfo=pytz.UTC), upgrade_deadline=future_date,
enrollment__user=user, enrollment__user=user,
enrollment__course__id=CourseLocator('edX', 'toy', 'Course{}'.format(course_num)) enrollment__course__id=CourseLocator('edX', 'toy', 'Course{}'.format(course_num))
) )
for course_num in range(message_count) for course_num in range(message_count)
] ]
test_time = datetime.datetime(2017, 8, 3, 19, tzinfo=pytz.UTC) for schedule in schedules:
schedule.enrollment.course.self_paced = True
schedule.enrollment.course.save()
CourseModeFactory(
course_id=schedule.enrollment.course.id,
mode_slug=CourseMode.VERIFIED,
expiration_datetime=future_date
)
test_time = future_date
test_time_str = serialize(test_time) test_time_str = serialize(test_time)
patch_policies(self, [StubPolicy([ChannelType.PUSH])]) patch_policies(self, [StubPolicy([ChannelType.PUSH])])
...@@ -241,7 +279,10 @@ class TestUpgradeReminder(CacheIsolationTestCase): ...@@ -241,7 +279,10 @@ class TestUpgradeReminder(CacheIsolationTestCase):
with patch.object(tasks, '_upgrade_reminder_schedule_send') as mock_schedule_send: with patch.object(tasks, '_upgrade_reminder_schedule_send') as mock_schedule_send:
mock_schedule_send.apply_async = lambda args, *_a, **_kw: sent_messages.append(args) mock_schedule_send.apply_async = lambda args, *_a, **_kw: sent_messages.append(args)
with self.assertNumQueries(3): # we execute one query per course to see if it's opted out of dynamic upgrade deadlines, however,
# since we create a new course for each schedule in this test, we expect there to be one per message
num_expected_queries = NUM_QUERIES_WITH_MATCHES + NUM_QUERIES_WITH_DEADLINE + message_count
with self.assertNumQueries(num_expected_queries, table_blacklist=WAFFLE_TABLES):
tasks.upgrade_reminder_schedule_bin( tasks.upgrade_reminder_schedule_bin(
self.site_config.site.id, target_day_str=test_time_str, day_offset=day, self.site_config.site.id, target_day_str=test_time_str, day_offset=day,
bin_num=user.id % tasks.UPGRADE_REMINDER_NUM_BINS, bin_num=user.id % tasks.UPGRADE_REMINDER_NUM_BINS,
......
...@@ -112,7 +112,7 @@ def _get_upgrade_deadline_delta_setting(course_id): ...@@ -112,7 +112,7 @@ def _get_upgrade_deadline_delta_setting(course_id):
# Check if the course has a deadline # Check if the course has a deadline
course_config = CourseDynamicUpgradeDeadlineConfiguration.current(course_id) course_config = CourseDynamicUpgradeDeadlineConfiguration.current(course_id)
if course_config.enabled: if course_config.enabled and not course_config.opt_out:
delta = course_config.deadline_days delta = course_config.deadline_days
return delta return delta
...@@ -12,6 +12,7 @@ from django.core.urlresolvers import reverse ...@@ -12,6 +12,7 @@ from django.core.urlresolvers import reverse
from django.db.models import F, Min from django.db.models import F, Min
from django.db.utils import DatabaseError from django.db.utils import DatabaseError
from django.utils.formats import dateformat, get_format from django.utils.formats import dateformat, get_format
import pytz
from edx_ace import ace from edx_ace import ace
from edx_ace.message import Message from edx_ace.message import Message
...@@ -19,6 +20,8 @@ from edx_ace.recipient import Recipient ...@@ -19,6 +20,8 @@ from edx_ace.recipient import Recipient
from edx_ace.utils.date import deserialize from edx_ace.utils.date import deserialize
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from courseware.date_summary import verified_upgrade_deadline_link, verified_upgrade_link_is_valid
from edxmako.shortcuts import marketing_link from edxmako.shortcuts import marketing_link
from openedx.core.djangoapps.schedules.message_type import ScheduleMessageType from openedx.core.djangoapps.schedules.message_type import ScheduleMessageType
from openedx.core.djangoapps.schedules.models import Schedule, ScheduleConfig from openedx.core.djangoapps.schedules.models import Schedule, ScheduleConfig
...@@ -105,6 +108,40 @@ def _recurring_nudge_schedule_send(site_id, msg_str): ...@@ -105,6 +108,40 @@ def _recurring_nudge_schedule_send(site_id, msg_str):
# TODO: delete once _recurring_nudge_schedules_for_bin is fully rolled out # TODO: delete once _recurring_nudge_schedules_for_bin is fully rolled out
def _recurring_nudge_schedules_for_hour(site, target_hour, org_list, exclude_orgs=False): def _recurring_nudge_schedules_for_hour(site, target_hour, org_list, exclude_orgs=False):
users, schedules = _gather_users_and_schedules_for_target_hour(target_hour, org_list, exclude_orgs)
dashboard_relative_url = reverse('dashboard')
for (user, user_schedules) in groupby(schedules, lambda s: s.enrollment.user):
user_schedules = list(user_schedules)
course_id_strs = [str(schedule.enrollment.course_id) for schedule in user_schedules]
first_schedule = user_schedules[0]
template_context = {
'student_name': user.profile.name,
'course_name': first_schedule.enrollment.course.display_name,
'course_url': absolute_url(site, reverse('course_root', args=[str(first_schedule.enrollment.course_id)])),
# This is used by the bulk email optout policy
'course_ids': course_id_strs,
# Platform information
'homepage_url': encode_url(marketing_link('ROOT')),
'dashboard_url': absolute_url(site, dashboard_relative_url),
'template_revision': settings.EDX_PLATFORM_REVISION,
'platform_name': settings.PLATFORM_NAME,
'contact_mailing_address': settings.CONTACT_MAILING_ADDRESS,
'social_media_urls': encode_urls_in_dict(getattr(settings, 'SOCIAL_MEDIA_FOOTER_URLS', {})),
'mobile_store_urls': encode_urls_in_dict(getattr(settings, 'MOBILE_STORE_URLS', {})),
}
# Information for including upsell messaging in template.
_add_upsell_button_information_to_template_context(user, first_schedule, template_context)
yield (user, first_schedule.enrollment.course.language, template_context)
def _gather_users_and_schedules_for_target_hour(target_hour, org_list, exclude_orgs):
beginning_of_day = target_hour.replace(hour=0, minute=0, second=0) beginning_of_day = target_hour.replace(hour=0, minute=0, second=0)
users = User.objects.filter( users = User.objects.filter(
courseenrollment__schedule__start__gte=beginning_of_day, courseenrollment__schedule__start__gte=beginning_of_day,
...@@ -120,6 +157,8 @@ def _recurring_nudge_schedules_for_hour(site, target_hour, org_list, exclude_org ...@@ -120,6 +157,8 @@ def _recurring_nudge_schedules_for_hour(site, target_hour, org_list, exclude_org
schedules = Schedule.objects.select_related( schedules = Schedule.objects.select_related(
'enrollment__user__profile', 'enrollment__user__profile',
'enrollment__course', 'enrollment__course',
).prefetch_related(
'enrollment__course__modes'
).filter( ).filter(
enrollment__user__in=users, enrollment__user__in=users,
start__gte=beginning_of_day, start__gte=beginning_of_day,
...@@ -138,32 +177,7 @@ def _recurring_nudge_schedules_for_hour(site, target_hour, org_list, exclude_org ...@@ -138,32 +177,7 @@ def _recurring_nudge_schedules_for_hour(site, target_hour, org_list, exclude_org
LOG.debug('Scheduled Nudge: Query = %r', schedules.query.sql_with_params()) LOG.debug('Scheduled Nudge: Query = %r', schedules.query.sql_with_params())
dashboard_relative_url = reverse('dashboard') return users, schedules
for (user, user_schedules) in groupby(schedules, lambda s: s.enrollment.user):
user_schedules = list(user_schedules)
course_id_strs = [str(schedule.enrollment.course_id) for schedule in user_schedules]
first_schedule = user_schedules[0]
template_context = {
'student_name': user.profile.name,
'course_name': first_schedule.enrollment.course.display_name,
'course_url': absolute_url(site, reverse('course_root', args=[str(first_schedule.enrollment.course_id)])),
# This is used by the bulk email optout policy
'course_ids': course_id_strs,
# Platform information
'homepage_url': encode_url(marketing_link('ROOT')),
'dashboard_url': absolute_url(site, dashboard_relative_url),
'template_revision': settings.EDX_PLATFORM_REVISION,
'platform_name': settings.PLATFORM_NAME,
'contact_mailing_address': settings.CONTACT_MAILING_ADDRESS,
'social_media_urls': encode_urls_in_dict(getattr(settings, 'SOCIAL_MEDIA_FOOTER_URLS', {})),
'mobile_store_urls': encode_urls_in_dict(getattr(settings, 'MOBILE_STORE_URLS', {})),
}
yield (user, first_schedule.enrollment.course.language, template_context)
@task(ignore_result=True, routing_key=ROUTING_KEY) @task(ignore_result=True, routing_key=ROUTING_KEY)
...@@ -219,6 +233,10 @@ def _recurring_nudge_schedules_for_bin(site, target_day, bin_num, org_list, excl ...@@ -219,6 +233,10 @@ def _recurring_nudge_schedules_for_bin(site, target_day, bin_num, org_list, excl
# This is used by the bulk email optout policy # This is used by the bulk email optout policy
'course_ids': course_id_strs, 'course_ids': course_id_strs,
}) })
# Information for including upsell messaging in template.
_add_upsell_button_information_to_template_context(user, first_schedule, template_context)
yield (user, first_schedule.enrollment.course.language, template_context) yield (user, first_schedule.enrollment.course.language, template_context)
...@@ -289,14 +307,6 @@ def _upgrade_reminder_schedules_for_bin(site, target_day, bin_num, org_list, exc ...@@ -289,14 +307,6 @@ def _upgrade_reminder_schedules_for_bin(site, target_day, bin_num, org_list, exc
template_context.update({ template_context.update({
'student_name': user.profile.name, 'student_name': user.profile.name,
'user_personal_address': user.profile.name if user.profile.name else user.username, 'user_personal_address': user.profile.name if user.profile.name else user.username,
'user_schedule_upgrade_deadline_time': dateformat.format(
schedule.upgrade_deadline,
get_format(
'DATE_FORMAT',
lang=first_schedule.enrollment.course.language,
use_l10n=True
)
),
'course_name': first_schedule.enrollment.course.display_name, 'course_name': first_schedule.enrollment.course.display_name,
'course_url': absolute_url(site, reverse('course_root', args=[str(first_schedule.enrollment.course_id)])), 'course_url': absolute_url(site, reverse('course_root', args=[str(first_schedule.enrollment.course_id)])),
...@@ -306,6 +316,8 @@ def _upgrade_reminder_schedules_for_bin(site, target_day, bin_num, org_list, exc ...@@ -306,6 +316,8 @@ def _upgrade_reminder_schedules_for_bin(site, target_day, bin_num, org_list, exc
'cert_image': absolute_url(site, static('course_experience/images/verified-cert.png')), 'cert_image': absolute_url(site, static('course_experience/images/verified-cert.png')),
}) })
_add_upsell_button_information_to_template_context(user, first_schedule, template_context)
yield (user, first_schedule.enrollment.course.language, template_context) yield (user, first_schedule.enrollment.course.language, template_context)
...@@ -344,6 +356,8 @@ def get_schedules_with_target_date_by_bin_and_orgs(schedule_date_field, target_d ...@@ -344,6 +356,8 @@ def get_schedules_with_target_date_by_bin_and_orgs(schedule_date_field, target_d
schedules = Schedule.objects.select_related( schedules = Schedule.objects.select_related(
'enrollment__user__profile', 'enrollment__user__profile',
'enrollment__course', 'enrollment__course',
).prefetch_related(
'enrollment__course__modes'
).filter( ).filter(
enrollment__user__in=users, enrollment__user__in=users,
enrollment__is_active=True, enrollment__is_active=True,
...@@ -360,3 +374,32 @@ def get_schedules_with_target_date_by_bin_and_orgs(schedule_date_field, target_d ...@@ -360,3 +374,32 @@ def get_schedules_with_target_date_by_bin_and_orgs(schedule_date_field, target_d
schedules = schedules.using("read_replica") schedules = schedules.using("read_replica")
return schedules return schedules
def _add_upsell_button_information_to_template_context(user, schedule, template_context):
enrollment = schedule.enrollment
course = enrollment.course
verified_upgrade_link = _get_link_to_purchase_verified_certificate(user, schedule)
has_verified_upgrade_link = verified_upgrade_link is not None
if has_verified_upgrade_link:
template_context['upsell_link'] = verified_upgrade_link
template_context['user_schedule_upgrade_deadline_time'] = dateformat.format(
enrollment.dynamic_upgrade_deadline,
get_format(
'DATE_FORMAT',
lang=course.language,
use_l10n=True
)
)
template_context['show_upsell'] = has_verified_upgrade_link
def _get_link_to_purchase_verified_certificate(a_user, a_schedule):
enrollment = a_schedule.enrollment
if enrollment.dynamic_upgrade_deadline is None or not verified_upgrade_link_is_valid(enrollment):
return None
return verified_upgrade_deadline_link(a_user, enrollment.course)
...@@ -36,7 +36,7 @@ ...@@ -36,7 +36,7 @@
</p> </p>
<p> <p>
<!-- email client support for style sheets is pretty spotty, so we have to inline all of these styles --> {# email client support for style sheets is pretty spotty, so we have to inline all of these styles #}
<a <a
{% if course_ids|length > 1 %} {% if course_ids|length > 1 %}
href="{{ dashboard_url }}" href="{{ dashboard_url }}"
...@@ -46,20 +46,48 @@ ...@@ -46,20 +46,48 @@
style=" style="
color: #ffffff; color: #ffffff;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: .3rem;
-webkit-border-radius: 4px; -webkit-border-radius: .3rem;
-moz-border-radius: 4px; -moz-border-radius: .3rem;
background-color: #005686; background-color: #005686;
border-top: 10px solid #005686; border-top: .15rem solid #005686;
border-bottom: 10px solid #005686; border-bottom: .15rem solid #005686;
border-right: 16px solid #005686; border-right: .15rem solid #005686;
border-left: 16px solid #005686; border-left: .15rem solid #005686;
display: inline-block; display: inline-block;
padding: 1rem 5rem;
"> ">
<!-- old email clients require the use of the font tag :( --> {# old email clients require the use of the font tag :( #}
<font color="#ffffff"><b>{% trans "Start learning now" %}</b></font> <font color="#ffffff"><b>{% trans "Start learning now" %}</b></font>
</a> </a>
</p> </p>
{% if show_upsell %}
<p>
{% blocktrans trimmed %}
Don't miss the opportunity to highlight your new knowledge and skills by earning a verified
certificate. Upgrade by {{ user_schedule_upgrade_deadline_time }}.
{% endblocktrans %}
</p>
<p>
<a href="{{ upsell_link }}"
style="
color: #1e8142;
text-decoration: none;
border-radius: .3rem;
-webkit-border-radius: .3rem;
-moz-border-radius: .3rem;
background-color: #FFFFFF;
border-top: .15rem solid #1e8142;
border-bottom: .15rem solid #1e8142;
border-right: .15rem solid #1e8142;
border-left: .15rem solid #1e8142;
display: inline-block;
padding: 1rem 6.1rem;
">
<font color="#1e8142"><b>{% trans "Upgrade Now" %}</b></font>
</a>
</p>
{% endif %}
</td> </td>
</tr> </tr>
</table> </table>
......
...@@ -15,3 +15,12 @@ ...@@ -15,3 +15,12 @@
{% trans "Start learning now" %} <{{ course_url }}> {% trans "Start learning now" %} <{{ course_url }}>
{% endif %} {% endif %}
{% if show_upsell %}
{% blocktrans trimmed %}
Don't miss the opportunity to highlight your new knowledge and skills by earning a verified
certificate. Upgrade by {{ user_schedule_upgrade_deadline_time }}.
Upgrade Now! <{{ upsell_link }}>
{% endblocktrans %}
{% endif %}
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
{% endblocktrans %} {% endblocktrans %}
</p> </p>
<a href="{{ course_url }}"> <a href="{{ upsell_link }}">
<img <img
src="{{ cert_image }}" src="{{ cert_image }}"
alt="{% blocktrans %}Example print-out of a verified certificate{% endblocktrans %}" alt="{% blocktrans %}Example print-out of a verified certificate{% endblocktrans %}"
...@@ -50,7 +50,7 @@ ...@@ -50,7 +50,7 @@
<p> <p>
<!-- email client support for style sheets is pretty spotty, so we have to inline all of these styles --> <!-- email client support for style sheets is pretty spotty, so we have to inline all of these styles -->
<a <a
href="{{ course_url }}" href="{{ upsell_link }}"
style=" style="
color: #ffffff; color: #ffffff;
text-decoration: none; text-decoration: none;
......
...@@ -11,4 +11,4 @@ Dear {{ user_personal_address }}, ...@@ -11,4 +11,4 @@ Dear {{ user_personal_address }},
Upgrade by {{ user_schedule_upgrade_deadline_time }}. Upgrade by {{ user_schedule_upgrade_deadline_time }}.
{% endblocktrans %} {% endblocktrans %}
{% trans "Upgrade now at" %} <{{ course_url }}> {% trans "Upgrade now at" %} <{{ upsell_link }}>
...@@ -126,7 +126,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase): ...@@ -126,7 +126,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
course_updates_url(self.course) course_updates_url(self.course)
# Fetch the view and verify that the query counts haven't changed # Fetch the view and verify that the query counts haven't changed
with self.assertNumQueries(33, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): with self.assertNumQueries(30, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with check_mongo_calls(4): with check_mongo_calls(4):
url = course_updates_url(self.course) url = course_updates_url(self.course)
self.client.get(url) self.client.get(url)
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