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:
Mubbshar Anwar
2023-07-18 15:35:27 +05:00
committed by GitHub
parent 13a5cfc66f
commit 2e2687ad38
32 changed files with 2 additions and 1269 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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