diff --git a/cms/envs/common.py b/cms/envs/common.py index 084eb4103c..8e6de0ce6d 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -308,9 +308,7 @@ MIDDLEWARE_CLASSES = ( 'embargo.middleware.EmbargoMiddleware', # Detects user-requested locale from 'accept-language' header in http request - # TODO: Re-import the Django version once we upgrade to Django 1.8 [PLAT-671] - # 'django.middleware.locale.LocaleMiddleware', - 'django_locale.middleware.LocaleMiddleware', + 'django.middleware.locale.LocaleMiddleware', 'django.middleware.transaction.TransactionMiddleware', # needs to run after locale middleware (or anything that modifies the request context) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 216817d66c..7ba5d46121 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -115,7 +115,10 @@ CACHES = { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'LOCATION': 'edx_location_mem_cache', }, - + 'course_structure_cache': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'edx_course_structure_mem_cache', + }, } # Make the keyedcache startup warnings go away diff --git a/cms/envs/test.py b/cms/envs/test.py index 08e5fe73ed..f1351851cd 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -166,7 +166,9 @@ CACHES = { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'LOCATION': 'edx_location_mem_cache', }, - + 'course_structure_cache': { + 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + }, } # Add external_auth to Installed apps for testing diff --git a/cms/static/build.js b/cms/static/build.js index 340ce02456..933041abe5 100644 --- a/cms/static/build.js +++ b/cms/static/build.js @@ -43,6 +43,7 @@ 'js/certificates/factories/certificates_page_factory', 'js/factories/import', 'js/factories/index', + 'js/factories/library', 'js/factories/login', 'js/factories/manage_users', 'js/factories/outline', diff --git a/cms/templates/js/add-xblock-component-menu-problem.underscore b/cms/templates/js/add-xblock-component-menu-problem.underscore index 301064935c..d3324d1b62 100644 --- a/cms/templates/js/add-xblock-component-menu-problem.underscore +++ b/cms/templates/js/add-xblock-component-menu-problem.underscore @@ -3,9 +3,6 @@
  • <%= gettext("Common Problem Types") %>
  • -
  • - <%= gettext("Common Problems with Hints and Feedback") %> -
  • <%= gettext("Advanced") %>
  • diff --git a/common/djangoapps/dark_lang/middleware.py b/common/djangoapps/dark_lang/middleware.py index 7a7a6b8868..b18d064969 100644 --- a/common/djangoapps/dark_lang/middleware.py +++ b/common/djangoapps/dark_lang/middleware.py @@ -12,11 +12,9 @@ the SessionMiddleware. """ from django.conf import settings -from dark_lang.models import DarkLangConfig +from django.utils.translation.trans_real import parse_accept_lang_header -# TODO re-import this once we're on Django 1.5 or greater. [PLAT-671] -# from django.utils.translation.trans_real import parse_accept_lang_header -from django_locale.trans_real import parse_accept_lang_header +from dark_lang.models import DarkLangConfig def dark_parse_accept_lang_header(accept): @@ -83,17 +81,11 @@ class DarkLangMiddleware(object): self._clean_accept_headers(request) self._activate_preview_language(request) - def _fuzzy_match(self, lang_code): - """Returns a fuzzy match for lang_code""" - if lang_code in self.released_langs: - return lang_code - - lang_prefix = lang_code.partition('-')[0] - for released_lang in self.released_langs: - released_prefix = released_lang.partition('-')[0] - if lang_prefix == released_prefix: - return released_lang - return None + def _is_released(self, lang_code): + """ + ``True`` iff one of the values in ``self.released_langs`` is a prefix of ``lang_code``. + """ + return any(lang_code.lower().startswith(released_lang.lower()) for released_lang in self.released_langs) def _format_accept_value(self, lang, priority=1.0): """ @@ -110,13 +102,12 @@ class DarkLangMiddleware(object): if accept is None or accept == '*': return - new_accept = [] - for lang, priority in dark_parse_accept_lang_header(accept): - fuzzy_code = self._fuzzy_match(lang.lower()) - if fuzzy_code: - new_accept.append(self._format_accept_value(fuzzy_code, priority)) - - new_accept = ", ".join(new_accept) + new_accept = ", ".join( + self._format_accept_value(lang, priority) + for lang, priority + in dark_parse_accept_lang_header(accept) + if self._is_released(lang) + ) request.META['HTTP_ACCEPT_LANGUAGE'] = new_accept diff --git a/common/djangoapps/dark_lang/models.py b/common/djangoapps/dark_lang/models.py index e61ea41fb2..1daec994e8 100644 --- a/common/djangoapps/dark_lang/models.py +++ b/common/djangoapps/dark_lang/models.py @@ -25,7 +25,7 @@ class DarkLangConfig(ConfigurationModel): if not self.released_languages.strip(): # pylint: disable=no-member return [] - languages = [lang.lower().strip() for lang in self.released_languages.split(',')] # pylint: disable=no-member + languages = [lang.strip() for lang in self.released_languages.split(',')] # pylint: disable=no-member # Put in alphabetical order languages.sort() return languages diff --git a/common/djangoapps/dark_lang/tests.py b/common/djangoapps/dark_lang/tests.py index b7210088e9..6dd0b41882 100644 --- a/common/djangoapps/dark_lang/tests.py +++ b/common/djangoapps/dark_lang/tests.py @@ -4,10 +4,8 @@ Tests of DarkLangMiddleware from django.contrib.auth.models import User from django.http import HttpRequest -import ddt from django.test import TestCase from mock import Mock -import unittest from dark_lang.middleware import DarkLangMiddleware from dark_lang.models import DarkLangConfig @@ -25,7 +23,6 @@ def set_if_set(dct, key, value): dct[key] = value -@ddt.ddt class DarkLangMiddlewareTests(TestCase): """ Tests of DarkLangMiddleware @@ -85,10 +82,6 @@ class DarkLangMiddlewareTests(TestCase): def test_wildcard_accept(self): self.assertAcceptEquals('*', self.process_request(accept='*')) - def test_malformed_accept(self): - self.assertAcceptEquals('', self.process_request(accept='xxxxxxxxxxxx')) - self.assertAcceptEquals('', self.process_request(accept='en;q=1.0, es-419:q-0.8')) - def test_released_accept(self): self.assertAcceptEquals( 'rel;q=1.0', @@ -130,17 +123,14 @@ class DarkLangMiddlewareTests(TestCase): ) def test_accept_released_territory(self): - # We will munge 'rel-ter' to be 'rel', so the 'rel-ter' - # user will actually receive the released language 'rel' - # (Otherwise, the user will actually end up getting the server default) self.assertAcceptEquals( - 'rel;q=1.0, rel;q=0.5', + 'rel-ter;q=1.0, rel;q=0.5', self.process_request(accept='rel-ter;q=1.0, rel;q=0.5') ) def test_accept_mixed_case(self): self.assertAcceptEquals( - 'rel;q=1.0, rel;q=0.5', + 'rel-TER;q=1.0, REL;q=0.5', self.process_request(accept='rel-TER;q=1.0, REL;q=0.5') ) @@ -150,85 +140,11 @@ class DarkLangMiddlewareTests(TestCase): enabled=True ).save() - # Since we have only released "rel-ter", the requested code "rel" will - # fuzzy match to "rel-ter", in addition to "rel-ter" exact matching "rel-ter" self.assertAcceptEquals( - 'rel-ter;q=1.0, rel-ter;q=0.5', + 'rel-ter;q=1.0', self.process_request(accept='rel-ter;q=1.0, rel;q=0.5') ) - @ddt.data( - ('es;q=1.0, pt;q=0.5', 'es-419;q=1.0'), # 'es' should get 'es-419', not English - ('es-AR;q=1.0, pt;q=0.5', 'es-419;q=1.0'), # 'es-AR' should get 'es-419', not English - ) - @ddt.unpack - def test_partial_match_es419(self, accept_header, expected): - # Release es-419 - DarkLangConfig( - released_languages=('es-419, en'), - changed_by=self.user, - enabled=True - ).save() - - self.assertAcceptEquals( - expected, - self.process_request(accept=accept_header) - ) - - def test_partial_match_esar_es(self): - # If I release 'es', 'es-AR' should get 'es', not English - DarkLangConfig( - released_languages=('es, en'), - changed_by=self.user, - enabled=True - ).save() - - self.assertAcceptEquals( - 'es;q=1.0', - self.process_request(accept='es-AR;q=1.0, pt;q=0.5') - ) - - @ddt.data( - # Test condition: If I release 'es-419, es, es-es'... - ('es;q=1.0, pt;q=0.5', 'es;q=1.0'), # 1. es should get es - ('es-419;q=1.0, pt;q=0.5', 'es-419;q=1.0'), # 2. es-419 should get es-419 - ('es-es;q=1.0, pt;q=0.5', 'es-es;q=1.0'), # 3. es-es should get es-es - ) - @ddt.unpack - def test_exact_match_gets_priority(self, accept_header, expected): - # Release 'es-419, es, es-es' - DarkLangConfig( - released_languages=('es-419, es, es-es'), - changed_by=self.user, - enabled=True - ).save() - self.assertAcceptEquals( - expected, - self.process_request(accept=accept_header) - ) - - @unittest.skip("This won't work until fallback is implemented for LA country codes. See LOC-86") - @ddt.data( - 'es-AR', # Argentina - 'es-PY', # Paraguay - ) - def test_partial_match_es_la(self, latin_america_code): - # We need to figure out the best way to implement this. There are a ton of LA country - # codes that ought to fall back to 'es-419' rather than 'es-es'. - # http://unstats.un.org/unsd/methods/m49/m49regin.htm#americas - # If I release 'es, es-419' - # Latin American codes should get es-419 - DarkLangConfig( - released_languages=('es, es-419'), - changed_by=self.user, - enabled=True - ).save() - - self.assertAcceptEquals( - 'es-419;q=1.0', - self.process_request(accept='{};q=1.0, pt;q=0.5'.format(latin_america_code)) - ) - def assertSessionLangEquals(self, value, request): """ Assert that the 'django_language' set in request.session is equal to value @@ -308,6 +224,6 @@ class DarkLangMiddlewareTests(TestCase): ).save() self.assertAcceptEquals( - 'zh-cn;q=1.0, zh-tw;q=0.5, zh-hk;q=0.3', + 'zh-CN;q=1.0, zh-TW;q=0.5, zh-HK;q=0.3', self.process_request(accept='zh-Hans;q=1.0, zh-Hant-TW;q=0.5, zh-HK;q=0.3') ) diff --git a/common/djangoapps/django_locale/__init__.py b/common/djangoapps/django_locale/__init__.py deleted file mode 100644 index 655019022e..0000000000 --- a/common/djangoapps/django_locale/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -TODO: This module is imported from the stable Django 1.8 branch, as a -copy of https://github.com/django/django/blob/stable/1.8.x/django/middleware/locale.py. - -Remove this file and re-import this middleware from Django once the -codebase is upgraded with a modern version of Django. [PLAT-671] -""" diff --git a/common/djangoapps/django_locale/middleware.py b/common/djangoapps/django_locale/middleware.py deleted file mode 100644 index b0601a807e..0000000000 --- a/common/djangoapps/django_locale/middleware.py +++ /dev/null @@ -1,83 +0,0 @@ -# TODO: This file is imported from the stable Django 1.8 branch. Remove this file -# and re-import this middleware from Django once the codebase is upgraded. [PLAT-671] -# pylint: disable=invalid-name, missing-docstring -"This is the locale selecting middleware that will look at accept headers" - -from django.conf import settings -from django.core.urlresolvers import ( - LocaleRegexURLResolver, get_resolver, get_script_prefix, is_valid_path, -) -from django.http import HttpResponseRedirect -from django.utils import translation -from django.utils.cache import patch_vary_headers -# Override the Django 1.4 implementation with the 1.8 implementation -from django_locale.trans_real import get_language_from_request - - -class LocaleMiddleware(object): - """ - This is a very simple middleware that parses a request - and decides what translation object to install in the current - thread context. This allows pages to be dynamically - translated to the language the user desires (if the language - is available, of course). - """ - response_redirect_class = HttpResponseRedirect - - def __init__(self): - self._is_language_prefix_patterns_used = False - for url_pattern in get_resolver(None).url_patterns: - if isinstance(url_pattern, LocaleRegexURLResolver): - self._is_language_prefix_patterns_used = True - break - - def process_request(self, request): - check_path = self.is_language_prefix_patterns_used() - # This call is broken in Django 1.4: - # https://github.com/django/django/blob/stable/1.4.x/django/utils/translation/trans_real.py#L399 - # (we override parse_accept_lang_header to a fixed version in dark_lang.middleware) - language = get_language_from_request( - request, check_path=check_path) - translation.activate(language) - request.LANGUAGE_CODE = translation.get_language() - - def process_response(self, request, response): - language = translation.get_language() - language_from_path = translation.get_language_from_path(request.path_info) - if (response.status_code == 404 and not language_from_path - and self.is_language_prefix_patterns_used()): - urlconf = getattr(request, 'urlconf', None) - language_path = '/%s%s' % (language, request.path_info) - path_valid = is_valid_path(language_path, urlconf) - if (not path_valid and settings.APPEND_SLASH - and not language_path.endswith('/')): - path_valid = is_valid_path("%s/" % language_path, urlconf) - - if path_valid: - script_prefix = get_script_prefix() - language_url = "%s://%s%s" % ( - request.scheme, - request.get_host(), - # insert language after the script prefix and before the - # rest of the URL - request.get_full_path().replace( - script_prefix, - '%s%s/' % (script_prefix, language), - 1 - ) - ) - return self.response_redirect_class(language_url) - - if not (self.is_language_prefix_patterns_used() - and language_from_path): - patch_vary_headers(response, ('Accept-Language',)) - if 'Content-Language' not in response: - response['Content-Language'] = language - return response - - def is_language_prefix_patterns_used(self): - """ - Returns `True` if the `LocaleRegexURLResolver` is used - at root level of the urlpatterns, else it returns `False`. - """ - return self._is_language_prefix_patterns_used diff --git a/common/djangoapps/django_locale/tests.py b/common/djangoapps/django_locale/tests.py deleted file mode 100644 index cc40ce9d4a..0000000000 --- a/common/djangoapps/django_locale/tests.py +++ /dev/null @@ -1,157 +0,0 @@ -# pylint: disable=invalid-name, line-too-long, super-method-not-called -""" -Tests taken from Django upstream: -https://github.com/django/django/blob/e6b34193c5c7d117ededdab04bb16caf8864f07c/tests/regressiontests/i18n/tests.py -""" -from django.conf import settings -from django.test import TestCase, RequestFactory -from django_locale.trans_real import ( - parse_accept_lang_header, get_language_from_request, LANGUAGE_SESSION_KEY -) - -# Added to test middleware around dark lang -from django.contrib.auth.models import User -from django.test.utils import override_settings -from dark_lang.models import DarkLangConfig - - -# Adding to support test differences between Django and our own settings -@override_settings(LANGUAGES=[ - ('pt', 'Portuguese'), - ('pt-br', 'Portuguese-Brasil'), - ('es', 'Spanish'), - ('es-ar', 'Spanish (Argentina)'), - ('de', 'Deutch'), - ('zh-cn', 'Chinese (China)'), - ('ar-sa', 'Arabic (Saudi Arabia)'), -]) -class MiscTests(TestCase): - """ - Tests taken from Django upstream: - https://github.com/django/django/blob/e6b34193c5c7d117ededdab04bb16caf8864f07c/tests/regressiontests/i18n/tests.py - """ - def setUp(self): - self.rf = RequestFactory() - # Added to test middleware around dark lang - user = User() - user.save() - DarkLangConfig( - released_languages='pt, pt-br, es, de, es-ar, zh-cn, ar-sa', - changed_by=user, - enabled=True - ).save() - - def test_parse_spec_http_header(self): - """ - Testing HTTP header parsing. First, we test that we can parse the - values according to the spec (and that we extract all the pieces in - the right order). - """ - p = parse_accept_lang_header - # Good headers. - self.assertEqual([('de', 1.0)], p('de')) - self.assertEqual([('en-AU', 1.0)], p('en-AU')) - self.assertEqual([('es-419', 1.0)], p('es-419')) - self.assertEqual([('*', 1.0)], p('*;q=1.00')) - self.assertEqual([('en-AU', 0.123)], p('en-AU;q=0.123')) - self.assertEqual([('en-au', 0.5)], p('en-au;q=0.5')) - self.assertEqual([('en-au', 1.0)], p('en-au;q=1.0')) - self.assertEqual([('da', 1.0), ('en', 0.5), ('en-gb', 0.25)], p('da, en-gb;q=0.25, en;q=0.5')) - self.assertEqual([('en-au-xx', 1.0)], p('en-au-xx')) - self.assertEqual([('de', 1.0), ('en-au', 0.75), ('en-us', 0.5), ('en', 0.25), ('es', 0.125), ('fa', 0.125)], p('de,en-au;q=0.75,en-us;q=0.5,en;q=0.25,es;q=0.125,fa;q=0.125')) - self.assertEqual([('*', 1.0)], p('*')) - self.assertEqual([('de', 1.0)], p('de;q=0.')) - self.assertEqual([('en', 1.0), ('*', 0.5)], p('en; q=1.0, * ; q=0.5')) - self.assertEqual([], p('')) - - # Bad headers; should always return []. - self.assertEqual([], p('en-gb;q=1.0000')) - self.assertEqual([], p('en;q=0.1234')) - self.assertEqual([], p('en;q=.2')) - self.assertEqual([], p('abcdefghi-au')) - self.assertEqual([], p('**')) - self.assertEqual([], p('en,,gb')) - self.assertEqual([], p('en-au;q=0.1.0')) - self.assertEqual([], p('XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXZ,en')) - self.assertEqual([], p('da, en-gb;q=0.8, en;q=0.7,#')) - self.assertEqual([], p('de;q=2.0')) - self.assertEqual([], p('de;q=0.a')) - self.assertEqual([], p('12-345')) - self.assertEqual([], p('')) - - def test_parse_literal_http_header(self): - """ - Now test that we parse a literal HTTP header correctly. - """ - g = get_language_from_request - r = self.rf.get('/') - r.COOKIES = {} - r.META = {'HTTP_ACCEPT_LANGUAGE': 'pt-br'} - self.assertEqual('pt-br', g(r)) - - r.META = {'HTTP_ACCEPT_LANGUAGE': 'pt'} - self.assertEqual('pt', g(r)) - - r.META = {'HTTP_ACCEPT_LANGUAGE': 'es,de'} - self.assertEqual('es', g(r)) - - r.META = {'HTTP_ACCEPT_LANGUAGE': 'es-ar,de'} - self.assertEqual('es-ar', g(r)) - - # This test assumes there won't be a Django translation to a US - # variation of the Spanish language, a safe assumption. When the - # user sets it as the preferred language, the main 'es' - # translation should be selected instead. - r.META = {'HTTP_ACCEPT_LANGUAGE': 'es-us'} - self.assertEqual(g(r), 'es') - - # This tests the following scenario: there isn't a main language (zh) - # translation of Django but there is a translation to variation (zh_CN) - # the user sets zh-cn as the preferred language, it should be selected - # by Django without falling back nor ignoring it. - r.META = {'HTTP_ACCEPT_LANGUAGE': 'zh-cn,de'} - self.assertEqual(g(r), 'zh-cn') - - def test_logic_masked_by_darklang(self): - g = get_language_from_request - r = self.rf.get('/') - r.COOKIES = {} - r.META = {'HTTP_ACCEPT_LANGUAGE': 'ar-qa'} - self.assertEqual('ar-sa', g(r)) - - r.session = {LANGUAGE_SESSION_KEY: 'es'} - self.assertEqual('es', g(r)) - - def test_parse_language_cookie(self): - """ - Now test that we parse language preferences stored in a cookie correctly. - """ - g = get_language_from_request - r = self.rf.get('/') - r.COOKIES = {settings.LANGUAGE_COOKIE_NAME: 'pt-br'} - r.META = {} - self.assertEqual('pt-br', g(r)) - - r.COOKIES = {settings.LANGUAGE_COOKIE_NAME: 'pt'} - r.META = {} - self.assertEqual('pt', g(r)) - - r.COOKIES = {settings.LANGUAGE_COOKIE_NAME: 'es'} - r.META = {'HTTP_ACCEPT_LANGUAGE': 'de'} - self.assertEqual('es', g(r)) - - # This test assumes there won't be a Django translation to a US - # variation of the Spanish language, a safe assumption. When the - # user sets it as the preferred language, the main 'es' - # translation should be selected instead. - r.COOKIES = {settings.LANGUAGE_COOKIE_NAME: 'es-us'} - r.META = {} - self.assertEqual(g(r), 'es') - - # This tests the following scenario: there isn't a main language (zh) - # translation of Django but there is a translation to variation (zh_CN) - # the user sets zh-cn as the preferred language, it should be selected - # by Django without falling back nor ignoring it. - r.COOKIES = {settings.LANGUAGE_COOKIE_NAME: 'zh-cn'} - r.META = {'HTTP_ACCEPT_LANGUAGE': 'de'} - self.assertEqual(g(r), 'zh-cn') diff --git a/common/djangoapps/django_locale/trans_real.py b/common/djangoapps/django_locale/trans_real.py deleted file mode 100644 index 3ec6b6d026..0000000000 --- a/common/djangoapps/django_locale/trans_real.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Translation helper functions.""" -# Imported from Django 1.8 -# pylint: disable=invalid-name -import re -from django.conf import settings -from django.conf.locale import LANG_INFO -from django.utils import translation - - -# Format of Accept-Language header values. From RFC 2616, section 14.4 and 3.9. -# and RFC 3066, section 2.1 -accept_language_re = re.compile(r''' - ([A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*|\*) # "en", "en-au", "x-y-z", "*" - (?:\s*;\s*q=(0(?:\.\d{,3})?|1(?:.0{,3})?))? # Optional "q=1.00", "q=0.8" - (?:\s*,\s*|$) # Multiple accepts per header. - ''', re.VERBOSE) - - -language_code_re = re.compile(r'^[a-z]{1,8}(?:-[a-z0-9]{1,8})*$', re.IGNORECASE) - - -LANGUAGE_SESSION_KEY = '_language' - - -def parse_accept_lang_header(lang_string): - """ - Parses the lang_string, which is the body of an HTTP Accept-Language - header, and returns a list of (lang, q-value), ordered by 'q' values. - - Any format errors in lang_string results in an empty list being returned. - """ - # parse_accept_lang_header is broken until we are on Django 1.5 or greater - # See https://code.djangoproject.com/ticket/19381 - result = [] - pieces = accept_language_re.split(lang_string) - if pieces[-1]: - return [] - for i in range(0, len(pieces) - 1, 3): - first, lang, priority = pieces[i: i + 3] - if first: - return [] - priority = priority and float(priority) or 1.0 - result.append((lang, priority)) - result.sort(key=lambda k: k[1], reverse=True) - return result - - -def get_supported_language_variant(lang_code, strict=False): - """ - Returns the language-code that's listed in supported languages, possibly - selecting a more generic variant. Raises LookupError if nothing found. - If `strict` is False (the default), the function will look for an alternative - country-specific variant when the currently checked is not found. - lru_cache should have a maxsize to prevent from memory exhaustion attacks, - as the provided language codes are taken from the HTTP request. See also - . - """ - if lang_code: - # If 'fr-ca' is not supported, try special fallback or language-only 'fr'. - possible_lang_codes = [lang_code] - try: - # TODO skip this, or import updated LANG_INFO format from __future__ - # (fallback option wasn't added until - # https://github.com/django/django/commit/5dcdbe95c749d36072f527e120a8cb463199ae0d) - possible_lang_codes.extend(LANG_INFO[lang_code]['fallback']) - except KeyError: - pass - generic_lang_code = lang_code.split('-')[0] - possible_lang_codes.append(generic_lang_code) - supported_lang_codes = dict(settings.LANGUAGES) - - for code in possible_lang_codes: - # Note: django 1.4 implementation of check_for_language is OK to use - if code in supported_lang_codes and translation.check_for_language(code): - return code - if not strict: - # if fr-fr is not supported, try fr-ca. - for supported_code in supported_lang_codes: - if supported_code.startswith(generic_lang_code + '-'): - return supported_code - raise LookupError(lang_code) - - -def get_language_from_request(request, check_path=False): - """ - Analyzes the request to find what language the user wants the system to - show. Only languages listed in settings.LANGUAGES are taken into account. - If the user requests a sublanguage where we have a main language, we send - out the main language. - If check_path is True, the URL path prefix will be checked for a language - code, otherwise this is skipped for backwards compatibility. - """ - if check_path: - # Note: django 1.4 implementation of get_language_from_path is OK to use - lang_code = translation.get_language_from_path(request.path_info) - if lang_code is not None: - return lang_code - - supported_lang_codes = dict(settings.LANGUAGES) - - if hasattr(request, 'session'): - lang_code = request.session.get(LANGUAGE_SESSION_KEY) - # Note: django 1.4 implementation of check_for_language is OK to use - if lang_code in supported_lang_codes and lang_code is not None and translation.check_for_language(lang_code): - return lang_code - - lang_code = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME) - - try: - return get_supported_language_variant(lang_code) - except LookupError: - pass - - accept = request.META.get('HTTP_ACCEPT_LANGUAGE', '') - # broken in 1.4, so defined above - for accept_lang, unused in parse_accept_lang_header(accept): - if accept_lang == '*': - break - - if not language_code_re.search(accept_lang): - continue - - try: - return get_supported_language_variant(accept_lang) - except LookupError: - continue - - try: - return get_supported_language_variant(settings.LANGUAGE_CODE) - except LookupError: - return settings.LANGUAGE_CODE diff --git a/common/djangoapps/student/tests/test_certificates.py b/common/djangoapps/student/tests/test_certificates.py index dea4192a90..03d5b66e00 100644 --- a/common/djangoapps/student/tests/test_certificates.py +++ b/common/djangoapps/student/tests/test_certificates.py @@ -59,7 +59,8 @@ class CertificateDisplayTest(ModuleStoreTestCase): def test_linked_student_to_web_view_credential(self, enrollment_mode): test_url = get_certificate_url( user_id=self.user.id, - course_id=unicode(self.course.id) + course_id=unicode(self.course.id), + verify_uuid='abcdefg12345678' ) self._create_certificate(enrollment_mode) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 58b6425eaf..43bcc2bd07 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -306,11 +306,14 @@ def _cert_info(user, course, cert_status, course_mode): # showing the certificate web view button if certificate is ready state and feature flags are enabled. if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False): if get_active_web_certificate(course) is not None: + certificate_url = get_certificate_url( + user_id=user.id, + course_id=unicode(course.id), + verify_uuid=None + ) status_dict.update({ 'show_cert_web_view': True, - 'cert_web_view_url': u'{url}'.format( - url=get_certificate_url(user_id=user.id, course_id=unicode(course.id)) - ) + 'cert_web_view_url': u'{url}'.format(url=certificate_url) }) else: # don't show download certificate button if we don't have an active certificate for course diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index 6949472011..9d997cc2a5 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -377,6 +377,19 @@ class EditInfo(object): source_version="UNSET" if self.source_version is None else self.source_version, ) # pylint: disable=bad-continuation + def __eq__(self, edit_info): + """ + Two EditInfo instances are equal iff their storable representations + are equal. + """ + return self.to_storable() == edit_info.to_storable() + + def __neq__(self, edit_info): + """ + Two EditInfo instances are not equal if they're not equal. + """ + return not self == edit_info + class BlockData(object): """ @@ -434,6 +447,19 @@ class BlockData(object): classname=self.__class__.__name__, ) # pylint: disable=bad-continuation + def __eq__(self, block_data): + """ + Two BlockData objects are equal iff all their attributes are equal. + """ + attrs = ['fields', 'block_type', 'definition', 'defaults', 'edit_info'] + return all(getattr(self, attr) == getattr(block_data, attr) for attr in attrs) + + def __neq__(self, block_data): + """ + Just define this as not self.__eq__(block_data) + """ + return not self == block_data + new_contract('BlockData', BlockData) diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py index ab7f922df5..fc179c81ba 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py @@ -2,7 +2,9 @@ Segregation of pymongo functions from the data modeling mechanisms for split modulestore. """ import datetime +import cPickle as pickle import math +import zlib import pymongo import pytz import re @@ -11,6 +13,8 @@ from time import time # Import this just to export it from pymongo.errors import DuplicateKeyError # pylint: disable=unused-import +from django.core.cache import get_cache, InvalidCacheBackendError +import dogstats_wrapper as dog_stats_api from contracts import check, new_contract from mongodb_proxy import autoretry_read, MongoProxy @@ -203,6 +207,50 @@ def structure_to_mongo(structure, course_context=None): return new_structure +class CourseStructureCache(object): + """ + Wrapper around django cache object to cache course structure objects. + The course structures are pickled and compressed when cached. + + If the 'course_structure_cache' doesn't exist, then don't do anything for + for set and get. + """ + def __init__(self): + self.no_cache_found = False + try: + self.cache = get_cache('course_structure_cache') + except InvalidCacheBackendError: + self.no_cache_found = True + + def get(self, key): + """Pull the compressed, pickled struct data from cache and deserialize.""" + if self.no_cache_found: + return None + + compressed_pickled_data = self.cache.get(key) + if compressed_pickled_data is None: + return None + return pickle.loads(zlib.decompress(compressed_pickled_data)) + + def set(self, key, structure): + """Given a structure, will pickle, compress, and write to cache.""" + if self.no_cache_found: + return None + + pickled_data = pickle.dumps(structure, pickle.HIGHEST_PROTOCOL) + # 1 = Fastest (slightly larger results) + compressed_pickled_data = zlib.compress(pickled_data, 1) + + # record compressed course structure sizes + dog_stats_api.histogram( + 'compressed_course_structure.size', + len(compressed_pickled_data), + tags=[key] + ) + # Stuctures are immutable, so we set a timeout of "never" + self.cache.set(key, compressed_pickled_data, None) + + class MongoConnection(object): """ Segregation of pymongo functions from the data modeling mechanisms for split modulestore. @@ -256,15 +304,23 @@ class MongoConnection(object): def get_structure(self, key, course_context=None): """ - Get the structure from the persistence mechanism whose id is the given key + Get the structure from the persistence mechanism whose id is the given key. + + This method will use a cached version of the structure if it is availble. """ with TIMER.timer("get_structure", course_context) as tagger_get_structure: - with TIMER.timer("get_structure.find_one", course_context) as tagger_find_one: - doc = self.structures.find_one({'_id': key}) - tagger_find_one.measure("blocks", len(doc['blocks'])) - tagger_get_structure.measure("blocks", len(doc['blocks'])) + cache = CourseStructureCache() - return structure_from_mongo(doc, course_context) + structure = cache.get(key) + tagger_get_structure.tag(from_cache='true' if structure else 'false') + if not structure: + with TIMER.timer("get_structure.find_one", course_context) as tagger_find_one: + doc = self.structures.find_one({'_id': key}) + tagger_find_one.measure("blocks", len(doc['blocks'])) + structure = structure_from_mongo(doc, course_context) + cache.set(key, structure) + + return structure @autoretry_read() def find_structures_by_id(self, ids, course_context=None): diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py index dfafc920e1..01022fdb24 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -794,7 +794,7 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): # find: find parent (definition.children) 2x, find draft item, get inheritance items # send: one delete query for specific item # Split: - # find: active_version & structure + # find: active_version & structure (cached) # send: update structure and active_versions @ddt.data(('draft', 4, 1), ('split', 2, 2)) @ddt.unpack diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py index a47655f1a7..06b5067223 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py @@ -12,6 +12,7 @@ import uuid from contracts import contract from nose.plugins.attrib import attr +from django.core.cache import get_cache, InvalidCacheBackendError from openedx.core.lib import tempdir from xblock.fields import Reference, ReferenceList, ReferenceValueDict @@ -29,6 +30,7 @@ from xmodule.fields import Date, Timedelta from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore from xmodule.modulestore.tests.test_modulestore import check_has_course_method from xmodule.modulestore.split_mongo import BlockKey +from xmodule.modulestore.tests.factories import check_mongo_calls from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST from xmodule.modulestore.tests.utils import mock_tab_from_json from xmodule.modulestore.edit_info import EditInfoMixin @@ -771,6 +773,79 @@ class SplitModuleCourseTests(SplitModuleTest): self.assertEqual(result.children[0].children[0].locator.version_guid, versions[0]) +class TestCourseStructureCache(SplitModuleTest): + """Tests for the CourseStructureCache""" + + def setUp(self): + # use the default cache, since the `course_structure_cache` + # is a dummy cache during testing + self.cache = get_cache('default') + + # make sure we clear the cache before every test... + self.cache.clear() + # ... and after + self.addCleanup(self.cache.clear) + + # make a new course: + self.user = random.getrandbits(32) + self.new_course = modulestore().create_course( + 'org', 'course', 'test_run', self.user, BRANCH_NAME_DRAFT, + ) + + super(TestCourseStructureCache, self).setUp() + + @patch('xmodule.modulestore.split_mongo.mongo_connection.get_cache') + def test_course_structure_cache(self, mock_get_cache): + # force get_cache to return the default cache so we can test + # its caching behavior + mock_get_cache.return_value = self.cache + + with check_mongo_calls(1): + not_cached_structure = self._get_structure(self.new_course) + + # when cache is warmed, we should have one fewer mongo call + with check_mongo_calls(0): + cached_structure = self._get_structure(self.new_course) + + # now make sure that you get the same structure + self.assertEqual(cached_structure, not_cached_structure) + + @patch('xmodule.modulestore.split_mongo.mongo_connection.get_cache') + def test_course_structure_cache_no_cache_configured(self, mock_get_cache): + mock_get_cache.side_effect = InvalidCacheBackendError + + with check_mongo_calls(1): + not_cached_structure = self._get_structure(self.new_course) + + # if the cache isn't configured, we expect to have to make + # another mongo call here if we want the same course structure + with check_mongo_calls(1): + cached_structure = self._get_structure(self.new_course) + + # now make sure that you get the same structure + self.assertEqual(cached_structure, not_cached_structure) + + def test_dummy_cache(self): + with check_mongo_calls(1): + not_cached_structure = self._get_structure(self.new_course) + + # Since the test is using the dummy cache, it's not actually caching + # anything + with check_mongo_calls(1): + cached_structure = self._get_structure(self.new_course) + + # now make sure that you get the same structure + self.assertEqual(cached_structure, not_cached_structure) + + def _get_structure(self, course): + """ + Helper function to get a structure from a course. + """ + return modulestore().db_connection.get_structure( + course.location.as_object_id(course.location.version_guid) + ) + + class SplitModuleItemTests(SplitModuleTest): ''' Item read tests including inheritance diff --git a/common/lib/xmodule/xmodule/templates/problem/checkboxes_response_hint.yaml b/common/lib/xmodule/xmodule/templates/problem/checkboxes_response_hint.yaml deleted file mode 100644 index de88f5500e..0000000000 --- a/common/lib/xmodule/xmodule/templates/problem/checkboxes_response_hint.yaml +++ /dev/null @@ -1,70 +0,0 @@ ---- -metadata: - display_name: Checkboxes with Hints and Feedback - tab: hint - markdown: | - - You can provide feedback for each option in a checkbox problem, with distinct feedback depending on whether or not the learner selects that option. - - You can also provide compound feedback for a specific combination of answers. For example, if you have three possible answers in the problem, you can configure specific feedback for when a learner selects each combination of possible answers. - - You can also add hints for learners. - - Be sure to select Settings to specify a Display Name and other values that apply. - - Use the following example problem as a model. - - >>Which of the following is a fruit? Check all that apply.<< - - [x] apple {{ selected: You are correct that an apple is a fruit because it is the fertilized ovary that comes from an apple tree and contains seeds. }, { unselected: Remember that an apple is also a fruit.}} - [x] pumpkin {{ selected: You are correct that a pumpkin is a fruit because it is the fertilized ovary of a squash plant and contains seeds. }, { unselected: Remember that a pumpkin is also a fruit.}} - [ ] potato {{ U: You are correct that a potato is a vegetable because it is an edible part of a plant in tuber form.}, { S: A potato is a vegetable, not a fruit, because it does not come from a flower and does not contain seeds.}} - [x] tomato {{ S: You are correct that a tomato is a fruit because it is the fertilized ovary of a tomato plant and contains seeds. }, { U: Many people mistakenly think a tomato is a vegetable. However, because a tomato is the fertilized ovary of a tomato plant and contains seeds, it is a fruit.}} - - - {{ ((A B D)) An apple, pumpkin, and tomato are all fruits as they all are fertilized ovaries of a plant and contain seeds. }} - {{ ((A B C D)) You are correct that an apple, pumpkin, and tomato are all fruits as they all are fertilized ovaries of a plant and contain seeds. However, a potato is not a fruit as it is an edible part of a plant in tuber form and is a vegetable. }} - - ||A fruit is the fertilized ovary from a flower.|| - ||A fruit contains seeds of the plant.|| - - -data: | - - -

    You can provide feedback for each option in a checkbox problem, with distinct feedback depending on whether or not the learner selects that option.

    - -

    You can also provide compound feedback for a specific combination of answers. For example, if you have three possible answers in the problem, you can configure specific feedback for when a learner selects each combination of possible answers.

    - -

    You can also add hints for learners.

    - -

    Use the following example problem as a model.

    - -

    Which of the following is a fruit? Check all that apply.

    - - - apple - You are correct that an apple is a fruit because it is the fertilized ovary that comes from an apple tree and contains seeds. - Remember that an apple is also a fruit. - - pumpkin - You are correct that a pumpkin is a fruit because it is the fertilized ovary of a squash plant and contains seeds. - Remember that a pumpkin is also a fruit. - - potato - A potato is a vegetable, not a fruit, because it does not come from a flower and does not contain seeds. - You are correct that a potato is a vegetable because it is an edible part of a plant in tuber form. - - tomato - You are correct that a tomato is a fruit because it is the fertilized ovary of a tomato plant and contains seeds. - Many people mistakenly think a tomato is a vegetable. However, because a tomato is the fertilized ovary of a tomato plant and contains seeds, it a fruit. - - An apple, pumpkin, and tomato are all fruits as they all are fertilized ovaries of a plant and contain seeds. - You are correct that an apple, pumpkin, and tomato are all fruits as they all are fertilized ovaries of a plant and contain seeds. However, a potato is not a fruit as it is an edible part of a plant in tuber form and is classified as a vegetable. - - - - A fruit is the fertilized ovary from a flower. - A fruit contains seeds of the plant. - -
    diff --git a/common/lib/xmodule/xmodule/templates/problem/multiplechoice_hint.yaml b/common/lib/xmodule/xmodule/templates/problem/multiplechoice_hint.yaml deleted file mode 100644 index 99998e751a..0000000000 --- a/common/lib/xmodule/xmodule/templates/problem/multiplechoice_hint.yaml +++ /dev/null @@ -1,46 +0,0 @@ ---- -metadata: - display_name: Multiple Choice with Hints and Feedback - tab: hint - markdown: | - - You can provide feedback for each option in a multiple choice problem. - - You can also add hints for learners. - - Be sure to select Settings to specify a Display Name and other values that apply. - - Use the following example problem as a model. - - >>Which of the following is a vegetable?<< - ( ) apple {{An apple is the fertilized ovary that comes from an apple tree and contains seeds, meaning it is a fruit.}} - ( ) pumpkin {{A pumpkin is the fertilized ovary of a squash plant and contains seeds, meaning it is a fruit.}} - (x) potato {{A potato is an edible part of a plant in tuber form and is a vegetable.}} - ( ) tomato {{Many people mistakenly think a tomato is a vegetable. However, because a tomato is the fertilized ovary of a tomato plant and contains seeds, it is a fruit.}} - - ||A fruit is the fertilized ovary from a flower.|| - ||A fruit contains seeds of the plant.|| - -data: | - - -

    You can provide feedback for each option in a multiple choice problem.

    - -

    You can also add hints for learners.

    - -

    Use the following example problem as a model.

    - -

    Which of the following is a vegetable?

    - - - apple An apple is the fertilized ovary that comes from an apple tree and contains seeds, meaning it is a fruit. - pumpkin A pumpkin is the fertilized ovary of a squash plant and contains seeds, meaning it is a fruit. - potato A potato is an edible part of a plant in tuber form and is a vegetable. - tomato Many people mistakenly think a tomato is a vegetable. However, because a tomato is the fertilized ovary of a tomato plant and contains seeds, it is a fruit. - - - - A fruit is the fertilized ovary from a flower. - A fruit contains seeds of the plant. - -
    diff --git a/common/lib/xmodule/xmodule/templates/problem/numericalresponse_hint.yaml b/common/lib/xmodule/xmodule/templates/problem/numericalresponse_hint.yaml deleted file mode 100644 index d315ea291d..0000000000 --- a/common/lib/xmodule/xmodule/templates/problem/numericalresponse_hint.yaml +++ /dev/null @@ -1,54 +0,0 @@ ---- -metadata: - display_name: Numerical Input with Hints and Feedback - tab: hint - markdown: | - - You can provide feedback for correct answers in numerical input problems. You cannot provide feedback for incorrect answers. - - Use feedback for the correct answer to reinforce the process for arriving at the numerical value. - - You can also add hints for learners. - - Be sure to select Settings to specify a Display Name and other values that apply. - - Use the following example problem as a model. - - >>What is the arithmetic mean for the following set of numbers? (1, 5, 6, 3, 5)<< - - = 4 {{The mean for this set of numbers is 20 / 5, which equals 4.}} - - ||The mean is calculated by summing the set of numbers and dividing by n.|| - ||n is the count of items in the set.|| - - [explanation] - The mean is calculated by summing the set of numbers and dividing by n. In this case: (1 + 5 + 6 + 3 + 5) / 5 = 20 / 5 = 4. - [explanation] - -data: | - - -

    You can provide feedback for correct answers in numerical input problems. You cannot provide feedback for incorrect answers.

    - -

    Use feedback for the correct answer to reinforce the process for arriving at the numerical value.

    - -

    Use the following example problem as a model.

    - -

    What is the arithmetic mean for the following set of numbers? (1, 5, 6, 3, 5)

    - - - The mean for this set of numbers is 20 / 5, which equals 4. - - - -
    -

    Explanation

    -

    The mean is calculated by summing the set of numbers and dividing by n. In this case: (1 + 5 + 6 + 3 + 5) / 5 = 20 / 5 = 4.

    -
    -
    - - - The mean is calculated by summing the set of numbers and dividing by n. - n is the count of items in the set. - -
    \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/templates/problem/optionresponse_hint.yaml b/common/lib/xmodule/xmodule/templates/problem/optionresponse_hint.yaml deleted file mode 100644 index c78933aa7a..0000000000 --- a/common/lib/xmodule/xmodule/templates/problem/optionresponse_hint.yaml +++ /dev/null @@ -1,51 +0,0 @@ ---- -metadata: - display_name: Dropdown with Hints and Feedback - tab: hint - markdown: | - - You can provide feedback for each available option in a dropdown problem. - - You can also add hints for learners. - - Be sure to select Settings to specify a Display Name and other values that apply. - - Use the following example problem as a model. - - >> A/an ________ is a vegetable.<< - - [[ - apple {{An apple is the fertilized ovary that comes from an apple tree and contains seeds, meaning it is a fruit.}} - pumpkin {{A pumpkin is the fertilized ovary of a squash plant and contains seeds, meaning it is a fruit.}} - (potato) {{A potato is an edible part of a plant in tuber form and is a vegetable.}} - tomato {{Many people mistakenly think a tomato is a vegetable. However, because a tomato is the fertilized ovary of a tomato plant and contains seeds, it is a fruit.}} - ]] - - ||A fruit is the fertilized ovary from a flower.|| - ||A fruit contains seeds of the plant.|| - -data: | - - -

    You can provide feedback for each available option in a dropdown problem.

    - -

    You can also add hints for learners.

    - -

    Use the following example problem as a model.

    - -

    A/an ________ is a vegetable.

    -
    - - - - - - - - - - - A fruit is the fertilized ovary from a flower. - A fruit contains seeds of the plant. - -
    diff --git a/common/lib/xmodule/xmodule/templates/problem/string_response_hint.yaml b/common/lib/xmodule/xmodule/templates/problem/string_response_hint.yaml deleted file mode 100644 index e52bf9f8f3..0000000000 --- a/common/lib/xmodule/xmodule/templates/problem/string_response_hint.yaml +++ /dev/null @@ -1,54 +0,0 @@ ---- -metadata: - display_name: Text Input with Hints and Feedback - tab: hint - markdown: | - - You can provide feedback for the correct answer in text input problems, as well as for specific incorrect answers. - - Use feedback on expected incorrect answers to address common misconceptions and to provide guidance on how to arrive at the correct answer. - - Be sure to select Settings to specify a Display Name and other values that apply. - - Use the following example problem as a model. - - >>Which U.S. state has the largest land area?<< - - =Alaska {{Alaska is 576,400 square miles, more than double the land area - of the second largest state, Texas.}} - - not=Texas {{While many people think Texas is the largest state, it is actually the second largest, with 261,797 square miles.}} - - not=California {{California is the third largest state, with 155,959 square miles.}} - - ||Consider the square miles, not population.|| - ||Consider all 50 states, not just the continental United States.|| - -data: | - - -

    You can provide feedback for the correct answer in text input problems, as well as for specific incorrect answers.

    - -

    Use feedback on expected incorrect answers to address common misconceptions and to provide guidance on how to arrive at the correct answer.

    - -

    Use the following example problem as a model.

    - -

    Which U.S. state has the largest land area?

    - - - - Alaska is 576,400 square miles, more than double the land area of the second largest state, Texas. - - While many people think Texas is the largest state, it is actually the second largest, with 261,797 square miles. - - California is the third largest state, with 155,959 square miles. - - - - - - Consider the square miles, not population. - Consider all 50 states, not just the continental United States. - - -
    diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index 311801dd0f..58b2b48a9f 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -253,15 +253,18 @@ def example_certificates_status(course_key): # pylint: disable=no-member -def get_certificate_url(user_id, course_id): +def get_certificate_url(user_id, course_id, verify_uuid): """ :return certificate url """ - url = u'{url}'.format(url=reverse('cert_html_view', - kwargs=dict( - user_id=str(user_id), - course_id=unicode(course_id)))) - return url + if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False): + return u'{url}'.format( + url=reverse( + 'cert_html_view', + kwargs=dict(user_id=str(user_id), course_id=unicode(course_id)) + ) + ) + return '{url}{uuid}'.format(url=settings.CERTIFICATES_STATIC_VERIFY_URL, uuid=verify_uuid) def get_active_web_certificate(course, is_preview_mode=None): @@ -290,7 +293,7 @@ def emit_certificate_event(event_name, user, course_id, course=None, event_data= data = { 'user_id': user.id, 'course_id': unicode(course_id), - 'certificate_url': get_certificate_url(user.id, course_id) + 'certificate_url': get_certificate_url(user.id, course_id, event_data['certificate_id']) } event_data = event_data or {} event_data.update(data) diff --git a/lms/djangoapps/certificates/tests/test_api.py b/lms/djangoapps/certificates/tests/test_api.py index bd77a2ff94..043bdf6639 100644 --- a/lms/djangoapps/certificates/tests/test_api.py +++ b/lms/djangoapps/certificates/tests/test_api.py @@ -148,7 +148,7 @@ class GenerateUserCertificatesTest(EventTestMixin, ModuleStoreTestCase): 'edx.certificate.created', user_id=self.student.id, course_id=unicode(self.course.id), - certificate_url=certs_api.get_certificate_url(self.student.id, self.course.id), + certificate_url=certs_api.get_certificate_url(self.student.id, self.course.id, cert.verify_uuid), certificate_id=cert.verify_uuid, enrollment_mode=cert.mode, generation_mode='batch' @@ -164,7 +164,7 @@ class GenerateUserCertificatesTest(EventTestMixin, ModuleStoreTestCase): self.assertEqual(cert.status, 'error') self.assertIn(self.ERROR_REASON, cert.error_reason) - @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) + @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) def test_new_cert_requests_returns_generating_for_html_certificate(self): """ Test no message sent to Xqueue if HTML certificate view is enabled diff --git a/lms/djangoapps/certificates/tests/test_views.py b/lms/djangoapps/certificates/tests/test_views.py index 979603a82a..e4f3eb7b80 100644 --- a/lms/djangoapps/certificates/tests/test_views.py +++ b/lms/djangoapps/certificates/tests/test_views.py @@ -308,7 +308,8 @@ class MicrositeCertificatesViewsTests(ModuleStoreTestCase): self.assertEquals(config.configuration, test_configuration_string) test_url = get_certificate_url( user_id=self.user.id, - course_id=self.course.id.to_deprecated_string() # pylint: disable=no-member + course_id=unicode(self.course.id), + verify_uuid=self.cert.verify_uuid ) self._add_course_certificates(count=1, signatory_count=2) response = self.client.get(test_url) @@ -341,7 +342,8 @@ class MicrositeCertificatesViewsTests(ModuleStoreTestCase): self.assertEquals(config.configuration, test_configuration_string) test_url = get_certificate_url( user_id=self.user.id, - course_id=self.course.id.to_deprecated_string() # pylint: disable=no-member + course_id=unicode(self.course.id), + verify_uuid=self.cert.verify_uuid ) self._add_course_certificates(count=1, signatory_count=2) response = self.client.get(test_url) @@ -427,7 +429,8 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): def test_render_html_view_valid_certificate(self): test_url = get_certificate_url( user_id=self.user.id, - course_id=unicode(self.course.id) # pylint: disable=no-member + course_id=unicode(self.course.id), + verify_uuid=self.cert.verify_uuid ) self._add_course_certificates(count=1, signatory_count=2) response = self.client.get(test_url) @@ -449,7 +452,8 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): def test_render_html_view_with_valid_signatories(self): test_url = get_certificate_url( user_id=self.user.id, - course_id=self.course.id.to_deprecated_string() # pylint: disable=no-member + course_id=unicode(self.course.id), + verify_uuid=self.cert.verify_uuid ) self._add_course_certificates(count=1, signatory_count=2) response = self.client.get(test_url) @@ -465,7 +469,8 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): # if certificate in descriptor has not course_title then course name should not be overridden with this title. test_url = get_certificate_url( user_id=self.user.id, - course_id=self.course.id.to_deprecated_string() # pylint: disable=no-member + course_id=unicode(self.course.id), + verify_uuid=self.cert.verify_uuid ) test_certificates = [ { @@ -488,7 +493,8 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): def test_certificate_view_without_org_logo(self): test_url = get_certificate_url( user_id=self.user.id, - course_id=self.course.id.to_deprecated_string() # pylint: disable=no-member + course_id=unicode(self.course.id), + verify_uuid=self.cert.verify_uuid ) test_certificates = [ { @@ -510,7 +516,8 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): def test_render_html_view_without_signatories(self): test_url = get_certificate_url( user_id=self.user.id, - course_id=self.course.id.to_deprecated_string() # pylint: disable=no-member + course_id=unicode(self.course), + verify_uuid=self.cert.verify_uuid ) self._add_course_certificates(count=1, signatory_count=0) response = self.client.get(test_url) @@ -518,19 +525,20 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): self.assertNotIn('Signatory_Title 0', response.content) @override_settings(FEATURES=FEATURES_WITH_CERTS_DISABLED) - def test_render_html_view_invalid_feature_flag(self): + def test_render_html_view_disabled_feature_flag_returns_static_url(self): test_url = get_certificate_url( user_id=self.user.id, - course_id=self.course.id.to_deprecated_string() # pylint: disable=no-member + course_id=unicode(self.course.id), + verify_uuid=self.cert.verify_uuid ) - response = self.client.get(test_url) - self.assertIn('invalid', response.content) + self.assertIn(str(self.cert.verify_uuid), test_url) @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) def test_render_html_view_invalid_course_id(self): test_url = get_certificate_url( user_id=self.user.id, - course_id='az/23423/4vs' + course_id='az/23423/4vs', + verify_uuid=self.cert.verify_uuid ) response = self.client.get(test_url) @@ -540,7 +548,8 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): def test_render_html_view_invalid_course(self): test_url = get_certificate_url( user_id=self.user.id, - course_id='missing/course/key' + course_id='missing/course/key', + verify_uuid=self.cert.verify_uuid ) response = self.client.get(test_url) self.assertIn('invalid', response.content) @@ -549,7 +558,8 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): def test_render_html_view_invalid_user(self): test_url = get_certificate_url( user_id=111, - course_id=self.course.id.to_deprecated_string() # pylint: disable=no-member + course_id=unicode(self.course.id), + verify_uuid=self.cert.verify_uuid ) response = self.client.get(test_url) self.assertIn('invalid', response.content) @@ -560,7 +570,8 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): self.assertEqual(len(GeneratedCertificate.objects.all()), 0) test_url = get_certificate_url( user_id=self.user.id, - course_id=self.course.id.to_deprecated_string() # pylint: disable=no-member + course_id=unicode(self.course.id), + verify_uuid=self.cert.verify_uuid ) response = self.client.get(test_url) self.assertIn('invalid', response.content) @@ -576,7 +587,8 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): self._add_course_certificates(count=1, signatory_count=2) test_url = get_certificate_url( user_id=self.user.id, - course_id=self.course.id.to_deprecated_string() # pylint: disable=no-member + course_id=unicode(self.course.id), + verify_uuid=self.cert.verify_uuid ) response = self.client.get(test_url + '?preview=honor') self.assertNotIn(self.course.display_name, response.content) @@ -594,7 +606,8 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): def test_render_html_view_invalid_certificate_configuration(self): test_url = get_certificate_url( user_id=self.user.id, - course_id=unicode(self.course.id) + course_id=unicode(self.course.id), + verify_uuid=self.cert.verify_uuid ) response = self.client.get(test_url) self.assertIn("Invalid Certificate", response.content) @@ -606,7 +619,8 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): self.recreate_tracker() test_url = get_certificate_url( user_id=self.user.id, - course_id=unicode(self.course.id) + course_id=unicode(self.course.id), + verify_uuid=self.cert.verify_uuid ) response = self.client.get(test_url) self.assertEqual(response.status_code, 200) @@ -626,7 +640,12 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) def test_evidence_event_sent(self): - test_url = get_certificate_url(user_id=self.user.id, course_id=self.course_id) + '?evidence_visit=1' + cert_url = get_certificate_url( + user_id=self.user.id, + course_id=self.course_id, + verify_uuid=self.cert.verify_uuid + ) + test_url = '{}?evidence_visit=1'.format(cert_url) self.recreate_tracker() assertion = BadgeAssertion( user=self.user, course_id=self.course_id, mode='honor', diff --git a/lms/djangoapps/certificates/views.py b/lms/djangoapps/certificates/views.py index 963ce40304..7d0c7bf40a 100644 --- a/lms/djangoapps/certificates/views.py +++ b/lms/djangoapps/certificates/views.py @@ -439,7 +439,8 @@ def _update_certificate_context(context, course, user, user_certificate): user_certificate.mode, get_certificate_url( user_id=user.id, - course_id=course.id.to_deprecated_string() + course_id=unicode(course.id), + verify_uuid=user_certificate.verify_uuid ) ) diff --git a/lms/djangoapps/courseware/tests/test_i18n.py b/lms/djangoapps/courseware/tests/test_i18n.py index 4e4c147865..a67442e64b 100644 --- a/lms/djangoapps/courseware/tests/test_i18n.py +++ b/lms/djangoapps/courseware/tests/test_i18n.py @@ -4,59 +4,37 @@ Tests i18n in courseware import re from nose.plugins.attrib import attr -from django.contrib.auth.models import User from django.test import TestCase - -from dark_lang.models import DarkLangConfig - - -class BaseI18nTestCase(TestCase): - """ - Base utilities for i18n test classes to derive from - """ - def assert_tag_has_attr(self, content, tag, attname, value): - """Assert that a tag in `content` has a certain value in a certain attribute.""" - regex = r"""<{tag} [^>]*\b{attname}=['"]([\w\d\- ]+)['"][^>]*>""".format(tag=tag, attname=attname) - match = re.search(regex, content) - self.assertTrue(match, "Couldn't find desired tag '%s' with attr '%s' in %r" % (tag, attname, content)) - attvalues = match.group(1).split() - self.assertIn(value, attvalues) - - def release_languages(self, languages): - """ - Release a set of languages using the dark lang interface. - languages is a list of comma-separated lang codes, eg, 'ar, es-419' - """ - user = User() - user.save() - DarkLangConfig( - released_languages=languages, - changed_by=user, - enabled=True - ).save() +from django.test.utils import override_settings @attr('shard_1') -class I18nTestCase(BaseI18nTestCase): +@override_settings(LANGUAGES=[('eo', 'Esperanto'), ('ar', 'Arabic')]) +class I18nTestCase(TestCase): """ Tests for i18n """ + def assert_tag_has_attr(self, content, tag, attname, value): + """Assert that a tag in `content` has a certain value in a certain attribute.""" + regex = r"""<{tag} [^>]*\b{attname}=['"]([\w\d ]+)['"][^>]*>""".format(tag=tag, attname=attname) + match = re.search(regex, content) + self.assertTrue(match, "Couldn't find desired tag in %r" % content) + attvalues = match.group(1).split() + self.assertIn(value, attvalues) + def test_default_is_en(self): - self.release_languages('fr') response = self.client.get('/') self.assert_tag_has_attr(response.content, "html", "lang", "en") self.assertEqual(response['Content-Language'], 'en') self.assert_tag_has_attr(response.content, "body", "class", "lang_en") def test_esperanto(self): - self.release_languages('fr, eo') response = self.client.get('/', HTTP_ACCEPT_LANGUAGE='eo') self.assert_tag_has_attr(response.content, "html", "lang", "eo") self.assertEqual(response['Content-Language'], 'eo') self.assert_tag_has_attr(response.content, "body", "class", "lang_eo") def test_switching_languages_bidi(self): - self.release_languages('ar, eo') response = self.client.get('/') self.assert_tag_has_attr(response.content, "html", "lang", "en") self.assertEqual(response['Content-Language'], 'en') @@ -68,26 +46,3 @@ class I18nTestCase(BaseI18nTestCase): self.assertEqual(response['Content-Language'], 'ar') self.assert_tag_has_attr(response.content, "body", "class", "lang_ar") self.assert_tag_has_attr(response.content, "body", "class", "rtl") - - -@attr('shard_1') -class I18nRegressionTests(BaseI18nTestCase): - """ - Tests for i18n - """ - def test_es419_acceptance(self): - # Regression test; LOC-72, and an issue with Django - self.release_languages('es-419') - response = self.client.get('/', HTTP_ACCEPT_LANGUAGE='es-419') - self.assert_tag_has_attr(response.content, "html", "lang", "es-419") - - def test_unreleased_lang_resolution(self): - # Regression test; LOC-85 - self.release_languages('fa') - - # We've released 'fa', AND we have language files for 'fa-ir' but - # we want to keep 'fa-ir' as a dark language. Requesting 'fa-ir' - # in the http request (NOT with the ?preview-lang query param) should - # receive files for 'fa' - response = self.client.get('/', HTTP_ACCEPT_LANGUAGE='fa-ir') - self.assert_tag_has_attr(response.content, "html", "lang", "fa") diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 5e511086af..217da31f53 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -824,7 +824,7 @@ class ProgressPageTests(ModuleStoreTestCase): If certificate web view is enabled then certificate web view button should appear for user who certificate is available/generated """ - GeneratedCertificateFactory.create( + certificate = GeneratedCertificateFactory.create( user=self.user, course_id=self.course.id, status=CertificateStatuses.downloadable, @@ -859,7 +859,12 @@ class ProgressPageTests(ModuleStoreTestCase): resp = views.progress(self.request, course_id=unicode(self.course.id)) self.assertContains(resp, u"View Certificate") self.assertContains(resp, u"You can now view your certificate") - self.assertContains(resp, certs_api.get_certificate_url(user_id=self.user.id, course_id=self.course.id)) + cert_url = certs_api.get_certificate_url( + user_id=self.user.id, + course_id=self.course.id, + verify_uuid=certificate.verify_uuid + ) + self.assertContains(resp, cert_url) # when course certificate is not active certificates[0]['is_active'] = False diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 13968d0e98..de5252bbfe 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -1086,7 +1086,11 @@ def _progress(request, course_key, student_id): context.update({ 'show_cert_web_view': True, 'cert_web_view_url': u'{url}'.format( - url=certs_api.get_certificate_url(user_id=student.id, course_id=unicode(course.id)) + url=certs_api.get_certificate_url( + user_id=student.id, + course_id=unicode(course.id), + verify_uuid=None + ) ) }) else: diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 1f113305a6..23163c3c96 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -640,3 +640,7 @@ EDXNOTES_INTERNAL_API = ENV_TOKENS.get('EDXNOTES_INTERNAL_API', EDXNOTES_INTERNA ##### Credit Provider Integration ##### CREDIT_PROVIDER_SECRET_KEYS = AUTH_TOKENS.get("CREDIT_PROVIDER_SECRET_KEYS", {}) + + +############ CERTIFICATE VERIFICATION URL (STATIC FILES) ########### +ENV_TOKENS.get('CERTIFICATES_STATIC_VERIFY_URL', CERTIFICATES_STATIC_VERIFY_URL) diff --git a/lms/envs/common.py b/lms/envs/common.py index 7e21453cc7..3428c8d937 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1152,9 +1152,7 @@ MIDDLEWARE_CLASSES = ( 'lang_pref.middleware.LanguagePreferenceMiddleware', # Detects user-requested locale from 'accept-language' header in http request - # TODO: Re-import the Django version once we upgrade to Django 1.8 [PLAT-671] - # 'django.middleware.locale.LocaleMiddleware', - 'django_locale.middleware.LocaleMiddleware', + 'django.middleware.locale.LocaleMiddleware', 'django.middleware.transaction.TransactionMiddleware', # 'debug_toolbar.middleware.DebugToolbarMiddleware', @@ -2095,6 +2093,9 @@ REGISTRATION_EXTRA_FIELDS = { CERT_NAME_SHORT = "Certificate" CERT_NAME_LONG = "Certificate of Achievement" +############ CERTIFICATE VERIFICATION URL (STATIC FILES) ########### +CERTIFICATES_STATIC_VERIFY_URL = "https://verify-test.edx.org/cert/" + #################### Badgr OpenBadges generation ####################### # Be sure to set up images for course modes using the BadgeImageConfiguration model in the certificates app. BADGR_API_TOKEN = None diff --git a/lms/envs/dev.py b/lms/envs/dev.py index b8060f230d..a0e3dee83f 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -94,6 +94,10 @@ CACHES = { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'LOCATION': 'edx_location_mem_cache', }, + 'course_structure_cache': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'edx_course_structure_mem_cache', + }, } diff --git a/lms/envs/test.py b/lms/envs/test.py index d62756b8c3..326572da14 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -207,7 +207,9 @@ CACHES = { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'LOCATION': 'edx_location_mem_cache', }, - + 'course_structure_cache': { + 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + }, } # Dummy secret key for dev @@ -472,9 +474,6 @@ FACEBOOK_APP_SECRET = "Test" FACEBOOK_APP_ID = "Test" FACEBOOK_API_VERSION = "v2.2" -# Certificates Views -FEATURES['CERTIFICATES_HTML_VIEW'] = True - ######### custom courses ######### INSTALLED_APPS += ('ccx',) FEATURES['CUSTOM_COURSES_EDX'] = True diff --git a/lms/templates/instructor/instructor_dashboard_2/executive_summary.html b/lms/templates/instructor/instructor_dashboard_2/executive_summary.html index 4312892a00..e118fb2377 100644 --- a/lms/templates/instructor/instructor_dashboard_2/executive_summary.html +++ b/lms/templates/instructor/instructor_dashboard_2/executive_summary.html @@ -28,7 +28,7 @@ h2 { -

    ${_("Executive Summary for {display_name}".format(display_name=display_name))}

    +

    ${_("Executive Summary for {display_name}").format(display_name=display_name)}

    diff --git a/lms/templates/shoppingcart/shopping_cart.html b/lms/templates/shoppingcart/shopping_cart.html index 2cbfac71f2..14ceab73c4 100644 --- a/lms/templates/shoppingcart/shopping_cart.html +++ b/lms/templates/shoppingcart/shopping_cart.html @@ -105,13 +105,13 @@ from django.utils.translation import ungettext
    - +
    - - @@ -310,15 +310,15 @@ from django.utils.translation import ungettext var wasBusinessType = false; var studentField = $(this).parent().find("input[type='text']"); var unit_cost = parseFloat(studentField.data('unit-cost')); - var ItemId = studentField.attr('id'); + var ItemId = studentField.data('item-id'); var $button = $(this); - var oldValue = $("#"+ItemId).data('qty'); + var oldValue = studentField.data('qty'); var newVal = 1; // initialize with 1. oldValue = parseFloat(oldValue); hideErrorMsg('students-'+ItemId); if ($.isNumeric(oldValue)){ - if ($button.text() == "+") { + if ($button.data("operation") == "inc") { if(oldValue > 0){ newVal = oldValue + 1; if(newVal > 1000){ @@ -338,7 +338,7 @@ from django.utils.translation import ungettext $button.parent().find("input").val(newVal); isBusinessType = getBusinessType(); update_user_cart(ItemId, newVal, oldValue, unit_cost, wasBusinessType, isBusinessType); - $("#"+ItemId).data('qty', newVal); + studentField.data('qty', newVal); } }); @@ -390,7 +390,7 @@ from django.utils.translation import ungettext typeChanged = true; $('html').css({'cursor':'wait'}); $(".button").css({'cursor':'wait'}); - $('.col-2.relative').find("input[type='submit']").attr('disabled', true); + $('.col-2.relative').find("button[type='submit']").attr('disabled', true); } @@ -406,11 +406,14 @@ from django.utils.translation import ungettext $(".button").css({'cursor':'default'}); $("#processor_form").html(data['form_html']); if(typeChanged){ - var submit_button = $('.col-2.relative').find("input[type='submit']") + var submit_button = $('.col-2.relative').find("button[type='submit']"); submit_button.removeAttr('disabled'); for (var i = 0; i< data['oldToNewIdMap'].length; i++) { - $('#'+data['oldToNewIdMap'][i]['oldId']+'').attr('id',data['oldToNewIdMap'][i]['newId']); - $('a.btn-remove[data-item-id]=' +data['oldToNewIdMap'][i]['oldId']+'').data('item-id', data['oldToNewIdMap'][i]['newId']); + var oldId = data['oldToNewIdMap'][i]['oldId']; + var newId = data['oldToNewIdMap'][i]['newId']; + + $('input.spin-counter[data-item-id]=' + oldId ).data('item-id', newId); + $('button.btn-remove[data-item-id]=' + oldId ).data('item-id', newId); } if(isbusinessType){ $( "div[name='payment']").addClass('hidden'); @@ -447,8 +450,9 @@ from django.utils.translation import ungettext function updateTextFieldQty(event){ if(isSpinnerBtnEnabled){ - var itemId = event.currentTarget.id; - var prevQty = $("#"+itemId).data('qty'); + var el_id = event.currentTarget.id; + var itemId = $("#"+el_id).data('item-id'); + var prevQty = $("#"+el_id).data('qty'); var newQty = event.currentTarget.value; var unitCost = event.currentTarget.dataset.unitCost; var isBusinessType = getBusinessType(); @@ -456,7 +460,7 @@ from django.utils.translation import ungettext var wasBusinessType = !isBusinessType; isSpinnerBtnEnabled = false; update_user_cart(itemId, newQty, prevQty, unitCost, wasBusinessType, isBusinessType); - $("#"+itemId).data('qty', newQty); + $("#"+el_id).data('qty', newQty); } } diff --git a/lms/urls.py b/lms/urls.py index 52103e5991..18578206ac 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -662,11 +662,10 @@ if settings.FEATURES.get('ENABLE_OAUTH2_PROVIDER'): ) # Certificates Web/HTML View -if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False): - urlpatterns += ( - url(r'^certificates/user/(?P[^/]*)/course/{course_id}'.format(course_id=settings.COURSE_ID_PATTERN), - 'certificates.views.render_html_view', name='cert_html_view'), - ) +urlpatterns += ( + url(r'^certificates/user/(?P[^/]*)/course/{course_id}'.format(course_id=settings.COURSE_ID_PATTERN), + 'certificates.views.render_html_view', name='cert_html_view'), +) BADGE_SHARE_TRACKER_URL = url( r'^certificates/badge_share_tracker/{}/(?P[^/]+)/(?P[^/]+)/$'.format(