Commit d9bfab5b by Feanil Patel

Merge branch 'master' into release-candidate

parents 39fdbd90 70dc3359
...@@ -136,10 +136,10 @@ class CourseMode(models.Model): ...@@ -136,10 +136,10 @@ class CourseMode(models.Model):
HONOR = 'honor' HONOR = 'honor'
PROFESSIONAL = 'professional' PROFESSIONAL = 'professional'
VERIFIED = "verified" VERIFIED = 'verified'
AUDIT = "audit" AUDIT = 'audit'
NO_ID_PROFESSIONAL_MODE = "no-id-professional" NO_ID_PROFESSIONAL_MODE = 'no-id-professional'
CREDIT_MODE = "credit" CREDIT_MODE = 'credit'
DEFAULT_MODE = Mode( DEFAULT_MODE = Mode(
settings.COURSE_MODE_DEFAULTS['slug'], settings.COURSE_MODE_DEFAULTS['slug'],
......
...@@ -9,6 +9,7 @@ from datetime import datetime, timedelta ...@@ -9,6 +9,7 @@ from datetime import datetime, timedelta
import ddt import ddt
import freezegun import freezegun
import httpretty import httpretty
import pytest
import pytz import pytz
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
...@@ -68,6 +69,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest ...@@ -68,6 +69,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
(False, None, False, False), (False, None, False, False),
) )
@ddt.unpack @ddt.unpack
@pytest.mark.django111_expected_failure
def test_redirect_to_dashboard(self, is_active, enrollment_mode, redirect, has_started): def test_redirect_to_dashboard(self, is_active, enrollment_mode, redirect, has_started):
# Configure whether course has started # Configure whether course has started
# If it has go to course home instead of dashboard # If it has go to course home instead of dashboard
......
...@@ -24,3 +24,10 @@ class CourseEntitlement(TimeStampedModel): ...@@ -24,3 +24,10 @@ class CourseEntitlement(TimeStampedModel):
help_text='The current Course enrollment for this entitlement. If NULL the Learner has not enrolled.' help_text='The current Course enrollment for this entitlement. If NULL the Learner has not enrolled.'
) )
order_number = models.CharField(max_length=128, null=True) order_number = models.CharField(max_length=128, null=True)
@property
def expired_at_datetime(self):
"""
Getter to be used instead of expired_at because of the conditional check and update
"""
return self.expired_at
<%page expression_filter="h"/>
# intentionally left blank # intentionally left blank
<%page expression_filter="h"/>
# intentionally left blank # intentionally left blank
<%page expression_filter="h"/>
# intentionally left blank # intentionally left blank
<%page expression_filter="h"/>
# intentionally left blank # intentionally left blank
## mako ## mako
<%page expression_filter="h"/>
<%namespace name='static' file='/static_content.html'/> <%namespace name='static' file='/static_content.html'/>
<%include file="${static.get_template_path('courseware/test_relative_path.html')}" /> <%include file="${static.get_template_path('courseware/test_relative_path.html')}" />
<%include file="${static.get_template_path('/courseware/test_absolute_path.html')}" /> <%include file="${static.get_template_path('/courseware/test_absolute_path.html')}" />
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
%> %>
<%page args="tab_list, active_page, default_tab, tab_image" /> <%page args="tab_list, active_page, default_tab, tab_image" expression_filter="h" />
<% <%
def url_class(is_active): def url_class(is_active):
......
## mako ## mako
<%page expression_filter="h"/>
<%namespace name='static' file='/static_content.html'/> <%namespace name='static' file='/static_content.html'/>
<div>Microsite absolute path template contents</div> <div>Microsite absolute path template contents</div>
\ No newline at end of file
## mako ## mako
<%page expression_filter="h"/>
<%namespace name='static' file='/static_content.html'/> <%namespace name='static' file='/static_content.html'/>
<div>Microsite relative path template contents</div> <div>Microsite relative path template contents</div>
\ No newline at end of file
## mako ## mako
<%page expression_filter="h"/>
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<%! <%!
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
......
<%page expression_filter="h"/>
<%namespace name='static' file='../../static_content.html'/> <%namespace name='static' file='../../static_content.html'/>
<% style_overrides_file = static.get_value('css_overrides_file') %> <% style_overrides_file = static.get_value('css_overrides_file') %>
......
<%! <%page expression_filter="h"/>
<%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
%> %>
......
<%! <%page expression_filter="h"/>
<%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
%> %>
......
<%page expression_filter="h"/>
<%! from django.core.urlresolvers import reverse %> <%! from django.core.urlresolvers import reverse %>
<%namespace name='static' file='../../../static_content.html'/> <%namespace name='static' file='../../../static_content.html'/>
......
<%page expression_filter="h"/>
<%inherit file="../main.html" /> <%inherit file="../main.html" />
<%namespace name='static' file='../static_content.html'/> <%namespace name='static' file='../static_content.html'/>
<%! <%!
......
<%page expression_filter="h"/>
This is a copyright page for an Open edX site. This is a copyright page for an Open edX site.
<%page expression_filter="h"/>
<%inherit file="../main.html" /> <%inherit file="../main.html" />
<%namespace name='static' file='../static_content.html'/> <%namespace name='static' file='../static_content.html'/>
<%! <%!
......
<%page expression_filter="h"/>
<%inherit file="../main.html" /> <%inherit file="../main.html" />
<%namespace name='static' file='../static_content.html'/> <%namespace name='static' file='../static_content.html'/>
<%! <%!
......
...@@ -40,7 +40,7 @@ from courseware.courses import get_course_by_id ...@@ -40,7 +40,7 @@ from courseware.courses import get_course_by_id
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from edxmako.template import Template from edxmako.template import Template
from openedx.core.djangoapps.catalog.utils import get_course_run_details from openedx.core.djangoapps.catalog.utils import get_course_run_details
from openedx.core.djangoapps.lang_pref.api import released_languages from openedx.core.djangoapps.lang_pref.api import get_closest_released_language
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.lib.courses import course_image_url from openedx.core.lib.courses import course_image_url
from openedx.core.djangoapps.certificates.api import display_date_for_certificate, certificates_viewable_for_course from openedx.core.djangoapps.certificates.api import display_date_for_certificate, certificates_viewable_for_course
...@@ -656,7 +656,7 @@ def _get_custom_template_and_language(course_id, course_mode, course_language): ...@@ -656,7 +656,7 @@ def _get_custom_template_and_language(course_id, course_mode, course_language):
Return the custom certificate template, if any, that should be rendered for the provided course/mode/language Return the custom certificate template, if any, that should be rendered for the provided course/mode/language
combination, along with the language that should be used to render that template. combination, along with the language that should be used to render that template.
""" """
closest_released_language = _get_closest_released_language(course_language) if course_language else None closest_released_language = get_closest_released_language(course_language) if course_language else None
template = get_certificate_template(course_id, course_mode, closest_released_language) template = get_certificate_template(course_id, course_mode, closest_released_language)
if template and template.language: if template and template.language:
...@@ -667,24 +667,6 @@ def _get_custom_template_and_language(course_id, course_mode, course_language): ...@@ -667,24 +667,6 @@ def _get_custom_template_and_language(course_id, course_mode, course_language):
return (None, None) return (None, None)
def _get_closest_released_language(target):
"""
Return the language code that most closely matches the target and is fully supported by the LMS, or None
if there are no fully supported languages that match the target.
"""
match = None
languages = released_languages()
for language in languages:
if language.code == target:
match = language.code
break
elif (match is None) and (language.code[:2] == target[:2]):
match = language.code
return match
def _render_invalid_certificate(course_id, platform_name, configuration): def _render_invalid_certificate(course_id, platform_name, configuration):
context = {} context = {}
_update_context_with_basic_info(context, course_id, platform_name, configuration) _update_context_with_basic_info(context, course_id, platform_name, configuration)
......
...@@ -337,6 +337,7 @@ class TestCourseGrades(TestSubmittingProblems): ...@@ -337,6 +337,7 @@ class TestCourseGrades(TestSubmittingProblems):
@attr(shard=3) @attr(shard=3)
@ddt.ddt @ddt.ddt
@pytest.mark.django111_expected_failure
class TestCourseGrader(TestSubmittingProblems): class TestCourseGrader(TestSubmittingProblems):
""" """
Suite of tests for the course grader. Suite of tests for the course grader.
......
...@@ -4,6 +4,7 @@ Unit tests for instructor_dashboard.py. ...@@ -4,6 +4,7 @@ Unit tests for instructor_dashboard.py.
import datetime import datetime
import ddt import ddt
import pytest
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test.client import RequestFactory from django.test.client import RequestFactory
...@@ -320,6 +321,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT ...@@ -320,6 +321,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT
# Max number of student per page is one. Patched setting MAX_STUDENTS_PER_PAGE_GRADE_BOOK = 1 # Max number of student per page is one. Patched setting MAX_STUDENTS_PER_PAGE_GRADE_BOOK = 1
self.assertEqual(len(response.mako_context['students']), 1) # pylint: disable=no-member self.assertEqual(len(response.mako_context['students']), 1) # pylint: disable=no-member
@pytest.mark.django111_expected_failure
def test_open_response_assessment_page(self): def test_open_response_assessment_page(self):
""" """
Test that Open Responses is available only if course contains at least one ORA block Test that Open Responses is available only if course contains at least one ORA block
...@@ -339,6 +341,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT ...@@ -339,6 +341,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertIn(ora_section, response.content) self.assertIn(ora_section, response.content)
@pytest.mark.django111_expected_failure
def test_open_response_assessment_page_orphan(self): def test_open_response_assessment_page_orphan(self):
""" """
Tests that the open responses tab loads if the course contains an Tests that the open responses tab loads if the course contains an
......
...@@ -11,6 +11,7 @@ import textwrap ...@@ -11,6 +11,7 @@ import textwrap
from collections import namedtuple from collections import namedtuple
import ddt import ddt
import pytest
from celery.states import FAILURE, SUCCESS from celery.states import FAILURE, SUCCESS
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
...@@ -67,6 +68,7 @@ class TestIntegrationTask(InstructorTaskModuleTestCase): ...@@ -67,6 +68,7 @@ class TestIntegrationTask(InstructorTaskModuleTestCase):
@attr(shard=3) @attr(shard=3)
@ddt.ddt @ddt.ddt
@pytest.mark.django111_expected_failure
class TestRescoringTask(TestIntegrationTask): class TestRescoringTask(TestIntegrationTask):
""" """
Integration-style tests for rescoring problems in a background task. Integration-style tests for rescoring problems in a background task.
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
Tests for the LTI provider views Tests for the LTI provider views
""" """
import pytest
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
...@@ -163,6 +164,7 @@ class LtiLaunchTest(LtiTestMixin, TestCase): ...@@ -163,6 +164,7 @@ class LtiLaunchTest(LtiTestMixin, TestCase):
@attr(shard=3) @attr(shard=3)
@pytest.mark.django111_expected_failure
class LtiLaunchTestRender(LtiTestMixin, RenderXBlockTestMixin, ModuleStoreTestCase): class LtiLaunchTestRender(LtiTestMixin, RenderXBlockTestMixin, ModuleStoreTestCase):
""" """
Tests for the rendering returned by lti_launch view. Tests for the rendering returned by lti_launch view.
......
...@@ -8,6 +8,7 @@ from decimal import Decimal ...@@ -8,6 +8,7 @@ from decimal import Decimal
from urlparse import urlparse from urlparse import urlparse
import ddt import ddt
import pytest
import pytz import pytz
from django.conf import settings from django.conf import settings
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
...@@ -198,7 +199,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin): ...@@ -198,7 +199,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin):
self.client.login(username=self.user.username, password="password") self.client.login(username=self.user.username, password="password")
def test_add_course_to_cart_anon(self): def test_add_course_to_cart_anon(self):
resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_key.to_deprecated_string()])) resp = self.client.post(reverse('add_course_to_cart', args=[self.course_key.to_deprecated_string()]))
self.assertEqual(resp.status_code, 403) self.assertEqual(resp.status_code, 403)
@patch('shoppingcart.views.render_to_response', render_mock) @patch('shoppingcart.views.render_to_response', render_mock)
...@@ -260,7 +261,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin): ...@@ -260,7 +261,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin):
self.login_user() self.login_user()
# add first course to user cart # add first course to user cart
resp = self.client.post( resp = self.client.post(
reverse('shoppingcart.views.add_course_to_cart', args=[self.course_key.to_deprecated_string()]) reverse('add_course_to_cart', args=[self.course_key.to_deprecated_string()])
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
# add and apply the coupon code to course in the cart # add and apply the coupon code to course in the cart
...@@ -273,7 +274,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin): ...@@ -273,7 +274,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin):
#now add the second course to cart, the coupon code should be #now add the second course to cart, the coupon code should be
# applied when adding the second course to the cart # applied when adding the second course to the cart
resp = self.client.post( resp = self.client.post(
reverse('shoppingcart.views.add_course_to_cart', args=[self.testing_course.id.to_deprecated_string()]) reverse('add_course_to_cart', args=[self.testing_course.id.to_deprecated_string()])
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
#now check the user cart and see that the discount has been applied on both the courses #now check the user cart and see that the discount has been applied on both the courses
...@@ -286,7 +287,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin): ...@@ -286,7 +287,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin):
def test_add_course_to_cart_already_in_cart(self): def test_add_course_to_cart_already_in_cart(self):
PaidCourseRegistration.add_to_order(self.cart, self.course_key) PaidCourseRegistration.add_to_order(self.cart, self.course_key)
self.login_user() self.login_user()
resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_key.to_deprecated_string()])) resp = self.client.post(reverse('add_course_to_cart', args=[self.course_key.to_deprecated_string()]))
self.assertEqual(resp.status_code, 400) self.assertEqual(resp.status_code, 400)
self.assertIn('The course {0} is already in your cart.'.format(self.course_key.to_deprecated_string()), resp.content) self.assertIn('The course {0} is already in your cart.'.format(self.course_key.to_deprecated_string()), resp.content)
...@@ -475,6 +476,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin): ...@@ -475,6 +476,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin):
self.assertIn("Cart item quantity should not be greater than 1 when applying activation code", resp.content) self.assertIn("Cart item quantity should not be greater than 1 when applying activation code", resp.content)
@ddt.data(True, False) @ddt.data(True, False)
@pytest.mark.django111_expected_failure
def test_reg_code_uses_associated_mode(self, expired_mode): def test_reg_code_uses_associated_mode(self, expired_mode):
"""Tests the use of reg codes on verified courses, expired or active. """ """Tests the use of reg codes on verified courses, expired or active. """
course_key = self.course_key.to_deprecated_string() course_key = self.course_key.to_deprecated_string()
...@@ -487,6 +489,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin): ...@@ -487,6 +489,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin):
self.assertIn(self.course.display_name.encode('utf-8'), resp.content) self.assertIn(self.course.display_name.encode('utf-8'), resp.content)
@ddt.data(True, False) @ddt.data(True, False)
@pytest.mark.django111_expected_failure
def test_reg_code_uses_unknown_mode(self, expired_mode): def test_reg_code_uses_unknown_mode(self, expired_mode):
"""Tests the use of reg codes on verified courses, expired or active. """ """Tests the use of reg codes on verified courses, expired or active. """
course_key = self.course_key.to_deprecated_string() course_key = self.course_key.to_deprecated_string()
...@@ -769,20 +772,20 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin): ...@@ -769,20 +772,20 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin):
def test_add_course_to_cart_already_registered(self): def test_add_course_to_cart_already_registered(self):
CourseEnrollment.enroll(self.user, self.course_key) CourseEnrollment.enroll(self.user, self.course_key)
self.login_user() self.login_user()
resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_key.to_deprecated_string()])) resp = self.client.post(reverse('add_course_to_cart', args=[self.course_key.to_deprecated_string()]))
self.assertEqual(resp.status_code, 400) self.assertEqual(resp.status_code, 400)
self.assertIn('You are already registered in course {0}.'.format(self.course_key.to_deprecated_string()), resp.content) self.assertIn('You are already registered in course {0}.'.format(self.course_key.to_deprecated_string()), resp.content)
def test_add_nonexistent_course_to_cart(self): def test_add_nonexistent_course_to_cart(self):
self.login_user() self.login_user()
resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=['non/existent/course'])) resp = self.client.post(reverse('add_course_to_cart', args=['non/existent/course']))
self.assertEqual(resp.status_code, 404) self.assertEqual(resp.status_code, 404)
self.assertIn("The course you requested does not exist.", resp.content) self.assertIn("The course you requested does not exist.", resp.content)
def test_add_course_to_cart_success(self): def test_add_course_to_cart_success(self):
self.login_user() self.login_user()
reverse('shoppingcart.views.add_course_to_cart', args=[self.course_key.to_deprecated_string()]) reverse('add_course_to_cart', args=[self.course_key.to_deprecated_string()])
resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_key.to_deprecated_string()])) resp = self.client.post(reverse('add_course_to_cart', args=[self.course_key.to_deprecated_string()]))
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertTrue(PaidCourseRegistration.contained_in_order(self.cart, self.course_key)) self.assertTrue(PaidCourseRegistration.contained_in_order(self.cart, self.course_key))
...@@ -1379,7 +1382,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin): ...@@ -1379,7 +1382,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin):
self._assert_404(reverse('shoppingcart.views.show_cart', args=[])) self._assert_404(reverse('shoppingcart.views.show_cart', args=[]))
self._assert_404(reverse('shoppingcart.views.clear_cart', args=[])) self._assert_404(reverse('shoppingcart.views.clear_cart', args=[]))
self._assert_404(reverse('shoppingcart.views.remove_item', args=[]), use_post=True) self._assert_404(reverse('shoppingcart.views.remove_item', args=[]), use_post=True)
self._assert_404(reverse('shoppingcart.views.register_code_redemption', args=["testing"])) self._assert_404(reverse('register_code_redemption', args=["testing"]))
self._assert_404(reverse('shoppingcart.views.use_code', args=[]), use_post=True) self._assert_404(reverse('shoppingcart.views.use_code', args=[]), use_post=True)
self._assert_404(reverse('shoppingcart.views.update_user_cart', args=[])) self._assert_404(reverse('shoppingcart.views.update_user_cart', args=[]))
self._assert_404(reverse('shoppingcart.views.reset_code_redemption', args=[]), use_post=True) self._assert_404(reverse('shoppingcart.views.reset_code_redemption', args=[]), use_post=True)
...@@ -1440,6 +1443,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin): ...@@ -1440,6 +1443,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin):
} }
) )
@pytest.mark.django111_expected_failure
def test_shopping_cart_navigation_link_not_in_microsite(self): def test_shopping_cart_navigation_link_not_in_microsite(self):
""" """
Tests shopping cart link is available in navigation header if request is not from a microsite. Tests shopping cart link is available in navigation header if request is not from a microsite.
...@@ -1474,6 +1478,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin): ...@@ -1474,6 +1478,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin):
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertIn('<a class="shopping-cart"', resp.content) self.assertIn('<a class="shopping-cart"', resp.content)
@pytest.mark.django111_expected_failure
def test_shopping_cart_navigation_link_in_microsite_courseware_page(self): def test_shopping_cart_navigation_link_in_microsite_courseware_page(self):
""" """
Tests shopping cart link is not available in navigation header if request is from a microsite Tests shopping cart link is not available in navigation header if request is from a microsite
......
...@@ -8,6 +8,7 @@ from urllib import urlencode ...@@ -8,6 +8,7 @@ from urllib import urlencode
import ddt import ddt
import mock import mock
import pytest
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
...@@ -470,6 +471,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi ...@@ -470,6 +471,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
('register_user', 'register'), ('register_user', 'register'),
) )
@ddt.unpack @ddt.unpack
@pytest.mark.django111_expected_failure
def test_hinted_login_dialog_disabled(self, url_name, auth_entry): def test_hinted_login_dialog_disabled(self, url_name, auth_entry):
"""Test that the dialog doesn't show up for hinted logins when disabled. """ """Test that the dialog doesn't show up for hinted logins when disabled. """
self.google_provider.skip_hinted_login_dialog = True self.google_provider.skip_hinted_login_dialog = True
...@@ -513,6 +515,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi ...@@ -513,6 +515,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
('register_user', 'register'), ('register_user', 'register'),
) )
@ddt.unpack @ddt.unpack
@pytest.mark.django111_expected_failure
def test_settings_tpa_hinted_login_dialog_disabled(self, url_name, auth_entry): def test_settings_tpa_hinted_login_dialog_disabled(self, url_name, auth_entry):
"""Test that the dialog doesn't show up for hinted logins when disabled via settings.THIRD_PARTY_AUTH_HINT. """ """Test that the dialog doesn't show up for hinted logins when disabled via settings.THIRD_PARTY_AUTH_HINT. """
self.google_provider.skip_hinted_login_dialog = True self.google_provider.skip_hinted_login_dialog = True
...@@ -585,6 +588,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi ...@@ -585,6 +588,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
self.assertEqual(enterprise_cookie.value, '') self.assertEqual(enterprise_cookie.value, '')
@override_settings(SITE_NAME=settings.MICROSITE_TEST_HOSTNAME) @override_settings(SITE_NAME=settings.MICROSITE_TEST_HOSTNAME)
@pytest.mark.django111_expected_failure
def test_microsite_uses_old_login_page(self): def test_microsite_uses_old_login_page(self):
# Retrieve the login page from a microsite domain # Retrieve the login page from a microsite domain
# and verify that we're served the old page. # and verify that we're served the old page.
...@@ -595,6 +599,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi ...@@ -595,6 +599,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
self.assertContains(resp, "Log into your Test Site Account") self.assertContains(resp, "Log into your Test Site Account")
self.assertContains(resp, "login-form") self.assertContains(resp, "login-form")
@pytest.mark.django111_expected_failure
def test_microsite_uses_old_register_page(self): def test_microsite_uses_old_register_page(self):
# Retrieve the register page from a microsite domain # Retrieve the register page from a microsite domain
# and verify that we're served the old page. # and verify that we're served the old page.
......
...@@ -9,6 +9,7 @@ import re ...@@ -9,6 +9,7 @@ import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
import ddt import ddt
import pytest
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db.models import signals from django.db.models import signals
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
...@@ -66,6 +67,7 @@ class SupportViewAccessTests(SupportViewTestCase): ...@@ -66,6 +67,7 @@ class SupportViewAccessTests(SupportViewTestCase):
)) ))
)) ))
@ddt.unpack @ddt.unpack
@pytest.mark.django111_expected_failure
def test_access(self, url_name, role, has_access): def test_access(self, url_name, role, has_access):
if role is not None: if role is not None:
role().add_users(self.user) role().add_users(self.user)
......
...@@ -20,6 +20,11 @@ ...@@ -20,6 +20,11 @@
.btn { .btn {
font-size: 20px; font-size: 20px;
font-weight: $font-weight-bold;
.original-price {
text-decoration: line-through;
font-weight: $font-weight-normal;
}
} }
.btn, .btn,
......
...@@ -62,6 +62,8 @@ endorser_org = endorser_position.get('organization_name') or corporate_endorseme ...@@ -62,6 +62,8 @@ endorser_org = endorser_position.get('organization_name') or corporate_endorseme
faqs = program['faq'] faqs = program['faq']
courses = program['courses'] courses = program['courses']
instructors = program['instructors'] instructors = program['instructors']
full_program_price_format = '{0:.0f}' if program['full_program_price'].is_integer() else '{0:.2f}'
full_program_price = full_program_price_format.format(program['full_program_price'])
%> %>
<div id="program-details-hero"> <div id="program-details-hero">
<div class="main-banner" <div class="main-banner"
...@@ -83,9 +85,30 @@ endorser_org = endorser_position.get('organization_name') or corporate_endorseme ...@@ -83,9 +85,30 @@ endorser_org = endorser_position.get('organization_name') or corporate_endorseme
<h2>${program['subtitle']}</h2> <h2>${program['subtitle']}</h2>
</div> </div>
<div> <div>
## Note: Weird formatting to fix the inline spacing issue.
% if program.get('is_learner_eligible_for_one_click_purchase'): % if program.get('is_learner_eligible_for_one_click_purchase'):
<a href="${buy_button_href}" class="btn btn-success"> <a href="${buy_button_href}" class="btn btn-success">
${_('Purchase the Program')} <span>${_('Purchase the Program (')}</span
% if program.get('discount_data') and program['discount_data']['is_discounted']:
><span aria-label="${_('Original Price')}" class="original-price"
>${Text(_('${oldPrice}')).format(
oldPrice=full_program_price_format.format(program['discount_data']['total_incl_tax_excl_discounts'])
)}</span
><span aria-label="${_('Discounted Price')}" class="discount">
${Text(_('${newPrice}')).format(
newPrice=full_program_price,
)}
</span
><span class="savings">
${Text(_('{currency})')).format(
discount_value=full_program_price_format.format(program['discount_data']['discount_value']),
currency=program['discount_data']['currency']
)}
</span>
% else:
><span>${"${price})".format(price=full_program_price)}
</span>
% endif
</a> </a>
% else: % else:
<a href="#courses" class="btn btn-success"> <a href="#courses" class="btn btn-success">
......
<%page expression_filter="h"/>
<html> <html>
<head> <head>
<title>Payment Error</title> <title>Payment Error</title>
......
<%page expression_filter="h"/>
<html> <html>
<head><title>Payment Form</title> <head><title>Payment Form</title>
</head> </head>
......
...@@ -8,6 +8,7 @@ from faker import Faker ...@@ -8,6 +8,7 @@ from faker import Faker
fake = Faker() fake = Faker()
VERIFIED_MODE = 'verified'
def generate_instances(factory_class, count=3): def generate_instances(factory_class, count=3):
...@@ -103,10 +104,18 @@ class SeatFactory(DictFactoryBase): ...@@ -103,10 +104,18 @@ class SeatFactory(DictFactoryBase):
currency = 'USD' currency = 'USD'
price = factory.Faker('random_int') price = factory.Faker('random_int')
sku = factory.LazyFunction(generate_seat_sku) sku = factory.LazyFunction(generate_seat_sku)
type = 'verified' type = VERIFIED_MODE
upgrade_deadline = factory.LazyFunction(generate_zulu_datetime) upgrade_deadline = factory.LazyFunction(generate_zulu_datetime)
class EntitlementFactory(DictFactoryBase):
currency = 'USD'
price = factory.Faker('random_int')
sku = factory.LazyFunction(generate_seat_sku)
mode = VERIFIED_MODE
expires = None
class CourseRunFactory(DictFactoryBase): class CourseRunFactory(DictFactoryBase):
eligible_for_financial_aid = True eligible_for_financial_aid = True
end = factory.LazyFunction(generate_zulu_datetime) end = factory.LazyFunction(generate_zulu_datetime)
...@@ -121,7 +130,7 @@ class CourseRunFactory(DictFactoryBase): ...@@ -121,7 +130,7 @@ class CourseRunFactory(DictFactoryBase):
start = factory.LazyFunction(generate_zulu_datetime) start = factory.LazyFunction(generate_zulu_datetime)
status = 'published' status = 'published'
title = factory.Faker('catch_phrase') title = factory.Faker('catch_phrase')
type = 'verified' type = VERIFIED_MODE
uuid = factory.Faker('uuid4') uuid = factory.Faker('uuid4')
content_language = 'en' content_language = 'en'
max_effort = 4 max_effort = 4
...@@ -130,6 +139,7 @@ class CourseRunFactory(DictFactoryBase): ...@@ -130,6 +139,7 @@ class CourseRunFactory(DictFactoryBase):
class CourseFactory(DictFactoryBase): class CourseFactory(DictFactoryBase):
course_runs = factory.LazyFunction(partial(generate_instances, CourseRunFactory)) course_runs = factory.LazyFunction(partial(generate_instances, CourseRunFactory))
entitlements = factory.LazyFunction(partial(generate_instances, EntitlementFactory))
image = ImageFactory() image = ImageFactory()
key = factory.LazyFunction(generate_course_key) key = factory.LazyFunction(generate_course_key)
owners = factory.LazyFunction(partial(generate_instances, OrganizationFactory, count=1)) owners = factory.LazyFunction(partial(generate_instances, OrganizationFactory, count=1))
......
...@@ -17,6 +17,7 @@ from model_utils.models import TimeStampedModel ...@@ -17,6 +17,7 @@ from model_utils.models import TimeStampedModel
from config_models.models import ConfigurationModel from config_models.models import ConfigurationModel
from lms.djangoapps import django_comment_client from lms.djangoapps import django_comment_client
from openedx.core.djangoapps.catalog.models import CatalogIntegration from openedx.core.djangoapps.catalog.models import CatalogIntegration
from openedx.core.djangoapps.lang_pref.api import get_closest_released_language
from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.djangoapps.models.course_details import CourseDetails
from static_replace.models import AssetBaseUrlConfig from static_replace.models import AssetBaseUrlConfig
from xmodule import course_metadata_utils, block_metadata_utils from xmodule import course_metadata_utils, block_metadata_utils
...@@ -610,6 +611,15 @@ class CourseOverview(TimeStampedModel): ...@@ -610,6 +611,15 @@ class CourseOverview(TimeStampedModel):
""" """
return 'self' if self.self_paced else 'instructor' return 'self' if self.self_paced else 'instructor'
@property
def closest_released_language(self):
"""
Returns the language code that most closely matches this course' language and is fully
supported by the LMS, or None if there are no fully supported languages that
match the target.
"""
return get_closest_released_language(self.language) if self.language else None
def apply_cdn_to_urls(self, image_urls): def apply_cdn_to_urls(self, image_urls):
""" """
Given a dict of resolutions -> urls, return a copy with CDN applied. Given a dict of resolutions -> urls, return a copy with CDN applied.
......
...@@ -59,7 +59,8 @@ def _log_start_date_change(previous_course_overview, updated_course_overview): ...@@ -59,7 +59,8 @@ def _log_start_date_change(previous_course_overview, updated_course_overview):
new_start_str = 'None' new_start_str = 'None'
if updated_course_overview.start is not None: if updated_course_overview.start is not None:
new_start_str = updated_course_overview.start.isoformat() new_start_str = updated_course_overview.start.isoformat()
LOG.info('Course start date changed: previous={0} new={1}'.format( LOG.info('Course start date changed: course={0} previous={1} new={2}'.format(
updated_course_overview.id,
previous_start_str, previous_start_str,
new_start_str, new_start_str,
)) ))
......
...@@ -18,6 +18,7 @@ from PIL import Image ...@@ -18,6 +18,7 @@ from PIL import Image
from lms.djangoapps.certificates.api import get_active_web_certificate from lms.djangoapps.certificates.api import get_active_web_certificate
from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin
from openedx.core.djangoapps.dark_lang.models import DarkLangConfig
from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.djangoapps.models.course_details import CourseDetails
from openedx.core.lib.courses import course_image_url from openedx.core.lib.courses import course_image_url
from static_replace.models import AssetBaseUrlConfig from static_replace.models import AssetBaseUrlConfig
...@@ -37,6 +38,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase ...@@ -37,6 +38,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls_range from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls_range
from ..models import CourseOverview, CourseOverviewImageSet, CourseOverviewImageConfig from ..models import CourseOverview, CourseOverviewImageSet, CourseOverviewImageConfig
from .factories import CourseOverviewFactory
@attr(shard=3) @attr(shard=3)
...@@ -289,6 +291,21 @@ class CourseOverviewTestCase(CatalogIntegrationMixin, ModuleStoreTestCase): ...@@ -289,6 +291,21 @@ class CourseOverviewTestCase(CatalogIntegrationMixin, ModuleStoreTestCase):
else: else:
self.assertEqual(course_overview.language, course.language) self.assertEqual(course_overview.language, course.language)
@ddt.data(
('fa', 'fa-ir', 'fa'),
('fa', 'fa', 'fa'),
('es-419', 'es-419', 'es-419'),
('es-419', 'es-es', 'es-419'),
('es-419', 'es', 'es-419'),
('es-419', None, None),
('es-419', 'fr', None),
)
@ddt.unpack
def test_closest_released_language(self, released_languages, course_language, expected_language):
DarkLangConfig(released_languages=released_languages, enabled=True, changed_by=self.user).save()
course_overview = CourseOverviewFactory.create(language=course_language)
self.assertEqual(course_overview.closest_released_language, expected_language)
@ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo) @ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
def test_get_non_existent_course(self, modulestore_type): def test_get_non_existent_course(self, modulestore_type):
""" """
......
...@@ -5,7 +5,10 @@ Tests for Shibboleth Authentication ...@@ -5,7 +5,10 @@ Tests for Shibboleth Authentication
@jbau @jbau
""" """
import unittest import unittest
from importlib import import_module
from urllib import urlencode
import pytest
from ddt import ddt, data from ddt import ddt, data
from django.conf import settings from django.conf import settings
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
...@@ -14,14 +17,12 @@ from django.test.client import RequestFactory, Client as DjangoTestClient ...@@ -14,14 +17,12 @@ from django.test.client import RequestFactory, Client as DjangoTestClient
from django.test.utils import override_settings from django.test.utils import override_settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.contrib.auth.models import AnonymousUser, User from django.contrib.auth.models import AnonymousUser, User
from importlib import import_module
from openedx.core.djangoapps.external_auth.models import ExternalAuthMap from openedx.core.djangoapps.external_auth.models import ExternalAuthMap
from openedx.core.djangoapps.external_auth.views import ( from openedx.core.djangoapps.external_auth.views import (
shib_login, course_specific_login, course_specific_register, _flatten_to_ascii shib_login, course_specific_login, course_specific_register, _flatten_to_ascii
) )
from mock import patch from mock import patch
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from urllib import urlencode
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
from student.views import change_enrollment from student.views import change_enrollment
...@@ -297,6 +298,7 @@ class ShibSPTest(CacheIsolationTestCase): ...@@ -297,6 +298,7 @@ class ShibSPTest(CacheIsolationTestCase):
@unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
@data(*gen_all_identities()) @data(*gen_all_identities())
@pytest.mark.django111_expected_failure
def test_registration_form_submit(self, identity): def test_registration_form_submit(self, identity):
""" """
Tests user creation after the registration form that pops is submitted. If there is no shib Tests user creation after the registration form that pops is submitted. If there is no shib
......
...@@ -73,3 +73,22 @@ def all_languages(): ...@@ -73,3 +73,22 @@ def all_languages():
""" """
languages = [(lang[0], _(lang[1])) for lang in settings.ALL_LANGUAGES] # pylint: disable=translation-of-non-string languages = [(lang[0], _(lang[1])) for lang in settings.ALL_LANGUAGES] # pylint: disable=translation-of-non-string
return sorted(languages, key=lambda lang: lang[1]) return sorted(languages, key=lambda lang: lang[1])
def get_closest_released_language(target_language_code):
"""
Return the language code that most closely matches the target and is fully
supported by the LMS, or None if there are no fully supported languages that
match the target.
"""
match = None
languages = released_languages()
for language in languages:
if language.code == target_language_code:
match = language.code
break
elif (match is None) and (language.code[:2] == target_language_code[:2]):
match = language.code
return match
...@@ -460,57 +460,99 @@ class ProgramDataExtender(object): ...@@ -460,57 +460,99 @@ class ProgramDataExtender(object):
def _attach_course_run_may_certify(self, run_mode): def _attach_course_run_may_certify(self, run_mode):
run_mode['may_certify'] = self.course_overview.may_certify() run_mode['may_certify'] = self.course_overview.may_certify()
def _check_enrollment_for_user(self, course_run): def _filter_out_courses_with_entitlements(self, courses):
applicable_seat_types = self.data['applicable_seat_types'] """
Removes courses for which the current user already holds an applicable entitlement.
TODO:
Add a NULL value of enrollment_course_run to filter, as courses with entitlements spent on applicable
enrollments will already have been filtered out by _filter_out_courses_with_enrollments.
(enrollment_mode, active) = CourseEnrollment.enrollment_mode_for_user( Arguments:
self.user, courses (list): Containing dicts representing courses in a program
CourseKey.from_string(course_run['key'])
Returns:
A subset of the given list of course dicts
"""
course_uuids = set(course['uuid'] for course in courses)
# Filter the entitlements' modes with a case-insensitive match against applicable seat_types
entitlements = self.user.courseentitlement_set.filter(
mode__in=self.data['applicable_seat_types'],
course_uuid__in=course_uuids,
) )
# Here we check the entitlements' expired_at_datetime property rather than filter by the expired_at attribute
# to ensure that the expiration status is as up to date as possible
entitlements = [e for e in entitlements if not e.expired_at_datetime]
courses_with_entitlements = set(unicode(entitlement.course_uuid) for entitlement in entitlements)
return [course for course in courses if course['uuid'] not in courses_with_entitlements]
is_paid_seat = False def _filter_out_courses_with_enrollments(self, courses):
if enrollment_mode is not None and active is not None and active is True: """
# Check all the applicable seat types Removes courses for which the current user already holds an active and applicable enrollment
# this will also check for no-id-professional as professional for one of that course's runs.
is_paid_seat = any(seat_type in enrollment_mode for seat_type in applicable_seat_types)
return is_paid_seat Arguments:
courses (list): Containing dicts representing courses in a program
Returns:
A subset of the given list of course dicts
"""
enrollments = self.user.courseenrollment_set.filter(
is_active=True,
mode__in=self.data['applicable_seat_types']
)
course_runs_with_enrollments = set(unicode(enrollment.course_id) for enrollment in enrollments)
courses_without_enrollments = []
for course in courses:
if all(unicode(run['key']) not in course_runs_with_enrollments for run in course['course_runs']):
courses_without_enrollments.append(course)
return courses_without_enrollments
def _collect_one_click_purchase_eligibility_data(self): def _collect_one_click_purchase_eligibility_data(self):
""" """
Extend the program data with data about learner's eligibility for one click purchase, Extend the program data with data about learner's eligibility for one click purchase,
discount data of the program and SKUs of seats that should be added to basket. discount data of the program and SKUs of seats that should be added to basket.
""" """
applicable_seat_types = self.data['applicable_seat_types'] if 'professional' in self.data['applicable_seat_types']:
self.data['applicable_seat_types'].append('no-id-professional')
applicable_seat_types = set(seat for seat in self.data['applicable_seat_types'] if seat != 'credit')
is_learner_eligible_for_one_click_purchase = self.data['is_program_eligible_for_one_click_purchase'] is_learner_eligible_for_one_click_purchase = self.data['is_program_eligible_for_one_click_purchase']
skus = [] skus = []
bundle_variant = 'full' bundle_variant = 'full'
if is_learner_eligible_for_one_click_purchase:
for course in self.data['courses']:
add_course_sku = True
course_runs = course.get('course_runs', [])
published_course_runs = filter(lambda run: run['status'] == 'published', course_runs)
if len(published_course_runs) == 1: if is_learner_eligible_for_one_click_purchase:
for course_run in course_runs: courses = self.data['courses']
is_paid_seat = self._check_enrollment_for_user(course_run) if not self.user.is_anonymous():
courses = self._filter_out_courses_with_enrollments(courses)
if is_paid_seat: courses = self._filter_out_courses_with_entitlements(courses)
add_course_sku = False
break if len(courses) < len(self.data['courses']):
bundle_variant = 'partial'
if add_course_sku:
for course in courses:
entitlement_product = False
for entitlement in course.get('entitlements', []):
# We add the first entitlement product found with an applicable seat type because, at this time,
# we are assuming that, for any given course, there is at most one paid entitlement available.
if entitlement['mode'] in applicable_seat_types:
skus.append(entitlement['sku'])
entitlement_product = True
break
if not entitlement_product:
course_runs = course.get('course_runs', [])
published_course_runs = [run for run in course_runs if run['status'] == 'published']
if len(published_course_runs) == 1:
for seat in published_course_runs[0]['seats']: for seat in published_course_runs[0]['seats']:
if seat['type'] in applicable_seat_types and seat['sku']: if seat['type'] in applicable_seat_types and seat['sku']:
skus.append(seat['sku']) skus.append(seat['sku'])
break
else: else:
bundle_variant = 'partial' # If a course in the program has more than 1 published course run
else: # learner won't be eligible for a one click purchase.
# If a course in the program has more than 1 published course run skus = []
# learner won't be eligible for a one click purchase. break
is_learner_eligible_for_one_click_purchase = False
skus = []
break
if skus: if skus:
try: try:
...@@ -604,7 +646,7 @@ class ProgramMarketingDataExtender(ProgramDataExtender): ...@@ -604,7 +646,7 @@ class ProgramMarketingDataExtender(ProgramDataExtender):
def __init__(self, program_data, user): def __init__(self, program_data, user):
super(ProgramMarketingDataExtender, self).__init__(program_data, user) super(ProgramMarketingDataExtender, self).__init__(program_data, user)
# Aggregate list of instructors for the program # Aggregate list of instructors for the program keyed by name
self.instructors = [] self.instructors = []
# Values for programs' price calculation. # Values for programs' price calculation.
......
...@@ -101,9 +101,16 @@ Glossary ...@@ -101,9 +101,16 @@ Glossary
the number of emails each task must send. the number of emails each task must send.
- **Email Backend**: An external service that ACE will use to deliver emails. - **Email Backend**: An external service that ACE will use to deliver emails.
Right now, ACE only supports `Sailthru <http://www.sailthru.com/>` as an For now, ACE only supports `Sailthru <http://www.sailthru.com/>`__ as an
email backend. email backend.
An Overview of edX's Dynamic Pacing System
------------------------------------------
.. image:: img/system_diagram.png
Running the Management Commands Running the Management Commands
------------------------------- -------------------------------
...@@ -366,6 +373,78 @@ Course Update ...@@ -366,6 +373,78 @@ Course Update
- Their Schedule ``start_date`` must be 7, 14, or any increment of 7 - Their Schedule ``start_date`` must be 7, 14, or any increment of 7
days up to 77 days before the current date. days up to 77 days before the current date.
Analytics
~~~~~~~~~
To track the performance of these communications, there is an integration setup
with Google Analytics and Segment. When a message is sent a Segment event is
emitted that contains the unique message identifier and a bunch of other data
about the message that was sent. When a user opens an email, an invisible
tracking pixel is rendered that records an event in Google Analytics. When a
user clicks a link in the email,
`UTM parameters <https://en.wikipedia.org/wiki/UTM_parameters>`__ are included
in the query string which allow Google Analytics to know that the traffic was
driven to the LMS by that email.
Using these three pieces of information you can track many key metrics.
Specifically: you can monitor the number of messages sent, the ratio of messages
opened to messages sent, and the ratio of links clicked in messages to the
messages opened. These help you answer a few key questions: How many people
am I reaching? How many people are opening my messages? How many people are
persuaded to actually come back to my site after reading my message?
You can also filter Google Analytics to compare the behavior of the users
coming to your platform from these emails relative to other sources of traffic.
Enabling Tracking
^^^^^^^^^^^^^^^^^
- In either your site configuration or django settings set
``GOOGLE_ANALYTICS_TRACKING_ID`` to your Google Analytics tracking ID. This
will look something like UA-XXXXXXX-X
- In your django settings set ``LMS_SEGMENT_KEY`` to your Segment project
write key.
Emitted Events
^^^^^^^^^^^^^^
The segment event that is emitted when a message is sent is named
"edx.bi.email.sent" and contains the following information:
- ``send_uuid`` uniquely identifies this batch of emails that are being sent to
many learners.
- ``uuid`` uniquely identifies this particular message being sent to exactly
one learner.
- ``site`` is the site that the email was sent for.
- ``app_label`` will always be "schedules" for the emails sent from here.
- ``name`` will be the name of the message that was sent: recurringnudge_day3,
recurringnudge_day10, upgradereminder, or courseupdate.
- ``primary_course_id`` identifies the primary course discussed in the email if
the email was sent on behalf of several courses.
- ``language`` is the language the email was translated into.
- ``course_ids`` is a list of all courses that this email was sent on behalf of.
This can be truncated if the list of courses is long.
- ``num_courses`` is the actual number of courses covered by this message. This
may differ from the course_ids list if the list was truncated.
The Google Analytics event that is emitted when a learner opens an email has
the following properties:
- ``action`` is "edx.bi.email.opened"
- ``category`` is "email"
- ``label`` is the primary_course_id described above
- ``campaign source`` is "schedules"
- ``campaign medium`` is "email"
- ``campaign content`` is the unique identifier for the message
When the user clicks a link in the email the following UTM parameters are
included in the URL:
- ``campaign source`` is "schedules"
- ``campaign medium`` is "email"
- ``campaign content`` is the unique identifier for the message
- ``campaign term`` is the primary_course_id described above
Litmus Litmus
------ ------
......
...@@ -8,6 +8,8 @@ from django.utils.translation import ugettext_lazy as _ ...@@ -8,6 +8,8 @@ from django.utils.translation import ugettext_lazy as _
from openedx.core.djangolib.markup import HTML from openedx.core.djangolib.markup import HTML
from . import models from . import models
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from opaque_keys.edx.keys import CourseKey
class ScheduleExperienceAdminInline(admin.StackedInline): class ScheduleExperienceAdminInline(admin.StackedInline):
...@@ -45,7 +47,10 @@ for (db_name, human_name) in models.ScheduleExperience.EXPERIENCES: ...@@ -45,7 +47,10 @@ for (db_name, human_name) in models.ScheduleExperience.EXPERIENCES:
class KnownErrorCases(admin.SimpleListFilter): class KnownErrorCases(admin.SimpleListFilter):
title = _('KnownErrorCases') """
Filter schedules by a list of known error cases.
"""
title = _('Known Error Case')
parameter_name = 'error' parameter_name = 'error'
...@@ -59,14 +64,65 @@ class KnownErrorCases(admin.SimpleListFilter): ...@@ -59,14 +64,65 @@ class KnownErrorCases(admin.SimpleListFilter):
return queryset.filter(start__lt=F('enrollment__course__start')) return queryset.filter(start__lt=F('enrollment__course__start'))
class CourseIdFilter(admin.SimpleListFilter):
"""
Filter schedules to by course id using a dropdown list.
"""
template = "dropdown_filter.html"
title = _("Course Id")
parameter_name = "course_id"
def __init__(self, request, params, model, model_admin):
super(CourseIdFilter, self).__init__(request, params, model, model_admin)
self.unused_parameters = params.copy()
self.unused_parameters.pop(self.parameter_name, None)
def value(self):
value = super(CourseIdFilter, self).value()
if value == "None" or value is None:
return None
else:
return CourseKey.from_string(value)
def lookups(self, request, model_admin):
return (
(overview.id, unicode(overview.id)) for overview in CourseOverview.objects.all().order_by('id')
)
def queryset(self, request, queryset):
value = self.value()
if value is None:
return queryset
else:
return queryset.filter(enrollment__course_id=value)
def choices(self, changelist): # pylint: disable=unused-argument
yield {
'selected': self.value() is None,
'value': None,
'display': _('All'),
}
for lookup, title in self.lookup_choices:
yield {
'selected': self.value() == lookup,
'value': unicode(lookup),
'display': title,
}
@admin.register(models.Schedule) @admin.register(models.Schedule)
class ScheduleAdmin(admin.ModelAdmin): class ScheduleAdmin(admin.ModelAdmin):
list_display = ('username', 'course_id', 'active', 'start', 'upgrade_deadline', 'experience_display') list_display = ('username', 'course_id', 'active', 'start', 'upgrade_deadline', 'experience_display')
list_display_links = ('start', 'upgrade_deadline', 'experience_display') list_display_links = ('start', 'upgrade_deadline', 'experience_display')
list_filter = ('experience__experience_type', 'active', KnownErrorCases) list_filter = (
CourseIdFilter,
'experience__experience_type',
'active',
KnownErrorCases
)
raw_id_fields = ('enrollment',) raw_id_fields = ('enrollment',)
readonly_fields = ('modified',) readonly_fields = ('modified',)
search_fields = ('enrollment__user__username', 'enrollment__course__id',) search_fields = ('enrollment__user__username',)
inlines = (ScheduleExperienceAdminInline,) inlines = (ScheduleExperienceAdminInline,)
actions = ['deactivate_schedules', 'activate_schedules'] + experience_actions actions = ['deactivate_schedules', 'activate_schedules'] + experience_actions
......
...@@ -195,7 +195,7 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver): ...@@ -195,7 +195,7 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver):
except InvalidContextError: except InvalidContextError:
continue continue
yield (user, first_schedule.enrollment.course.language, template_context) yield (user, first_schedule.enrollment.course.closest_released_language, template_context)
def get_template_context(self, user, user_schedules): def get_template_context(self, user, user_schedules):
""" """
...@@ -317,7 +317,7 @@ def _get_upsell_information_for_schedule(user, schedule): ...@@ -317,7 +317,7 @@ def _get_upsell_information_for_schedule(user, schedule):
enrollment.dynamic_upgrade_deadline, enrollment.dynamic_upgrade_deadline,
get_format( get_format(
'DATE_FORMAT', 'DATE_FORMAT',
lang=course.language, lang=course.closest_released_language,
use_l10n=True use_l10n=True
) )
) )
...@@ -370,7 +370,7 @@ class CourseUpdateResolver(BinnedSchedulesBaseResolver): ...@@ -370,7 +370,7 @@ class CourseUpdateResolver(BinnedSchedulesBaseResolver):
}) })
template_context.update(_get_upsell_information_for_schedule(user, schedule)) template_context.update(_get_upsell_information_for_schedule(user, schedule))
yield (user, schedule.enrollment.course.language, template_context) yield (user, schedule.enrollment.course.closest_released_language, template_context)
def _get_trackable_course_home_url(course_id): def _get_trackable_course_home_url(course_id):
......
{% load i18n %}
<h3>{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}</h3>
<form method="GET">
{% for name, param in spec.unused_parameters.items %}
<input type="hidden" name="{{ name }}" value="{{ param }}"/>
{% endfor %}
<select name="{{ spec.parameter_name }}">
{% for choice in choices %}
<option{% if choice.selected %} selected="selected" {% endif %} value="{{ choice.value }}">
{{ choice.display }}
</option>
{% endfor %}
</select>
<input type="submit" value="Filter!"/>
</form>
...@@ -47,7 +47,7 @@ edx-lint==0.4.3 ...@@ -47,7 +47,7 @@ edx-lint==0.4.3
astroid==1.3.8 astroid==1.3.8
edx-django-oauth2-provider==1.2.5 edx-django-oauth2-provider==1.2.5
edx-django-sites-extensions==2.3.0 edx-django-sites-extensions==2.3.0
edx-enterprise==0.55.0 edx-enterprise==0.55.1
edx-oauth2-provider==1.2.2 edx-oauth2-provider==1.2.2
edx-opaque-keys==0.4.0 edx-opaque-keys==0.4.0
edx-organizations==0.4.8 edx-organizations==0.4.8
......
...@@ -101,6 +101,8 @@ git+https://github.com/edx/xblock-utils.git@v1.0.5#egg=xblock-utils==1.0.5 ...@@ -101,6 +101,8 @@ 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/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.6#egg=lti_consumer-xblock==1.1.6 git+https://github.com/edx/xblock-lti-consumer.git@v1.1.6#egg=lti_consumer-xblock==1.1.6
git+https://github.com/edx/edx-proctoring.git@1.3.1#egg=edx-proctoring==1.3.1 git+https://github.com/edx/edx-proctoring.git@1.3.1#egg=edx-proctoring==1.3.1
# This is here because all of the other XBlocks are located here. However, it is published to PyPI and will be installed that way
xblock-review==1.1.1
# Third Party XBlocks # Third Party XBlocks
......
...@@ -8,16 +8,16 @@ ...@@ -8,16 +8,16 @@
"javascript-jquery-insert-into-target": 23, "javascript-jquery-insert-into-target": 23,
"javascript-jquery-insertion": 19, "javascript-jquery-insertion": 19,
"javascript-jquery-prepend": 7, "javascript-jquery-prepend": 7,
"mako-html-entities": 0, "mako-html-entities": 1,
"mako-invalid-html-filter": 11, "mako-invalid-html-filter": 11,
"mako-invalid-js-filter": 192, "mako-invalid-js-filter": 192,
"mako-js-html-string": 0, "mako-js-html-string": 0,
"mako-js-missing-quotes": 0, "mako-js-missing-quotes": 0,
"mako-missing-default": 181, "mako-missing-default": 162,
"mako-multiple-page-tags": 0, "mako-multiple-page-tags": 0,
"mako-unknown-context": 0, "mako-unknown-context": 0,
"mako-unparseable-expression": 0, "mako-unparseable-expression": 0,
"mako-unwanted-html-filter": 0, "mako-unwanted-html-filter": 2,
"python-close-before-format": 0, "python-close-before-format": 0,
"python-concat-html": 24, "python-concat-html": 24,
"python-custom-escape": 13, "python-custom-escape": 13,
...@@ -28,5 +28,5 @@ ...@@ -28,5 +28,5 @@
"python-wrap-html": 226, "python-wrap-html": 226,
"underscore-not-escaped": 507 "underscore-not-escaped": 507
}, },
"total": 1770 "total": 1754
} }
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