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
...@@ -16,6 +16,7 @@ from nose.plugins.attrib import attr ...@@ -16,6 +16,7 @@ from nose.plugins.attrib import attr
from pytz import utc from pytz import utc
from course_modes.models import CourseMode from course_modes.models import CourseMode
from entitlements.tests.factories import CourseEntitlementFactory
from lms.djangoapps.certificates.api import MODES from lms.djangoapps.certificates.api import MODES
from lms.djangoapps.commerce.tests.test_utils import update_commerce_config from lms.djangoapps.commerce.tests.test_utils import update_commerce_config
from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.commerce.utils import EcommerceService
...@@ -23,6 +24,7 @@ from lms.djangoapps.grades.tests.utils import mock_passing_grade ...@@ -23,6 +24,7 @@ from lms.djangoapps.grades.tests.utils import mock_passing_grade
from openedx.core.djangoapps.catalog.tests.factories import ( from openedx.core.djangoapps.catalog.tests.factories import (
CourseFactory, CourseFactory,
CourseRunFactory, CourseRunFactory,
EntitlementFactory,
ProgramFactory, ProgramFactory,
SeatFactory, SeatFactory,
generate_course_run_key generate_course_run_key
...@@ -63,7 +65,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -63,7 +65,7 @@ class TestProgramProgressMeter(TestCase):
def _create_enrollments(self, *course_run_ids): def _create_enrollments(self, *course_run_ids):
"""Variadic helper used to create course run enrollments.""" """Variadic helper used to create course run enrollments."""
for course_run_id in course_run_ids: for course_run_id in course_run_ids:
CourseEnrollmentFactory(user=self.user, course_id=course_run_id, mode='verified') CourseEnrollmentFactory(user=self.user, course_id=course_run_id, mode=CourseMode.VERIFIED)
def _assert_progress(self, meter, *progresses): def _assert_progress(self, meter, *progresses):
"""Variadic helper used to verify progress calculations.""" """Variadic helper used to verify progress calculations."""
...@@ -225,22 +227,22 @@ class TestProgramProgressMeter(TestCase): ...@@ -225,22 +227,22 @@ class TestProgramProgressMeter(TestCase):
course_run_key = generate_course_run_key() course_run_key = generate_course_run_key()
now = datetime.datetime.now(utc) now = datetime.datetime.now(utc)
upgrade_deadline = None if not offset else str(now + datetime.timedelta(days=offset)) upgrade_deadline = None if not offset else str(now + datetime.timedelta(days=offset))
required_seat = SeatFactory(type='verified', upgrade_deadline=upgrade_deadline) required_seat = SeatFactory(type=CourseMode.VERIFIED, upgrade_deadline=upgrade_deadline)
enrolled_seat = SeatFactory(type='audit') enrolled_seat = SeatFactory(type=CourseMode.AUDIT)
seats = [required_seat, enrolled_seat] seats = [required_seat, enrolled_seat]
data = [ data = [
ProgramFactory( ProgramFactory(
courses=[ courses=[
CourseFactory(course_runs=[ CourseFactory(course_runs=[
CourseRunFactory(key=course_run_key, type='verified', seats=seats), CourseRunFactory(key=course_run_key, type=CourseMode.VERIFIED, seats=seats),
]), ]),
] ]
) )
] ]
mock_get_programs.return_value = data mock_get_programs.return_value = data
CourseEnrollmentFactory(user=self.user, course_id=course_run_key, mode='audit') CourseEnrollmentFactory(user=self.user, course_id=course_run_key, mode=CourseMode.AUDIT)
meter = ProgramProgressMeter(self.site, self.user) meter = ProgramProgressMeter(self.site, self.user)
...@@ -537,7 +539,9 @@ class TestProgramProgressMeter(TestCase): ...@@ -537,7 +539,9 @@ class TestProgramProgressMeter(TestCase):
Verify that the method can find course run certificates when not mocked out. Verify that the method can find course run certificates when not mocked out.
""" """
mock_get_certificates_for_user.return_value = [ mock_get_certificates_for_user.return_value = [
self._make_certificate_result(status='downloadable', type='verified', course_key='downloadable-course'), self._make_certificate_result(
status='downloadable', type=CourseMode.VERIFIED, course_key='downloadable-course'
),
self._make_certificate_result(status='generating', type='honor', course_key='generating-course'), self._make_certificate_result(status='generating', type='honor', course_key='generating-course'),
self._make_certificate_result(status='unknown', course_key='unknown-course'), self._make_certificate_result(status='unknown', course_key='unknown-course'),
] ]
...@@ -546,7 +550,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -546,7 +550,7 @@ class TestProgramProgressMeter(TestCase):
self.assertEqual( self.assertEqual(
meter.completed_course_runs, meter.completed_course_runs,
[ [
{'course_run_id': 'downloadable-course', 'type': 'verified'}, {'course_run_id': 'downloadable-course', 'type': CourseMode.VERIFIED},
{'course_run_id': 'generating-course', 'type': 'honor'}, {'course_run_id': 'generating-course', 'type': 'honor'},
] ]
) )
...@@ -558,9 +562,10 @@ class TestProgramProgressMeter(TestCase): ...@@ -558,9 +562,10 @@ class TestProgramProgressMeter(TestCase):
Verify that 'no-id-professional' certificates are treated as if they were Verify that 'no-id-professional' certificates are treated as if they were
'professional' certificates when determining program completion. 'professional' certificates when determining program completion.
""" """
# Create serialized course runs like the ones we expect to receive from # Create serialized course runs like the ones we expect to receive from the discovery service's API.
# the discovery service's API. These runs are of type 'professional'. # These runs are of type 'professional' because there is no seat type for no-id-professional;
course_runs = CourseRunFactory.create_batch(2, type='professional') # it uses professional as the seat type instead.
course_runs = CourseRunFactory.create_batch(2, type=CourseMode.PROFESSIONAL)
program = ProgramFactory(courses=[CourseFactory(course_runs=course_runs)]) program = ProgramFactory(courses=[CourseFactory(course_runs=course_runs)])
mock_get_programs.return_value = [program] mock_get_programs.return_value = [program]
...@@ -571,7 +576,9 @@ class TestProgramProgressMeter(TestCase): ...@@ -571,7 +576,9 @@ class TestProgramProgressMeter(TestCase):
# Grant a 'no-id-professional' certificate for one of the course runs, # Grant a 'no-id-professional' certificate for one of the course runs,
# thereby completing the program. # thereby completing the program.
mock_get_certificates_for_user.return_value = [ mock_get_certificates_for_user.return_value = [
self._make_certificate_result(status='downloadable', type='no-id-professional', course_key=course_runs[0]['key']) self._make_certificate_result(
status='downloadable', type=CourseMode.NO_ID_PROFESSIONAL_MODE, course_key=course_runs[0]['key']
)
] ]
# Verify that the program is complete. # Verify that the program is complete.
...@@ -592,7 +599,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -592,7 +599,7 @@ class TestProgramProgressMeter(TestCase):
mock_get_programs.return_value = [program] mock_get_programs.return_value = [program]
self._create_enrollments(course_run_key) self._create_enrollments(course_run_key)
meter = ProgramProgressMeter(self.site, self.user) meter = ProgramProgressMeter(self.site, self.user)
mock_completed_course_runs.return_value = [{'course_run_id': course_run_key, 'type': 'verified'}] mock_completed_course_runs.return_value = [{'course_run_id': course_run_key, 'type': CourseMode.VERIFIED}]
self.assertEqual(meter._is_course_complete(course), True) self.assertEqual(meter._is_course_complete(course), True)
def test_course_grade_results(self, mock_get_programs): def test_course_grade_results(self, mock_get_programs):
...@@ -628,7 +635,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -628,7 +635,7 @@ class TestProgramProgressMeter(TestCase):
self.assertEqual(meter.progress(count_only=False), expected) self.assertEqual(meter.progress(count_only=False), expected)
def _create_course(self, course_price, course_run_count=1): def _create_course(self, course_price, course_run_count=1, make_entitlement=False):
""" """
Creates the course in mongo and update it with the instructor data. Creates the course in mongo and update it with the instructor data.
Also creates catalog course with respect to course run. Also creates catalog course with respect to course run.
...@@ -646,8 +653,9 @@ def _create_course(self, course_price, course_run_count=1): ...@@ -646,8 +653,9 @@ def _create_course(self, course_price, course_run_count=1):
run = CourseRunFactory(key=unicode(course.id), seats=[SeatFactory(price=course_price)]) run = CourseRunFactory(key=unicode(course.id), seats=[SeatFactory(price=course_price)])
course_runs.append(run) course_runs.append(run)
entitlements = [EntitlementFactory()] if make_entitlement else []
return CourseFactory(course_runs=course_runs) return CourseFactory(course_runs=course_runs, entitlements=entitlements)
@ddt.ddt @ddt.ddt
...@@ -879,12 +887,12 @@ class TestProgramDataExtender(ModuleStoreTestCase): ...@@ -879,12 +887,12 @@ class TestProgramDataExtender(ModuleStoreTestCase):
course1 = _create_course(self, self.course_price) course1 = _create_course(self, self.course_price)
course2 = _create_course(self, self.course_price) course2 = _create_course(self, self.course_price)
CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode='verified') CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode=CourseMode.VERIFIED)
CourseEnrollmentFactory(user=self.user, course_id=course2['course_runs'][0]['key'], mode='audit') CourseEnrollmentFactory(user=self.user, course_id=course2['course_runs'][0]['key'], mode=CourseMode.AUDIT)
program2 = ProgramFactory( program2 = ProgramFactory(
courses=[course1, course2], courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True, is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=['verified'], applicable_seat_types=[CourseMode.VERIFIED],
) )
data = ProgramDataExtender(program2, self.user).extend() data = ProgramDataExtender(program2, self.user).extend()
self.assertTrue(data['is_learner_eligible_for_one_click_purchase']) self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
...@@ -897,12 +905,12 @@ class TestProgramDataExtender(ModuleStoreTestCase): ...@@ -897,12 +905,12 @@ class TestProgramDataExtender(ModuleStoreTestCase):
""" """
course1 = _create_course(self, self.course_price, course_run_count=2) course1 = _create_course(self, self.course_price, course_run_count=2)
course2 = _create_course(self, self.course_price) course2 = _create_course(self, self.course_price)
CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode='verified') CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode=CourseMode.VERIFIED)
course1['course_runs'][0]['status'] = 'unpublished' course1['course_runs'][0]['status'] = 'unpublished'
program2 = ProgramFactory( program2 = ProgramFactory(
courses=[course1, course2], courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True, is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=['verified'], applicable_seat_types=[CourseMode.VERIFIED],
) )
data = ProgramDataExtender(program2, self.user).extend() data = ProgramDataExtender(program2, self.user).extend()
self.assertEqual(len(data['skus']), 1) self.assertEqual(len(data['skus']), 1)
...@@ -915,12 +923,13 @@ class TestProgramDataExtender(ModuleStoreTestCase): ...@@ -915,12 +923,13 @@ class TestProgramDataExtender(ModuleStoreTestCase):
This test is primarily for the case of no-id-professional enrollment modes This test is primarily for the case of no-id-professional enrollment modes
""" """
course1 = _create_course(self, self.course_price) course1 = _create_course(self, self.course_price)
CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode='no-id-professional') CourseEnrollmentFactory(
user=self.user, course_id=course1['course_runs'][0]['key'], mode=CourseMode.NO_ID_PROFESSIONAL_MODE
)
program2 = ProgramFactory( program2 = ProgramFactory(
courses=[course1], courses=[course1],
is_program_eligible_for_one_click_purchase=True, is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=['professional'], # There is no seat type for no-id-professional, it applicable_seat_types=[CourseMode.PROFESSIONAL]
# instead uses professional
) )
data = ProgramDataExtender(program2, self.user).extend() data = ProgramDataExtender(program2, self.user).extend()
self.assertFalse(data['is_learner_eligible_for_one_click_purchase']) self.assertFalse(data['is_learner_eligible_for_one_click_purchase'])
...@@ -938,7 +947,7 @@ class TestProgramDataExtender(ModuleStoreTestCase): ...@@ -938,7 +947,7 @@ class TestProgramDataExtender(ModuleStoreTestCase):
key=str(ModuleStoreCourseFactory().id), key=str(ModuleStoreCourseFactory().id),
status='published' status='published'
) )
course = CourseFactory(course_runs=[course_run_1, course_run_2]) course = CourseFactory(course_runs=[course_run_1, course_run_2], entitlements=[])
program = ProgramFactory( program = ProgramFactory(
courses=[ courses=[
CourseFactory(course_runs=[ CourseFactory(course_runs=[
...@@ -956,7 +965,7 @@ class TestProgramDataExtender(ModuleStoreTestCase): ...@@ -956,7 +965,7 @@ class TestProgramDataExtender(ModuleStoreTestCase):
]) ])
], ],
is_program_eligible_for_one_click_purchase=True, is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=['verified'] applicable_seat_types=[CourseMode.VERIFIED]
) )
data = ProgramDataExtender(program, self.user).extend() data = ProgramDataExtender(program, self.user).extend()
...@@ -967,6 +976,147 @@ class TestProgramDataExtender(ModuleStoreTestCase): ...@@ -967,6 +976,147 @@ class TestProgramDataExtender(ModuleStoreTestCase):
self.assertTrue(data['is_learner_eligible_for_one_click_purchase']) self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
def test_learner_eligibility_for_one_click_purchase_entitlement_products(self):
"""
Learner should be eligible for one click purchase if:
- program is eligible for one click purchase
- There are remaining unpurchased courses with entitlement products
"""
course1 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
course2 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
expected_skus = set([course1['entitlements'][0]['sku'], course2['entitlements'][0]['sku']])
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(set(data['skus']), expected_skus)
def test_learner_eligibility_for_one_click_purchase_ineligible_program(self):
"""
Learner should not be eligible for one click purchase if the program is not eligible for one click purchase
"""
course1 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
course2 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=False,
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertFalse(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(data['skus'], [])
def test_learner_eligibility_for_one_click_purchase_user_entitlements(self):
"""
Learner should be eligibile for one click purchase if they hold an entitlement in one or more courses
in the program and there are remaining unpurchased courses in the program with entitlement products.
"""
course1 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
course2 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
CourseEntitlementFactory(user=self.user, course_uuid=course1['uuid'], mode=CourseMode.VERIFIED)
expected_skus = set([course2['entitlements'][0]['sku']])
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(set(data['skus']), expected_skus)
def test_all_courses_owned(self):
"""
Learner should not be eligible for one click purchase if they hold entitlements in all courses in the program.
"""
course1 = _create_course(self, self.course_price, make_entitlement=True)
course2 = _create_course(self, self.course_price)
CourseEntitlementFactory(user=self.user, course_uuid=course1['uuid'], mode=CourseMode.VERIFIED)
CourseEntitlementFactory(user=self.user, course_uuid=course2['uuid'], mode=CourseMode.VERIFIED)
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertFalse(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(data['skus'], [])
def test_entitlement_product_wrong_mode(self):
"""
Learner should not be eligible for one click purchase if the only entitlement product
for a course in the program is not in an applicable mode, and that course has multiple course runs.
"""
course1 = _create_course(self, self.course_price)
course2 = _create_course(self, self.course_price, course_run_count=2)
course2['entitlements'].append(EntitlementFactory(mode=CourseMode.PROFESSIONAL))
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertFalse(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(data['skus'], [])
def test_second_entitlement_product_wrong_mode(self):
"""
Learner should be eligible for one click purchase if a course has multiple entitlement products
and at least one of them is in an applicable mode, even if one is not in an applicable mode.
"""
course1 = _create_course(self, self.course_price)
course2 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
# The above statement makes a verfied entitlement for the course, which is an applicable seat type
# and the statement below makes a professional entitlement for the same course, which is not applicable
course2['entitlements'].append(EntitlementFactory(mode=CourseMode.PROFESSIONAL))
expected_skus = set([course1['course_runs'][0]['seats'][0]['sku'], course2['entitlements'][0]['sku']])
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(set(data['skus']), expected_skus)
def test_entitlement_product_and_user_enrollment(self):
"""
Learner should be eligible for one click purchase if they hold an enrollment
but not an entitlement in a course for which there exists an entitlement product.
"""
course1 = _create_course(self, self.course_price, make_entitlement=True)
course2 = _create_course(self, self.course_price)
expected_skus = set([course2['course_runs'][0]['seats'][0]['sku']])
CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode=CourseMode.VERIFIED)
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(set(data['skus']), expected_skus)
def test_user_enrollment_with_other_course_entitlement_product(self):
"""
Learner should be eligible for one click purchase if they hold an enrollment in one course of the program
and there is an entitlement product for another course in the program.
"""
course1 = _create_course(self, self.course_price, course_run_count=2)
course2 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode=CourseMode.VERIFIED)
expected_skus = set([course2['entitlements'][0]['sku']])
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=[CourseMode.VERIFIED, CourseMode.PROFESSIONAL],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(set(data['skus']), expected_skus)
@skip_unless_lms @skip_unless_lms
@mock.patch(UTILS_MODULE + '.get_credentials') @mock.patch(UTILS_MODULE + '.get_credentials')
...@@ -1095,7 +1245,7 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): ...@@ -1095,7 +1245,7 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase):
self.number_of_courses = 2 self.number_of_courses = 2
self.program = ProgramFactory( self.program = ProgramFactory(
courses=[_create_course(self, self.course_price) for __ in range(self.number_of_courses)], courses=[_create_course(self, self.course_price) for __ in range(self.number_of_courses)],
applicable_seat_types=['verified'] applicable_seat_types=[CourseMode.VERIFIED]
) )
def _prepare_program_for_discounted_price_calculation_endpoint(self): def _prepare_program_for_discounted_price_calculation_endpoint(self):
...@@ -1212,8 +1362,9 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): ...@@ -1212,8 +1362,9 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase):
body=json.dumps(mock_discount_data), body=json.dumps(mock_discount_data),
content_type='application/json' content_type='application/json'
) )
user = AnonymousUserFactory()
data = ProgramMarketingDataExtender(self.program, AnonymousUserFactory()).extend() data = ProgramMarketingDataExtender(self.program, user).extend()
self._update_discount_data(mock_discount_data) self._update_discount_data(mock_discount_data)
self.assertEqual( self.assertEqual(
......
...@@ -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>
"""
Test scenarios for the review xblock.
"""
import ddt
import unittest
from django.conf import settings
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from nose.plugins.attrib import attr
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory
from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from review import get_review_ids
import crum
class TestReviewXBlock(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Create the test environment with the review xblock.
"""
STUDENTS = [
{'email': 'learner@test.com', 'password': 'foo'},
]
XBLOCK_NAMES = ['review']
URL_BEGINNING = settings.LMS_ROOT_URL + \
'/xblock/block-v1:DillonX/DAD101x_review/3T2017+type@'
@classmethod
def setUpClass(cls):
# Nose runs setUpClass methods even if a class decorator says to skip
# the class: https://github.com/nose-devs/nose/issues/946
# So, skip the test class here if we are not in the LMS.
if settings.ROOT_URLCONF != 'lms.urls':
raise unittest.SkipTest('Test only valid in lms')
super(TestReviewXBlock, cls).setUpClass()
# Set up for the actual course
cls.course_actual = CourseFactory.create(
display_name='Review_Test_Course_ACTUAL',
org='DillonX',
number='DAD101x',
run='3T2017'
)
# There are multiple sections so the learner can load different
# problems, but should only be shown review problems from what they have loaded
with cls.store.bulk_operations(cls.course_actual.id, emit_signals=False):
cls.chapter_actual = ItemFactory.create(
parent=cls.course_actual, display_name='Overview'
)
cls.section1_actual = ItemFactory.create(
parent=cls.chapter_actual, display_name='Section 1'
)
cls.unit1_actual = ItemFactory.create(
parent=cls.section1_actual, display_name='New Unit 1'
)
cls.xblock1_actual = ItemFactory.create(
parent=cls.unit1_actual,
category='problem',
display_name='Problem 1'
)
cls.xblock2_actual = ItemFactory.create(
parent=cls.unit1_actual,
category='problem',
display_name='Problem 2'
)
cls.xblock3_actual = ItemFactory.create(
parent=cls.unit1_actual,
category='problem',
display_name='Problem 3'
)
cls.xblock4_actual = ItemFactory.create(
parent=cls.unit1_actual,
category='problem',
display_name='Problem 4'
)
cls.section2_actual = ItemFactory.create(
parent=cls.chapter_actual, display_name='Section 2'
)
cls.unit2_actual = ItemFactory.create(
parent=cls.section2_actual, display_name='New Unit 2'
)
cls.xblock5_actual = ItemFactory.create(
parent=cls.unit2_actual,
category='problem',
display_name='Problem 5'
)
cls.section3_actual = ItemFactory.create(
parent=cls.chapter_actual, display_name='Section 3'
)
cls.unit3_actual = ItemFactory.create(
parent=cls.section3_actual, display_name='New Unit 3'
)
cls.xblock6_actual = ItemFactory.create(
parent=cls.unit3_actual,
category='problem',
display_name='Problem 6'
)
cls.course_actual_url = reverse(
'courseware_section',
kwargs={
'course_id': unicode(cls.course_actual.id),
'chapter': 'Overview',
'section': 'Welcome',
}
)
# Set up for the review course where the review problems are hosted
cls.course_review = CourseFactory.create(
display_name='Review_Test_Course_REVIEW',
org='DillonX',
number='DAD101x_review',
run='3T2017'
)
with cls.store.bulk_operations(cls.course_review.id, emit_signals=True):
cls.chapter_review = ItemFactory.create(
parent=cls.course_review, display_name='Overview'
)
cls.section_review = ItemFactory.create(
parent=cls.chapter_review, display_name='Welcome'
)
cls.unit1_review = ItemFactory.create(
parent=cls.section_review, display_name='New Unit 1'
)
cls.xblock1_review = ItemFactory.create(
parent=cls.unit1_review,
category='problem',
display_name='Problem 1'
)
cls.xblock2_review = ItemFactory.create(
parent=cls.unit1_review,
category='problem',
display_name='Problem 2'
)
cls.xblock3_review = ItemFactory.create(
parent=cls.unit1_review,
category='problem',
display_name='Problem 3'
)
cls.xblock4_review = ItemFactory.create(
parent=cls.unit1_review,
category='problem',
display_name='Problem 4'
)
cls.unit2_review = ItemFactory.create(
parent=cls.section_review, display_name='New Unit 2'
)
cls.xblock5_review = ItemFactory.create(
parent=cls.unit2_review,
category='problem',
display_name='Problem 5'
)
cls.unit3_review = ItemFactory.create(
parent=cls.section_review, display_name='New Unit 3'
)
cls.xblock6_review = ItemFactory.create(
parent=cls.unit3_review,
category='problem',
display_name='Problem 6'
)
cls.course_review_url = reverse(
'courseware_section',
kwargs={
'course_id': unicode(cls.course_review.id),
'chapter': 'Overview',
'section': 'Welcome',
}
)
def setUp(self):
super(TestReviewXBlock, self).setUp()
for idx, student in enumerate(self.STUDENTS):
username = 'u{}'.format(idx)
self.create_account(username, student['email'], student['password'])
self.activate_user(student['email'])
self.staff_user = GlobalStaffFactory()
def enroll_student(self, email, password, course):
"""
Student login and enroll for the course
"""
self.login(email, password)
self.enroll(course, verify=True)
@attr(shard=1)
@ddt.ddt
class TestReviewFunctions(TestReviewXBlock):
"""
Check that the essential functions of the Review xBlock work as expected.
Tests cover the basic process of receiving a hint, adding a new hint,
and rating/reporting hints.
"""
def test_no_review_problems(self):
"""
If a user has not seen any problems, they should
receive a response to go out and try more problems so they have
material to review.
"""
self.enroll_student(self.STUDENTS[0]['email'], self.STUDENTS[0]['password'], self.course_actual)
self.enroll_student(self.STUDENTS[0]['email'], self.STUDENTS[0]['password'], self.course_review)
with self.store.bulk_operations(self.course_actual.id, emit_signals=False):
review_section_actual = ItemFactory.create(
parent=self.chapter_actual, display_name='Review Subsection'
)
review_unit_actual = ItemFactory.create(
parent=review_section_actual, display_name='Review Unit'
)
review_xblock_actual = ItemFactory.create( # pylint: disable=unused-variable
parent=review_unit_actual,
category='review',
display_name='Review Tool'
)
# Loading the review section
response = self.client.get(reverse(
'courseware_section',
kwargs={
'course_id': self.course_actual.id,
'chapter': self.chapter_actual.location.name,
'section': review_section_actual.location.name,
}
))
expected_h2 = 'Nothing to review'
self.assertIn(expected_h2, response.content)
@ddt.data(5, 7)
def test_too_few_review_problems(self, num_desired):
"""
If a user does not have enough problems to review, they should
receive a response to go out and try more problems so they have
material to review.
Testing loading 4 problems and asking for 5 and then loading every
problem and asking for more than that.
"""
self.enroll_student(self.STUDENTS[0]['email'], self.STUDENTS[0]['password'], self.course_actual)
self.enroll_student(self.STUDENTS[0]['email'], self.STUDENTS[0]['password'], self.course_review)
# Want to load fewer problems than num_desired
self.client.get(reverse(
'courseware_section',
kwargs={
'course_id': self.course_actual.id,
'chapter': self.chapter_actual.location.name,
'section': self.section1_actual.location.name,
}
))
if num_desired > 6:
self.client.get(reverse(
'courseware_section',
kwargs={
'course_id': self.course_actual.id,
'chapter': self.chapter_actual.location.name,
'section': self.section2_actual.location.name,
}
))
self.client.get(reverse(
'courseware_section',
kwargs={
'course_id': self.course_actual.id,
'chapter': self.chapter_actual.location.name,
'section': self.section3_actual.location.name,
}
))
with self.store.bulk_operations(self.course_actual.id, emit_signals=False):
review_section_actual = ItemFactory.create(
parent=self.chapter_actual, display_name='Review Subsection'
)
review_unit_actual = ItemFactory.create(
parent=review_section_actual, display_name='Review Unit'
)
review_xblock_actual = ItemFactory.create( # pylint: disable=unused-variable
parent=review_unit_actual,
category='review',
display_name='Review Tool',
num_desired=num_desired
)
# Loading the review section
response = self.client.get(reverse(
'courseware_section',
kwargs={
'course_id': self.course_actual.id,
'chapter': self.chapter_actual.location.name,
'section': review_section_actual.location.name,
}
))
expected_h2 = 'Nothing to review'
self.assertIn(expected_h2, response.content)
@ddt.data(2, 6)
def test_review_problems(self, num_desired):
"""
If a user has enough problems to review, they should
receive a response where there are review problems for them to try.
"""
self.enroll_student(self.STUDENTS[0]['email'], self.STUDENTS[0]['password'], self.course_actual)
self.enroll_student(self.STUDENTS[0]['email'], self.STUDENTS[0]['password'], self.course_review)
# Loading problems so the learner has enough problems in the CSM
self.client.get(reverse(
'courseware_section',
kwargs={
'course_id': self.course_actual.id,
'chapter': self.chapter_actual.location.name,
'section': self.section1_actual.location.name,
}
))
self.client.get(reverse(
'courseware_section',
kwargs={
'course_id': self.course_actual.id,
'chapter': self.chapter_actual.location.name,
'section': self.section2_actual.location.name,
}
))
self.client.get(reverse(
'courseware_section',
kwargs={
'course_id': self.course_actual.id,
'chapter': self.chapter_actual.location.name,
'section': self.section3_actual.location.name,
}
))
with self.store.bulk_operations(self.course_actual.id, emit_signals=False):
review_section_actual = ItemFactory.create(
parent=self.chapter_actual, display_name='Review Subsection'
)
review_unit_actual = ItemFactory.create(
parent=review_section_actual, display_name='Review Unit'
)
review_xblock_actual = ItemFactory.create( # pylint: disable=unused-variable
parent=review_unit_actual,
category='review',
display_name='Review Tool',
num_desired=num_desired
)
# Loading the review section
response = self.client.get(reverse(
'courseware_section',
kwargs={
'course_id': self.course_actual.id,
'chapter': self.chapter_actual.location.name,
'section': review_section_actual.location.name,
}
))
expected_header_text = 'Review Problems'
# The problems are defaulted to correct upon load
# This happens because the problems "raw_possible" field is 0 and the
# "raw_earned" field is also 0.
expected_correctness_text = 'correct'
expected_problems = ['Review Problem 1', 'Review Problem 2', 'Review Problem 3',
'Review Problem 4', 'Review Problem 5', 'Review Problem 6']
self.assertIn(expected_header_text, response.content)
self.assertEqual(response.content.count(expected_correctness_text), num_desired)
# Since the problems are randomly selected, we have to check
# the correct number of problems are returned.
count = 0
for problem in expected_problems:
if problem in response.content:
count += 1
self.assertEqual(count, num_desired)
self.assertEqual(response.content.count(self.URL_BEGINNING), num_desired)
@ddt.data(2, 6)
def test_review_problem_urls(self, num_desired):
"""
Verify that the URLs returned from the Review xBlock are valid and
correct URLs for the problems the learner has seen.
"""
self.enroll_student(self.STUDENTS[0]['email'], self.STUDENTS[0]['password'], self.course_actual)
self.enroll_student(self.STUDENTS[0]['email'], self.STUDENTS[0]['password'], self.course_review)
# Loading problems so the learner has enough problems in the CSM
self.client.get(reverse(
'courseware_section',
kwargs={
'course_id': self.course_actual.id,
'chapter': self.chapter_actual.location.name,
'section': self.section1_actual.location.name,
}
))
self.client.get(reverse(
'courseware_section',
kwargs={
'course_id': self.course_actual.id,
'chapter': self.chapter_actual.location.name,
'section': self.section2_actual.location.name,
}
))
self.client.get(reverse(
'courseware_section',
kwargs={
'course_id': self.course_actual.id,
'chapter': self.chapter_actual.location.name,
'section': self.section3_actual.location.name,
}
))
user = User.objects.get(email=self.STUDENTS[0]['email'])
crum.set_current_user(user)
result_urls = get_review_ids.get_problems(num_desired, self.course_actual.id)
expected_urls = [
(self.URL_BEGINNING + 'problem+block@Problem_1', True, 0),
(self.URL_BEGINNING + 'problem+block@Problem_2', True, 0),
(self.URL_BEGINNING + 'problem+block@Problem_3', True, 0),
(self.URL_BEGINNING + 'problem+block@Problem_4', True, 0),
(self.URL_BEGINNING + 'problem+block@Problem_5', True, 0),
(self.URL_BEGINNING + 'problem+block@Problem_6', True, 0)
]
# Since the problems are randomly selected, we have to check
# the correct number of urls are returned.
count = 0
for url in expected_urls:
if url in result_urls:
count += 1
self.assertEqual(count, num_desired)
@ddt.data(2, 5)
def test_review_problem_urls_unique_problem(self, num_desired):
"""
Verify that the URLs returned from the Review xBlock are valid and
correct URLs for the problems the learner has seen. This test will give
a unique problem to a learner and verify only that learner sees
it as a review. It will also ensure that if a learner has not loaded a
problem, it should never show up as a review problem
"""
self.enroll_student(self.STUDENTS[0]['email'], self.STUDENTS[0]['password'], self.course_actual)
self.enroll_student(self.STUDENTS[0]['email'], self.STUDENTS[0]['password'], self.course_review)
# Loading problems so the learner has enough problems in the CSM
self.client.get(reverse(
'courseware_section',
kwargs={
'course_id': self.course_actual.id,
'chapter': self.chapter_actual.location.name,
'section': self.section1_actual.location.name,
}
))
self.client.get(reverse(
'courseware_section',
kwargs={
'course_id': self.course_actual.id,
'chapter': self.chapter_actual.location.name,
'section': self.section3_actual.location.name,
}
))
user = User.objects.get(email=self.STUDENTS[0]['email'])
crum.set_current_user(user)
result_urls = get_review_ids.get_problems(num_desired, self.course_actual.id)
expected_urls = [
(self.URL_BEGINNING + 'problem+block@Problem_1', True, 0),
(self.URL_BEGINNING + 'problem+block@Problem_2', True, 0),
(self.URL_BEGINNING + 'problem+block@Problem_3', True, 0),
(self.URL_BEGINNING + 'problem+block@Problem_4', True, 0),
# This is the unique problem when num_desired == 5
(self.URL_BEGINNING + 'problem+block@Problem_6', True, 0)
]
expected_not_loaded_problem = (self.URL_BEGINNING + 'problem+block@Problem_5', True, 0)
# Since the problems are randomly selected, we have to check
# the correct number of urls are returned.
count = 0
for url in expected_urls:
if url in result_urls:
count += 1
self.assertEqual(count, num_desired)
self.assertNotIn(expected_not_loaded_problem, result_urls)
# NOTE: This test is failing because when I grab the problem from the CSM,
# it is unable to find its parents. This is some issue with the BlockStructure
# and it not being populated the way we want. For now, this is being left out
# since the first course I'm working with does not use this function.
# TODO: Fix get_vertical from get_review_ids to have the block structure for this test
# or fix something in this file to make sure it populates the block structure for the CSM
@unittest.skip
def test_review_vertical_url(self):
"""
Verify that the URL returned from the Review xBlock is a valid and
correct URL for the vertical the learner has seen.
"""
self.enroll_student(self.STUDENTS[0]['email'], self.STUDENTS[0]['password'], self.course_actual)
self.enroll_student(self.STUDENTS[0]['email'], self.STUDENTS[0]['password'], self.course_review)
# Loading problems so the learner has problems and thus a vertical in the CSM
self.client.get(reverse(
'courseware_section',
kwargs={
'course_id': self.course_actual.id,
'chapter': self.chapter_actual.location.name,
'section': self.section1_actual.location.name,
}
))
user = User.objects.get(email=self.STUDENTS[0]['email'])
crum.set_current_user(user)
result_url = get_review_ids.get_vertical(self.course_actual.id)
expected_url = self.URL_BEGINNING + 'vertical+block@New_Unit_1'
self.assertEqual(result_url, expected_url)
...@@ -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