Merge branch 'master' into edx-depr31
This commit is contained in:
@@ -8,3 +8,5 @@ build:
|
||||
python:
|
||||
install:
|
||||
- requirements: "requirements/edx/doc.txt"
|
||||
- method: pip
|
||||
path: .
|
||||
|
||||
2
Makefile
2
Makefile
@@ -94,10 +94,10 @@ shell: ## launch a bash shell in a Docker container with all edx-platform depend
|
||||
# Order is very important in this list: files must appear after everything they include!
|
||||
REQ_FILES = \
|
||||
requirements/edx/coverage \
|
||||
requirements/edx/doc \
|
||||
requirements/edx/paver \
|
||||
requirements/edx-sandbox/py38 \
|
||||
requirements/edx/base \
|
||||
requirements/edx/doc \
|
||||
requirements/edx/testing \
|
||||
requirements/edx/development \
|
||||
scripts/xblock/requirements
|
||||
|
||||
@@ -296,7 +296,7 @@ class CourseExportTask(UserTask): # pylint: disable=abstract-method
|
||||
arguments_dict (dict): The arguments given to the task function
|
||||
|
||||
Returns:
|
||||
text_type: The generated name
|
||||
str: The generated name
|
||||
"""
|
||||
key = arguments_dict['course_key_string']
|
||||
return f'Export of {key}'
|
||||
@@ -431,7 +431,7 @@ class CourseImportTask(UserTask): # pylint: disable=abstract-method
|
||||
arguments_dict (dict): The arguments given to the task function
|
||||
|
||||
Returns:
|
||||
text_type: The generated name
|
||||
str: The generated name
|
||||
"""
|
||||
key = arguments_dict['course_key_string']
|
||||
filename = arguments_dict['archive_name']
|
||||
|
||||
@@ -63,7 +63,14 @@ define(
|
||||
t = -1;
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
// this is added to compensate for custom css that accidentally hide mathjax
|
||||
$('.MathJax_SVG>svg').toArray().forEach(el => {
|
||||
if ($(el).width() === 0) {
|
||||
$(el).css('max-width', 'inherit');
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
);
|
||||
window.CodeMirror = CodeMirror;
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""
|
||||
Utilities for returning XModule JS (used by requirejs)
|
||||
"""
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
|
||||
|
||||
def get_xmodule_urls():
|
||||
"""
|
||||
Returns a list of the URLs to hit to grab all the XModule JS
|
||||
"""
|
||||
pipeline_js_settings = settings.PIPELINE['JAVASCRIPT']["module-js"]
|
||||
if settings.DEBUG:
|
||||
paths = [path.replace(".coffee", ".js") for path in pipeline_js_settings["source_filenames"]]
|
||||
else:
|
||||
paths = [pipeline_js_settings["output_filename"]]
|
||||
return [staticfiles_storage.url(path) for path in paths]
|
||||
@@ -1380,15 +1380,6 @@ PIPELINE['JAVASCRIPT'] = {
|
||||
'source_filenames': base_vendor_js,
|
||||
'output_filename': 'js/cms-base-vendor.js',
|
||||
},
|
||||
'module-js': {
|
||||
'source_filenames': (
|
||||
rooted_glob(COMMON_ROOT / 'static/', 'xmodule/descriptors/js/*.js') +
|
||||
rooted_glob(COMMON_ROOT / 'static/', 'xmodule/modules/js/*.js') +
|
||||
rooted_glob(COMMON_ROOT / 'static/', 'common/js/discussion/*.js')
|
||||
),
|
||||
'output_filename': 'js/cms-modules.js',
|
||||
'test_order': 1
|
||||
},
|
||||
}
|
||||
|
||||
STATICFILES_IGNORE_PATTERNS = (
|
||||
|
||||
@@ -338,7 +338,7 @@ AWS_S3_CUSTOM_DOMAIN = AUTH_TOKENS.get('AWS_S3_CUSTOM_DOMAIN', 'edxuploads.s3.am
|
||||
if AUTH_TOKENS.get('DEFAULT_FILE_STORAGE'):
|
||||
DEFAULT_FILE_STORAGE = AUTH_TOKENS.get('DEFAULT_FILE_STORAGE')
|
||||
elif AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY:
|
||||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto.S3BotoStorage'
|
||||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
|
||||
else:
|
||||
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from openedx.core.djangolib.markup import HTML, Text
|
||||
from openedx.core.djangolib.js_utils import (
|
||||
dump_js_escaped_json, js_escaped_string
|
||||
)
|
||||
import six
|
||||
from six.moves.urllib.parse import quote
|
||||
%>
|
||||
|
||||
@@ -104,7 +103,7 @@ CMS.User.isGlobalStaff = '${is_global_staff | n, js_escaped_string}'=='True' ? t
|
||||
<div class="bit">
|
||||
% if context_course:
|
||||
<%
|
||||
url_encoded_course_id = quote(six.text_type(context_course.id).encode('utf-8'), safe='')
|
||||
url_encoded_course_id = quote(str(context_course.id).encode('utf-8'), safe='')
|
||||
details_url = utils.reverse_course_url('settings_handler', context_course.id)
|
||||
grading_url = utils.reverse_course_url('grading_handler', context_course.id)
|
||||
course_team_url = utils.reverse_course_url('course_team_handler', context_course.id)
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
<%inherit file="base.html" />
|
||||
<%def name="online_help_token()"><% return "files" %></%def>
|
||||
<%!
|
||||
import six
|
||||
|
||||
from cms.djangoapps.contentstore import utils
|
||||
from cms.djangoapps.contentstore.config.waffle_utils import should_show_checklists_quality
|
||||
from django.urls import reverse
|
||||
@@ -40,7 +38,7 @@
|
||||
<%static:studiofrontend entry="courseHealthCheck">
|
||||
|
||||
<%
|
||||
course_key = six.text_type(context_course.id)
|
||||
course_key = str(context_course.id)
|
||||
certificates_url = ''
|
||||
if has_certificates_enabled(context_course):
|
||||
certificates_url = utils.reverse_course_url('certificates_list_handler', course_key)
|
||||
|
||||
@@ -115,6 +115,13 @@
|
||||
t = -1;
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// this is added to compensate for custom css that accidentally hide mathjax
|
||||
$('.MathJax_SVG>svg').toArray().forEach(el => {
|
||||
if ($(el).width() === 0) {
|
||||
$(el).css('max-width', 'inherit');
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
<script type="text/x-mathjax-config">
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<%def name="online_help_token()"><% return "develop_course" %></%def>
|
||||
<%!
|
||||
import logging
|
||||
import six
|
||||
from six.moves.urllib.parse import quote
|
||||
|
||||
from cms.djangoapps.contentstore.config.waffle_utils import should_show_checklists_quality
|
||||
@@ -160,7 +159,7 @@ from django.urls import reverse
|
||||
<h2 class="title title-3">${_("This course has proctored exam settings that are incomplete or invalid.")}</h2>
|
||||
<p>
|
||||
% if mfe_proctored_exam_settings_url:
|
||||
<% url_encoded_course_id = quote(six.text_type(context_course.id).encode('utf-8'), safe='') %>
|
||||
<% url_encoded_course_id = quote(str(context_course.id).encode('utf-8'), safe='') %>
|
||||
${Text(_("To update these settings go to the {link_start}Proctored Exam Settings page{link_end}.")).format(
|
||||
link_start=HTML('<a href="${mfe_proctored_exam_settings_url}">').format(
|
||||
mfe_proctored_exam_settings_url=mfe_proctored_exam_settings_url
|
||||
@@ -263,7 +262,7 @@ from django.urls import reverse
|
||||
},
|
||||
"enable_quality": ${should_show_checklists_quality(context_course.id) | n, dump_js_escaped_json},
|
||||
"links": {
|
||||
"settings": ${reverse('settings_handler', kwargs={'course_key_string': six.text_type(course_key)})| n, dump_js_escaped_json}
|
||||
"settings": ${reverse('settings_handler', kwargs={'course_key_string': str(course_key)})| n, dump_js_escaped_json}
|
||||
}
|
||||
}
|
||||
</%static:studiofrontend>
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
<%!
|
||||
import six
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
%>
|
||||
@@ -41,7 +39,7 @@
|
||||
% else:
|
||||
<ul class="list-actions">
|
||||
<li class="item-action">
|
||||
<a class="action action-export-git action-primary" href="${reverse('export_git', kwargs=dict(course_key_string=six.text_type(context_course.id)))}?action=push">
|
||||
<a class="action action-export-git action-primary" href="${reverse('export_git', kwargs=dict(course_key_string=str(context_course.id)))}?action=push">
|
||||
<span class="icon fa fa-arrow-circle-o-down" aria-hidden="true"></span>
|
||||
<span class="copy">${_("Export to Git")}</span>
|
||||
</a>
|
||||
|
||||
@@ -11,7 +11,6 @@ from openedx.core.djangolib.js_utils import (
|
||||
dump_js_escaped_json, js_escaped_string
|
||||
)
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
import six
|
||||
from six.moves.urllib.parse import quote
|
||||
%>
|
||||
|
||||
@@ -115,7 +114,7 @@ from six.moves.urllib.parse import quote
|
||||
<div class="bit">
|
||||
% if context_course:
|
||||
<%
|
||||
url_encoded_course_id = quote(six.text_type(context_course.id).encode('utf-8'), safe='')
|
||||
url_encoded_course_id = quote(str(context_course.id).encode('utf-8'), safe='')
|
||||
details_url = utils.reverse_course_url('settings_handler', context_course.id)
|
||||
grading_url = utils.reverse_course_url('grading_handler', context_course.id)
|
||||
course_team_url = utils.reverse_course_url('course_team_handler', context_course.id)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
## xss-lint: disable=mako-missing-default
|
||||
<%inherit file="base.html" />
|
||||
<%!
|
||||
import six
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -125,7 +123,7 @@ from openedx.core.djangolib.js_utils import (
|
||||
"${context_course.display_name_with_default | h}",
|
||||
${users | n, dump_js_escaped_json},
|
||||
// xss-lint: disable=mako-invalid-js-filter
|
||||
"${reverse('course_team_handler', kwargs={'course_key_string': six.text_type(context_course.id), 'email': '@@EMAIL@@'}) | n, js_escaped_string}",
|
||||
"${reverse('course_team_handler', kwargs={'course_key_string': str(context_course.id), 'email': '@@EMAIL@@'}) | n, js_escaped_string}",
|
||||
${request.user.id | n, dump_js_escaped_json},
|
||||
${allow_actions | n, dump_js_escaped_json}
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
dump_js_escaped_json, js_escaped_string
|
||||
)
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
import six
|
||||
from six.moves.urllib.parse import quote
|
||||
from six.moves.urllib import parse as urllib
|
||||
%>
|
||||
@@ -711,7 +710,7 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url | n, js_escaped_string}'
|
||||
<div class="bit">
|
||||
% if context_course:
|
||||
<%
|
||||
url_encoded_course_id = quote(six.text_type(context_course.id).encode('utf-8'), safe='')
|
||||
url_encoded_course_id = quote(str(context_course.id).encode('utf-8'), safe='')
|
||||
course_team_url = utils.reverse_course_url('course_team_handler', context_course.id)
|
||||
grading_config_url = utils.reverse_course_url('grading_handler', context_course.id)
|
||||
advanced_config_url = utils.reverse_course_url('advanced_settings_handler', context_course.id)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<%def name="online_help_token()"><% return "advanced" %></%def>
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
import six
|
||||
from six.moves.urllib.parse import quote
|
||||
from django.utils.translation import gettext as _
|
||||
from cms.djangoapps.contentstore import utils
|
||||
@@ -43,7 +42,7 @@
|
||||
<h2 class="title title-3">${_("This course has proctored exam settings that are incomplete or invalid.")}</h2>
|
||||
<p>
|
||||
% if mfe_proctored_exam_settings_url:
|
||||
<% url_encoded_course_id = quote(six.text_type(context_course.id).encode('utf-8'), safe='') %>
|
||||
<% url_encoded_course_id = quote(str(context_course.id).encode('utf-8'), safe='') %>
|
||||
${Text(_("You will be unable to make changes until the errors are resolved. To update these settings go to the {link_start}Proctored Exam Settings page{link_end}.")).format(
|
||||
link_start=HTML('<a href="{mfe_proctored_exam_settings_url}">').format(
|
||||
mfe_proctored_exam_settings_url=mfe_proctored_exam_settings_url
|
||||
@@ -136,7 +135,7 @@
|
||||
<div class="bit">
|
||||
% if context_course:
|
||||
<%
|
||||
url_encoded_course_id = quote(six.text_type(context_course.id).encode('utf-8'), safe='')
|
||||
url_encoded_course_id = quote(str(context_course.id).encode('utf-8'), safe='')
|
||||
details_url = utils.reverse_course_url('settings_handler', context_course.id)
|
||||
grading_url = utils.reverse_course_url('grading_handler', context_course.id)
|
||||
course_team_url = utils.reverse_course_url('course_team_handler', context_course.id)
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
import six
|
||||
from six.moves.urllib.parse import quote
|
||||
import json
|
||||
from cms.djangoapps.contentstore import utils
|
||||
@@ -158,7 +157,7 @@
|
||||
<div class="bit">
|
||||
% if context_course:
|
||||
<%
|
||||
url_encoded_course_id = quote(six.text_type(context_course.id).encode('utf-8'), safe='')
|
||||
url_encoded_course_id = quote(str(context_course.id).encode('utf-8'), safe='')
|
||||
detailed_settings_url = utils.reverse_course_url('settings_handler', context_course.id)
|
||||
course_team_url = utils.reverse_course_url('course_team_handler', context_course.id)
|
||||
advanced_settings_url = utils.reverse_course_url('advanced_settings_handler', context_course.id)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<%page expression_filter="h" args="online_help_token"/>
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
import six
|
||||
from six.moves.urllib.parse import quote
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
@@ -39,23 +38,23 @@
|
||||
% if context_course:
|
||||
<%
|
||||
course_key = context_course.id
|
||||
url_encoded_course_key = quote(six.text_type(course_key).encode('utf-8'), safe='')
|
||||
index_url = reverse('course_handler', kwargs={'course_key_string': six.text_type(course_key)})
|
||||
course_team_url = reverse('course_team_handler', kwargs={'course_key_string': six.text_type(course_key)})
|
||||
assets_url = reverse('assets_handler', kwargs={'course_key_string': six.text_type(course_key)})
|
||||
textbooks_url = reverse('textbooks_list_handler', kwargs={'course_key_string': six.text_type(course_key)})
|
||||
videos_url = reverse('videos_handler', kwargs={'course_key_string': six.text_type(course_key)})
|
||||
import_url = reverse('import_handler', kwargs={'course_key_string': six.text_type(course_key)})
|
||||
course_info_url = reverse('course_info_handler', kwargs={'course_key_string': six.text_type(course_key)})
|
||||
export_url = reverse('export_handler', kwargs={'course_key_string': six.text_type(course_key)})
|
||||
settings_url = reverse('settings_handler', kwargs={'course_key_string': six.text_type(course_key)})
|
||||
grading_url = reverse('grading_handler', kwargs={'course_key_string': six.text_type(course_key)})
|
||||
advanced_settings_url = reverse('advanced_settings_handler', kwargs={'course_key_string': six.text_type(course_key)})
|
||||
tabs_url = reverse('tabs_handler', kwargs={'course_key_string': six.text_type(course_key)})
|
||||
url_encoded_course_key = quote(str(course_key).encode('utf-8'), safe='')
|
||||
index_url = reverse('course_handler', kwargs={'course_key_string': str(course_key)})
|
||||
course_team_url = reverse('course_team_handler', kwargs={'course_key_string': str(course_key)})
|
||||
assets_url = reverse('assets_handler', kwargs={'course_key_string': str(course_key)})
|
||||
textbooks_url = reverse('textbooks_list_handler', kwargs={'course_key_string': str(course_key)})
|
||||
videos_url = reverse('videos_handler', kwargs={'course_key_string': str(course_key)})
|
||||
import_url = reverse('import_handler', kwargs={'course_key_string': str(course_key)})
|
||||
course_info_url = reverse('course_info_handler', kwargs={'course_key_string': str(course_key)})
|
||||
export_url = reverse('export_handler', kwargs={'course_key_string': str(course_key)})
|
||||
settings_url = reverse('settings_handler', kwargs={'course_key_string': str(course_key)})
|
||||
grading_url = reverse('grading_handler', kwargs={'course_key_string': str(course_key)})
|
||||
advanced_settings_url = reverse('advanced_settings_handler', kwargs={'course_key_string': str(course_key)})
|
||||
tabs_url = reverse('tabs_handler', kwargs={'course_key_string': str(course_key)})
|
||||
certificates_url = ''
|
||||
if settings.FEATURES.get("CERTIFICATES_HTML_VIEW") and context_course.cert_html_view_enabled:
|
||||
certificates_url = reverse('certificates_list_handler', kwargs={'course_key_string': six.text_type(course_key)})
|
||||
checklists_url = reverse('checklists_handler', kwargs={'course_key_string': six.text_type(course_key)})
|
||||
certificates_url = reverse('certificates_list_handler', kwargs={'course_key_string': str(course_key)})
|
||||
checklists_url = reverse('checklists_handler', kwargs={'course_key_string': str(course_key)})
|
||||
pages_and_resources_mfe_enabled = ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND.is_enabled(context_course.id)
|
||||
studio_home_mfe_enabled = toggles.use_new_home_page()
|
||||
course_outline_mfe_enabled = toggles.use_new_course_outline_page(context_course.id)
|
||||
@@ -192,7 +191,7 @@
|
||||
</li>
|
||||
% endif
|
||||
<li class="nav-item nav-course-settings-group-configurations">
|
||||
<a href="${reverse('group_configurations_list_handler', kwargs={'course_key_string': six.text_type(course_key)})}">${_("Group Configurations")}</a>
|
||||
<a href="${reverse('group_configurations_list_handler', kwargs={'course_key_string': str(course_key)})}">${_("Group Configurations")}</a>
|
||||
</li>
|
||||
% if mfe_proctored_exam_settings_url:
|
||||
<li class="nav-item nav-course-settings-exams">
|
||||
@@ -251,7 +250,7 @@
|
||||
% endif
|
||||
% if toggles.EXPORT_GIT.is_enabled() and context_course.giturl:
|
||||
<li class="nav-item nav-course-tools-export-git">
|
||||
<a href="${reverse('export_git', kwargs=dict(course_key_string=six.text_type(course_key)))}">${_("Export to Git")}</a>
|
||||
<a href="${reverse('export_git', kwargs=dict(course_key_string=str(course_key)))}">${_("Export to Git")}</a>
|
||||
</li>
|
||||
% endif
|
||||
<li class="nav-item nav-course-tools-checklists">
|
||||
@@ -266,10 +265,10 @@
|
||||
% elif context_library:
|
||||
<%
|
||||
library_key = context_library.location.course_key
|
||||
index_url = reverse('library_handler', kwargs={'library_key_string': six.text_type(library_key)})
|
||||
import_url = reverse('import_handler', kwargs={'course_key_string': six.text_type(library_key)})
|
||||
lib_users_url = reverse('manage_library_users', kwargs={'library_key_string': six.text_type(library_key)})
|
||||
export_url = reverse('export_handler', kwargs={'course_key_string': six.text_type(library_key)})
|
||||
index_url = reverse('library_handler', kwargs={'library_key_string': str(library_key)})
|
||||
import_url = reverse('import_handler', kwargs={'course_key_string': str(library_key)})
|
||||
lib_users_url = reverse('manage_library_users', kwargs={'library_key_string': str(library_key)})
|
||||
export_url = reverse('export_handler', kwargs={'course_key_string': str(library_key)})
|
||||
%>
|
||||
<h2 class="info-course">
|
||||
<span class="sr">${_("Current Library:")}</span>
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
import hashlib
|
||||
import copy
|
||||
import json
|
||||
from six import text_type
|
||||
from xmodule.modulestore import EdxJSONEncoder
|
||||
hlskey = hashlib.md5(text_type(module.location).encode('utf-8')).hexdigest()
|
||||
hlskey = hashlib.md5(str(module.location).encode('utf-8')).hexdigest()
|
||||
%>
|
||||
|
||||
## js templates
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
<%
|
||||
import hashlib
|
||||
from openedx.core.djangolib.js_utils import js_escaped_string
|
||||
from six import text_type
|
||||
hlskey = hashlib.md5(text_type(module.location).encode('utf-8')).hexdigest()
|
||||
hlskey = hashlib.md5(str(module.location).encode('utf-8')).hexdigest()
|
||||
%>
|
||||
|
||||
<section id="hls-modal-${hlskey}" class="upload-modal modal" style="overflow:scroll; background:#ddd; padding: 10px 0;box-shadow: 0 0 5px 0 #555;" >
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"""
|
||||
Set up lookup paths for mako templates.
|
||||
"""
|
||||
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
import pkg_resources
|
||||
import six
|
||||
from django.conf import settings
|
||||
from mako.exceptions import TopLevelLookupException
|
||||
from mako.lookup import TemplateLookup
|
||||
@@ -54,7 +51,7 @@ class DynamicTemplateLookup(TemplateLookup):
|
||||
# and "foo.html.py" in the module directory has no way to know that.
|
||||
# Update the module_directory argument to point to a directory
|
||||
# specifically for this lookup path.
|
||||
unique = hashlib.md5(six.b(":".join(str(d) for d in self.directories))).hexdigest()
|
||||
unique = hashlib.md5((":".join(str(d) for d in self.directories)).encode()).hexdigest()
|
||||
self.template_args['module_directory'] = os.path.join(self.__original_module_directory, unique)
|
||||
|
||||
# Also clear the internal caches. Ick.
|
||||
|
||||
@@ -1392,4 +1392,4 @@ class RevokeSubscriptionsVerifiedAccessViewTest(ModuleStoreTestCase):
|
||||
assert response.status_code == 204
|
||||
mock_task.assert_called_once_with(args=([str(course_entitlement.uuid)],
|
||||
[str(enrollment.course_id)],
|
||||
self.user.id))
|
||||
self.user.username))
|
||||
|
||||
@@ -4,6 +4,7 @@ Views for the Entitlements v1 API.
|
||||
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponseBadRequest
|
||||
@@ -42,6 +43,7 @@ from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticati
|
||||
from openedx.core.djangoapps.credentials.utils import get_courses_completion_status
|
||||
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
|
||||
|
||||
User = get_user_model()
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -552,36 +554,38 @@ class SubscriptionsRevokeVerifiedAccessView(APIView):
|
||||
course completion status.
|
||||
"""
|
||||
entitled_course_ids = []
|
||||
user = User.objects.get(id=user_id)
|
||||
username = user.username
|
||||
for course_entitlement in course_entitlements:
|
||||
if course_entitlement.enrollment_course_run is not None:
|
||||
entitled_course_ids.append(str(course_entitlement.enrollment_course_run.course_id))
|
||||
|
||||
log.info('B2C_SUBSCRIPTIONS: Getting course completion status for user %s and entitled_course_ids %s',
|
||||
user_id,
|
||||
log.info('B2C_SUBSCRIPTIONS: Getting course completion status for user [%s] and entitled_course_ids %s',
|
||||
username,
|
||||
entitled_course_ids)
|
||||
awarded_cert_course_ids, is_exception = get_courses_completion_status(user_id, entitled_course_ids)
|
||||
awarded_cert_course_ids, is_exception = get_courses_completion_status(username, entitled_course_ids)
|
||||
|
||||
log.info('B2C_SUBSCRIPTIONS: Got course completion response %s for user %s and entitled_course_ids %s',
|
||||
log.info('B2C_SUBSCRIPTIONS: Got course completion response %s for user [%s] and entitled_course_ids %s',
|
||||
awarded_cert_course_ids,
|
||||
user_id,
|
||||
username,
|
||||
entitled_course_ids)
|
||||
|
||||
if is_exception:
|
||||
# Trigger the retry task asynchronously
|
||||
log.exception('B2C_SUBSCRIPTIONS: Exception occurred while getting course completion status for user %s '
|
||||
'and entitled_course_ids %s',
|
||||
user_id,
|
||||
username,
|
||||
entitled_course_ids)
|
||||
retry_revoke_subscriptions_verified_access.apply_async(args=(revocable_entitlement_uuids,
|
||||
entitled_course_ids,
|
||||
user_id))
|
||||
username))
|
||||
return
|
||||
log.info('B2C_SUBSCRIPTIONS: Starting revoke_entitlements_and_downgrade_courses_to_audit for user %s and '
|
||||
log.info('B2C_SUBSCRIPTIONS: Starting revoke_entitlements_and_downgrade_courses_to_audit for user [%s] and '
|
||||
'awarded_cert_course_ids %s and revocable_entitlement_uuids %s',
|
||||
user_id,
|
||||
username,
|
||||
awarded_cert_course_ids,
|
||||
revocable_entitlement_uuids)
|
||||
revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, user_id, awarded_cert_course_ids,
|
||||
revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, username, awarded_cert_course_ids,
|
||||
revocable_entitlement_uuids)
|
||||
|
||||
def post(self, request):
|
||||
|
||||
@@ -157,7 +157,7 @@ def expire_and_create_entitlements(self, entitlement_ids, support_username):
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def retry_revoke_subscriptions_verified_access(self, revocable_entitlement_uuids, entitled_course_ids, user_id):
|
||||
def retry_revoke_subscriptions_verified_access(self, revocable_entitlement_uuids, entitled_course_ids, username):
|
||||
"""
|
||||
Task to process course access revoke and move to audit.
|
||||
This is called only if call to get_courses_completion_status fails due to any exception.
|
||||
@@ -165,7 +165,7 @@ def retry_revoke_subscriptions_verified_access(self, revocable_entitlement_uuids
|
||||
course_entitlements = CourseEntitlement.objects.filter(uuid__in=revocable_entitlement_uuids)
|
||||
course_entitlements = course_entitlements.select_related('user').select_related('enrollment_course_run')
|
||||
if course_entitlements.exists():
|
||||
awarded_cert_course_ids, is_exception = get_courses_completion_status(user_id, entitled_course_ids)
|
||||
awarded_cert_course_ids, is_exception = get_courses_completion_status(username, entitled_course_ids)
|
||||
if is_exception:
|
||||
try:
|
||||
countdown = 2 ** self.request.retries
|
||||
@@ -173,20 +173,20 @@ def retry_revoke_subscriptions_verified_access(self, revocable_entitlement_uuids
|
||||
except MaxRetriesExceededError:
|
||||
log.exception(
|
||||
'B2C_SUBSCRIPTIONS: Failed to process retry_revoke_subscriptions_verified_access '
|
||||
'for user_id %s and entitlement_uuids %s',
|
||||
user_id,
|
||||
'for user [%s] and entitlement_uuids %s',
|
||||
username,
|
||||
revocable_entitlement_uuids
|
||||
)
|
||||
return
|
||||
log.info('B2C_SUBSCRIPTIONS: Starting revoke_entitlements_and_downgrade_courses_to_audit for user %s and '
|
||||
log.info('B2C_SUBSCRIPTIONS: Starting revoke_entitlements_and_downgrade_courses_to_audit for user [%s] and '
|
||||
'awarded_cert_course_ids %s and revocable_entitlement_uuids %s from retry task',
|
||||
user_id,
|
||||
username,
|
||||
awarded_cert_course_ids,
|
||||
revocable_entitlement_uuids)
|
||||
revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, user_id, awarded_cert_course_ids,
|
||||
revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, username, awarded_cert_course_ids,
|
||||
revocable_entitlement_uuids)
|
||||
else:
|
||||
log.info('B2C_SUBSCRIPTIONS: Entitlements not found for the provided entitlements uuids %s '
|
||||
'for user_id %s duing the retry_revoke_subscriptions_verified_access task',
|
||||
'for user [%s] duing the retry_revoke_subscriptions_verified_access task',
|
||||
revocable_entitlement_uuids,
|
||||
user_id)
|
||||
username)
|
||||
|
||||
@@ -61,7 +61,7 @@ def is_course_run_entitlement_fulfillable(
|
||||
return course_overview.start and can_upgrade and (is_enrolled or can_enroll)
|
||||
|
||||
|
||||
def revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, user_id, awarded_cert_course_ids,
|
||||
def revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, username, awarded_cert_course_ids,
|
||||
revocable_entitlement_uuids):
|
||||
"""
|
||||
This method expires the entitlements for provided course_entitlements and also moves the enrollments
|
||||
@@ -69,8 +69,8 @@ def revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, user
|
||||
"""
|
||||
|
||||
log.info('B2C_SUBSCRIPTIONS: Starting revoke_entitlements_and_downgrade_courses_to_audit for '
|
||||
'user: %s, course_entitlements_uuids: %s, awarded_cert_course_ids: %s',
|
||||
user_id,
|
||||
'user: [%s], course_entitlements_uuids: %s, awarded_cert_course_ids: %s',
|
||||
username,
|
||||
revocable_entitlement_uuids,
|
||||
awarded_cert_course_ids)
|
||||
for course_entitlement in course_entitlements:
|
||||
@@ -87,11 +87,11 @@ def revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, user
|
||||
course_entitlement.expire_entitlement()
|
||||
update_enrollment(username, str(course_id), CourseMode.AUDIT, include_expired=True)
|
||||
else:
|
||||
log.warning('B2C_SUBSCRIPTIONS: Enrollment mode mismatch for user_id: %s and course_id: %s',
|
||||
user_id,
|
||||
log.warning('B2C_SUBSCRIPTIONS: Enrollment mode mismatch for user: %s and course_id: %s',
|
||||
username,
|
||||
course_id)
|
||||
log.info('B2C_SUBSCRIPTIONS: Completed revoke_entitlements_and_downgrade_courses_to_audit for '
|
||||
'user: %s, course_entitlements_uuids: %s, awarded_cert_course_ids: %s',
|
||||
user_id,
|
||||
'user: [%s], course_entitlements_uuids: %s, awarded_cert_course_ids: %s',
|
||||
username,
|
||||
revocable_entitlement_uuids,
|
||||
awarded_cert_course_ids)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
import pytest
|
||||
import six
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
@@ -50,7 +49,7 @@ class BulkChangeEnrollmentCSVTests(SharedModuleStoreTestCase):
|
||||
"""Write a test csv file with the lines provided"""
|
||||
csv.write(b"course_id,user,mode,\n")
|
||||
for line in lines:
|
||||
csv.write(six.b(line))
|
||||
csv.write(line.encode())
|
||||
csv.seek(0)
|
||||
return csv
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
import six
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.management import call_command
|
||||
from testfixtures import LogCapture
|
||||
@@ -48,7 +47,7 @@ class BulkUnenrollTests(SharedModuleStoreTestCase):
|
||||
"""Write a test csv file with the lines provided"""
|
||||
csv.write(b"username,course_id\n")
|
||||
for line in lines:
|
||||
csv.write(six.b(line))
|
||||
csv.write(line.encode())
|
||||
csv.seek(0)
|
||||
return csv
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ Test cases for recover account management command
|
||||
import re
|
||||
from tempfile import NamedTemporaryFile
|
||||
import pytest
|
||||
import six
|
||||
|
||||
from django.core import mail
|
||||
from django.conf import settings
|
||||
@@ -40,7 +39,7 @@ class RecoverAccountTests(TestCase):
|
||||
"""Write a test csv file with the lines provided"""
|
||||
csv.write(b"username,current_email,desired_email\n")
|
||||
for line in lines:
|
||||
csv.write(six.b(line))
|
||||
csv.write(line.encode())
|
||||
csv.seek(0)
|
||||
return csv
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from tempfile import NamedTemporaryFile
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import six
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.test import TestCase
|
||||
@@ -34,7 +33,7 @@ class UnsubscribeUserEmailTests(TestCase):
|
||||
|
||||
csv.write(b"email\n")
|
||||
for line in lines:
|
||||
csv.write(six.b(line))
|
||||
csv.write(line.encode())
|
||||
csv.seek(0)
|
||||
return csv
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ Tests of student.roles
|
||||
|
||||
|
||||
import ddt
|
||||
import six
|
||||
from django.test import TestCase
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
@@ -70,7 +69,7 @@ class RolesTestCase(TestCase):
|
||||
f'Student has premature access to {self.course_key}'
|
||||
CourseStaffRole(self.course_key).add_users(self.student)
|
||||
assert CourseStaffRole(self.course_key).has_user(self.student), \
|
||||
f"Student doesn't have access to {six.text_type(self.course_key)}"
|
||||
f"Student doesn't have access to {str(self.course_key)}"
|
||||
|
||||
# remove access and confirm
|
||||
CourseStaffRole(self.course_key).remove_users(self.student)
|
||||
@@ -85,7 +84,7 @@ class RolesTestCase(TestCase):
|
||||
f'Student has premature access to {self.course_key.org}'
|
||||
OrgStaffRole(self.course_key.org).add_users(self.student)
|
||||
assert OrgStaffRole(self.course_key.org).has_user(self.student), \
|
||||
f"Student doesn't have access to {six.text_type(self.course_key.org)}"
|
||||
f"Student doesn't have access to {str(self.course_key.org)}"
|
||||
|
||||
# remove access and confirm
|
||||
OrgStaffRole(self.course_key.org).remove_users(self.student)
|
||||
@@ -101,16 +100,16 @@ class RolesTestCase(TestCase):
|
||||
OrgInstructorRole(self.course_key.org).add_users(self.student)
|
||||
CourseInstructorRole(self.course_key).add_users(self.student)
|
||||
assert OrgInstructorRole(self.course_key.org).has_user(self.student), \
|
||||
f"Student doesn't have access to {six.text_type(self.course_key.org)}"
|
||||
f"Student doesn't have access to {str(self.course_key.org)}"
|
||||
assert CourseInstructorRole(self.course_key).has_user(self.student), \
|
||||
f"Student doesn't have access to {six.text_type(self.course_key)}"
|
||||
f"Student doesn't have access to {str(self.course_key)}"
|
||||
|
||||
# remove access and confirm
|
||||
OrgInstructorRole(self.course_key.org).remove_users(self.student)
|
||||
assert not OrgInstructorRole(self.course_key.org).has_user(self.student), \
|
||||
f'Student still has access to {self.course_key.org}'
|
||||
assert CourseInstructorRole(self.course_key).has_user(self.student), \
|
||||
f"Student doesn't have access to {six.text_type(self.course_key)}"
|
||||
f"Student doesn't have access to {str(self.course_key)}"
|
||||
|
||||
# ok now keep org role and get rid of course one
|
||||
OrgInstructorRole(self.course_key.org).add_users(self.student)
|
||||
@@ -118,7 +117,7 @@ class RolesTestCase(TestCase):
|
||||
assert OrgInstructorRole(self.course_key.org).has_user(self.student), \
|
||||
f'Student lost has access to {self.course_key.org}'
|
||||
assert not CourseInstructorRole(self.course_key).has_user(self.student), \
|
||||
f"Student doesn't have access to {six.text_type(self.course_key)}"
|
||||
f"Student doesn't have access to {str(self.course_key)}"
|
||||
|
||||
def test_get_user_for_role(self):
|
||||
"""
|
||||
|
||||
@@ -202,7 +202,7 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
|
||||
if content is not None:
|
||||
if not six.PY2 and isinstance(content, str):
|
||||
if isinstance(content, str):
|
||||
content = content.encode('utf-8')
|
||||
self.wfile.write(content)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import logging
|
||||
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey, LearningContextKey
|
||||
from six import text_type # lint-amnesty, pylint: disable=unused-import
|
||||
|
||||
from openedx.core.lib.request_utils import COURSE_REGEX
|
||||
|
||||
|
||||
@@ -114,6 +114,13 @@
|
||||
t = -1;
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// this is added to compensate for custom css that accidentally hide mathjax
|
||||
$('.MathJax_SVG>svg').toArray().forEach(el => {
|
||||
if ($(el).width() === 0) {
|
||||
$(el).css('max-width', 'inherit');
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -278,11 +278,14 @@ autodoc_mock_imports = [
|
||||
# run sphinx-apidoc against and the directories under "docs" in which to store
|
||||
# the generated *.rst files
|
||||
modules = {
|
||||
'cms': 'references/docstrings/cms',
|
||||
'lms': 'references/docstrings/lms',
|
||||
'openedx': 'references/docstrings/openedx',
|
||||
'common': 'references/docstrings/common',
|
||||
'xmodule': 'references/docstrings/xmodule',
|
||||
# Commenting this out for now because they blow up the build
|
||||
# time and memory limits for RTD. We can come back to these
|
||||
# later once we get parallel builds working hopefully.
|
||||
# 'cms': 'references/docstrings/cms',
|
||||
# 'common': 'references/docstrings/common',
|
||||
# 'xmodule': 'references/docstrings/xmodule',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,13 +9,22 @@ import git
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = "edx-platform Technical Reference"
|
||||
copyright = f'{datetime.now().year}, Axim Collaborative, Inc' # pylint: disable=redefined-builtin
|
||||
author = 'Axim Collaborative, Inc'
|
||||
copyright = f"{datetime.now().year}, Axim Collaborative, Inc" # pylint: disable=redefined-builtin
|
||||
author = "Axim Collaborative, Inc"
|
||||
release = ""
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
extensions = ["code_annotations.contrib.sphinx.extensions.featuretoggles", "code_annotations.contrib.sphinx.extensions.settings"]
|
||||
extensions = [
|
||||
"code_annotations.contrib.sphinx.extensions.featuretoggles",
|
||||
"code_annotations.contrib.sphinx.extensions.settings",
|
||||
"sphinx_reredirects",
|
||||
]
|
||||
|
||||
redirects = {
|
||||
"*": "https://docs.openedx.org/projects/edx-platform/en/latest/$source.html",
|
||||
}
|
||||
|
||||
templates_path = ["_templates"]
|
||||
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
||||
|
||||
@@ -38,7 +47,7 @@ settings_repo_version = edx_platform_version
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
|
||||
html_theme = 'sphinx_book_theme'
|
||||
html_theme = "sphinx_book_theme"
|
||||
html_static_path = ["_static"]
|
||||
html_favicon = "https://logos.openedx.org/open-edx-favicon.ico"
|
||||
html_logo = "https://logos.openedx.org/open-edx-logo-color.png"
|
||||
@@ -72,5 +81,5 @@ html_theme_options = {
|
||||
rel="license"
|
||||
href="https://creativecommons.org/licenses/by-sa/4.0/"
|
||||
>Creative Commons Attribution-ShareAlike 4.0 International License</a>.
|
||||
"""
|
||||
""",
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""
|
||||
Views related to the Custom Courses feature.
|
||||
"""
|
||||
|
||||
|
||||
import csv
|
||||
import datetime
|
||||
import functools
|
||||
@@ -11,7 +9,6 @@ import logging
|
||||
from copy import deepcopy
|
||||
|
||||
import pytz
|
||||
import six
|
||||
from ccx_keys.locator import CCXLocator
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
@@ -538,8 +535,7 @@ def ccx_grades_csv(request, course, ccx=None):
|
||||
if not header:
|
||||
# Encode the header row in utf-8 encoding in case there are
|
||||
# unicode characters
|
||||
header = [section['label'].encode('utf-8') if six.PY2 else section['label']
|
||||
for section in course_grade.summary['section_breakdown']]
|
||||
header = [section['label'] for section in course_grade.summary['section_breakdown']]
|
||||
rows.append(["id", "email", "username", "grade"] + header)
|
||||
|
||||
percents = {
|
||||
|
||||
@@ -7,7 +7,6 @@ from base64 import urlsafe_b64decode, urlsafe_b64encode
|
||||
from binascii import Error
|
||||
from hashlib import sha256
|
||||
|
||||
import six
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher
|
||||
from cryptography.hazmat.primitives.ciphers.algorithms import AES
|
||||
@@ -53,7 +52,7 @@ class UsernameCipher:
|
||||
@staticmethod
|
||||
def _get_aes_cipher(initialization_vector):
|
||||
hash_ = sha256()
|
||||
hash_.update(six.b(settings.SECRET_KEY))
|
||||
hash_.update(settings.SECRET_KEY.encode())
|
||||
return Cipher(AES(hash_.digest()), CBC(initialization_vector), backend=default_backend())
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -9,7 +9,6 @@ from datetime import datetime
|
||||
import pytz
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from six import text_type
|
||||
|
||||
from common.djangoapps.track.event_transaction_utils import create_new_event_transaction_id, set_event_transaction_type
|
||||
# Public Grades Modules
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
from abc import ABC, abstractmethod
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.http import Http404
|
||||
from django.template.loader import render_to_string
|
||||
@@ -65,18 +66,22 @@ class ProgramsFragmentView(EdxFragmentView):
|
||||
if is_user_b2c_subscriptions_enabled
|
||||
else []
|
||||
)
|
||||
subscriptions_marketing_url = (
|
||||
get_program_subscriptions_marketing_url()
|
||||
subscription_upsell_data = (
|
||||
{
|
||||
'marketing_url': get_program_subscriptions_marketing_url(),
|
||||
'minimum_price': settings.SUBSCRIPTIONS_MINIMUM_PRICE,
|
||||
'trial_length': settings.SUBSCRIPTIONS_TRIAL_LENGTH,
|
||||
}
|
||||
if is_user_b2c_subscriptions_enabled
|
||||
else ''
|
||||
else {}
|
||||
)
|
||||
|
||||
context = {
|
||||
'marketing_url': get_program_marketing_url(programs_config, mobile_only),
|
||||
'subscriptions_marketing_url': subscriptions_marketing_url,
|
||||
'programs': meter.engaged_programs,
|
||||
'progress': meter.progress(),
|
||||
'programs_subscription_data': programs_subscription_data,
|
||||
'subscription_upsell_data': subscription_upsell_data,
|
||||
'user_preferences': get_user_preferences(user),
|
||||
'is_user_b2c_subscriptions_enabled': is_user_b2c_subscriptions_enabled,
|
||||
'mobile_only': bool(mobile_only)
|
||||
@@ -152,12 +157,13 @@ class ProgramDetailsFragmentView(EdxFragmentView):
|
||||
'user_preferences': get_user_preferences(user),
|
||||
'program_data': program_data,
|
||||
'program_subscription_data': program_subscription_data,
|
||||
'is_user_b2c_subscriptions_enabled': is_user_b2c_subscriptions_enabled,
|
||||
'course_data': course_data,
|
||||
'certificate_data': certificate_data,
|
||||
'industry_pathways': industry_pathways,
|
||||
'credit_pathways': credit_pathways,
|
||||
'program_tab_view_enabled': program_tab_view_enabled(),
|
||||
'is_user_b2c_subscriptions_enabled': is_user_b2c_subscriptions_enabled,
|
||||
'subscriptions_trial_length': settings.SUBSCRIPTIONS_TRIAL_LENGTH,
|
||||
'discussion_fragment': {
|
||||
'configured': program_discussion_lti.is_configured,
|
||||
'iframe': program_discussion_lti.render_iframe()
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
""" Django admin pages for save_for_later app """
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from lms.djangoapps.save_for_later.models import SavedCourse, SavedProgram
|
||||
|
||||
|
||||
class SavedCourseAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin for the Saved Course table.
|
||||
"""
|
||||
|
||||
list_display = ['email', 'course_id', 'email_sent_count', 'reminder_email_sent']
|
||||
|
||||
search_fields = ['email', 'course_id']
|
||||
|
||||
|
||||
class SavedProgramAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin for the Saved Program table.
|
||||
"""
|
||||
|
||||
list_display = ['email', 'program_uuid', 'email_sent_count', 'reminder_email_sent']
|
||||
|
||||
search_fields = ['email', 'program_uuid']
|
||||
|
||||
|
||||
admin.site.register(SavedCourse, SavedCourseAdmin)
|
||||
admin.site.register(SavedProgram, SavedProgramAdmin)
|
||||
@@ -1,13 +0,0 @@
|
||||
"""
|
||||
URL definitions for the save_for_later API.
|
||||
"""
|
||||
|
||||
|
||||
from django.conf.urls import include
|
||||
from django.urls import path
|
||||
|
||||
app_name = 'lms.djangoapps.save_for_later'
|
||||
|
||||
urlpatterns = [
|
||||
path('v1/', include(('lms.djangoapps.save_for_later.api.v1.urls', 'v1'), namespace='v1')),
|
||||
]
|
||||
@@ -1,180 +0,0 @@
|
||||
"""
|
||||
Save for later tests
|
||||
"""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.urls import reverse
|
||||
from django.test.utils import override_settings
|
||||
from rest_framework.test import APITestCase
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
from common.djangoapps.third_party_auth.tests.testutil import ThirdPartyAuthTestMixin
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
||||
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@ddt.ddt
|
||||
class CourseSaveForLaterApiViewTest(ThirdPartyAuthTestMixin, APITestCase):
|
||||
"""
|
||||
Tests for CourseSaveForLaterApiView
|
||||
"""
|
||||
|
||||
def setUp(self): # pylint: disable=arguments-differ
|
||||
"""
|
||||
Test Setup
|
||||
"""
|
||||
super().setUp()
|
||||
|
||||
self.api_url = reverse('api:v1:save_course')
|
||||
self.email = 'test@edx.org'
|
||||
self.invalid_email = 'test@edx'
|
||||
self.course_id = 'course-v1:TestX+ProEnroll+P'
|
||||
self.org_img_url = '/path/logo.png'
|
||||
self.course_key = CourseKey.from_string(self.course_id)
|
||||
CourseOverviewFactory.create(id=self.course_key)
|
||||
|
||||
@override_settings(
|
||||
EDX_BRAZE_API_KEY='test-key',
|
||||
EDX_BRAZE_API_SERVER='http://test.url'
|
||||
)
|
||||
@patch('lms.djangoapps.utils.BrazeClient', MagicMock())
|
||||
def test_save_course_using_email(self):
|
||||
"""
|
||||
Test successfully email sent
|
||||
"""
|
||||
request_payload = {
|
||||
'email': self.email,
|
||||
'course_id': self.course_id,
|
||||
'marketing_url': 'http://google.com',
|
||||
'org_img_url': self.org_img_url,
|
||||
}
|
||||
response = self.client.post(self.api_url, data=request_payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
'LOCATION': 'registration_proxy',
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_save_course_api_rate_limiting(self):
|
||||
"""
|
||||
Test api rate limit
|
||||
"""
|
||||
request_payload = {
|
||||
'email': self.email,
|
||||
'course_id': self.course_id,
|
||||
'marketing_url': 'http://google.com',
|
||||
'org_img_url': self.org_img_url,
|
||||
}
|
||||
for _ in range(int(settings.SAVE_FOR_LATER_EMAIL_RATE_LIMIT.split('/')[0])):
|
||||
response = self.client.post(self.api_url, data=request_payload)
|
||||
assert response.status_code != 403
|
||||
|
||||
response = self.client.post(self.api_url, data=request_payload)
|
||||
assert response.status_code == 403
|
||||
cache.clear()
|
||||
|
||||
for _ in range(int(settings.SAVE_FOR_LATER_IP_RATE_LIMIT.split('/')[0])):
|
||||
request_payload['email'] = 'test${_}@edx.org'.format(_=_)
|
||||
response = self.client.post(self.api_url, data=request_payload)
|
||||
assert response.status_code != 403
|
||||
|
||||
response = self.client.post(self.api_url, data=request_payload)
|
||||
assert response.status_code == 403
|
||||
cache.clear()
|
||||
|
||||
def test_invalid_email_address(self):
|
||||
"""
|
||||
Test email validation
|
||||
"""
|
||||
request_payload = {'email': self.invalid_email, 'course_id': self.course_id}
|
||||
response = self.client.post(self.api_url, data=request_payload)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@ddt.ddt
|
||||
class ProgramSaveForLaterApiViewTest(ThirdPartyAuthTestMixin, APITestCase):
|
||||
"""
|
||||
Tests for ProgramSaveForLaterApiView
|
||||
"""
|
||||
|
||||
def setUp(self): # pylint: disable=arguments-differ
|
||||
"""
|
||||
Test Setup
|
||||
"""
|
||||
super().setUp()
|
||||
|
||||
self.api_url = reverse('api:v1:save_program')
|
||||
self.email = 'test@edx.org'
|
||||
self.invalid_email = 'test@edx'
|
||||
self.uuid = '587f6abe-bfa4-4125-9fbe-4789bf3f97f1'
|
||||
self.program = ProgramFactory(uuid=self.uuid)
|
||||
|
||||
@override_settings(
|
||||
EDX_BRAZE_API_KEY='test-key',
|
||||
EDX_BRAZE_API_SERVER='http://test.url'
|
||||
)
|
||||
@patch('lms.djangoapps.utils.BrazeClient', MagicMock())
|
||||
@patch('lms.djangoapps.save_for_later.api.v1.views.get_programs')
|
||||
def test_save_program_using_email(self, mock_get_programs):
|
||||
"""
|
||||
Test successfully email sent
|
||||
"""
|
||||
mock_get_programs.return_value = self.program
|
||||
request_payload = {
|
||||
'email': self.email,
|
||||
'program_uuid': self.uuid,
|
||||
}
|
||||
response = self.client.post(self.api_url, data=request_payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
'LOCATION': 'registration_proxy',
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_save_program_api_rate_limiting(self):
|
||||
"""
|
||||
Test api rate limit
|
||||
"""
|
||||
request_payload = {
|
||||
'email': self.email,
|
||||
'program_uuid': self.uuid,
|
||||
}
|
||||
for _ in range(int(settings.SAVE_FOR_LATER_EMAIL_RATE_LIMIT.split('/')[0])):
|
||||
response = self.client.post(self.api_url, data=request_payload)
|
||||
assert response.status_code != 403
|
||||
|
||||
response = self.client.post(self.api_url, data=request_payload)
|
||||
assert response.status_code == 403
|
||||
cache.clear()
|
||||
|
||||
for _ in range(int(settings.SAVE_FOR_LATER_IP_RATE_LIMIT.split('/')[0])):
|
||||
request_payload['email'] = 'test${_}@edx.org'.format(_=_)
|
||||
response = self.client.post(self.api_url, data=request_payload)
|
||||
assert response.status_code != 403
|
||||
|
||||
response = self.client.post(self.api_url, data=request_payload)
|
||||
assert response.status_code == 403
|
||||
cache.clear()
|
||||
|
||||
def test_invalid_email_address(self):
|
||||
"""
|
||||
Test email validation
|
||||
"""
|
||||
request_payload = {'email': self.invalid_email, 'program_uuid': self.uuid}
|
||||
response = self.client.post(self.api_url, data=request_payload)
|
||||
assert response.status_code == 400
|
||||
@@ -1,12 +0,0 @@
|
||||
"""
|
||||
URLs for save_for_later v1
|
||||
"""
|
||||
|
||||
from django.urls import re_path
|
||||
|
||||
from lms.djangoapps.save_for_later.api.v1.views import CourseSaveForLaterApiView, ProgramSaveForLaterApiView
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^save/program/$', ProgramSaveForLaterApiView.as_view(), name='save_program'),
|
||||
re_path(r'^save/course/$', CourseSaveForLaterApiView.as_view(), name='save_course'),
|
||||
]
|
||||
@@ -1,195 +0,0 @@
|
||||
"""
|
||||
Save for later views
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.decorators import method_decorator
|
||||
from django_ratelimit.decorators import ratelimit
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from django.db import transaction
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys import InvalidKeyError
|
||||
|
||||
from openedx.core.djangoapps.user_api.accounts.api import get_email_validation_error
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.catalog.utils import get_programs
|
||||
|
||||
from lms.djangoapps.save_for_later.helper import send_email
|
||||
from lms.djangoapps.save_for_later.models import SavedCourse, SavedProgram
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
POST_EMAIL_KEY = 'openedx.core.djangoapps.util.ratelimit.request_data_email'
|
||||
REAL_IP_KEY = 'openedx.core.djangoapps.util.ratelimit.real_ip'
|
||||
USER_SEND_SAVE_FOR_LATER_EMAIL = 'user.send.save.for.later.email'
|
||||
|
||||
|
||||
class CourseSaveForLaterApiView(APIView):
|
||||
"""
|
||||
Save course API VIEW
|
||||
"""
|
||||
|
||||
@transaction.atomic
|
||||
@method_decorator(ratelimit(key=POST_EMAIL_KEY,
|
||||
rate=settings.SAVE_FOR_LATER_EMAIL_RATE_LIMIT,
|
||||
method='POST', block=False))
|
||||
@method_decorator(ratelimit(key=REAL_IP_KEY,
|
||||
rate=settings.SAVE_FOR_LATER_IP_RATE_LIMIT,
|
||||
method='POST', block=False))
|
||||
def post(self, request):
|
||||
"""
|
||||
**Use Case**
|
||||
|
||||
* Send favorite course through email to user for later learning.
|
||||
|
||||
**Example Request for course**
|
||||
|
||||
POST /api/v1/save/course/
|
||||
|
||||
**Example POST Request for course**
|
||||
|
||||
{
|
||||
"email": "test@edx.org",
|
||||
"course_id": "course-v1:edX+DemoX+2021",
|
||||
"marketing_url": "https://test.com",
|
||||
"org_img_url": "https://test.com/logo.png",
|
||||
"weeks_to_complete": 7,
|
||||
"min_effort": 4,
|
||||
"max_effort": 5,
|
||||
|
||||
}
|
||||
"""
|
||||
user = request.user
|
||||
data = request.data
|
||||
course_id = data.get('course_id')
|
||||
email = data.get('email')
|
||||
org_img_url = data.get('org_img_url')
|
||||
marketing_url = data.get('marketing_url')
|
||||
weeks_to_complete = data.get('weeks_to_complete', 0)
|
||||
min_effort = data.get('min_effort', 0)
|
||||
max_effort = data.get('max_effort', 0)
|
||||
user_id = request.user.id
|
||||
pref_lang = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME, 'en')
|
||||
send_to_self = bool(not request.user.is_anonymous and request.user.email == email)
|
||||
|
||||
if getattr(request, 'limited', False):
|
||||
return Response({'error_code': 'rate-limited'}, status=403)
|
||||
|
||||
if get_email_validation_error(email):
|
||||
return Response({'error_code': 'incorrect-email'}, status=400)
|
||||
|
||||
try:
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
course = CourseOverview.get_from_id(course_key)
|
||||
except InvalidKeyError:
|
||||
return Response({'error_code': 'invalid-course-key'}, status=400)
|
||||
except CourseOverview.DoesNotExist:
|
||||
return Response({'error_code': 'course-not-found'}, status=404)
|
||||
|
||||
SavedCourse.objects.update_or_create(
|
||||
email=email,
|
||||
course_id=course_id,
|
||||
defaults={
|
||||
'user_id': user.id,
|
||||
'org_img_url': org_img_url,
|
||||
'marketing_url': marketing_url,
|
||||
'weeks_to_complete': weeks_to_complete,
|
||||
'min_effort': min_effort,
|
||||
'max_effort': max_effort,
|
||||
'reminder_email_sent': False,
|
||||
}
|
||||
)
|
||||
course_data = {
|
||||
'course': course,
|
||||
'send_to_self': send_to_self,
|
||||
'user_id': user_id,
|
||||
'pref-lang': pref_lang,
|
||||
'org_img_url': org_img_url,
|
||||
'marketing_url': marketing_url,
|
||||
'weeks_to_complete': weeks_to_complete,
|
||||
'min_effort': min_effort,
|
||||
'max_effort': max_effort,
|
||||
'type': 'course',
|
||||
'reminder': False,
|
||||
'braze_event': USER_SEND_SAVE_FOR_LATER_EMAIL,
|
||||
}
|
||||
if send_email(email, course_data):
|
||||
return Response({'result': 'success'}, status=200)
|
||||
else:
|
||||
return Response({'error_code': 'email-not-send'}, status=400)
|
||||
|
||||
|
||||
class ProgramSaveForLaterApiView(APIView):
|
||||
"""
|
||||
API VIEW
|
||||
"""
|
||||
|
||||
@transaction.atomic
|
||||
@method_decorator(ratelimit(key=POST_EMAIL_KEY,
|
||||
rate=settings.SAVE_FOR_LATER_EMAIL_RATE_LIMIT,
|
||||
method='POST', block=False))
|
||||
@method_decorator(ratelimit(key=REAL_IP_KEY,
|
||||
rate=settings.SAVE_FOR_LATER_IP_RATE_LIMIT,
|
||||
method='POST', block=False))
|
||||
def post(self, request):
|
||||
"""
|
||||
**Use Case**
|
||||
|
||||
* Send favorite program through email to user for later learning.
|
||||
|
||||
**Example Request for program**
|
||||
|
||||
POST /api/v1/save/program/
|
||||
|
||||
**Example POST Request for program**
|
||||
|
||||
{
|
||||
"email": "test@edx.org",
|
||||
"program_uuid": "587f6abe-bfa4-4125-9fbe-4789bf3f97f1"
|
||||
}
|
||||
"""
|
||||
user = request.user
|
||||
data = request.data
|
||||
program_uuid = data.get('program_uuid')
|
||||
email = data.get('email')
|
||||
user_id = request.user.id
|
||||
pref_lang = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME, 'en')
|
||||
send_to_self = bool(not request.user.is_anonymous and request.user.email == email)
|
||||
|
||||
if getattr(request, 'limited', False):
|
||||
return Response({'error_code': 'rate-limited'}, status=403)
|
||||
|
||||
if get_email_validation_error(email):
|
||||
return Response({'error_code': 'incorrect-email'}, status=400)
|
||||
|
||||
if not program_uuid:
|
||||
return Response({'error_code': 'program-uuid-missing'}, status=400)
|
||||
|
||||
program = get_programs(uuid=program_uuid)
|
||||
SavedProgram.objects.update_or_create(
|
||||
email=email,
|
||||
program_uuid=program_uuid,
|
||||
defaults={
|
||||
'user_id': user.id,
|
||||
'reminder_email_sent': False,
|
||||
}
|
||||
)
|
||||
if program:
|
||||
program_data = {
|
||||
'program': program,
|
||||
'send_to_self': send_to_self,
|
||||
'user_id': user_id,
|
||||
'pref-lang': pref_lang,
|
||||
'type': 'program',
|
||||
'reminder': False,
|
||||
'braze_event': USER_SEND_SAVE_FOR_LATER_EMAIL,
|
||||
}
|
||||
if send_email(email, program_data):
|
||||
return Response({'result': 'success'}, status=200)
|
||||
else:
|
||||
return Response({'error_code': 'email-not-send'}, status=400)
|
||||
|
||||
return Response({'error_code': 'program-not-found'}, status=404)
|
||||
@@ -1,148 +0,0 @@
|
||||
"""
|
||||
helper functions
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from django.conf import settings
|
||||
from eventtracking import tracker
|
||||
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from lms.djangoapps.utils import get_braze_client
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
USER_SAVE_FOR_LATER_EMAIL_SENT = 'edx.bi.user.saveforlater.email.sent'
|
||||
USER_SAVE_FOR_LATER_REMINDER_EMAIL_SENT = 'edx.bi.user.saveforlater.reminder.email.sent'
|
||||
|
||||
|
||||
def _get_program_pacing(course_runs):
|
||||
"""
|
||||
get pacing type of published course run of course for program
|
||||
"""
|
||||
|
||||
pacing = [course_run.get('pacing_type') if course_run.get('status') == 'published'
|
||||
else '' for course_run in course_runs][0]
|
||||
return 'Self-paced' if pacing == 'self_paced' else 'Instructor-led'
|
||||
|
||||
|
||||
def _get_course_price(course):
|
||||
"""
|
||||
Get price of a course
|
||||
"""
|
||||
return CourseMode.min_course_price_for_currency(course_id=str(course.id), currency='USD')
|
||||
|
||||
|
||||
def _get_event_properties(data):
|
||||
"""
|
||||
set event properties for course and program which are required in braze email template
|
||||
"""
|
||||
lms_url = configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL)
|
||||
event_properties = {
|
||||
'time': datetime.now().isoformat(),
|
||||
'name': data.get('braze_event'),
|
||||
}
|
||||
|
||||
event_type = data.get('type')
|
||||
if event_type == 'course':
|
||||
course = data.get('course')
|
||||
price = _get_course_price(course)
|
||||
event_properties.update({
|
||||
'properties': {
|
||||
'course_image_url': '{base_url}{image_path}'.format(
|
||||
base_url=lms_url, image_path=course.course_image_url
|
||||
),
|
||||
'partner_image_url': data.get('org_img_url'),
|
||||
'enroll_course_url': '{base_url}/register?course_id={course_id}&enrollment_action=enroll&email_opt_in='
|
||||
'false&save_for_later=true'.format(base_url=lms_url, course_id=course.id),
|
||||
'view_course_url': data.get('marketing_url') + '?save_for_later=true' if data.get(
|
||||
'marketing_url') else '#',
|
||||
'display_name': course.display_name,
|
||||
'short_description': course.short_description,
|
||||
'weeks_to_complete': data.get('weeks_to_complete'),
|
||||
'min_effort': data.get('min_effort'),
|
||||
'max_effort': data.get('max_effort'),
|
||||
'pacing_type': 'Self-paced' if course.self_paced else 'Instructor-led',
|
||||
'type': event_type,
|
||||
'price': 'Free' if price == 0 else f'${price} USD',
|
||||
}
|
||||
})
|
||||
|
||||
if event_type == 'program':
|
||||
program = data.get('program')
|
||||
price = int(program.get('price_ranges')[0].get('total'))
|
||||
event_properties.update({
|
||||
'properties': {
|
||||
'program_image_url': program.get('card_image_url'),
|
||||
'partner_image_url': program.get('authoring_organizations')[0].get('logo_image_url') if program.get(
|
||||
'authoring_organizations') else None,
|
||||
'view_program_url': program.get('marketing_url') + '?save_for_later=true' if program.get(
|
||||
'marketing_url') else '#',
|
||||
'title': program.get('title'),
|
||||
'education_level': program.get('type'),
|
||||
'total_courses': len(program.get('courses')) if program.get('courses') else 0,
|
||||
'weeks_to_complete': program.get('weeks_to_complete'),
|
||||
'min_effort': program.get('min_hours_effort_per_week'),
|
||||
'max_effort': program.get('max_hours_effort_per_week'),
|
||||
'pacing_type': _get_program_pacing(program.get('courses')[0].get('course_runs')),
|
||||
'price': f'${price} USD',
|
||||
'registered': bool(program.get('type') in ['MicroMasters', 'MicroBachelors']),
|
||||
'type': event_type,
|
||||
}
|
||||
})
|
||||
return event_properties
|
||||
|
||||
|
||||
def send_email(email, data):
|
||||
"""
|
||||
Send email through Braze
|
||||
"""
|
||||
event_properties = _get_event_properties(data)
|
||||
braze_client = get_braze_client()
|
||||
|
||||
if not braze_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
attributes = None
|
||||
external_id = braze_client.get_braze_external_id(email)
|
||||
if external_id:
|
||||
event_properties.update({'external_id': external_id})
|
||||
else:
|
||||
braze_client.create_braze_alias(emails=[email], alias_label='save_for_later')
|
||||
user_alias = {
|
||||
'alias_label': 'save_for_later',
|
||||
'alias_name': email,
|
||||
}
|
||||
event_properties.update({'user_alias': user_alias})
|
||||
attributes = [{
|
||||
'user_alias': user_alias,
|
||||
'pref-lang': data.get('pref-lang', 'en')
|
||||
}]
|
||||
|
||||
braze_client.track_user(events=[event_properties], attributes=attributes)
|
||||
|
||||
event_type = data.get('type')
|
||||
event_data = {
|
||||
'user_id': data.get('user_id'),
|
||||
'category': 'save-for-later',
|
||||
'type': event_type,
|
||||
'send_to_self': data.get('send_to_self'),
|
||||
}
|
||||
if event_type == 'program':
|
||||
program = data.get('program')
|
||||
event_data.update({'program_uuid': program.get('uuid')})
|
||||
elif event_type == 'course':
|
||||
course = data.get('course')
|
||||
event_data.update({'course_key': str(course.id)})
|
||||
|
||||
tracker.emit(
|
||||
USER_SAVE_FOR_LATER_REMINDER_EMAIL_SENT if data.get('reminder') else USER_SAVE_FOR_LATER_EMAIL_SENT,
|
||||
event_data
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.warning('Unable to send save for later email ', exc_info=True)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
@@ -1,92 +0,0 @@
|
||||
"""
|
||||
Management command to send reminder emails.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from textwrap import dedent
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import BaseCommand
|
||||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
||||
|
||||
from lms.djangoapps.save_for_later.helper import send_email
|
||||
from lms.djangoapps.save_for_later.models import SavedCourse
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from common.djangoapps.util.query import use_read_replica_if_available
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USER_SEND_SAVE_FOR_LATER_REMINDER_EMAIL = 'user.send.save.for.later.reminder.email'
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Command to send reminder emails to those users who
|
||||
saved course by email but not register within 15 days.
|
||||
|
||||
|
||||
Examples:
|
||||
|
||||
./manage.py lms send_course_reminder_emails --batch-size=100
|
||||
"""
|
||||
help = dedent(__doc__)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--batch-size',
|
||||
type=int,
|
||||
default=1000,
|
||||
help='Maximum number of users to send reminder email in one chunk')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Handle the send save for later reminder emails.
|
||||
"""
|
||||
|
||||
reminder_email_threshold_date = datetime.now() - timedelta(
|
||||
days=settings.SAVE_FOR_LATER_REMINDER_EMAIL_THRESHOLD)
|
||||
saved_courses_ids = SavedCourse.objects.filter(
|
||||
reminder_email_sent=False, modified__lt=reminder_email_threshold_date
|
||||
).values_list('id', flat=True)
|
||||
total = saved_courses_ids.count()
|
||||
batch_size = max(1, options.get('batch_size'))
|
||||
num_batches = ((total - 1) / batch_size + 1) if total > 0 else 0
|
||||
|
||||
for batch_num in range(int(num_batches)):
|
||||
reminder_email_sent_ids = []
|
||||
start = batch_num * batch_size
|
||||
end = min(start + batch_size, total) - 1
|
||||
saved_courses_batch_ids = list(saved_courses_ids)[start:end + 1]
|
||||
|
||||
query = SavedCourse.objects.filter(id__in=saved_courses_batch_ids).order_by('-modified')
|
||||
saved_courses = use_read_replica_if_available(query)
|
||||
for saved_course in saved_courses:
|
||||
user = User.objects.filter(email=saved_course.email).first()
|
||||
course_overview = CourseOverview.get_from_id(saved_course.course_id)
|
||||
course_data = {
|
||||
'course': course_overview,
|
||||
'send_to_self': None,
|
||||
'user_id': saved_course.user_id,
|
||||
'org_img_url': saved_course.org_img_url,
|
||||
'marketing_url': saved_course.marketing_url,
|
||||
'weeks_to_complete': saved_course.weeks_to_complete,
|
||||
'min_effort': saved_course.min_effort,
|
||||
'max_effort': saved_course.max_effort,
|
||||
'type': 'course',
|
||||
'reminder': True,
|
||||
'braze_event': USER_SEND_SAVE_FOR_LATER_REMINDER_EMAIL,
|
||||
}
|
||||
if user:
|
||||
enrollment = CourseEnrollment.get_enrollment(user, saved_course.course_id)
|
||||
if enrollment:
|
||||
continue
|
||||
email_sent = send_email(saved_course.email, course_data)
|
||||
if email_sent:
|
||||
reminder_email_sent_ids.append(saved_course.id)
|
||||
else:
|
||||
logging.info("Unable to send reminder email to {user} for {course} course"
|
||||
.format(user=str(saved_course.email), course=str(saved_course.course_id)))
|
||||
SavedCourse.objects.filter(id__in=reminder_email_sent_ids).update(reminder_email_sent=True)
|
||||
@@ -1,89 +0,0 @@
|
||||
"""
|
||||
Management command to send program reminder emails.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from textwrap import dedent
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import BaseCommand
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
||||
|
||||
from lms.djangoapps.save_for_later.helper import send_email
|
||||
from lms.djangoapps.save_for_later.models import SavedProgram
|
||||
from lms.djangoapps.program_enrollments.api import get_program_enrollment
|
||||
from openedx.core.djangoapps.catalog.utils import get_programs
|
||||
from common.djangoapps.util.query import use_read_replica_if_available
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
USER_SEND_SAVE_FOR_LATER_REMINDER_EMAIL = 'user.send.save.for.later.reminder.email'
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Command to send reminder emails to those users who saved
|
||||
program by email but not enroll program within 15 days.
|
||||
|
||||
|
||||
Examples:
|
||||
|
||||
./manage.py lms send_program_reminder_emails --batch-size=100
|
||||
"""
|
||||
help = dedent(__doc__)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--batch-size',
|
||||
type=int,
|
||||
default=1000,
|
||||
help='Maximum number of users to send reminder email in one chunk')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Handle the send save for later reminder emails.
|
||||
"""
|
||||
reminder_email_threshold_date = datetime.now() - timedelta(
|
||||
days=settings.SAVE_FOR_LATER_REMINDER_EMAIL_THRESHOLD)
|
||||
saved_program_ids = SavedProgram.objects.filter(
|
||||
reminder_email_sent=False, modified__lt=reminder_email_threshold_date
|
||||
).values_list('id', flat=True)
|
||||
total = saved_program_ids.count()
|
||||
batch_size = max(1, options.get('batch_size'))
|
||||
num_batches = ((total - 1) / batch_size + 1) if total > 0 else 0
|
||||
|
||||
for batch_num in range(int(num_batches)):
|
||||
reminder_email_sent_ids = []
|
||||
start = batch_num * batch_size
|
||||
end = min(start + batch_size, total) - 1
|
||||
saved_program_batch_ids = list(saved_program_ids)[start:end + 1]
|
||||
|
||||
query = SavedProgram.objects.filter(id__in=saved_program_batch_ids).order_by('-modified')
|
||||
saved_programs = use_read_replica_if_available(query)
|
||||
for saved_program in saved_programs:
|
||||
user = User.objects.filter(email=saved_program.email).first()
|
||||
program = get_programs(uuid=saved_program.program_uuid)
|
||||
if program:
|
||||
program_data = {
|
||||
'program': program,
|
||||
'send_to_self': None,
|
||||
'user_id': saved_program.user_id,
|
||||
'type': 'program',
|
||||
'reminder': True,
|
||||
'braze_event': USER_SEND_SAVE_FOR_LATER_REMINDER_EMAIL,
|
||||
}
|
||||
try:
|
||||
if user and get_program_enrollment(program_uuid=saved_program.program_uuid, user=user):
|
||||
continue
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
email_sent = send_email(saved_program.email, program_data)
|
||||
if email_sent:
|
||||
reminder_email_sent_ids.append(saved_program.id)
|
||||
else:
|
||||
logging.info("Unable to send reminder email to {user} for {program} program"
|
||||
.format(user=str(saved_program.email), program=str(saved_program.program_uuid)))
|
||||
SavedProgram.objects.filter(id__in=reminder_email_sent_ids).update(reminder_email_sent=True)
|
||||
@@ -1,44 +0,0 @@
|
||||
""" Test the test_send_course_reminder_emails command line script."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import ddt
|
||||
from django.core.management import call_command
|
||||
from django.test.utils import override_settings
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from lms.djangoapps.save_for_later.tests.factories import SavedCourseFactory
|
||||
from lms.djangoapps.save_for_later.models import SavedCourse
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@skip_unless_lms
|
||||
class SavedCourseReminderEmailsTest(SharedModuleStoreTestCase):
|
||||
"""
|
||||
Test the test_send_course_reminder_emails management command
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.course_id = 'course-v1:edX+DemoX+Demo_Course'
|
||||
self.user = UserFactory(email='email@test.com', username='jdoe')
|
||||
self.saved_course = SavedCourseFactory.create(course_id=self.course_id, user_id=self.user.id)
|
||||
self.saved_course_1 = SavedCourseFactory.create(course_id=self.course_id)
|
||||
CourseOverviewFactory.create(id=self.saved_course.course_id)
|
||||
CourseOverviewFactory.create(id=self.saved_course_1.course_id)
|
||||
|
||||
@override_settings(
|
||||
EDX_BRAZE_API_KEY='test-key',
|
||||
EDX_BRAZE_API_SERVER='http://test.url'
|
||||
)
|
||||
def test_send_reminder_emails(self):
|
||||
with patch('lms.djangoapps.utils.BrazeClient') as mock_task:
|
||||
call_command('send_course_reminder_emails', '--batch-size=1')
|
||||
mock_task.assert_called()
|
||||
|
||||
saved_course = SavedCourse.objects.filter(course_id=self.course_id).first()
|
||||
assert saved_course.reminder_email_sent is True
|
||||
assert saved_course.email_sent_count > 0
|
||||
@@ -1,43 +0,0 @@
|
||||
""" Test the test_send_program_reminder_emails command line script."""
|
||||
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import ddt
|
||||
from django.core.management import call_command
|
||||
from django.test.utils import override_settings
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
from lms.djangoapps.save_for_later.tests.factories import SavedPogramFactory
|
||||
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
|
||||
from lms.djangoapps.save_for_later.models import SavedProgram
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@skip_unless_lms
|
||||
class SavedProgramReminderEmailsTest(SharedModuleStoreTestCase):
|
||||
"""
|
||||
Test the send_program_reminder_emails management command
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.uuid = '587f6abe-bfa4-4125-9fbe-4789bf3f97f1'
|
||||
self.program = ProgramFactory(uuid=self.uuid)
|
||||
self.saved_program = SavedPogramFactory.create(program_uuid=self.uuid)
|
||||
|
||||
@override_settings(
|
||||
EDX_BRAZE_API_KEY='test-key',
|
||||
EDX_BRAZE_API_SERVER='http://test.url'
|
||||
)
|
||||
@patch('lms.djangoapps.save_for_later.management.commands.send_program_reminder_emails.get_programs')
|
||||
def test_send_reminder_emails(self, mock_get_programs):
|
||||
mock_get_programs.return_value = self.program
|
||||
with patch('lms.djangoapps.utils.BrazeClient') as mock_task:
|
||||
call_command('send_program_reminder_emails', '--batch-size=1')
|
||||
mock_task.assert_called()
|
||||
|
||||
saved_program = SavedProgram.objects.filter(program_uuid=self.uuid).first()
|
||||
assert saved_program.reminder_email_sent is True
|
||||
assert saved_program.email_sent_count > 0
|
||||
@@ -1,13 +0,0 @@
|
||||
"""
|
||||
ACE message types for the save_for_later module.
|
||||
"""
|
||||
|
||||
|
||||
from openedx.core.djangoapps.ace_common.message import BaseMessageType
|
||||
|
||||
|
||||
class SaveForLater(BaseMessageType):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.options['transactional'] = True
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.20 on 2023-07-11 14:31
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('save_for_later', '0002_auto_20220322_1621'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='SavedCourse',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='SavedProgram',
|
||||
),
|
||||
]
|
||||
@@ -1,59 +0,0 @@
|
||||
"""
|
||||
Django ORM models for save_for_later APP
|
||||
"""
|
||||
|
||||
|
||||
from model_utils.models import TimeStampedModel
|
||||
from django.db import models
|
||||
from opaque_keys.edx.django.models import CourseKeyField
|
||||
|
||||
from openedx.core.djangolib.model_mixins import DeletableByUserValue
|
||||
|
||||
|
||||
class SavedCourse(DeletableByUserValue, TimeStampedModel):
|
||||
"""
|
||||
Tracks save course by email.
|
||||
|
||||
.. pii: Stores email address of the User.
|
||||
.. pii_types: email_address
|
||||
.. pii_retirement: local_api
|
||||
"""
|
||||
user_id = models.IntegerField(null=True, blank=True)
|
||||
email = models.EmailField(db_index=True)
|
||||
course_id = CourseKeyField(max_length=255, db_index=True)
|
||||
marketing_url = models.CharField(max_length=255, null=True, blank=True)
|
||||
org_img_url = models.CharField(max_length=255, null=True, blank=True)
|
||||
weeks_to_complete = models.IntegerField(null=True)
|
||||
min_effort = models.IntegerField(null=True)
|
||||
max_effort = models.IntegerField(null=True)
|
||||
email_sent_count = models.IntegerField(null=True, default=0)
|
||||
reminder_email_sent = models.BooleanField(default=False, null=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('email', 'course_id',)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.email_sent_count = self.email_sent_count + 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class SavedProgram(DeletableByUserValue, TimeStampedModel):
|
||||
"""
|
||||
Tracks save program by email.
|
||||
|
||||
.. pii: Stores email address of the User.
|
||||
.. pii_types: email_address
|
||||
.. pii_retirement: local_api
|
||||
"""
|
||||
user_id = models.IntegerField(null=True, blank=True)
|
||||
email = models.EmailField(db_index=True)
|
||||
program_uuid = models.UUIDField()
|
||||
email_sent_count = models.IntegerField(null=True, default=0)
|
||||
reminder_email_sent = models.BooleanField(default=False, null=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('email', 'program_uuid',)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.email_sent_count = self.email_sent_count + 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
"""
|
||||
Signal handler for save for later
|
||||
"""
|
||||
from django.dispatch.dispatcher import receiver
|
||||
|
||||
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL
|
||||
from .models import SavedCourse, SavedProgram
|
||||
|
||||
|
||||
@receiver(USER_RETIRE_LMS_CRITICAL)
|
||||
def _listen_for_lms_retire(sender, **kwargs): # pylint: disable=unused-argument
|
||||
user = kwargs.get('user')
|
||||
SavedCourse.delete_by_user_value(user.id, field='user_id')
|
||||
SavedProgram.delete_by_user_value(user.id, field='user_id')
|
||||
@@ -1,46 +0,0 @@
|
||||
"""
|
||||
Provides factories for save_for_later models.
|
||||
"""
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
from pytz import UTC
|
||||
|
||||
import factory
|
||||
from factory.django import DjangoModelFactory
|
||||
|
||||
from lms.djangoapps.save_for_later.models import SavedCourse, SavedProgram
|
||||
|
||||
|
||||
class SavedCourseFactory(DjangoModelFactory):
|
||||
"""
|
||||
Factory for SavedCourses objects
|
||||
"""
|
||||
class Meta:
|
||||
model = SavedCourse
|
||||
django_get_or_create = ('course_id', 'user_id')
|
||||
|
||||
email = 'abc@test.com'
|
||||
course_id = factory.Sequence('{}'.format)
|
||||
user_id = factory.Sequence('{}'.format)
|
||||
reminder_email_sent = False
|
||||
email_sent_count = 0
|
||||
created = datetime(2020, 1, 1, tzinfo=UTC)
|
||||
modified = datetime(2020, 2, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
class SavedPogramFactory(DjangoModelFactory):
|
||||
"""
|
||||
Factory for SavedProgram objects
|
||||
"""
|
||||
class Meta:
|
||||
model = SavedProgram
|
||||
django_get_or_create = ('program_uuid', )
|
||||
|
||||
email = 'abc@test.com'
|
||||
user_id = factory.Sequence('{}'.format)
|
||||
program_uuid = factory.Sequence('{}'.format)
|
||||
reminder_email_sent = False
|
||||
email_sent_count = 0
|
||||
created = datetime(2020, 1, 1, tzinfo=UTC)
|
||||
modified = datetime(2020, 2, 1, tzinfo=UTC)
|
||||
@@ -1,42 +0,0 @@
|
||||
"""
|
||||
Unit tests for the signals
|
||||
"""
|
||||
from uuid import uuid4
|
||||
from django.test import TestCase
|
||||
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from ..models import SavedCourse, SavedProgram
|
||||
from ..signals import _listen_for_lms_retire
|
||||
|
||||
|
||||
class RetirementSignalTest(TestCase):
|
||||
"""
|
||||
Tests for the user retirement signal
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
self.email = self.user.email
|
||||
|
||||
def _create_objects(self):
|
||||
"""
|
||||
Create test objects.
|
||||
"""
|
||||
SavedCourse.objects.create(user_id=self.user.id, email=self.email, course_id='course-v1:TestX+TestX101+1T2022')
|
||||
SavedProgram.objects.create(user_id=self.user.id, email=self.email, program_uuid=uuid4())
|
||||
|
||||
assert SavedCourse.objects.filter(email=self.email).exists()
|
||||
assert SavedProgram.objects.filter(email=self.email).exists()
|
||||
|
||||
def test_retire_success(self):
|
||||
self._create_objects()
|
||||
_listen_for_lms_retire(sender=self.__class__, user=self.user, email=self.email)
|
||||
|
||||
assert not SavedCourse.objects.filter(email=self.email).exists()
|
||||
assert not SavedProgram.objects.filter(email=self.email).exists()
|
||||
|
||||
def test_retire_success_no_entries(self):
|
||||
assert not SavedCourse.objects.filter(email=self.email).exists()
|
||||
assert not SavedProgram.objects.filter(email=self.email).exists()
|
||||
_listen_for_lms_retire(sender=self.__class__, user=self.user, email=self.email)
|
||||
@@ -1,8 +0,0 @@
|
||||
""" URLs for save_for_later """
|
||||
|
||||
from django.conf.urls import include
|
||||
from django.urls import path
|
||||
|
||||
urlpatterns = [
|
||||
path('api/', include(('lms.djangoapps.save_for_later.api.urls', 'api'), namespace='api')),
|
||||
]
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
<%! import json %>
|
||||
<%!
|
||||
import six
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from openedx.core.djangolib.js_utils import (
|
||||
dump_js_escaped_json, js_escaped_string
|
||||
@@ -41,7 +39,7 @@ from openedx.core.djangolib.js_utils import (
|
||||
|
||||
<%static:require_module module_name="teams/js/teams_tab_factory" class_name="TeamsTabFactory">
|
||||
TeamsTabFactory({
|
||||
courseID: '${six.text_type(course.id) | n, js_escaped_string}',
|
||||
courseID: '${str(course.id) | n, js_escaped_string}',
|
||||
topics: ${topics | n, dump_js_escaped_json},
|
||||
hasManagedTopic: ${has_managed_teamset | n, dump_js_escaped_json},
|
||||
hasPublicManagedTopic: ${has_public_managed_teamset | n, dump_js_escaped_json},
|
||||
|
||||
@@ -96,7 +96,7 @@ class TestModelStrings(SharedModuleStoreTestCase):
|
||||
"<CourseTeamMembership id=1 user_id=1 team_id=1>"
|
||||
)
|
||||
|
||||
def test_team_membership_text_type(self):
|
||||
def test_team_membership_str(self):
|
||||
assert str(self.team_membership) == (
|
||||
"the-user is member of The Team in edx/the-course/1"
|
||||
)
|
||||
|
||||
@@ -1080,12 +1080,6 @@ MARKETING_EMAILS_OPT_IN = False
|
||||
# .. toggle_tickets: 'https://openedx.atlassian.net/browse/VAN-622'
|
||||
ENABLE_COPPA_COMPLIANCE = False
|
||||
|
||||
# VAN-741 - save for later api put behind a flag to make it only available for edX
|
||||
ENABLE_SAVE_FOR_LATER = False
|
||||
|
||||
# VAN-887 - save for later reminder emails threshold days
|
||||
SAVE_FOR_LATER_REMINDER_EMAIL_THRESHOLD = 15
|
||||
|
||||
############################# SET PATH INFORMATION #############################
|
||||
PROJECT_ROOT = path(__file__).abspath().dirname().dirname() # /edx-platform/lms
|
||||
REPO_ROOT = PROJECT_ROOT.dirname()
|
||||
@@ -2586,14 +2580,6 @@ PIPELINE['JAVASCRIPT'] = {
|
||||
'source_filenames': main_vendor_js,
|
||||
'output_filename': 'js/lms-main_vendor.js',
|
||||
},
|
||||
'module-descriptor-js': {
|
||||
'source_filenames': rooted_glob(COMMON_ROOT / 'static/', 'xmodule/descriptors/js/*.js'),
|
||||
'output_filename': 'js/lms-module-descriptors.js',
|
||||
},
|
||||
'module-js': {
|
||||
'source_filenames': rooted_glob(COMMON_ROOT / 'static', 'xmodule/modules/js/*.js'),
|
||||
'output_filename': 'js/lms-modules.js',
|
||||
},
|
||||
'discussion': {
|
||||
'source_filenames': discussion_js,
|
||||
'output_filename': 'js/discussion.js',
|
||||
@@ -4784,10 +4770,6 @@ OPTIONAL_FIELD_API_RATELIMIT = '10/h'
|
||||
PASSWORD_RESET_IP_RATE = '1/m'
|
||||
PASSWORD_RESET_EMAIL_RATE = '2/h'
|
||||
|
||||
#### SAVE FOR LATER EMAIL RATE LIMIT SETTINGS ####
|
||||
SAVE_FOR_LATER_IP_RATE_LIMIT = '100/d'
|
||||
SAVE_FOR_LATER_EMAIL_RATE_LIMIT = '5/h'
|
||||
|
||||
|
||||
#### BRAZE API SETTINGS ####
|
||||
|
||||
@@ -5332,6 +5314,8 @@ SUBSCRIPTIONS_API_PATH = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscription/"
|
||||
SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL = None
|
||||
SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscribe/"
|
||||
SUBSCRIPTIONS_MANAGE_SUBSCRIPTION_URL = None
|
||||
SUBSCRIPTIONS_MINIMUM_PRICE = '$39'
|
||||
SUBSCRIPTIONS_TRIAL_LENGTH = 7
|
||||
SUBSCRIPTIONS_SERVICE_WORKER_USERNAME = 'subscriptions_worker'
|
||||
|
||||
############## NOTIFICATIONS EXPIRY ##############
|
||||
|
||||
@@ -509,6 +509,8 @@ SUBSCRIPTIONS_API_PATH = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscription/"
|
||||
SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL = None
|
||||
SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscribe/"
|
||||
SUBSCRIPTIONS_MANAGE_SUBSCRIPTION_URL = None
|
||||
SUBSCRIPTIONS_MINIMUM_PRICE = '$39'
|
||||
SUBSCRIPTIONS_TRIAL_LENGTH = 7
|
||||
|
||||
# API access management
|
||||
API_ACCESS_MANAGER_EMAIL = 'api-access@example.com'
|
||||
|
||||
@@ -633,13 +633,6 @@ RESET_PASSWORD_API_RATELIMIT = '2/m'
|
||||
|
||||
CORS_ORIGIN_WHITELIST = ['https://sandbox.edx.org']
|
||||
|
||||
# enable /api/v1/save/course/ api for testing
|
||||
ENABLE_SAVE_FOR_LATER = True
|
||||
|
||||
# rate limit for /api/v1/save/course/ api
|
||||
SAVE_FOR_LATER_IP_RATE_LIMIT = '5/d'
|
||||
SAVE_FOR_LATER_EMAIL_RATE_LIMIT = '5/m'
|
||||
|
||||
#################### Network configuration ####################
|
||||
# Tests are not behind any proxies
|
||||
CLOSEST_CLIENT_IP_FROM_HEADERS = []
|
||||
@@ -682,3 +675,5 @@ SUBSCRIPTIONS_API_PATH = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscription/"
|
||||
SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL = None
|
||||
SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscribe/"
|
||||
SUBSCRIPTIONS_MANAGE_SUBSCRIPTION_URL = None
|
||||
SUBSCRIPTIONS_MINIMUM_PRICE = '$39'
|
||||
SUBSCRIPTIONS_TRIAL_LENGTH = 7
|
||||
|
||||
@@ -15,6 +15,7 @@ class ProgramSubscriptionModel extends Backbone.Model {
|
||||
programData: { subscription_prices },
|
||||
urls = {},
|
||||
userPreferences = {},
|
||||
subscriptionsTrialLength: trialLength = 7,
|
||||
} = context;
|
||||
|
||||
const priceInUSD = subscription_prices?.find(({ currency }) => currency === 'USD');
|
||||
@@ -56,8 +57,6 @@ class ProgramSubscriptionModel extends Backbone.Model {
|
||||
userPreferences
|
||||
);
|
||||
|
||||
const trialLength = 7;
|
||||
|
||||
super(
|
||||
{
|
||||
hasActiveTrial,
|
||||
|
||||
@@ -6,7 +6,11 @@ describe('Sidebar View', () => {
|
||||
let view = null;
|
||||
const context = {
|
||||
marketingUrl: 'https://www.example.org/programs',
|
||||
subscriptionsMarketingUrl: 'https://www.example.org/program-subscriptions',
|
||||
subscriptionUpsellData: {
|
||||
marketing_url: 'https://www.example.org/program-subscriptions',
|
||||
minimum_price: '$39',
|
||||
trial_length: 7,
|
||||
},
|
||||
isUserB2CSubscriptionsEnabled: true,
|
||||
};
|
||||
|
||||
@@ -72,7 +76,7 @@ describe('Sidebar View', () => {
|
||||
el: '.sidebar',
|
||||
context: {
|
||||
isUserB2CSubscriptionsEnabled: true,
|
||||
subscriptionsMarketingUrl: '',
|
||||
subscriptionUpsellData: context.subscriptionUpsellData,
|
||||
},
|
||||
});
|
||||
view.render();
|
||||
|
||||
@@ -31,7 +31,7 @@ class SidebarView extends Backbone.View {
|
||||
postRender() {
|
||||
if (this.context.isUserB2CSubscriptionsEnabled) {
|
||||
this.subscriptionUpsellView = new SubscriptionUpsellView({
|
||||
context: this.context,
|
||||
subscriptionUpsellData: this.context.subscriptionUpsellData,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,17 +12,16 @@ class SubscriptionUpsellView extends Backbone.View {
|
||||
super(Object.assign({}, defaults, options));
|
||||
}
|
||||
|
||||
initialize({ context }) {
|
||||
initialize(options) {
|
||||
this.tpl = HtmlUtils.template(subscriptionUpsellTpl);
|
||||
this.context = context;
|
||||
this.subscriptionUpsellModel = new Backbone.Model(
|
||||
options.subscriptionUpsellData,
|
||||
);
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
const data = $.extend({}, this.context, {
|
||||
minSubscriptionPrice: '$39',
|
||||
trialLength: 7,
|
||||
});
|
||||
const data = this.subscriptionUpsellModel.toJSON();
|
||||
HtmlUtils.setHtml(this.$el, this.tpl(data));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from six import text_type
|
||||
%>
|
||||
|
||||
<%
|
||||
def _message(reqm, message):
|
||||
return Text(message).format(link=HTML("<a href={url}>{url_name}</a>").format(
|
||||
url = reverse('jump_to', kwargs=dict(course_id=text_type(reqm.course_id),
|
||||
location=text_type(reqm.location))),
|
||||
url = reverse('jump_to', kwargs=dict(course_id=str(reqm.course_id),
|
||||
location=str(reqm.location))),
|
||||
url_name = reqm.display_name_with_default))
|
||||
%>
|
||||
% if message:
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
<%!
|
||||
from django.utils.translation import gettext as _
|
||||
from django.urls import reverse
|
||||
from six import text_type
|
||||
%>
|
||||
<%page args="course" expression_filter="h"/>
|
||||
<article class="course" id="${course.id}" role="region" aria-label="${course.display_name_with_default}">
|
||||
<a href="${reverse('about_course', args=[text_type(course.id)])}">
|
||||
<a href="${reverse('about_course', args=[str(course.id)])}">
|
||||
<header class="course-image">
|
||||
<div class="cover-image">
|
||||
<img src="${course.course_image_url}" alt="${course.display_name_with_default} ${course.display_number_with_default}" />
|
||||
|
||||
@@ -6,13 +6,10 @@ from django.utils.translation import pgettext
|
||||
from django.urls import reverse
|
||||
from lms.djangoapps.courseware.courses import get_course_about_section
|
||||
from django.conf import settings
|
||||
from six import text_type
|
||||
from common.djangoapps.edxmako.shortcuts import marketing_link
|
||||
from openedx.core.djangolib.js_utils import js_escaped_string
|
||||
from openedx.core.djangolib.markup import clean_dangerous_html, HTML, Text
|
||||
from openedx.core.lib.courses import course_image_url
|
||||
|
||||
from six import string_types
|
||||
%>
|
||||
|
||||
<%inherit file="../main.html" />
|
||||
@@ -171,7 +168,7 @@ from six import string_types
|
||||
<li class="important-dates-item">
|
||||
<span class="icon fa fa-calendar" aria-hidden="true"></span>
|
||||
<p class="important-dates-item-title">${_("Classes Start")}</p>
|
||||
% if isinstance(course_start_date, string_types):
|
||||
% if isinstance(course_start_date, str):
|
||||
<span class="important-dates-item-text start-date">${course_start_date}</span>
|
||||
% else:
|
||||
<%
|
||||
@@ -191,7 +188,7 @@ from six import string_types
|
||||
<li class="important-dates-item">
|
||||
<span class="icon fa fa-calendar" aria-hidden="true"></span>
|
||||
<p class="important-dates-item-title">${_("Classes End")}</p>
|
||||
% if isinstance(course_end_date, string_types):
|
||||
% if isinstance(course_end_date, str):
|
||||
<span class="important-dates-item-text final-date">${course_end_date}</span>
|
||||
% else:
|
||||
<%
|
||||
@@ -217,7 +214,7 @@ from six import string_types
|
||||
%endif
|
||||
|
||||
% if pre_requisite_courses:
|
||||
<% prc_target = reverse('about_course', args=[text_type(pre_requisite_courses[0]['key'])]) %>
|
||||
<% prc_target = reverse('about_course', args=[str(pre_requisite_courses[0]['key'])]) %>
|
||||
<li class="prerequisite-course important-dates-item">
|
||||
<span class="icon fa fa-list-ul" aria-hidden="true"></span>
|
||||
<p class="important-dates-item-title">${_("Prerequisites")}</p>
|
||||
|
||||
@@ -6,7 +6,6 @@ import six
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
from six import text_type
|
||||
%>
|
||||
|
||||
<header>
|
||||
@@ -20,7 +19,7 @@ from six import text_type
|
||||
site_domain = static.get_value('site_domain', settings.SITE_NAME)
|
||||
site_protocol = 'https' if settings.HTTPS == 'on' else 'http'
|
||||
platform_name = static.get_platform_name()
|
||||
course_path = reverse('about_course', args=[text_type(course.id)])
|
||||
course_path = reverse('about_course', args=[str(course.id)])
|
||||
course_url = f"{site_protocol}://{site_domain}{course_path}"
|
||||
|
||||
## Translators: This text will be automatically posted to the student's
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
<%def name="online_help_token()"><% return "courseware" %></%def>
|
||||
<%!
|
||||
import six
|
||||
import waffle
|
||||
|
||||
from django.conf import settings
|
||||
@@ -224,13 +223,13 @@ ${HTML(fragment.foot_html())}
|
||||
% endif
|
||||
% if chapter:
|
||||
<span class="nav-item nav-item-chapter" data-course-position="${course.position}" data-chapter-position="${chapter.position}">
|
||||
<a href="${course_url}#${six.text_type(chapter.location)}">${chapter.display_name_with_default}</a>
|
||||
<a href="${course_url}#${str(chapter.location)}">${chapter.display_name_with_default}</a>
|
||||
</span>
|
||||
<span class="icon fa fa-angle-right" aria-hidden="true"></span>
|
||||
% endif
|
||||
% if section:
|
||||
<span class="nav-item nav-item-section">
|
||||
<a href="${course_url}#${six.text_type(section.location)}">${section.display_name_with_default}</a>
|
||||
<a href="${course_url}#${str(section.location)}">${section.display_name_with_default}</a>
|
||||
</span>
|
||||
<span class="icon fa fa-angle-right" aria-hidden="true"></span>
|
||||
% endif
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.urls import reverse
|
||||
from six import text_type
|
||||
%>
|
||||
|
||||
<%block name="js_extra">
|
||||
@@ -58,7 +57,7 @@ from six import text_type
|
||||
%for student in students:
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${reverse('student_progress', kwargs=dict(course_id=text_type(course_id), student_id=student['id']))}">${student['username']}</a>
|
||||
<a href="${reverse('student_progress', kwargs=dict(course_id=str(course_id), student_id=student['id']))}">${student['username']}</a>
|
||||
</td>
|
||||
</tr>
|
||||
%endfor
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
## xss-lint: disable=mako-missing-default
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
<%!
|
||||
import six
|
||||
%>
|
||||
|
||||
<script type="text/javascript" src="${static.url('js/vendor/jquery.leanModal.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/staff_debug_actions.js')}"></script>
|
||||
@@ -28,7 +25,7 @@ function setup_debug(element_id, edit_link, staff_context){
|
||||
var username_or_email = $("#" + element_id + "_history_student_username").val();
|
||||
var location = $("#" + element_id + "_history_location").val();
|
||||
// xss-lint: disable=mako-invalid-js-filter
|
||||
$("#" + element_id + "_history_text").load('/courses/' + "${six.text_type(getattr(course,'id','')) | u}" +
|
||||
$("#" + element_id + "_history_text").load('/courses/' + "${str(getattr(course,'id','')) | u}" +
|
||||
"/submission_history/" + encodeURIComponent(username_or_email) + "/" + location);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
import pytz
|
||||
import six
|
||||
from datetime import datetime, timedelta
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -220,7 +219,7 @@ from common.djangoapps.student.models import CourseEnrollment
|
||||
is_paid_course = True if entitlement else (session_id in enrolled_courses_either_paid)
|
||||
is_course_voucher_refundable = (session_id in enrolled_courses_voucher_refundable)
|
||||
course_requirements = courses_requirements_not_met.get(session_id)
|
||||
related_programs = inverted_programs.get(six.text_type(entitlement.course_uuid if is_unfulfilled_entitlement else session_id))
|
||||
related_programs = inverted_programs.get(str(entitlement.course_uuid if is_unfulfilled_entitlement else session_id))
|
||||
show_consent_link = (session_id in consent_required_courses)
|
||||
resume_button_url = resume_button_urls[dashboard_index]
|
||||
%>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
<%!
|
||||
import datetime
|
||||
import six
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.http import urlencode, urlquote_plus
|
||||
@@ -174,7 +173,7 @@ from lms.djangoapps.experiments.utils import UPSELL_TRACKING_FLAG
|
||||
% endif
|
||||
</span>
|
||||
% else:
|
||||
% if isinstance(course_date, six.string_types):
|
||||
% if isinstance(course_date, str):
|
||||
<span class="info-date-block">${container_string.format(date=course_date)}</span>
|
||||
% elif course_date is not None:
|
||||
<span class="info-date-block localized-datetime" data-language="${user_language}" data-timezone="${user_timezone}" data-datetime="${course_date.strftime('%Y-%m-%dT%H:%M:%S%z')}" data-format=${format} data-string="${container_string}"></span>
|
||||
@@ -272,7 +271,7 @@ from lms.djangoapps.experiments.utils import UPSELL_TRACKING_FLAG
|
||||
<ul class="actions-dropdown-list" id="actions-dropdown-list-${dashboard_index}" aria-label="${_('Available Actions')}" role="menu">
|
||||
% if can_unenroll:
|
||||
<li class="actions-item" id="actions-item-unenroll-${dashboard_index}" role="menuitem">
|
||||
<% course_refund_url = reverse('course_run_refund_status', args=[six.text_type(course_overview.id)]) %>
|
||||
<% course_refund_url = reverse('course_run_refund_status', args=[str(course_overview.id)]) %>
|
||||
<a href="#unenroll-modal" class="action action-unenroll" rel="leanModal"
|
||||
data-course-id="${course_overview.id}"
|
||||
data-course-number="${course_overview.number}"
|
||||
@@ -419,7 +418,7 @@ from lms.djangoapps.experiments.utils import UPSELL_TRACKING_FLAG
|
||||
|
||||
% if course_requirements:
|
||||
## Multiple pre-requisite courses are not supported on frontend that's why we are pulling first element
|
||||
<% prc_target = reverse('about_course', args=[six.text_type(course_requirements['courses'][0]['key'])]) %>
|
||||
<% prc_target = reverse('about_course', args=[str(course_requirements['courses'][0]['key'])]) %>
|
||||
<div class="prerequisites">
|
||||
<p class="tip">
|
||||
${Text(_("You must successfully complete {link_start}{prc_display}{link_end} before you begin this course.")).format(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<%page args="course_overview, entitlement, dashboard_index, can_refund_entitlement, show_email_settings" expression_filter="h"/>
|
||||
|
||||
<%!
|
||||
import six
|
||||
from django.utils.translation import gettext as _
|
||||
from django.urls import reverse
|
||||
%>
|
||||
@@ -39,7 +38,7 @@ dropdown_btn_id = "entitlement-actions-dropdown-btn-{}".format(dashboard_index)
|
||||
data-dropdown-button-selector="#${dropdown_btn_id}"
|
||||
data-course-name="${course_overview.display_name_with_default}"
|
||||
data-course-number="${course_overview.number}"
|
||||
data-entitlement-api-endpoint="${reverse('entitlements_api:v1:enrollments', args=[six.text_type(entitlement.uuid)]) + '?is_refund=true'}">
|
||||
data-entitlement-api-endpoint="${reverse('entitlements_api:v1:enrollments', args=[str(entitlement.uuid)]) + '?is_refund=true'}">
|
||||
${_('Unenroll')}
|
||||
</a>
|
||||
</li>
|
||||
@@ -54,7 +53,7 @@ dropdown_btn_id = "entitlement-actions-dropdown-btn-{}".format(dashboard_index)
|
||||
data-course-id="${course_overview.id}"
|
||||
data-course-number="${course_overview.number}"
|
||||
data-dashboard-index="${dashboard_index}"
|
||||
data-optout="${six.text_type(course_overview.id) in course_optouts}">${_('Email Settings')}</a>
|
||||
data-optout="${str(course_overview.id) in course_optouts}">${_('Email Settings')}</a>
|
||||
</li>
|
||||
% endif
|
||||
</ul>
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from six import text_type
|
||||
|
||||
from openedx.core.djangoapps.user_authn.toggles import should_redirect_to_authn_microfrontend
|
||||
%>
|
||||
|
||||
@@ -54,7 +54,8 @@ from openedx.core.djangolib.markup import HTML
|
||||
<script type="text/javascript" src="${static.url('js/vendor/tinymce/js/tinymce/jquery.tinymce.min.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/jQuery-File-Upload/js/jquery.fileupload.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/jquery.qubit.js')}"></script>
|
||||
<%static:js group='module-descriptor-js'/>
|
||||
<%static:webpack entry='HtmlBlockEditor'/>
|
||||
<link rel="stylesheet" href="${static.url('css/HtmlBlockEditor.css')}">
|
||||
<%static:js group='instructor_dash'/>
|
||||
<%static:js group='application'/>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ ProgramDetailsFactory({
|
||||
creditPathways: ${credit_pathways | n, dump_js_escaped_json},
|
||||
programTabViewEnabled: ${program_tab_view_enabled | n, dump_js_escaped_json},
|
||||
isUserB2CSubscriptionsEnabled: ${is_user_b2c_subscriptions_enabled | n, dump_js_escaped_json},
|
||||
subscriptionsTrialLength: ${subscriptions_trial_length | n, dump_js_escaped_json},
|
||||
discussionFragment: ${discussion_fragment, | n, dump_js_escaped_json},
|
||||
live_fragment: ${live_fragment, | n, dump_js_escaped_json}
|
||||
});
|
||||
|
||||
@@ -30,9 +30,9 @@ from openedx.core.djangolib.js_utils import (
|
||||
<%static:webpack entry="ProgramListFactory">
|
||||
ProgramListFactory({
|
||||
marketingUrl: '${marketing_url | n, js_escaped_string}',
|
||||
subscriptionsMarketingUrl: '${subscriptions_marketing_url | n, js_escaped_string}',
|
||||
programsData: ${programs | n, dump_js_escaped_json},
|
||||
programsSubscriptionData: ${programs_subscription_data | n, dump_js_escaped_json},
|
||||
subscriptionUpsellData: ${subscription_upsell_data | n, dump_js_escaped_json},
|
||||
userProgress: ${progress | n, dump_js_escaped_json},
|
||||
userPreferences: ${user_preferences | n, dump_js_escaped_json},
|
||||
isUserB2CSubscriptionsEnabled: ${is_user_b2c_subscriptions_enabled | n, dump_js_escaped_json},
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
<p class="advertise-message">
|
||||
<%- StringUtils.interpolate(
|
||||
gettext('Now available for many popular programs, affordable monthly subscription pricing can help you manage your budget more effectively. Subscriptions start at {minSubscriptionPrice}/month USD per program, after a 7-day full access free trial. Cancel at any time.'),
|
||||
{ minSubscriptionPrice, trialLength }
|
||||
{
|
||||
minSubscriptionPrice: minimum_price,
|
||||
trialLength: trial_length,
|
||||
}
|
||||
) %>
|
||||
</p>
|
||||
<a href="<%- subscriptionsMarketingUrl %>" class="js-subscription-upsell-cta btn-brand btn cta-primary view-button align-self-stretch">
|
||||
<a href="<%- marketing_url %>" class="js-subscription-upsell-cta btn-brand btn cta-primary view-button align-self-stretch">
|
||||
<span class="icon fa fa-search" aria-hidden="true"></span>
|
||||
<span><%- gettext('Explore subscription options') %></span>
|
||||
</a>
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<% online_help_token = self.online_help_token() if hasattr(self, 'online_help_token') else None %>
|
||||
<%!
|
||||
import six
|
||||
from lms.djangoapps.branding import api as branding_api
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlquote_plus
|
||||
@@ -93,7 +92,7 @@ from common.djangoapps.pipeline_mako import render_require_js_path_overrides
|
||||
<%
|
||||
rtl_css_file = self.attr.main_css.replace('.css', '-rtl.css')
|
||||
%>
|
||||
<link rel="stylesheet" href="${six.text_type(static.url(rtl_css_file))}" type="text/css" media="all" />
|
||||
<link rel="stylesheet" href="${str(static.url(rtl_css_file))}" type="text/css" media="all" />
|
||||
% else:
|
||||
<link rel="stylesheet" href="${static.url(self.attr.main_css)}" type="text/css" media="all" />
|
||||
% endif
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<%!
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from six import text_type
|
||||
%>
|
||||
|
||||
<ol class="left list-inline nav-global">
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
import six
|
||||
from lms.djangoapps.branding import api as branding_api
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import get_language_bidi
|
||||
@@ -45,7 +44,7 @@ from openedx.core.djangolib.markup import HTML
|
||||
<%
|
||||
rtl_css_file = self.attr.main_css.replace('.css', '-rtl.css')
|
||||
%>
|
||||
<link rel="stylesheet" href="${six.text_type(static.url(rtl_css_file))}" type="text/css" media="all" />
|
||||
<link rel="stylesheet" href="${str(static.url(rtl_css_file))}" type="text/css" media="all" />
|
||||
% else:
|
||||
<link rel="stylesheet" href="${static.url(self.attr.main_css)}" type="text/css" media="all" />
|
||||
% endif
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
{% extends 'ace_common/edx_ace/common/base_body.html' %}
|
||||
|
||||
{% load django_markup %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% block content %}
|
||||
<div style="display: flex;">
|
||||
<div style="background-color: #1C8DBE;flex: 50%;">
|
||||
<div style="text-align: center;">
|
||||
<h1 style="color: #fff;
|
||||
line-height: 40px;
|
||||
font-size: 36px;
|
||||
padding-top: 110px;
|
||||
padding-bottom: 24px;
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
min-width: 256px;
|
||||
margin: 0;
|
||||
text-align: left;"
|
||||
>
|
||||
{% filter force_escape %}
|
||||
{% blocktrans %}Check out this course on edx{% endblocktrans %}
|
||||
{% endfilter %}
|
||||
</h1>
|
||||
<div style="text-align: left;
|
||||
padding-left: 32px;"
|
||||
>
|
||||
<a style="background: #00688D;
|
||||
width: 94px;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
line-height: 2.25rem;
|
||||
height: 36px;
|
||||
padding: 8px 12px;"
|
||||
href="{{ lms_url|add:enroll_course_url }}"
|
||||
>
|
||||
{% filter force_escape %}
|
||||
{% blocktrans %}Enroll now{% endblocktrans %}
|
||||
{% endfilter %}
|
||||
</a>
|
||||
<a style="background: #03c7e8;
|
||||
border: 1px solid #fff;
|
||||
margin-left: 8px;
|
||||
width: 107px;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
line-height: 2.25rem;
|
||||
height: 36px;
|
||||
padding: 8px 12px;"
|
||||
href="{{ view_course_url }}"
|
||||
>
|
||||
{% filter force_escape %}
|
||||
{% blocktrans %}View Course{% endblocktrans %}
|
||||
{% endfilter %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 50%;">
|
||||
<div style="height: 140px;min-width: 320px;background: url({{lms_url|add:course_image_url}})">
|
||||
{% if partner_image_url %}
|
||||
<div style="padding-top: 82px;padding-left: 24px;">
|
||||
<img style="width: 116px;
|
||||
height: 66px;
|
||||
background: #FFFFFF;
|
||||
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.15), 0px 1px 4px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 4px;
|
||||
"src={{partner_image_url}} />
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="background: #FBFAF9;">
|
||||
<h2 style="color: #002121;
|
||||
font-weight: bold;
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
padding-top: 24px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-bottom: 20px;
|
||||
margin: 0;"
|
||||
>
|
||||
{{ display_name }}
|
||||
</h2>
|
||||
<p style="color: #707070;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-bottom: 20px;
|
||||
margin: 0;"
|
||||
>
|
||||
{{ short_description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background-color: #F2F0EF;display: flex;color: #00262B;">
|
||||
<div style="width: 33.3%;">
|
||||
<div style="display: inline-block;">
|
||||
<svg width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
style="margin-bottom: 5px;margin-left: 20px;"
|
||||
>
|
||||
<path d="M16.24 7.76A5.974 5.974 0 0012 6v6l-4.24 4.24c2.34 2.34 6.14 2.34 8.49 0a5.99 5.99 0 00-.01-8.48zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" fill="currentColor"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div style="display: inline-block;margin-left: 5px !important">
|
||||
<p style="padding-top: 8px;
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
line-height: 24px;"
|
||||
>
|
||||
{% trans "Estimated 6 weeks" as estimated_weeks_heading %}{{ estimated_weeks_heading | force_escape }}
|
||||
</p>
|
||||
<p style=" margin: 0;
|
||||
padding-bottom: 9px;
|
||||
font-size: 12px;
|
||||
color: #707070;"
|
||||
>
|
||||
{% trans "4-6 hours per week" as estimated_weeks_msg %}{{ estimated_weeks_msg | force_escape }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width: 33.3%;">
|
||||
<div style="display: inline-block;">
|
||||
<svg width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
style="margin-bottom: 5px;"
|
||||
>
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div style="display: inline-block;margin-left: 5px;">
|
||||
<p style="padding-top: 8px;
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
line-height: 24px;"
|
||||
>
|
||||
{% trans "Self-paced" as self_paced_heading %}{{ self_paced_heading | force_escape }}
|
||||
</p>
|
||||
<p style=" margin: 0;
|
||||
padding-bottom: 9px;
|
||||
font-size: 12px;
|
||||
color: #707070;"
|
||||
>
|
||||
{% trans "Progress at your own speed" as self_paced_msg %}{{ self_paced_msg | force_escape }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width: 33.3%">
|
||||
<div style="display: inline-block;">
|
||||
<svg width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
style="margin-bottom: 5px;"
|
||||
>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.31-8.86c-1.77-.45-2.34-.94-2.34-1.67 0-.84.79-1.43 2.1-1.43 1.38 0 1.9.66 1.94 1.64h1.71c-.05-1.34-.87-2.57-2.49-2.97V5H10.9v1.69c-1.51.32-2.72 1.3-2.72 2.81 0 1.79 1.49 2.69 3.66 3.21 1.95.46 2.34 1.15 2.34 1.87 0 .53-.39 1.39-2.1 1.39-1.6 0-2.23-.72-2.32-1.64H8.04c.1 1.7 1.36 2.66 2.86 2.97V19h2.34v-1.67c1.52-.29 2.72-1.16 2.73-2.77-.01-2.2-1.9-2.96-3.66-3.42z" fill="currentColor"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div style="display: inline-block;margin-left: 5px;">
|
||||
<p style="padding-top: 8px;
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
line-height: 24px;"
|
||||
>
|
||||
{% trans "Free" as upgrade_heading %}{{ upgrade_heading | force_escape }}
|
||||
</p>
|
||||
<p style=" margin: 0;
|
||||
padding-bottom: 9px;
|
||||
font-size: 12px;
|
||||
color: #707070;"
|
||||
>
|
||||
{% trans "Optional upgrade available" as upgrade_msg %}{{ upgrade_msg | force_escape }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,8 +0,0 @@
|
||||
{% load i18n %}{% autoescape off %}
|
||||
{% blocktrans %}Check out this course on {{ platform_name }}.{% endblocktrans %}
|
||||
|
||||
{% blocktrans %}This email message was automatically sent by {{ lms_url }} {% endblocktrans %}
|
||||
|
||||
{{ confirm_link }}
|
||||
|
||||
{% endautoescape %}
|
||||
@@ -1 +0,0 @@
|
||||
{{ platform_name }}
|
||||
@@ -1 +0,0 @@
|
||||
{% extends 'ace_common/edx_ace/common/base_head.html' %}
|
||||
@@ -1,4 +0,0 @@
|
||||
{% load i18n %}
|
||||
{% autoescape off %}
|
||||
{% blocktrans trimmed %} {{ display_name }} {% endblocktrans %}
|
||||
{% endautoescape %}
|
||||
@@ -4,7 +4,6 @@
|
||||
from django.utils.translation import gettext as _
|
||||
from openedx.core.djangolib.markup import HTML
|
||||
from openedx.core.djangolib.js_utils import js_escaped_string
|
||||
from six import text_type
|
||||
%>
|
||||
|
||||
## The JS for this is defined in xqa_interface.html
|
||||
@@ -100,7 +99,7 @@ ${block_content | n, decode.utf8}
|
||||
|
||||
<div class="staff_info" style="display:block">
|
||||
is_released = ${is_released}
|
||||
location = ${text_type(location)}
|
||||
location = ${str(location)}
|
||||
|
||||
<table summary="${_('Module Fields')}">
|
||||
<tr><th>${_('Module Fields')}</th></tr>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
## mako
|
||||
<%page expression_filter="h"/>
|
||||
<%!
|
||||
import six
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
%>
|
||||
@@ -18,7 +17,7 @@ ${_("Student Support")}
|
||||
<h1>${_("Student Support")}</h1>
|
||||
<ul>
|
||||
% for url in urls:
|
||||
<li><a href="${url["url"]}">${six.text_type(url["name"])}</a>: ${six.text_type(url["description"])}</li>
|
||||
<li><a href="${url["url"]}">${str(url["name"])}</a>: ${str(url["description"])}</li>
|
||||
% endfor
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<%inherit file="../main.html" />
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
import six
|
||||
from django.utils.translation import gettext as _
|
||||
from django.urls import reverse
|
||||
from django.utils import html
|
||||
@@ -24,7 +23,7 @@ from openedx.core.djangolib.markup import Text, HTML
|
||||
<input type="hidden" name="_redirect_url" value="${redirect_url}" />
|
||||
|
||||
% if course:
|
||||
<input type="hidden" name="course_id" value="${six.text_type(course.id)}" />
|
||||
<input type="hidden" name="course_id" value="${str(course.id)}" />
|
||||
|
||||
<div class="header-survey">
|
||||
<h4 class="course-info">
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
</section>
|
||||
|
||||
{% javascript 'application' %}
|
||||
{% javascript 'module-js' %}
|
||||
{% with mathjax_mode='wiki' %}
|
||||
{% include "mathjax_include.html" %}
|
||||
{% endwith %}
|
||||
|
||||
@@ -1031,12 +1031,6 @@ if getattr(settings, 'PROVIDER_STATES_URL', None):
|
||||
)
|
||||
]
|
||||
|
||||
# save_for_later API urls
|
||||
if settings.ENABLE_SAVE_FOR_LATER:
|
||||
urlpatterns += [
|
||||
path('', include('lms.djangoapps.save_for_later.urls')),
|
||||
]
|
||||
|
||||
# Enhanced Staff Grader (ESG) URLs
|
||||
urlpatterns += [
|
||||
path('api/ora_staff_grader/', include('lms.djangoapps.ora_staff_grader.urls', 'ora-staff-grader')),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user