feat: save for later (#29089)

send favorite course through email to user

VAN-741
This commit is contained in:
Mubbshar Anwar
2021-12-01 16:10:20 +05:00
committed by GitHub
parent 2325150cf8
commit fec7849b26
24 changed files with 583 additions and 2 deletions

View File

@@ -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

View File

@@ -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/",

View File

@@ -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')),
]

View File

@@ -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

View File

@@ -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'),
]

View File

@@ -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)

View File

@@ -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'

View File

@@ -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

View File

@@ -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,
},
),
]

View File

@@ -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()

View File

@@ -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')),
]

View File

@@ -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_

View File

@@ -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'

View File

@@ -0,0 +1,205 @@
{% 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 %}

View File

@@ -0,0 +1,8 @@
{% 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 %}

View File

@@ -0,0 +1 @@
{{ platform_name }}

View File

@@ -0,0 +1 @@
{% extends 'ace_common/edx_ace/common/base_head.html' %}

View File

@@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans trimmed %} {{ display_name }} {% endblocktrans %}
{% endautoescape %}

View File

@@ -1020,3 +1020,9 @@ if getattr(settings, 'PROVIDER_STATES_URL', None):
name='courseware_xblock_handler_provider_state',
)
]
# save_for_later API urls
if settings.ENABLE_SAVE_FOR_LATER:
urlpatterns += [
path('', include('lms.djangoapps.save_for_later.urls')),
]