diff --git a/common/djangoapps/util/tests/test_db.py b/common/djangoapps/util/tests/test_db.py index 4a16c2a20a..5550bf3a0f 100644 --- a/common/djangoapps/util/tests/test_db.py +++ b/common/djangoapps/util/tests/test_db.py @@ -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): """ diff --git a/lms/djangoapps/save_for_later/admin.py b/lms/djangoapps/save_for_later/admin.py deleted file mode 100644 index 9c94e5e94b..0000000000 --- a/lms/djangoapps/save_for_later/admin.py +++ /dev/null @@ -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) diff --git a/lms/djangoapps/save_for_later/api/__init__.py b/lms/djangoapps/save_for_later/api/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/save_for_later/api/urls.py b/lms/djangoapps/save_for_later/api/urls.py deleted file mode 100644 index 888e03dfe9..0000000000 --- a/lms/djangoapps/save_for_later/api/urls.py +++ /dev/null @@ -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')), -] diff --git a/lms/djangoapps/save_for_later/api/v1/__init__.py b/lms/djangoapps/save_for_later/api/v1/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/save_for_later/api/v1/tests/__init__.py b/lms/djangoapps/save_for_later/api/v1/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/save_for_later/api/v1/tests/test_views.py b/lms/djangoapps/save_for_later/api/v1/tests/test_views.py deleted file mode 100644 index 038016cd09..0000000000 --- a/lms/djangoapps/save_for_later/api/v1/tests/test_views.py +++ /dev/null @@ -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 diff --git a/lms/djangoapps/save_for_later/api/v1/urls.py b/lms/djangoapps/save_for_later/api/v1/urls.py deleted file mode 100644 index b58e213179..0000000000 --- a/lms/djangoapps/save_for_later/api/v1/urls.py +++ /dev/null @@ -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'), -] diff --git a/lms/djangoapps/save_for_later/api/v1/views.py b/lms/djangoapps/save_for_later/api/v1/views.py deleted file mode 100644 index 598b7ee564..0000000000 --- a/lms/djangoapps/save_for_later/api/v1/views.py +++ /dev/null @@ -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) diff --git a/lms/djangoapps/save_for_later/helper.py b/lms/djangoapps/save_for_later/helper.py deleted file mode 100644 index e023f203ee..0000000000 --- a/lms/djangoapps/save_for_later/helper.py +++ /dev/null @@ -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 diff --git a/lms/djangoapps/save_for_later/management/__init__.py b/lms/djangoapps/save_for_later/management/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/save_for_later/management/commands/__init__.py b/lms/djangoapps/save_for_later/management/commands/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/save_for_later/management/commands/send_course_reminder_emails.py b/lms/djangoapps/save_for_later/management/commands/send_course_reminder_emails.py deleted file mode 100644 index 5669925dd4..0000000000 --- a/lms/djangoapps/save_for_later/management/commands/send_course_reminder_emails.py +++ /dev/null @@ -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) diff --git a/lms/djangoapps/save_for_later/management/commands/send_program_reminder_emails.py b/lms/djangoapps/save_for_later/management/commands/send_program_reminder_emails.py deleted file mode 100644 index 77bffd81cb..0000000000 --- a/lms/djangoapps/save_for_later/management/commands/send_program_reminder_emails.py +++ /dev/null @@ -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) diff --git a/lms/djangoapps/save_for_later/management/commands/tests/__init__.py b/lms/djangoapps/save_for_later/management/commands/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/save_for_later/management/commands/tests/test_send_course_reminder_emails.py b/lms/djangoapps/save_for_later/management/commands/tests/test_send_course_reminder_emails.py deleted file mode 100644 index 2c93492ae3..0000000000 --- a/lms/djangoapps/save_for_later/management/commands/tests/test_send_course_reminder_emails.py +++ /dev/null @@ -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 diff --git a/lms/djangoapps/save_for_later/management/commands/tests/test_send_program_reminder_emails.py b/lms/djangoapps/save_for_later/management/commands/tests/test_send_program_reminder_emails.py deleted file mode 100644 index b3665a0456..0000000000 --- a/lms/djangoapps/save_for_later/management/commands/tests/test_send_program_reminder_emails.py +++ /dev/null @@ -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 diff --git a/lms/djangoapps/save_for_later/message_types.py b/lms/djangoapps/save_for_later/message_types.py deleted file mode 100644 index b2fe0c7dfb..0000000000 --- a/lms/djangoapps/save_for_later/message_types.py +++ /dev/null @@ -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 diff --git a/lms/djangoapps/save_for_later/models.py b/lms/djangoapps/save_for_later/models.py index 23eef180b0..e69de29bb2 100644 --- a/lms/djangoapps/save_for_later/models.py +++ b/lms/djangoapps/save_for_later/models.py @@ -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) diff --git a/lms/djangoapps/save_for_later/signals.py b/lms/djangoapps/save_for_later/signals.py deleted file mode 100644 index 1537dcc549..0000000000 --- a/lms/djangoapps/save_for_later/signals.py +++ /dev/null @@ -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') diff --git a/lms/djangoapps/save_for_later/tests/__init__.py b/lms/djangoapps/save_for_later/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/save_for_later/tests/factories.py b/lms/djangoapps/save_for_later/tests/factories.py deleted file mode 100644 index 5c10295ec5..0000000000 --- a/lms/djangoapps/save_for_later/tests/factories.py +++ /dev/null @@ -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) diff --git a/lms/djangoapps/save_for_later/tests/test_signals.py b/lms/djangoapps/save_for_later/tests/test_signals.py deleted file mode 100644 index e3d19b8760..0000000000 --- a/lms/djangoapps/save_for_later/tests/test_signals.py +++ /dev/null @@ -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) diff --git a/lms/djangoapps/save_for_later/urls.py b/lms/djangoapps/save_for_later/urls.py deleted file mode 100644 index ae1e1158c5..0000000000 --- a/lms/djangoapps/save_for_later/urls.py +++ /dev/null @@ -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')), -] diff --git a/lms/envs/common.py b/lms/envs/common.py index c9b7a9f32e..23b6138ba8 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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 #### diff --git a/lms/envs/test.py b/lms/envs/test.py index cb55cfcfb8..932f00fdef 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -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 = [] diff --git a/lms/templates/save_for_later/edx_ace/saveforlater/email/body.html b/lms/templates/save_for_later/edx_ace/saveforlater/email/body.html deleted file mode 100644 index a85058c51d..0000000000 --- a/lms/templates/save_for_later/edx_ace/saveforlater/email/body.html +++ /dev/null @@ -1,205 +0,0 @@ -{% extends 'ace_common/edx_ace/common/base_body.html' %} - -{% load django_markup %} -{% load i18n %} -{% load static %} -{% block content %} -
- {{ short_description }} -
-- {% trans "Estimated 6 weeks" as estimated_weeks_heading %}{{ estimated_weeks_heading | force_escape }} -
-- {% trans "4-6 hours per week" as estimated_weeks_msg %}{{ estimated_weeks_msg | force_escape }} -
-- {% trans "Self-paced" as self_paced_heading %}{{ self_paced_heading | force_escape }} -
-- {% trans "Progress at your own speed" as self_paced_msg %}{{ self_paced_msg | force_escape }} -
-- {% trans "Free" as upgrade_heading %}{{ upgrade_heading | force_escape }} -
-- {% trans "Optional upgrade available" as upgrade_msg %}{{ upgrade_msg | force_escape }} -
-