revert: Removing the save_far_later (#32710)
Removing the save_for_later app after analysing the experiment results. We are not going to make this feature permanent. VAN-1451
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
from io import StringIO
|
||||
|
||||
import ddt
|
||||
import unittest
|
||||
from django.core.management import call_command
|
||||
from django.db.transaction import TransactionManagementError, atomic
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
@@ -120,6 +121,7 @@ class MigrationTests(TestCase):
|
||||
Tests for migrations.
|
||||
"""
|
||||
|
||||
@unittest.skip("Migration will delete several models. Need to skip not referencing it first.")
|
||||
@override_settings(MIGRATION_MODULES={})
|
||||
def test_migrations_are_in_sync(self):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -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')),
|
||||
]
|
||||
@@ -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()
|
||||
@@ -4800,10 +4794,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 ####
|
||||
|
||||
|
||||
@@ -635,13 +635,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 = []
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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')),
|
||||
|
||||
Reference in New Issue
Block a user