From 22b378e6054b23aa758fb1a508e4e9e1694e2d65 Mon Sep 17 00:00:00 2001 From: Mubbshar Anwar <78487564+mubbsharanwar@users.noreply.github.com> Date: Mon, 5 Sep 2022 16:55:46 +0500 Subject: [PATCH] feat: Pass segment properties (#30919) - For new enrollment email pass extra segment event properties. VAN-999 --- common/djangoapps/course_modes/views.py | 4 +- common/djangoapps/student/helpers.py | 93 +++++++++++- .../tests/test_transfer_students.py | 10 +- common/djangoapps/student/models.py | 65 ++++++++- .../student/tests/test_enrollment.py | 12 ++ common/djangoapps/student/tests/tests.py | 137 ++++++++++++++++-- common/djangoapps/student/toggles.py | 20 ++- common/djangoapps/student/views/management.py | 3 +- .../tests/test_field_override_performance.py | 5 + .../course_home_api/dates/serializers.py | 3 +- lms/djangoapps/courseware/courses.py | 25 +++- lms/djangoapps/courseware/tests/test_views.py | 18 ++- 12 files changed, 368 insertions(+), 27 deletions(-) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index 7322483b8d..5fbcd1513a 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -324,11 +324,11 @@ class ChooseModeView(View): # been configured. However, alternative enrollment workflows have been introduced into the # system, such as third-party discovery. These workflows result in learners arriving # directly at this screen, and they will not necessarily be pre-enrolled in the audit mode. - CourseEnrollment.enroll(request.user, course_key, CourseMode.AUDIT) + CourseEnrollment.enroll(request.user, course_key, CourseMode.AUDIT, request=request) return self._redirect_to_course_or_dashboard(course, course_key, user) if requested_mode == 'honor': - CourseEnrollment.enroll(user, course_key, mode=requested_mode) + CourseEnrollment.enroll(user, course_key, mode=requested_mode, request=request) return self._redirect_to_course_or_dashboard(course, course_key, user) mode_info = allowed_modes[requested_mode] diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index 71b46d5af5..f0f62292f1 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -20,7 +20,7 @@ from django.core.validators import ValidationError from django.db import IntegrityError, ProgrammingError, transaction from django.urls import NoReverseMatch, reverse from django.utils.translation import gettext as _ -from pytz import UTC +from pytz import UTC, timezone from common.djangoapps import third_party_auth from common.djangoapps.course_modes.models import CourseMode @@ -50,10 +50,15 @@ from lms.djangoapps.instructor import access from lms.djangoapps.verify_student.models import VerificationDeadline from lms.djangoapps.verify_student.services import IDVerificationService from lms.djangoapps.verify_student.utils import is_verification_expiring_soon, verification_for_datetime +from lms.djangoapps.courseware.courses import get_course_date_blocks, get_course_with_access +from lms.djangoapps.courseware.date_summary import TodaysDate +from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs +from lms.djangoapps.course_home_api.dates.serializers import DateSummarySerializer from openedx.core.djangoapps.content.block_structure.exceptions import UsageKeyNotInBlockStructure from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.theming.helpers import get_themes from openedx.core.djangoapps.user_authn.utils import is_safe_login_or_logout_redirect +from openedx.core.lib.time_zone_utils import get_time_zone_offset from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order # Enumeration of per-course verification statuses @@ -814,3 +819,89 @@ def user_has_passing_grade_in_course(enrollment): except AttributeError: pass return False + + +def get_instructors(course_run, marketing_root_url): + """ + Get course instructors. + """ + instructors = [] + staff = course_run.get('staff', []) + for instructor in staff: + instructor = { + 'name': f"{instructor.get('given_name')} {instructor.get('family_name')}", + 'profile_image_url': instructor.get('profile_image_url'), + 'organization_name': (instructor.get('position').get('organization_name') + if instructor.get('position') else ''), + 'bio_url': f"{marketing_root_url}/bio/{instructor.get('slug')}" + } + instructors.append(instructor) + + return instructors + + +def _prepare_date_block(block, block_date, user_timezone): + """ + Prepare date block which include assignment related data for this date + """ + timezone_offset = get_time_zone_offset(user_timezone, block_date) + block = { + 'title': block.get('title', ''), + 'assignment_type': block.get('assignment_type', '') or '', + 'assignment_count': 0, + 'link': block.get('link', ''), + 'date': block_date, + 'due_date': block_date.strftime("%a, %b %d, %Y"), + 'due_time': (f'{block_date.strftime("%H:%M %p")} GMT{timezone_offset}' if block.get('assignment_type') else '') + } + return block + + +def get_course_dates_for_email(user, course_id, request): + """ + Getting nearest dates from today one would be before today and one + would be after today. + """ + user_timezone_locale = user_timezone_locale_prefs(request) + user_timezone = timezone(user_timezone_locale['user_timezone'] or str(UTC)) + + course = get_course_with_access(user, 'load', course_id) + date_blocks = get_course_date_blocks(course, user, request, include_access=True, include_past_dates=True) + date_blocks = [block for block in date_blocks if not isinstance(block, TodaysDate)] + blocks = DateSummarySerializer(date_blocks, many=True).data + + today = datetime.now(user_timezone) + course_date = { + 'title': '', + 'assignment_type': '', + 'link': '', + 'assignment_count': 0, + 'date': '', + 'due_date': today.strftime("%a, %b %d, %Y"), + 'due_time': '' + } + course_date_list = [{**course_date, }, {**course_date, 'date': today}, {**course_date}] + for block in blocks: + block_date = datetime.strptime(block.get('date')[:19], '%Y-%m-%dT%H:%M:%S') + block_date = block_date.replace(tzinfo=UTC) + block_date = block_date.astimezone(user_timezone) + + if block_date < today: + if block_date == course_date_list[0]['date'] and block.get('assignment_type'): + course_date_list[0]['assignment_count'] += 1 + else: + course_date_list[0].update(_prepare_date_block(block, block_date, user_timezone)) + + if block_date == today: + if block.get('assignment_type') and course_date_list[1]['assignment_type'] != '': + course_date_list[1]['assignment_count'] += 1 + else: + course_date_list[1].update(_prepare_date_block(block, block_date, user_timezone)) + + if block_date > today: + if block_date == course_date_list[2]['date'] and block.get('assignment_type'): + course_date_list[2]['assignment_count'] += 1 + if course_date_list[2]['date'] == '': + course_date_list[2].update(_prepare_date_block(block, block_date, user_timezone)) + + return course_date_list diff --git a/common/djangoapps/student/management/tests/test_transfer_students.py b/common/djangoapps/student/management/tests/test_transfer_students.py index f42ab66e10..f9ee5c808a 100644 --- a/common/djangoapps/student/management/tests/test_transfer_students.py +++ b/common/djangoapps/student/management/tests/test_transfer_students.py @@ -20,6 +20,7 @@ from common.djangoapps.student.models import ( ) from common.djangoapps.student.signals import UNENROLL_DONE from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order @@ -54,7 +55,8 @@ class TestTransferStudents(ModuleStoreTestCase): assert skip_refund self.signal_fired = True - def test_transfer_students(self): + @patch('openedx.core.djangoapps.catalog.api.get_course_run_details') + def test_transfer_students(self, mock_get_course_run_details): """ Verify the transfer student command works as intended. """ @@ -65,6 +67,12 @@ class TestTransferStudents(ModuleStoreTestCase): # Original Course original_course_location = locator.CourseLocator('Org0', 'Course0', 'Run0') course = self._create_course(original_course_location) + + course_run = CourseRunFactory.create(key=course.id) + course_run['min_effort'] = 1 + course_run['enrollment_count'] = 12345 + + mock_get_course_run_details.return_value = course_run # Enroll the student in 'verified' CourseEnrollment.enroll(student, course.id, mode='verified') diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 2219afb572..2b4397ba4b 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -1426,7 +1426,7 @@ class CourseEnrollment(models.Model): from openedx.core.djangoapps.enrollments.permissions import ENROLL_IN_COURSE return not user.has_perm(ENROLL_IN_COURSE, course) - def update_enrollment(self, mode=None, is_active=None, skip_refund=False, enterprise_uuid=None): + def update_enrollment(self, mode=None, is_active=None, skip_refund=False, enterprise_uuid=None, request=None): """ Updates an enrollment for a user in a class. This includes options like changing the mode, toggling is_active True/False, etc. @@ -1491,7 +1491,7 @@ class CourseEnrollment(models.Model): if activation_changed: if self.is_active: - self.emit_event(EVENT_NAME_ENROLLMENT_ACTIVATED, enterprise_uuid=enterprise_uuid) + self.emit_event(EVENT_NAME_ENROLLMENT_ACTIVATED, enterprise_uuid=enterprise_uuid, request=request) else: UNENROLL_DONE.send(sender=None, course_enrollment=self, skip_refund=skip_refund) self.emit_event(EVENT_NAME_ENROLLMENT_DEACTIVATED) @@ -1554,11 +1554,16 @@ class CourseEnrollment(models.Model): mode=mode, course_id=course_id, cost=cost, currency=currency) - def emit_event(self, event_name, enterprise_uuid=None): + def emit_event(self, event_name, enterprise_uuid=None, request=None): # pylint: disable=too-many-statements """ Emits an event to explicitly track course enrollment and unenrollment. """ + from common.djangoapps.student.helpers import get_course_dates_for_email, get_instructors + from common.djangoapps.student.toggles import should_send_redesign_email from openedx.core.djangoapps.schedules.config import set_up_external_updates_for_enrollment + from openedx.core.djangoapps.catalog.api import get_course_run_details + from openedx.core.djangoapps.catalog.utils import get_owners_for_course, get_course_uuid_for_course + from openedx.features.course_experience import ENABLE_COURSE_GOALS from openedx.core.djangoapps.user_api.preferences.api import get_user_preference from openedx.features.enterprise_support.utils import is_enterprise_learner @@ -1596,6 +1601,55 @@ class CourseEnrollment(models.Model): segment_traits['email'] = self.user.email if event_name == EVENT_NAME_ENROLLMENT_ACTIVATED: + studio_request = settings.ROOT_URLCONF == 'cms.urls' + extra_segment_properties = { + 'studio_request': studio_request + } + if not studio_request and should_send_redesign_email(): + if not request: + request = crum.get_current_request() + + marketing_root_url = settings.MKTG_URLS.get('ROOT') + course_dates_list = get_course_dates_for_email(self.user, self.course.id, request) + course_run_fields = [ + 'key', 'title', 'short_description', 'marketing_url', 'pacing_type', 'min_effort', + 'max_effort', 'weeks_to_complete', 'enrollment_count', 'image', 'staff', + ] + owners, course_run = None, None + try: + course_uuid = get_course_uuid_for_course(str(self.course_id)) + owners = get_owners_for_course(course_uuid=course_uuid) + course_run = get_course_run_details(str(self.course_id), course_run_fields) + except Exception: # pylint: disable=broad-except + pass + + if course_run: + instructors = get_instructors(course_run, marketing_root_url) + extra_segment_properties.update({ + 'instructors': instructors, + 'instructors_count': 'even' if len(instructors) % 2 == 0 else 'odd', + 'pacing_type': course_run.get('pacing_type'), + 'min_effort': course_run.get('min_effort'), + 'max_effort': course_run.get('max_effort'), + 'weeks_to_complete': course_run.get('weeks_to_complete'), + 'learners_count': '{:,}'.format(course_run.get('enrollment_count')), + 'course_title': course_run.get('title'), + 'short_description': course_run.get('short_description'), + 'marketing_url': course_run.get('marketing_url'), + 'banner_image_url': course_run.get('image').get('src') if course_run.get('image') else '' + }) + extra_segment_properties.update({ + 'goals_enabled': ENABLE_COURSE_GOALS.is_enabled(self.course_id), + 'course_date_blocks': course_dates_list, + 'partner_image_url': owners[0].get('logo_image_url') if owners else '', + 'learner_name': self.user.profile.name, + 'course_run_key': str(self.course_id), + 'price': self.course_price, + 'lms_base_url': configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL), + 'learning_base_url': configuration_helpers.get_value('LEARNING_MICROFRONTEND_URL', + settings.LEARNING_MICROFRONTEND_URL) + }) + segment_properties.update(extra_segment_properties) segment_properties['email'] = self.user.email # This next property is for an experiment, see method's comments for more information segment_properties['external_course_updates'] = set_up_external_updates_for_enrollment(self.user, @@ -1640,7 +1694,8 @@ class CourseEnrollment(models.Model): ) @classmethod - def enroll(cls, user, course_key, mode=None, check_access=False, can_upgrade=False, enterprise_uuid=None): + def enroll(cls, user, course_key, mode=None, check_access=False, can_upgrade=False, + enterprise_uuid=None, request=None): """ Enroll a user in a course. This saves immediately. @@ -1735,7 +1790,7 @@ class CourseEnrollment(models.Model): # User is allowed to enroll if they've reached this point. enrollment = cls.get_or_create_enrollment(user, course_key) - enrollment.update_enrollment(is_active=True, mode=mode, enterprise_uuid=enterprise_uuid) + enrollment.update_enrollment(is_active=True, mode=mode, enterprise_uuid=enterprise_uuid, request=request) enrollment.send_signal(EnrollStatusChange.enroll) # .. event_implemented_name: COURSE_ENROLLMENT_CREATED diff --git a/common/djangoapps/student/tests/test_enrollment.py b/common/djangoapps/student/tests/test_enrollment.py index 520f5a594e..5e332bc2e5 100644 --- a/common/djangoapps/student/tests/test_enrollment.py +++ b/common/djangoapps/student/tests/test_enrollment.py @@ -26,6 +26,7 @@ from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRol from common.djangoapps.student.tests.factories import CourseEnrollmentAllowedFactory, UserFactory from common.djangoapps.util.testing import UrlResetMixin from openedx.core.djangoapps.embargo.test_utils import restrict_course +from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory @ddt.ddt @@ -74,6 +75,17 @@ class EnrollmentTest(UrlResetMixin, ModuleStoreTestCase, OpenEdxEventsTestMixin) # Set up proctored exam self._create_proctored_exam(self.proctored_course) + course_run = CourseRunFactory.create(key=self.course.id) + course_run.update({ + 'min_effort': 1, + 'enrollment_count': 12345 + }) + + patch_course_data = patch('openedx.core.djangoapps.catalog.api.get_course_run_details') + course_data = patch_course_data.start() + course_data.return_value = course_run + self.addCleanup(patch_course_data.stop) + def _create_proctored_exam(self, course): """ Helper function to create a proctored exam for a given course diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 14a3c360e8..72938e1b8d 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -21,6 +21,7 @@ from markupsafe import escape from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locations import CourseLocator from pyquery import PyQuery as pq +from edx_toggles.toggles.testutils import override_waffle_flag from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory @@ -38,6 +39,7 @@ from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, U from common.djangoapps.student.views import complete_course_mode_info from common.djangoapps.util.model_utils import USER_SETTINGS_CHANGED_EVENT_NAME from common.djangoapps.util.testing import EventTestMixin +from common.djangoapps.student.toggles import ENROLLMENT_CONFIRMATION_EMAIL_REDESIGN from lms.djangoapps.certificates.data import CertificateStatuses from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory from lms.djangoapps.verify_student.tests import TestVerificationBase @@ -47,6 +49,7 @@ from openedx.core.djangoapps.content.course_overviews.tests.factories import Cou from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from xmodule.modulestore.tests.django_utils import ModuleStoreEnum, ModuleStoreTestCase, SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls # lint-amnesty, pylint: disable=wrong-import-order from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order @@ -284,6 +287,11 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): self.client = Client() cache.clear() + patch_context = patch('common.djangoapps.student.helpers.get_course_dates_for_email') + get_course = patch_context.start() + get_course.return_value = [] + self.addCleanup(patch_context.stop) + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') def _check_verification_status_on(self, mode, value): """ @@ -651,7 +659,7 @@ class EnrollmentEventTestMixin(EventTestMixin): ) self.mock_segment_tracker.reset_mock() - def assert_enrollment_event_was_emitted(self, user, course_key, course, enrollment): + def assert_enrollment_event_was_emitted(self, user, course_key, course, enrollment, course_run=None): """Ensures an enrollment event was emitted since the last event related assertion""" self.mock_tracker.emit.assert_called_once_with( 'edx.course.enrollment.activated', @@ -662,7 +670,8 @@ class EnrollmentEventTestMixin(EventTestMixin): } ) self.mock_tracker.reset_mock() - properties, traits = self._build_segment_properties_and_traits(user, course_key, course, enrollment, True) + properties, traits = self._build_segment_properties_and_traits(user, course_key, course, + enrollment, True, course_run) self.mock_segment_tracker.track.assert_called_once_with( user.id, 'edx.course.enrollment.activated', properties, traits=traits ) @@ -685,7 +694,8 @@ class EnrollmentEventTestMixin(EventTestMixin): ) self.mock_segment_tracker.reset_mock() - def _build_segment_properties_and_traits(self, user, course_key, course, enrollment, activated=False): + def _build_segment_properties_and_traits(self, user, course_key, course, enrollment, + activated=False, course_run=None): """ Builds the segment properties and traits that are sent during enrollment events """ properties = { 'category': 'conversion', @@ -698,6 +708,10 @@ class EnrollmentEventTestMixin(EventTestMixin): traits = properties.copy() traits.update({'course_title': course.display_name, 'email': user.email}) + lms_root_url = configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL) + learning_base_url = configuration_helpers.get_value('LEARNING_MICROFRONTEND_URL', + settings.LEARNING_MICROFRONTEND_URL) + studio_request = settings.ROOT_URLCONF == 'cms.urls' if activated: properties.update({ 'email': user.email, @@ -707,19 +721,78 @@ class EnrollmentEventTestMixin(EventTestMixin): 'course_start': course.start, 'course_pacing': course.pacing, 'redesign_email': False, + 'studio_request': studio_request, }) + if not studio_request: + properties.update({ + 'price': 'Free', + 'goals_enabled': False, + 'learner_name': user.profile.name, + 'course_run_key': str(course_key), + 'lms_base_url': lms_root_url, + 'learning_base_url': learning_base_url, + 'course_title': course_run.get('title'), + 'short_description': course_run.get('short_description'), + 'marketing_url': course_run.get('marketing_url'), + 'pacing_type': course_run.get('pacing_type'), + 'partner_image_url': '', + 'banner_image_url': course_run.get('image').get('src'), + 'instructors': [], + 'instructors_count': 'even', + 'min_effort': course_run.get('min_effort'), + 'max_effort': course_run.get('max_effort'), + 'weeks_to_complete': course_run.get('weeks_to_complete'), + 'learners_count': '{:,}'.format(course_run.get('enrollment_count')), + 'course_date_blocks': [], + }) + return properties, traits -class EnrollInCourseTest(EnrollmentEventTestMixin, CacheIsolationTestCase): +@override_waffle_flag(ENROLLMENT_CONFIRMATION_EMAIL_REDESIGN, active=True) +@override_settings(PAID_COURSE_REGISTRATION_CURRENCY=["USD", "$"]) +class EnrollInCourseTest(EnrollmentEventTestMixin, CacheIsolationTestCase, ModuleStoreTestCase): """Tests enrolling and unenrolling in courses.""" + def setUp(self): + """ + Set up tests + """ + super().setUp() + + patch_context = patch('common.djangoapps.student.helpers.get_course_dates_for_email') + get_course = patch_context.start() + get_course.return_value = [] + self.addCleanup(patch_context.stop) + + @staticmethod + def _create_course_run(course_id, course): + """ + Discovery course run + """ + course_run = CourseRunFactory.create(key=course_id) + course_run.update({ + 'title': course.display_name, + 'short_description': course.short_description, + 'marketing_url': course.marketing_url, + 'pacing_type': 'self_paced' if course.self_paced else 'instructor_paced', + 'banner_image_url': course.banner_image_url, + 'min_effort': 1, + 'enrollment_count': 12345 + }) + return course_run + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') def test_enrollment(self): user = UserFactory.create(username="joe", email="joe@joe.com", password="password") course_id = CourseKey.from_string("edX/Test101/2013") course_id_partial = CourseKey.from_string("edX/Test101/") course = CourseOverviewFactory.create(id=course_id) + course_run = self._create_course_run(course_id, course) + + patch_course_data = patch('openedx.core.djangoapps.catalog.api.get_course_run_details') + course_data = patch_course_data.start() + course_data.return_value = course_run # Test basic enrollment assert not CourseEnrollment.is_enrolled(user, course_id) @@ -727,7 +800,7 @@ class EnrollInCourseTest(EnrollmentEventTestMixin, CacheIsolationTestCase): enrollment = CourseEnrollment.enroll(user, course_id) assert CourseEnrollment.is_enrolled(user, course_id) assert CourseEnrollment.is_enrolled_by_partial(user, course_id_partial) - self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment) + self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, course_run) # Enrolling them again should be harmless enrollment = CourseEnrollment.enroll(user, course_id) @@ -761,12 +834,19 @@ class EnrollInCourseTest(EnrollmentEventTestMixin, CacheIsolationTestCase): enrollment = CourseEnrollment.enroll(user, course_id, "audit") assert CourseEnrollment.is_enrolled(user, course_id) assert enrollment.mode == 'audit' + self.addCleanup(patch_course_data.stop) + @override_settings(LEARNING_MICROFRONTEND_URL='https://learningmfe.openedx.org') def test_enrollment_non_existent_user(self): # Testing enrollment of newly unsaved user (i.e. no database entry) user = UserFactory(username="rusty", email="rusty@fake.edx.org") course_id = CourseLocator("edX", "Test101", "2013") course = CourseOverviewFactory.create(id=course_id) + course_run = self._create_course_run(course_id, course) + + patch_course_data = patch('openedx.core.djangoapps.catalog.api.get_course_run_details') + course_data = patch_course_data.start() + course_data.return_value = course_run assert not CourseEnrollment.is_enrolled(user, course_id) @@ -778,17 +858,23 @@ class EnrollInCourseTest(EnrollmentEventTestMixin, CacheIsolationTestCase): # should still work enrollment = CourseEnrollment.enroll(user, course_id) assert CourseEnrollment.is_enrolled(user, course_id) - self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment) + self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, course_run) + self.addCleanup(patch_course_data.stop) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') def test_enrollment_by_email(self): user = UserFactory.create(username="jack", email="jack@fake.edx.org") course_id = CourseLocator("edX", "Test101", "2013") course = CourseOverviewFactory.create(id=course_id) + course_run = self._create_course_run(course_id, course) + + patch_course_data = patch('openedx.core.djangoapps.catalog.api.get_course_run_details') + course_data = patch_course_data.start() + course_data.return_value = course_run enrollment = CourseEnrollment.enroll_by_email("jack@fake.edx.org", course_id) assert CourseEnrollment.is_enrolled(user, course_id) - self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment) + self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, course_run) # This won't throw an exception, even though the user is not found assert CourseEnrollment.enroll_by_email('not_jack@fake.edx.org', course_id) is None @@ -816,6 +902,7 @@ class EnrollInCourseTest(EnrollmentEventTestMixin, CacheIsolationTestCase): # Unenroll on non-existent user shouldn't throw an error CourseEnrollment.unenroll_by_email("not_jack@fake.edx.org", course_id) self.assert_no_events_were_emitted() + self.addCleanup(patch_course_data.stop) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') def test_enrollment_multiple_classes(self): @@ -824,11 +911,24 @@ class EnrollInCourseTest(EnrollmentEventTestMixin, CacheIsolationTestCase): course_id2 = CourseLocator("MITx", "6.003z", "2012") course1 = CourseOverviewFactory.create(id=course_id1) course2 = CourseOverviewFactory.create(id=course_id2) + course_run1 = self._create_course_run(course_id1, course1) + course_run2 = self._create_course_run(course_id2, course2) + + patch_course_data1 = patch('openedx.core.djangoapps.catalog.api.get_course_run_details') + course_data1 = patch_course_data1.start() + course_data1.return_value = course_run1 enrollment1 = CourseEnrollment.enroll(user, course_id1) - self.assert_enrollment_event_was_emitted(user, course_id1, course1, enrollment1) + self.assert_enrollment_event_was_emitted(user, course_id1, course1, enrollment1, course_run1) + self.addCleanup(course_data1.stop) + + patch_course_data2 = patch('openedx.core.djangoapps.catalog.api.get_course_run_details') + course_data2 = patch_course_data2.start() + course_data2.return_value = course_run2 + enrollment2 = CourseEnrollment.enroll(user, course_id2) - self.assert_enrollment_event_was_emitted(user, course_id2, course2, enrollment2) + self.assert_enrollment_event_was_emitted(user, course_id2, course2, enrollment2, course_run2) + self.addCleanup(course_data2.stop) assert CourseEnrollment.is_enrolled(user, course_id1) assert CourseEnrollment.is_enrolled(user, course_id2) @@ -847,6 +947,11 @@ class EnrollInCourseTest(EnrollmentEventTestMixin, CacheIsolationTestCase): user = UserFactory.create(username="jack", email="jack@fake.edx.org") course_id = CourseLocator("edX", "Test101", "2013") course = CourseOverviewFactory.create(id=course_id) + course_run = self._create_course_run(course_id, course) + + patch_course_data = patch('openedx.core.djangoapps.catalog.api.get_course_run_details') + course_data = patch_course_data.start() + course_data.return_value = course_run assert not CourseEnrollment.is_enrolled(user, course_id) # Creating an enrollment doesn't actually enroll a student @@ -858,7 +963,7 @@ class EnrollInCourseTest(EnrollmentEventTestMixin, CacheIsolationTestCase): # Until you explicitly activate it enrollment.activate() assert CourseEnrollment.is_enrolled(user, course_id) - self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment) + self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, course_run) # Activating something that's already active does nothing enrollment.activate() @@ -879,15 +984,22 @@ class EnrollInCourseTest(EnrollmentEventTestMixin, CacheIsolationTestCase): # for that user/course_id combination CourseEnrollment.enroll(user, course_id) assert CourseEnrollment.is_enrolled(user, course_id) - self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment) + self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, course_run) + self.addCleanup(course_data.stop) + @override_settings(LEARNING_MICROFRONTEND_URL='https://learningmfe.openedx.org') def test_change_enrollment_modes(self): user = UserFactory.create(username="justin", email="jh@fake.edx.org") course_id = CourseLocator("edX", "Test101", "2013") course = CourseOverviewFactory.create(id=course_id) + course_run = self._create_course_run(course_id, course) + + patch_course_data = patch('openedx.core.djangoapps.catalog.api.get_course_run_details') + course_data = patch_course_data.start() + course_data.return_value = course_run enrollment = CourseEnrollment.enroll(user, course_id, "audit") - self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment) + self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, course_run) enrollment = CourseEnrollment.enroll(user, course_id, "honor") self.assert_enrollment_mode_change_event_was_emitted(user, course_id, "honor", course, enrollment) @@ -898,6 +1010,7 @@ class EnrollInCourseTest(EnrollmentEventTestMixin, CacheIsolationTestCase): enrollment = CourseEnrollment.enroll(user, course_id, "audit") self.assert_enrollment_mode_change_event_was_emitted(user, course_id, "audit", course, enrollment) + self.addCleanup(course_data.stop) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') diff --git a/common/djangoapps/student/toggles.py b/common/djangoapps/student/toggles.py index 9c50bd6290..6f18354af4 100644 --- a/common/djangoapps/student/toggles.py +++ b/common/djangoapps/student/toggles.py @@ -3,7 +3,6 @@ Toggles for Dashboard page. """ from edx_toggles.toggles import WaffleFlag - # Namespace for student waffle flags. WAFFLE_FLAG_NAMESPACE = 'student' @@ -22,3 +21,22 @@ ENABLE_AMPLITUDE_RECOMMENDATIONS = WaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.enable_a def should_show_amplitude_recommendations(): return ENABLE_AMPLITUDE_RECOMMENDATIONS.is_enabled() + + +# Waffle flag to enable redesigned course enrollment confirmation email. +# .. toggle_name: student.enable_redesign_enrollment_confirmation_email +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Enable redesign email template only for staff users for testing. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2022-08-05 +# .. toggle_target_removal_date: None +# .. toggle_warning: None +# .. toggle_tickets: VAN-1064 +ENROLLMENT_CONFIRMATION_EMAIL_REDESIGN = WaffleFlag( + f'{WAFFLE_FLAG_NAMESPACE}.enable_redesign_enrollment_confirmation_email', __name__ +) + + +def should_send_redesign_email(): + return ENROLLMENT_CONFIRMATION_EMAIL_REDESIGN.is_enabled() diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index d131575193..d04a6d492d 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -387,7 +387,8 @@ def change_enrollment(request, check_access=True): try: enroll_mode = CourseMode.auto_enroll_mode(course_id, available_modes) if enroll_mode: - CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode) + CourseEnrollment.enroll(user, course_id, check_access=check_access, + mode=enroll_mode, request=request) except Exception: # pylint: disable=broad-except return HttpResponseBadRequest(_("Could not enroll")) diff --git a/lms/djangoapps/ccx/tests/test_field_override_performance.py b/lms/djangoapps/ccx/tests/test_field_override_performance.py index a4af227fe8..4215dff245 100644 --- a/lms/djangoapps/ccx/tests/test_field_override_performance.py +++ b/lms/djangoapps/ccx/tests/test_field_override_performance.py @@ -76,6 +76,11 @@ class FieldOverridePerformanceTestCase(FieldOverrideTestMixin, ProceduralCourseT self.course = None self.ccx = None + patch_context = mock.patch('common.djangoapps.student.helpers.get_course_dates_for_email') + get_course = patch_context.start() + get_course.return_value = [] + self.addCleanup(patch_context.stop) + def setup_course(self, size, enable_ccx, view_as_ccx): """ Build a gradable course where each node has `size` children. diff --git a/lms/djangoapps/course_home_api/dates/serializers.py b/lms/djangoapps/course_home_api/dates/serializers.py index b4cd85e887..1c7b93b834 100644 --- a/lms/djangoapps/course_home_api/dates/serializers.py +++ b/lms/djangoapps/course_home_api/dates/serializers.py @@ -36,8 +36,7 @@ class DateSummarySerializer(serializers.Serializer): def get_link(self, block): if block.link: - request = self.context.get('request') - return request.build_absolute_uri(block.link) + return block.link return '' def get_first_component_block_id(self, block): diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index b46aaba2ba..87f5a54f63 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -7,6 +7,7 @@ import logging from collections import defaultdict, namedtuple from datetime import datetime +import six import pytz from crum import get_current_request from dateutil.parser import parse as parse_date @@ -494,6 +495,28 @@ def date_block_key_fn(block): return block.date or datetime.max.replace(tzinfo=pytz.UTC) +def _get_absolute_url(request, url_path): + """Construct an absolute URL back to the site. + + Arguments: + request (request): request object. + url_path (string): The path of the URL. + + Returns: + URL + + """ + if not url_path: + return '' + + if request: + return request.build_absolute_uri(url_path) + + site_name = configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME) + parts = ("https" if settings.HTTPS == "on" else "http", site_name, url_path, '', '', '') + return six.moves.urllib.parse.urlunparse(parts) + + def get_course_assignment_date_blocks(course, user, request, num_return=None, include_past_dates=False, include_access=False): """ @@ -510,7 +533,7 @@ def get_course_assignment_date_blocks(course, user, request, num_return=None, date_block.complete = assignment.complete date_block.assignment_type = assignment.assignment_type date_block.past_due = assignment.past_due - date_block.link = request.build_absolute_uri(assignment.url) if assignment.url else '' + date_block.link = _get_absolute_url(request, assignment.url) date_block.set_title(assignment.title, link=assignment.url) date_block._extra_info = assignment.extra_info # pylint: disable=protected-access date_blocks.append(date_block) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 17c87108e2..e549e62d3e 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -338,8 +338,10 @@ class IndexQueryTestCase(ModuleStoreTestCase): """ NUM_PROBLEMS = 20 - def test_index_query_counts(self): + @patch('common.djangoapps.student.helpers.get_course_dates_for_email') + def test_index_query_counts(self, mock_course_dates_for_email): # TODO: decrease query count as part of REVO-28 + mock_course_dates_for_email.return_value = [] ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) with self.store.default_store(ModuleStoreEnum.Type.split): course = CourseFactory.create() @@ -350,6 +352,20 @@ class IndexQueryTestCase(ModuleStoreTestCase): for _ in range(self.NUM_PROBLEMS): ItemFactory.create(category='problem', parent_location=vertical.location) + course_run = CourseRunFactory.create(key=course.id) + course_run['title'] = course.display_name + course_run['short_description'] = None + course_run['marketing_url'] = 'www.edx.org' + course_run['pacing_type'] = 'self_paced' + course_run['banner_image_url'] = '' + course_run['min_effort'] = 1 + course_run['enrollment_count'] = 12345 + + patch_course_data = patch('openedx.core.djangoapps.catalog.api.get_course_run_details') + course_data = patch_course_data.start() + course_data.return_value = course_run + self.addCleanup(patch_course_data.stop) + self.client.login(username=self.user.username, password=self.user_password) CourseEnrollment.enroll(self.user, course.id)