diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml index dc94f9c442..b93f13c4d0 100644 --- a/.github/workflows/pylint-checks.yml +++ b/.github/workflows/pylint-checks.yml @@ -15,7 +15,7 @@ jobs: matrix: include: - module-name: lms-1 - path: "lms/djangoapps/badges/ lms/djangoapps/branding/ lms/djangoapps/bulk_email/ lms/djangoapps/bulk_enroll/ lms/djangoapps/bulk_user_retirement/ lms/djangoapps/ccx/ lms/djangoapps/certificates/ lms/djangoapps/commerce/ lms/djangoapps/course_api/ lms/djangoapps/course_blocks/ lms/djangoapps/course_home_api/ lms/djangoapps/course_wiki/ lms/djangoapps/coursewarehistoryextended/ lms/djangoapps/debug/ lms/djangoapps/courseware/ lms/djangoapps/course_goals/ lms/djangoapps/rss_proxy/" + path: "lms/djangoapps/badges/ lms/djangoapps/branding/ lms/djangoapps/bulk_email/ lms/djangoapps/bulk_enroll/ lms/djangoapps/bulk_user_retirement/ lms/djangoapps/ccx/ lms/djangoapps/certificates/ lms/djangoapps/commerce/ lms/djangoapps/course_api/ lms/djangoapps/course_blocks/ lms/djangoapps/course_home_api/ lms/djangoapps/course_wiki/ lms/djangoapps/coursewarehistoryextended/ lms/djangoapps/debug/ lms/djangoapps/courseware/ lms/djangoapps/course_goals/ lms/djangoapps/rss_proxy/ lms/djangoapps/save_for_later/" - module-name: lms-2 path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/envs/ lms/lib/ lms/tests.py" - module-name: openedx-1 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index cf1feeaba2..8ef4f5d9d7 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -19,7 +19,7 @@ jobs: "lms/djangoapps/courseware/", "lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/", "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/", - "lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy/ lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/envs/ lms/lib/ lms/tests.py", + "lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy/ lms/djangoapps/save_for_later/ lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/envs/ lms/lib/ lms/tests.py", "openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/coursegraph/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/demographics/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/", "openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/self_paced/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/", "cms/djangoapps/api/ cms/djangoapps/cms_user_tasks/ cms/djangoapps/course_creators/ cms/djangoapps/export_course_metadata/ cms/djangoapps/maintenance/ cms/djangoapps/models/ cms/djangoapps/pipeline_js/ cms/djangoapps/xblock_config/ cms/envs/ cms/lib/", diff --git a/lms/djangoapps/save_for_later/__init__.py b/lms/djangoapps/save_for_later/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/save_for_later/api/__init__.py b/lms/djangoapps/save_for_later/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/save_for_later/api/urls.py b/lms/djangoapps/save_for_later/api/urls.py new file mode 100644 index 0000000000..4cfa0ce3d0 --- /dev/null +++ b/lms/djangoapps/save_for_later/api/urls.py @@ -0,0 +1,12 @@ +""" +URL definitions for the save_for_later API. +""" + + +from django.conf.urls import include, url + +app_name = 'lms.djangoapps.save_for_later' + +urlpatterns = [ + url(r'^v1/', include(('lms.djangoapps.save_for_later.api.v1.urls', 'v1'), namespace='v1')), +] diff --git a/lms/djangoapps/save_for_later/api/v1/__init__.py b/lms/djangoapps/save_for_later/api/v1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/save_for_later/api/v1/tests/__init__.py b/lms/djangoapps/save_for_later/api/v1/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/save_for_later/api/v1/tests/test_views.py b/lms/djangoapps/save_for_later/api/v1/tests/test_views.py new file mode 100644 index 0000000000..03d0e3604c --- /dev/null +++ b/lms/djangoapps/save_for_later/api/v1/tests/test_views.py @@ -0,0 +1,94 @@ +""" +Tests for /save/course/ API. +""" + + +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 + + +@skip_unless_lms +@ddt.ddt +class SaveForLaterApiViewTest(ThirdPartyAuthTestMixin, APITestCase): + """ + Save for later tests + """ + + def setUp(self): # pylint: disable=arguments-differ + """ + Test Setup + """ + super().setUp() + + self.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) + + 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.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_for_later_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.url, data=request_payload) + assert response.status_code != 403 + + response = self.client.post(self.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.url, data=request_payload) + assert response.status_code != 403 + + response = self.client.post(self.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.url, data=request_payload) + assert response.status_code == 400 diff --git a/lms/djangoapps/save_for_later/api/v1/urls.py b/lms/djangoapps/save_for_later/api/v1/urls.py new file mode 100644 index 0000000000..21cf74018d --- /dev/null +++ b/lms/djangoapps/save_for_later/api/v1/urls.py @@ -0,0 +1,12 @@ +""" +URLs for save_for_later v1 +""" + + +from django.conf.urls import url + +from lms.djangoapps.save_for_later.api.v1.views import SaveForLaterApiView + +urlpatterns = [ + url(r'^save/course/', SaveForLaterApiView.as_view(), name='save_course'), +] diff --git a/lms/djangoapps/save_for_later/api/v1/views.py b/lms/djangoapps/save_for_later/api/v1/views.py new file mode 100644 index 0000000000..55655beb0b --- /dev/null +++ b/lms/djangoapps/save_for_later/api/v1/views.py @@ -0,0 +1,119 @@ +""" +Save for later views +""" + + +import logging + +from django.conf import settings +from django.utils.decorators import method_decorator +from django.contrib.sites.models import Site +from 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 edx_ace import ace +from edx_ace.recipient import Recipient + +from common.djangoapps.track import segment +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +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.ace_common.template_context import get_base_template_context + +from lms.djangoapps.save_for_later.message_types import SaveForLater +from lms.djangoapps.save_for_later.models import SavedCourse + +log = logging.getLogger(__name__) + +POST_EMAIL_KEY = 'post:email' +REAL_IP_KEY = 'openedx.core.djangoapps.util.ratelimit.real_ip' + + +class SaveForLaterApiView(APIView): + """ + API VIEW + """ + @transaction.atomic + @method_decorator(ratelimit(key=POST_EMAIL_KEY, rate=settings.SAVE_FOR_LATER_EMAIL_RATE_LIMIT, method='POST')) + @method_decorator(ratelimit(key=REAL_IP_KEY, rate=settings.SAVE_FOR_LATER_IP_RATE_LIMIT, method='POST')) + def post(self, request): + """ + **Use Case** + + * Send favorite course through email to user for later learning. + + **Example Request** + + POST /api/v1/save/course/ + + **Example POST Request** + + { + "email": "test@edx.org", + "course_id": "course-v1:edX+DemoX+2021", + "marketing_url": "https://test.com", + "org_img_url": "https://test.com/logo.png" + + } + """ + user = request.user + course_id = request.POST.get('course_id') + email = request.POST.get('email') + marketing_url = request.POST.get('marketing_url') + org_img_url = request.POST.get('org_img_url') + + course_key = CourseKey.from_string(course_id) + + if getattr(request, 'limited', False): + return Response({'result': 'failure'}, status=403) + + if get_email_validation_error(email): + return Response({'result': 'failure'}, status=400) + + try: + course_overview = CourseOverview.get_from_id(course_key) + SavedCourse.objects.update_or_create( + user_id=user.id, + email=email, + course_id=course_id, + ) + except CourseOverview.DoesNotExist: + return Response({'result': 'failure'}, status=404) + + site = Site.objects.get_current() + message_context = get_base_template_context(site) + message_context.update({ + 'course_image_url': course_overview.course_image_url, + 'partner_image_url': org_img_url, + 'enroll_course_url': '/register?course_id={course_key}&enrollment_action=enroll&email_opt_in=false&' + 'save_for_later=true'.format(course_key=course_key), + 'view_course_url': marketing_url + '?save_for_later=true' if marketing_url else '#', + 'course_key': course_key, + 'display_name': course_overview.display_name, + 'short_description': course_overview.short_description, + 'lms_url': configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL), + 'from_address': configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL), + }) + + msg = SaveForLater().personalize( + recipient=Recipient(lms_user_id=0, email_address=email), + language=settings.LANGUAGE_CODE, + user_context=message_context, + ) + try: + ace.send(msg) + segment.track( + user.id, + 'edx.bi.user.sent.email.save.for.later', + { + 'category': 'save-for-later', + 'type': 'course' if course_id else 'program' + } + ) + except Exception: # pylint: disable=broad-except + log.warning('Unable to send save for later email ', exc_info=True) + return Response({'result': 'failure'}, status=400) + else: + return Response({'result': 'success'}, status=200) diff --git a/lms/djangoapps/save_for_later/apps.py b/lms/djangoapps/save_for_later/apps.py new file mode 100644 index 0000000000..e927019a34 --- /dev/null +++ b/lms/djangoapps/save_for_later/apps.py @@ -0,0 +1,14 @@ +""" +save_for_later Application Configuration +""" + + +from django.apps import AppConfig + + +class SaveForLaterConfig(AppConfig): + """ + Application Configuration for save_for_later. + """ + default_auto_field = 'django.db.models.BigAutoField' + name = 'lms.djangoapps.save_for_later' diff --git a/lms/djangoapps/save_for_later/message_types.py b/lms/djangoapps/save_for_later/message_types.py new file mode 100644 index 0000000000..b2fe0c7dfb --- /dev/null +++ b/lms/djangoapps/save_for_later/message_types.py @@ -0,0 +1,13 @@ +""" +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 diff --git a/lms/djangoapps/save_for_later/migrations/0001_initial.py b/lms/djangoapps/save_for_later/migrations/0001_initial.py new file mode 100644 index 0000000000..5214b8e8ce --- /dev/null +++ b/lms/djangoapps/save_for_later/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 3.2.8 on 2021-11-15 09:01 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +import opaque_keys.edx.django.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SavedProgram', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('email', models.EmailField(db_index=True, max_length=254)), + ('program_uuid', models.UUIDField()), + ('user_id', models.IntegerField(blank=True, null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SavedCourse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('email', models.EmailField(db_index=True, max_length=254)), + ('course_id', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)), + ('user_id', models.IntegerField(blank=True, null=True)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/lms/djangoapps/save_for_later/migrations/__init__.py b/lms/djangoapps/save_for_later/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/save_for_later/models.py b/lms/djangoapps/save_for_later/models.py new file mode 100644 index 0000000000..406f82bba9 --- /dev/null +++ b/lms/djangoapps/save_for_later/models.py @@ -0,0 +1,20 @@ +""" +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 + + +class SavedCourse(TimeStampedModel): + user_id = models.IntegerField(null=True, blank=True) + email = models.EmailField(db_index=True) + course_id = CourseKeyField(max_length=255, db_index=True) + + +class SavedProgram(TimeStampedModel): + user_id = models.IntegerField(null=True, blank=True) + email = models.EmailField(db_index=True) + program_uuid = models.UUIDField() diff --git a/lms/djangoapps/save_for_later/urls.py b/lms/djangoapps/save_for_later/urls.py new file mode 100644 index 0000000000..3972571bb3 --- /dev/null +++ b/lms/djangoapps/save_for_later/urls.py @@ -0,0 +1,7 @@ +""" URLs for save_for_later """ + +from django.conf.urls import include, url + +urlpatterns = [ + url(r'^api/', include(('lms.djangoapps.save_for_later.api.urls', 'api'), namespace='api')), +] diff --git a/lms/envs/common.py b/lms/envs/common.py index fd7a56ce36..43847aa031 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1001,6 +1001,9 @@ 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 + ############################# SET PATH INFORMATION ############################# PROJECT_ROOT = path(__file__).abspath().dirname().dirname() # /edx-platform/lms REPO_ROOT = PROJECT_ROOT.dirname() @@ -3194,6 +3197,9 @@ INSTALLED_APPS = [ # For edx ace template tags 'edx_ace', + + # For save for later + 'lms.djangoapps.save_for_later' ] ######################### CSRF ######################################### @@ -4561,6 +4567,10 @@ RESET_PASSWORD_API_RATELIMIT = '30/7d' 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/m' + ############### Settings for Retirement ##################### # .. setting_name: RETIRED_USERNAME_PREFIX # .. setting_default: retired__user_ diff --git a/lms/envs/test.py b/lms/envs/test.py index fc9e4d607c..ad1910f9e5 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -613,3 +613,10 @@ RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT = '2/m' 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' diff --git a/lms/templates/save_for_later/edx_ace/saveforlater/email/body.html b/lms/templates/save_for_later/edx_ace/saveforlater/email/body.html new file mode 100644 index 0000000000..a85058c51d --- /dev/null +++ b/lms/templates/save_for_later/edx_ace/saveforlater/email/body.html @@ -0,0 +1,205 @@ +{% extends 'ace_common/edx_ace/common/base_body.html' %} + +{% load django_markup %} +{% load i18n %} +{% load static %} +{% block content %} +
+ {{ short_description }} +
++ {% trans "Estimated 6 weeks" as estimated_weeks_heading %}{{ estimated_weeks_heading | force_escape }} +
++ {% trans "4-6 hours per week" as estimated_weeks_msg %}{{ estimated_weeks_msg | force_escape }} +
++ {% trans "Self-paced" as self_paced_heading %}{{ self_paced_heading | force_escape }} +
++ {% trans "Progress at your own speed" as self_paced_msg %}{{ self_paced_msg | force_escape }} +
++ {% trans "Free" as upgrade_heading %}{{ upgrade_heading | force_escape }} +
++ {% trans "Optional upgrade available" as upgrade_msg %}{{ upgrade_msg | force_escape }} +
+