diff --git a/lms/envs/common.py b/lms/envs/common.py index a34575ef50..1c55bed985 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2559,6 +2559,9 @@ INSTALLED_APPS = [ # Management of external user ids 'openedx.core.djangoapps.external_user_ids', + # Provides api for Demographics support + 'openedx.core.djangoapps.demographics', + # Management of per-user schedules 'openedx.core.djangoapps.schedules', 'rest_framework_jwt', diff --git a/lms/urls.py b/lms/urls.py index cfe3203a83..7c054b42ac 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -120,6 +120,9 @@ urlpatterns = [ url(r'^api/entitlements/', include(('entitlements.rest_api.urls', 'common.djangoapps.entitlements'), namespace='entitlements_api')), + # Demographics API RESTful endpoints + url(r'^api/demographics/', include('openedx.core.djangoapps.demographics.rest_api.urls')), + # Courseware search endpoints url(r'^search/', include('search.urls')), diff --git a/openedx/core/djangoapps/demographics/README.rst b/openedx/core/djangoapps/demographics/README.rst new file mode 100644 index 0000000000..9081b00058 --- /dev/null +++ b/openedx/core/djangoapps/demographics/README.rst @@ -0,0 +1,19 @@ +Status: Active + +Responsibilities +================ +The Demographics app is an application to support the Demographics feature set +and IDA. It serves as the access point for demographics related status. + +Direction: Decompose +=============== +This app may be removed in the future as the Demographics feature set expands +to a larger set of users. It is not recommended that new features are added +here at this time. + +Glossary +======== +IDA: Independently Deployable Application + +More Documentation +================== diff --git a/openedx/core/djangoapps/demographics/__init__.py b/openedx/core/djangoapps/demographics/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/demographics/api/status.py b/openedx/core/djangoapps/demographics/api/status.py new file mode 100644 index 0000000000..77b4f7fb0e --- /dev/null +++ b/openedx/core/djangoapps/demographics/api/status.py @@ -0,0 +1,12 @@ +""" +Python API for Demographics Status +""" + +from openedx.features.enterprise_support.utils import is_enterprise_learner +from openedx.core.djangoapps.programs.utils import is_user_enrolled_in_program_type + + +def show_user_demographics(user): + # Is the learner enrolled in MicroBachelors Program or is the learner an Enterprise learner? + is_user_in_microbachelors_program = is_user_enrolled_in_program_type(user, "microbachelors") + return is_user_in_microbachelors_program and not is_enterprise_learner(user) diff --git a/openedx/core/djangoapps/demographics/docs/decisions/0001-demographics-djangoapp-api.rst b/openedx/core/djangoapps/demographics/docs/decisions/0001-demographics-djangoapp-api.rst new file mode 100644 index 0000000000..343b635761 --- /dev/null +++ b/openedx/core/djangoapps/demographics/docs/decisions/0001-demographics-djangoapp-api.rst @@ -0,0 +1,22 @@ +Django Application to Support Demographics Features +--------------------------------------------------- + + +Status +====== + +Accepted + +Context +======= + +To support demographics features and the IDA we need to be able to access the +current state of a User from the LMS (i.e. Program Enrollments, Enterprise status). + +Decisions +========= + +* To meet this need we are creating the Demographics Django Application in the +Open edX core. This application will contain utilities and APIs that will support +the Demographics feature set until they are replaced with other more general APIs +or no longer needed. diff --git a/openedx/core/djangoapps/demographics/rest_api/__init__.py b/openedx/core/djangoapps/demographics/rest_api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/demographics/rest_api/urls.py b/openedx/core/djangoapps/demographics/rest_api/urls.py new file mode 100644 index 0000000000..9d363b17ff --- /dev/null +++ b/openedx/core/djangoapps/demographics/rest_api/urls.py @@ -0,0 +1,12 @@ +""" +Demographics API URLs. +""" +from django.conf.urls import include, url + +from .v1 import urls as v1_urls + +app_name = 'openedx.core.djangoapps.demographics' + +urlpatterns = [ + url(r'^v1/', include(v1_urls)) +] diff --git a/openedx/core/djangoapps/demographics/rest_api/v1/__init__.py b/openedx/core/djangoapps/demographics/rest_api/v1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/demographics/rest_api/v1/urls.py b/openedx/core/djangoapps/demographics/rest_api/v1/urls.py new file mode 100644 index 0000000000..881a76f379 --- /dev/null +++ b/openedx/core/djangoapps/demographics/rest_api/v1/urls.py @@ -0,0 +1,13 @@ +""" +URL Routes for this app. +""" +from django.conf.urls import url +from .views import DemographicsStatusView + +urlpatterns = [ + url( + r'^demographics/status/$', + DemographicsStatusView.as_view(), + name='demographics_status' + ), +] diff --git a/openedx/core/djangoapps/demographics/rest_api/v1/views.py b/openedx/core/djangoapps/demographics/rest_api/v1/views.py new file mode 100644 index 0000000000..4f2faee03b --- /dev/null +++ b/openedx/core/djangoapps/demographics/rest_api/v1/views.py @@ -0,0 +1,28 @@ + +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework.views import APIView +from rest_framework import permissions +from rest_framework.authentication import SessionAuthentication +from rest_framework.response import Response +from openedx.core.djangoapps.demographics.api.status import show_user_demographics + + +class DemographicsStatusView(APIView): + """ + Demographics display status for the User. + + The API will return whether or not to display the Demographics UI based on + the User's status in the Platform + """ + authentication_classes = (JwtAuthentication, SessionAuthentication) + permission_classes = (permissions.IsAuthenticated, ) + + def get(self, request): + """ + GET /api/user/v1/accounts/demographics_status + + This is a Web API to determine whether or not we should show Demographics to a learner + based on their enrollment status. + """ + user = request.user + return Response({'display': show_user_demographics(user)}) diff --git a/openedx/core/djangoapps/demographics/tests/__init__.py b/openedx/core/djangoapps/demographics/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/demographics/tests/test_status.py b/openedx/core/djangoapps/demographics/tests/test_status.py new file mode 100644 index 0000000000..f91bba0b8b --- /dev/null +++ b/openedx/core/djangoapps/demographics/tests/test_status.py @@ -0,0 +1,47 @@ +""" +Test status utilities +""" +import mock +from course_modes.models import CourseMode +from course_modes.tests.factories import CourseModeFactory +from opaque_keys.edx.keys import CourseKey +from student.tests.factories import CourseEnrollmentFactory, UserFactory +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase, TEST_DATA_SPLIT_MODULESTORE +from xmodule.modulestore.tests.factories import CourseFactory + +from openedx.core.djangoapps.catalog.tests.factories import ( + ProgramFactory, +) +from openedx.core.djangoapps.demographics.api.status import show_user_demographics +from openedx.features.enterprise_support.tests.factories import EnterpriseCustomerUserFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms + +MICROBACHELORS = 'microbachelors' + + +@skip_unless_lms +@mock.patch('openedx.core.djangoapps.programs.utils.get_programs_by_type') +class TestShowDemographics(SharedModuleStoreTestCase): + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.store = modulestore() + cls.user = UserFactory() + cls.program = ProgramFactory(type=MICROBACHELORS) + cls.catalog_course_run = cls.program['courses'][0]['course_runs'][0] + cls.course_key = CourseKey.from_string(cls.catalog_course_run['key']) + cls.course_run = CourseFactory.create( + org=cls.course_key.org, + number=cls.course_key.course, + run=cls.course_key.run, + modulestore=cls.store, + ) + CourseModeFactory.create(course_id=cls.course_run.id, mode_slug=CourseMode.VERIFIED) + + def test_user_enterprise(self, mock_get_programs_by_type): + mock_get_programs_by_type.return_value = [self.program] + EnterpriseCustomerUserFactory.create(user_id=self.user.id) + self.assertFalse(show_user_demographics(user=self.user)) diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index 61acf1dbd9..c988ae35ff 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -20,12 +20,13 @@ from testfixtures import LogCapture from waffle.testutils import override_switch from course_modes.models import CourseMode +from course_modes.tests.factories import CourseModeFactory from entitlements.tests.factories import CourseEntitlementFactory from lms.djangoapps.certificates.api import MODES from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory from lms.djangoapps.commerce.tests.test_utils import update_commerce_config from lms.djangoapps.commerce.utils import EcommerceService -from lms.djangoapps.grades.tests.utils import mock_passing_grade +from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.catalog.tests.factories import ( CourseFactory, CourseRunFactory, @@ -42,13 +43,17 @@ from openedx.core.djangoapps.programs.utils import ( ProgramMarketingDataExtender, ProgramProgressMeter, get_certificates, - get_logged_in_program_certificate_url + get_logged_in_program_certificate_url, + is_user_enrolled_in_program_type ) from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory from openedx.core.djangolib.testing.utils import skip_unless_lms from student.tests.factories import AnonymousUserFactory, CourseEnrollmentFactory, UserFactory from util.date_utils import strftime_localized -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, SharedModuleStoreTestCase, TEST_DATA_SPLIT_MODULESTORE +) from xmodule.modulestore.tests.factories import CourseFactory as ModuleStoreCourseFactory CERTIFICATES_API_MODULE = 'lms.djangoapps.certificates.api' @@ -1551,3 +1556,61 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): data['skus'], [course['course_runs'][0]['seats'][0]['sku'] for course in self.program['courses']] ) + + +@skip_unless_lms +@mock.patch('openedx.core.djangoapps.programs.utils.get_programs_by_type') +class TestProgramEnrollment(SharedModuleStoreTestCase): + """ + Tests to test program enrollment utility methods for program data from the program cache. + + Requests to the data in the Program cache are mocked out. + """ + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE + MICROBACHELORS = 'microbachelors' + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.store = modulestore() + cls.user = UserFactory() + cls.program = ProgramFactory(type=cls.MICROBACHELORS) + cls.catalog_course_run = cls.program['courses'][0]['course_runs'][0] + cls.course_key = CourseKey.from_string(cls.catalog_course_run['key']) + cls.course_run = ModuleStoreCourseFactory.create( + org=cls.course_key.org, + number=cls.course_key.course, + run=cls.course_key.run, + modulestore=cls.store, + ) + CourseModeFactory.create(course_id=cls.course_run.id, mode_slug=CourseMode.VERIFIED) + + def test_user_not_in_program(self, mock_get_programs_by_type): + mock_get_programs_by_type.return_value = [self.program] + self.assertFalse(is_user_enrolled_in_program_type(user=self.user, program_type=self.MICROBACHELORS)) + + def test_user_enrolled_in_mb_program(self, mock_get_programs_by_type): + CourseEnrollmentFactory.create(user=self.user, course_id=self.course_run.id, mode=CourseMode.VERIFIED) + mock_get_programs_by_type.return_value = [self.program] + self.assertTrue(is_user_enrolled_in_program_type(user=self.user, program_type=self.MICROBACHELORS)) + + def test_user_enrolled_unpaid_in_program(self, mock_get_programs_by_type): + CourseEnrollmentFactory.create(user=self.user, course_id=self.course_run.id, mode=CourseMode.AUDIT) + mock_get_programs_by_type.return_value = [self.program] + self.assertTrue(is_user_enrolled_in_program_type(user=self.user, program_type=self.MICROBACHELORS)) + + def test_user_enrolled_unpaid_in_program_paid_only_request(self, mock_get_programs_by_type): + CourseEnrollmentFactory.create(user=self.user, course_id=self.course_run.id, mode=CourseMode.AUDIT) + mock_get_programs_by_type.return_value = [self.program] + self.assertFalse( + is_user_enrolled_in_program_type(user=self.user, program_type=self.MICROBACHELORS, paid_modes=True) + ) + + def test_user_with_entitlement_no_enrollment(self, mock_get_programs_by_type): + CourseEntitlementFactory.create( + user=self.user, + mode=CourseMode.VERIFIED, + course_uuid=self.program['courses'][0]['uuid'] + ) + mock_get_programs_by_type.return_value = [self.program] + self.assertTrue(is_user_enrolled_in_program_type(user=self.user, program_type=self.MICROBACHELORS)) diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index 2e0905f65d..dd812fdfee 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -12,6 +12,7 @@ import six from dateutil.parser import parse from django.conf import settings from django.contrib.auth import get_user_model +from django.contrib.sites.models import Site from django.core.cache import cache from django.urls import reverse from django.utils.functional import cached_property @@ -22,15 +23,21 @@ from requests.exceptions import ConnectionError, Timeout from six.moves.urllib.parse import urljoin, urlparse, urlunparse # pylint: disable=import-error from course_modes.models import CourseMode +from entitlements.api import get_active_entitlement_list_for_user from entitlements.models import CourseEntitlement from lms.djangoapps.certificates import api as certificate_api from lms.djangoapps.certificates.models import GeneratedCertificate from lms.djangoapps.commerce.utils import EcommerceService -from openedx.core.djangoapps.catalog.utils import get_fulfillable_course_runs_for_entitlement, get_programs +from openedx.core.djangoapps.catalog.utils import ( + get_fulfillable_course_runs_for_entitlement, + get_programs, + get_programs_by_type +) from openedx.core.djangoapps.certificates.api import available_date_for_certificate from openedx.core.djangoapps.commerce.utils import ecommerce_api_client from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.credentials.utils import get_credentials +from openedx.core.djangoapps.enrollments.api import get_enrollments from openedx.core.djangoapps.enrollments.permissions import ENROLL_IN_COURSE from openedx.core.djangoapps.programs import ALWAYS_CALCULATE_PROGRAM_PRICE_AS_ANONYMOUS_USER from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -892,3 +899,51 @@ class ProgramMarketingDataExtender(ProgramDataExtender): for instructor in course_instructors.get('instructors', []): if instructor.get('name', '').strip() not in curr_instructors_names: self.instructors.append(instructor) + + +def is_user_enrolled_in_program_type(user, program_type, paid_modes=False): + """ + This method will Look at the learners Enrollments and Entitlements to determine + if a learner is enrolled in a Program of the given type. + + NOTE: This method relies on the Program Cache right now. The goal is to move away from this + in the future. + + Arguments: + user (User): The user we are looking for. + program_type (String): The Program type we are looking for. + paid_modes (bool): Request if the user is enrolled in a Program in a paid mode, False by default. + + Returns: + bool: True is the user is enrolled in programs of the requested Type + """ + course_runs = set() + course_uuids = set() + programs = get_programs_by_type(Site.objects.get_current(), program_type) + if not programs: + return False + + for program in programs: + for course in program.get('courses', []): + course_uuids.add(course.get('uuid')) + for course_run in course.get('course_runs', []): + course_runs.add(course_run['key']) + + # Check Entitlements first, because there will be less Course Entitlements than + # Course Run Enrollments. + student_entitlements = get_active_entitlement_list_for_user(user) + for entitlement in student_entitlements: + if str(entitlement.course_uuid) in course_uuids: + return True + + student_enrollments = get_enrollments(user.username) + for enrollment in student_enrollments: + course_run_id = enrollment['course_details']['course_id'] + if paid_modes: + course_run_key = CourseKey.from_string(course_run_id) + paid_modes = [mode.slug for mode in CourseMode.paid_modes_for_course(course_run_key)] + if enrollment['mode'] in paid_modes and course_run_id in course_runs: + return True + elif course_run_id in course_runs: + return True + return False diff --git a/openedx/tests/settings.py b/openedx/tests/settings.py index 950150edcc..6907fb4da3 100644 --- a/openedx/tests/settings.py +++ b/openedx/tests/settings.py @@ -88,6 +88,7 @@ INSTALLED_APPS = ( 'openedx.core.djangoapps.schedules.apps.SchedulesConfig', 'openedx.core.djangoapps.theming.apps.ThemingConfig', 'openedx.core.djangoapps.external_user_ids', + 'openedx.core.djangoapps.demographics', 'experiments', 'openedx.features.content_type_gating',