diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index ffeaa1db3b..42e34a54e9 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -516,7 +516,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, course_overview = CourseOverviewFactory( start=self.TOMORROW, self_paced=True, enrollment_end=self.TOMORROW ) - course_enrollment = CourseEnrollmentFactory(user=self.user) + course_enrollment = CourseEnrollmentFactory(user=self.user, course_id=course_overview.id) entitlement = CourseEntitlementFactory(user=self.user, enrollment_course_run=course_enrollment) course_runs = [{ 'key': six.text_type(course_overview.id), diff --git a/lms/djangoapps/experiments/utils.py b/lms/djangoapps/experiments/utils.py index 1076760319..e921ac7392 100644 --- a/lms/djangoapps/experiments/utils.py +++ b/lms/djangoapps/experiments/utils.py @@ -14,9 +14,10 @@ from django.utils.timezone import now from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey -from course_modes.models import format_course_price, get_cosmetic_verified_display_price +from course_modes.models import format_course_price, get_cosmetic_verified_display_price, CourseMode from courseware.access import has_staff_access_to_preview_mode from courseware.date_summary import verified_upgrade_deadline_link, verified_upgrade_link_is_valid +from entitlements.models import CourseEntitlement from lms.djangoapps.commerce.utils import EcommerceService from openedx.core.djangoapps.catalog.utils import get_programs from openedx.core.djangoapps.django_comment_common.models import Role @@ -232,14 +233,12 @@ def get_dashboard_course_info(user, dashboard_enrollments): if DASHBOARD_INFO_FLAG.is_enabled(): # Get the enrollments here since the dashboard filters out those with completed entitlements user_enrollments = CourseEnrollment.objects.select_related('course').filter(user_id=user.id) - audit_enrollments = user_enrollments.filter(mode='audit') course_info = { str(dashboard_enrollment.course): get_base_experiment_metadata_context(dashboard_enrollment.course, user, dashboard_enrollment, - user_enrollments, - audit_enrollments) + user_enrollments) for dashboard_enrollment in dashboard_enrollments } return course_info @@ -257,8 +256,7 @@ def get_experiment_user_metadata_context(course, user): has_non_audit_enrollments = False try: user_enrollments = CourseEnrollment.objects.select_related('course').filter(user_id=user.id) - audit_enrollments = user_enrollments.filter(mode='audit') - has_non_audit_enrollments = (len(audit_enrollments) != len(user_enrollments)) + has_non_audit_enrollments = user_enrollments.exclude(mode__in=CourseMode.UPSELL_TO_VERIFIED_MODES).exists() # TODO: clean up as part of REVO-28 (END) enrollment = CourseEnrollment.objects.select_related( 'course' @@ -266,7 +264,11 @@ def get_experiment_user_metadata_context(course, user): except CourseEnrollment.DoesNotExist: pass # Not enrolled, use the default values - context = get_base_experiment_metadata_context(course, user, enrollment, user_enrollments, audit_enrollments) + has_entitlements = False + if user.is_authenticated(): + has_entitlements = CourseEntitlement.objects.filter(user=user).exists() + + context = get_base_experiment_metadata_context(course, user, enrollment, user_enrollments) has_staff_access = has_staff_access_to_preview_mode(user, course.id) forum_roles = [] if user.is_authenticated: @@ -280,7 +282,7 @@ def get_experiment_user_metadata_context(course, user): user_partitions = {} # TODO: clean up as part of REVO-28 (START) - context['has_non_audit_enrollments'] = has_non_audit_enrollments + context['has_non_audit_enrollments'] = has_non_audit_enrollments or has_entitlements # TODO: clean up as part of REVO-28 (END) context['has_staff_access'] = has_staff_access context['forum_roles'] = forum_roles @@ -288,14 +290,14 @@ def get_experiment_user_metadata_context(course, user): return context -def get_base_experiment_metadata_context(course, user, enrollment, user_enrollments, audit_enrollments): +def get_base_experiment_metadata_context(course, user, enrollment, user_enrollments): """ Return a context dictionary with the keys used by dashboard_metadata.html and user_metadata.html """ enrollment_mode = None enrollment_time = None # TODO: clean up as part of REVEM-199 (START) - program_key = get_program_context(course, user_enrollments, audit_enrollments) + program_key = get_program_context(course, user_enrollments) # TODO: clean up as part of REVEM-199 (END) if enrollment and enrollment.is_active: enrollment_mode = enrollment.mode @@ -332,11 +334,13 @@ def get_audit_access_expiration(user, course): # TODO: clean up as part of REVEM-199 (START) -def get_program_context(course, user_enrollments, audit_enrollments): +def get_program_context(course, user_enrollments): """ Return a context dictionary with program information. """ program_key = None + non_audit_enrollments = user_enrollments.exclude(mode__in=CourseMode.UPSELL_TO_VERIFIED_MODES) + if PROGRAM_INFO_FLAG.is_enabled(): programs = get_programs(course=course.id) if programs: @@ -358,7 +362,6 @@ def get_program_context(course, user_enrollments, audit_enrollments): # program has 3 courses (A, B and C), and the user previously purchased a certificate for A. # The user is enrolled in audit mode for B. The "left to purchase price" should be the price of # B+C. - non_audit_enrollments = [en for en in user_enrollments if en not in audit_enrollments] courses_left_to_purchase = get_unenrolled_courses(courses, non_audit_enrollments) if courses_left_to_purchase: has_courses_left_to_purchase = True diff --git a/openedx/core/djangoapps/content/course_overviews/tests/factories.py b/openedx/core/djangoapps/content/course_overviews/tests/factories.py index e898ff2b02..d344795275 100644 --- a/openedx/core/djangoapps/content/course_overviews/tests/factories.py +++ b/openedx/core/djangoapps/content/course_overviews/tests/factories.py @@ -13,10 +13,12 @@ class CourseOverviewFactory(DjangoModelFactory): class Meta(object): model = CourseOverview django_get_or_create = ('id', ) + exclude = ('run', ) version = CourseOverview.VERSION pre_requisite_courses = [] org = 'edX' + run = factory.Sequence('2012_Fall_{}'.format) @factory.lazy_attribute def _pre_requisite_courses_json(self): @@ -28,7 +30,7 @@ class CourseOverviewFactory(DjangoModelFactory): @factory.lazy_attribute def id(self): - return CourseLocator(self.org, 'toy', '2012_Fall') + return CourseLocator(self.org, 'toy', self.run) @factory.lazy_attribute def display_name(self): diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py b/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py index 3eb6492499..e80dae6bda 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py @@ -139,6 +139,8 @@ class ScheduleSendEmailTestMixin(FilteredQueryCountMixin): factory_kwargs.setdefault('start', target_day) factory_kwargs.setdefault('upgrade_deadline', upgrade_deadline) factory_kwargs.setdefault('enrollment__course__self_paced', True) + # Make all schedules in the same course + factory_kwargs.setdefault('enrollment__course__run', '2012_Fall') if hasattr(self, 'experience_type'): factory_kwargs.setdefault('experience__experience_type', self.experience_type) schedule = ScheduleFactory(**factory_kwargs) diff --git a/openedx/features/discounts/applicability.py b/openedx/features/discounts/applicability.py index 4caa0a8d8b..0c2bb26ea1 100644 --- a/openedx/features/discounts/applicability.py +++ b/openedx/features/discounts/applicability.py @@ -9,8 +9,10 @@ not other discounts like coupons or enterprise/program offers configured in ecom """ from course_modes.models import CourseMode +from entitlements.models import CourseEntitlement from openedx.core.djangoapps.waffle_utils import WaffleFlag, WaffleFlagNamespace from openedx.features.discounts.models import DiscountRestrictionConfig +from student.models import CourseEnrollment # .. feature_toggle_name: discounts.enable_discounting # .. feature_toggle_type: flag @@ -55,6 +57,15 @@ def can_receive_discount(user, course): # pylint: disable=unused-argument if DiscountRestrictionConfig.disabled_for_course_stacked_config(course): return False + # Don't allow users who have enrolled in any courses in non-upsellable + # modes + if CourseEnrollment.objects.filter(user=user).exclude(mode__in=CourseMode.UPSELL_TO_VERIFIED_MODES).exists(): + return False + + # Don't allow any users who have entitlements (past or present) + if CourseEntitlement.objects.filter(user=user).exists(): + return False + return True diff --git a/openedx/features/discounts/tests/test_applicability.py b/openedx/features/discounts/tests/test_applicability.py index b0e9a3bd88..bd5c7d7e01 100644 --- a/openedx/features/discounts/tests/test_applicability.py +++ b/openedx/features/discounts/tests/test_applicability.py @@ -2,19 +2,23 @@ # -*- coding: utf-8 -*- from datetime import timedelta +import ddt from django.utils.timezone import now +from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory +from entitlements.tests.factories import CourseEntitlementFactory from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag from openedx.features.discounts.models import DiscountRestrictionConfig -from student.tests.factories import UserFactory +from student.tests.factories import UserFactory, CourseEnrollmentFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from ..applicability import can_receive_discount, DISCOUNT_APPLICABILITY_FLAG +@ddt.ddt class TestApplicability(ModuleStoreTestCase): """ Applicability determines if this combination of user and course can receive a discount. Make @@ -54,3 +58,40 @@ class TestApplicability(ModuleStoreTestCase): DiscountRestrictionConfig.objects.create(disabled=True, course=disabled_course_overview) applicability = can_receive_discount(user=self.user, course=disabled_course) self.assertEqual(applicability, False) + + @ddt.data(*( + [[]] + + [[mode] for mode in CourseMode.ALL_MODES] + + [ + [mode1, mode2] + for mode1 in CourseMode.ALL_MODES + for mode2 in CourseMode.ALL_MODES + if mode1 != mode2 + ] + )) + @override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True) + def test_can_receive_discount_previous_verified_enrollment(self, existing_enrollments): + """ + Ensure that only users who have not already purchased courses receive the discount. + """ + for mode in existing_enrollments: + CourseEnrollmentFactory.create(mode=mode, user=self.user) + + applicability = can_receive_discount(user=self.user, course=self.course) + assert applicability == all(mode in CourseMode.UPSELL_TO_VERIFIED_MODES for mode in existing_enrollments) + + @ddt.data( + None, + CourseMode.VERIFIED, + CourseMode.PROFESSIONAL, + ) + @override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True) + def test_can_receive_discount_entitlement(self, entitlement_mode): + """ + Ensure that only users who have not already purchased courses receive the discount. + """ + if entitlement_mode is not None: + CourseEntitlementFactory.create(mode=entitlement_mode, user=self.user) + + applicability = can_receive_discount(user=self.user, course=self.course) + assert applicability == (entitlement_mode is None) diff --git a/openedx/tests/settings.py b/openedx/tests/settings.py index 4dd346c915..b31f7846e0 100644 --- a/openedx/tests/settings.py +++ b/openedx/tests/settings.py @@ -94,6 +94,7 @@ INSTALLED_APPS = ( # Django 1.11 demands to have imported models supported by installed apps. 'completion', + 'entitlements', ) LMS_ROOT_URL = "http://localhost:8000"