예제 #1
0
    def clean_program(self):
        """
        Clean program.

        Try obtaining program treating form value as program UUID or title.

        Returns:
            dict: Program information if program found
        """
        program_id = self.cleaned_data[self.Fields.PROGRAM].strip()
        if not program_id:
            return None

        try:
            client = CourseCatalogApiClient(self._user)
            program = client.get_program_by_uuid(program_id) or client.get_program_by_title(program_id)
        except MultipleProgramMatchError as exc:
            raise ValidationError(ValidationMessages.MULTIPLE_PROGRAM_MATCH.format(program_count=exc.programs_matched))
        except (HttpClientError, HttpServerError):
            raise ValidationError(ValidationMessages.INVALID_PROGRAM_ID.format(program_id=program_id))

        if not program:
            raise ValidationError(ValidationMessages.INVALID_PROGRAM_ID.format(program_id=program_id))

        if program['status'] != ProgramStatuses.ACTIVE:
            raise ValidationError(
                ValidationMessages.PROGRAM_IS_INACTIVE.format(program_id=program_id, status=program['status'])
            )

        return program
예제 #2
0
    def _validate_program(self):
        """
        Verify that selected mode is available for program and all courses in the program
        """
        program = self.cleaned_data.get(self.Fields.PROGRAM)
        if not program:
            return

        course_runs = get_course_runs_from_program(program)
        try:
            client = CourseCatalogApiClient(self._user)
            available_modes = client.get_common_course_modes(course_runs)
            course_mode = self.cleaned_data.get(self.Fields.COURSE_MODE)
        except (HttpClientError, HttpServerError):
            raise ValidationError(
                ValidationMessages.FAILED_TO_OBTAIN_COURSE_MODES.format(
                    program_title=program.get("title")))

        if not course_mode:
            raise ValidationError(
                ValidationMessages.COURSE_WITHOUT_COURSE_MODE)
        if course_mode not in available_modes:
            raise ValidationError(
                ValidationMessages.COURSE_MODE_NOT_AVAILABLE.format(
                    mode=course_mode,
                    program_title=program.get("title"),
                    modes=", ".join(available_modes)))
예제 #3
0
    def courses(self, request, pk=None):  # pylint: disable=invalid-name
        """
        Retrieve the list of courses contained within this catalog.

        Only courses with active course runs are returned. A course run is considered active if it is currently
        open for enrollment, or will open in the future.
        ---
        serializer: serializers.CourseSerializerExcludingClosedRuns
        """
        page = request.GET.get('page', 1)
        catalog_api = CourseCatalogApiClient(request.user)
        courses = catalog_api.get_paginated_catalog_courses(pk, page)

        # if API returned an empty response, that means pagination has ended.
        # An empty response can also means that there was a problem fetching data from catalog API.
        if not courses:
            logger.error(
                "Unable to fetch API response for catalog courses from endpoint '/catalog/%s/courses?page=%s'.",
                pk,
                page,
            )
            raise NotFound("The resource you are looking for does not exist.")

        serializer = serializers.EnterpriseCatalogCoursesReadOnlySerializer(
            courses)

        # Add enterprise related context for the courses.
        serializer.update_enterprise_courses(request, pk)
        return get_paginated_response(serializer.data, request)
예제 #4
0
    def setUp(self):
        super(TestCourseCatalogApi, self).setUp()
        self.user_mock = mock.Mock(spec=User)
        self.get_data_mock = self._make_patch(self._make_catalog_api_location("get_edx_api_data"))
        self.catalog_api_config_mock = self._make_patch(self._make_catalog_api_location("CatalogIntegration"))
        self.jwt_builder_mock = self._make_patch(self._make_catalog_api_location("JwtBuilder"))

        self.api = CourseCatalogApiClient(self.user_mock)
예제 #5
0
    def setUp(self):
        super(TestCourseCatalogApi, self).setUp()
        self.user_mock = mock.Mock(spec=User)
        self.get_data_mock = self._make_patch(
            self._make_catalog_api_location("get_edx_api_data"))
        self.catalog_api_config_mock = self._make_patch(
            self._make_catalog_api_location("CatalogIntegration"))
        self.api_factory_mock = self._make_patch(
            self._make_catalog_api_location("course_discovery_api_client"))

        self.api = CourseCatalogApiClient(self.user_mock)
예제 #6
0
    def notify_enrolled_learners(cls, enterprise_customer, request, course_id, users):
        """
        Notify learners about a course in which they've been enrolled.

        Args:
            enterprise_customer: The EnterpriseCustomer being linked to
            request: The HTTP request that's being processed
            course_id: The specific course the learners were enrolled in
            users: An iterable of the users or pending users who were enrolled
        """
        course_details = CourseCatalogApiClient(request.user).get_course_run(course_id)
        if not course_details:
            logging.warning(
                _(
                    "Course details were not found for course key {} - Course Catalog API returned nothing. "
                    "Proceeding with enrollment, but notifications won't be sent"
                ).format(course_id)
            )
            return
        course_url = course_details.get('marketing_url')
        if course_url is None:
            # If we didn't get a useful path to the course on a marketing site from the catalog API,
            # then we should build a path to the course on the LMS directly.
            course_url = get_reversed_url_by_site(
                request,
                enterprise_customer.site,
                'about_course',
                args=(course_id,),
            )
        course_name = course_details.get('title')

        try:
            course_start = parse_lms_api_datetime(course_details.get('start'))
        except (TypeError, ValueError):
            course_start = None
            logging.exception(
                "None or empty value passed as course start date.\nCourse Details:\n{course_details}".format(
                    course_details=course_details,
                )
            )

        with mail.get_connection() as email_conn:
            for user in users:
                send_email_notification_message(
                    user=user,
                    enrolled_in={
                        'name': course_name,
                        'url': course_url,
                        'type': 'course',
                        'start': course_start,
                    },
                    enterprise_customer=enterprise_customer,
                    email_connection=email_conn
                )
예제 #7
0
    def list(self, request):
        """
        DRF view to list all catalogs.

        Arguments:
            request (HttpRequest): Current request

        Returns:
            (Response): DRF response object containing course catalogs.
        """
        # fetch all course catalogs.
        catalog_api = CourseCatalogApiClient(request.user)
        catalogs = catalog_api.get_all_catalogs()
        serializer = self.serializer_class(catalogs, many=True)
        return Response(serializer.data)
예제 #8
0
    def get_catalog_options(self):
        """
        Retrieve a list of catalog ID and name pairs.

        Once retrieved, these name pairs can be used directly as a value
        for the `choices` argument to a ChoiceField.
        """
        catalog_api = CourseCatalogApiClient(self.user)
        catalogs = catalog_api.get_all_catalogs()
        # order catalogs by name.
        catalogs = sorted(catalogs, key=lambda catalog: catalog.get('name', '').lower())

        return BLANK_CHOICE_DASH + [
            (catalog['id'], catalog['name'],)
            for catalog in catalogs
        ]
예제 #9
0
def get_course_runs(user, enterprise_customer):
    """
    List the course runs the given enterprise customer has in its catalog.

    Args:
        user: A Django user requesting the course list
        enterprise_customer: The given Enterprise Customer

    Returns:
        iterable: An iterable containing the details of each course run.
    """
    catalog_id = enterprise_customer.catalog

    client = CourseCatalogApiClient(user)

    catalog_courses = client.get_catalog_courses(catalog_id)
    LOGGER.info('Retrieving course list for catalog %s', catalog_id)

    for course in catalog_courses:
        course_key = course.get('key')
        course_details = client.get_course_details(course_key)
        for run in course_details.get('course_runs', []):
            yield get_complete_course_run_details(course_details, run)
예제 #10
0
    def retrieve(self, request, pk=None):  # pylint: disable=invalid-name
        """
        DRF view to get catalog details.

        Arguments:
            request (HttpRequest): Current request
            pk (int): Course catalog identifier

        Returns:
            (Response): DRF response object containing course catalogs.
        """
        # fetch course catalog for the given catalog id.
        catalog_api = CourseCatalogApiClient(request.user)
        catalog = catalog_api.get_catalog(pk)

        if not catalog:
            logger.error(
                "Unable to fetch API response for given catalog from endpoint '/catalog/%s/'.",
                pk)
            raise NotFound("The resource you are looking for does not exist.")

        serializer = self.serializer_class(catalog)
        return Response(serializer.data)
예제 #11
0
class TestCourseCatalogApi(unittest.TestCase):
    """
    Test course catalog API methods.
    """
    CATALOG_API_PATCH_PREFIX = "enterprise.course_catalog_api"

    EMPTY_RESPONSES = (None, {}, [], set(), "")

    def _make_catalog_api_location(self, catalog_api_member):
        """
        Return path for `catalog_api_member` to mock.
        """
        return "{}.{}".format(self.CATALOG_API_PATCH_PREFIX,
                              catalog_api_member)

    def _make_patch(self, patch_location, new=None):
        """
        Patch `patch_location`, register the patch to stop at test cleanup and return mock object
        """
        patch_mock = new if new is not None else mock.Mock()
        patcher = mock.patch(patch_location, patch_mock)
        patcher.start()
        self.addCleanup(patcher.stop)
        return patch_mock

    def setUp(self):
        super(TestCourseCatalogApi, self).setUp()
        self.user_mock = mock.Mock(spec=User)
        self.get_data_mock = self._make_patch(
            self._make_catalog_api_location("get_edx_api_data"))
        self.catalog_api_config_mock = self._make_patch(
            self._make_catalog_api_location("CatalogIntegration"))
        self.api_factory_mock = self._make_patch(
            self._make_catalog_api_location("course_discovery_api_client"))

        self.api = CourseCatalogApiClient(self.user_mock)

    @staticmethod
    def _get_important_parameters(get_data_mock):
        """
        Return important (i.e. varying) parmaameters to get_edx_api_data
        """
        args, kwargs = get_data_mock.call_args
        return args[2], kwargs.get('resource_id', None)

    @staticmethod
    def _make_course_run(key, *seat_types):
        """
        Return course_run json representation expected by CourseCatalogAPI.
        """
        return {
            "key": key,
            "seats": [{
                "type": seat_type
            } for seat_type in seat_types]
        }

    _make_run = _make_course_run.__func__  # unwrapping to use within class definition

    def test_get_all_catalogs(self):
        response_dict = {"very": "complex", "json": {"with": " nested object"}}
        self.get_data_mock.return_value = response_dict

        actual_result = self.api.get_all_catalogs()

        assert self.get_data_mock.call_count == 1
        resource, resource_id = self._get_important_parameters(
            self.get_data_mock)

        assert resource == "catalogs"
        assert resource_id is None
        assert actual_result == response_dict

    def test_get_catalog_courses(self):
        expected_result = ['item1', 'item2', 'item3']
        self.get_data_mock.return_value = expected_result

        actual_result = self.api.get_catalog_courses(123)

        assert self.get_data_mock.call_count == 1
        resource, _ = self._get_important_parameters(self.get_data_mock)

        assert resource == 'catalogs/123/courses'
        assert actual_result == expected_result

    def test_get_course_details(self):
        course_key = 'edX+DemoX'
        expected_result = {"complex": "dict"}
        self.get_data_mock.return_value = expected_result

        actual_result = self.api.get_course_details(course_key)

        assert self.get_data_mock.call_count == 1
        resource, resource_id = self._get_important_parameters(
            self.get_data_mock)

        assert resource == 'courses'
        assert resource_id == 'edX+DemoX'
        assert actual_result == expected_result

    @ddt.data(*EMPTY_RESPONSES)
    def test_get_all_catalogs_empty_response(self, response):
        self.get_data_mock.return_value = response

        assert self.api.get_all_catalogs() == []

    def test_get_catalog(self):
        """
        Verify get_catalog of CourseCatalogApiClient works as expected.
        """
        response_dict = {"very": "complex", "json": {"with": " nested object"}}
        self.get_data_mock.return_value = response_dict

        actual_result = self.api.get_catalog(catalog_id=1)

        assert self.get_data_mock.call_count == 1
        resource, resource_id = self._get_important_parameters(
            self.get_data_mock)

        assert resource == "catalogs"
        assert resource_id is 1
        assert actual_result == response_dict

    @ddt.data(*EMPTY_RESPONSES)
    def test_get_catalog_empty_response(self, response):
        """
        Verify get_catalog of CourseCatalogApiClient works as expected.
        """
        self.get_data_mock.return_value = response

        assert self.api.get_catalog(catalog_id=1) == []

    def test_get_paginated_catalog_courses(self):
        """
        Verify get_paginated_catalog_courses of CourseCatalogApiClient works as expected.
        """
        catalog_id = 1
        response_dict = {"very": "complex", "json": {"with": " nested object"}}
        self.get_data_mock.return_value = response_dict

        actual_result = self.api.get_paginated_catalog_courses(
            catalog_id=catalog_id)

        assert self.get_data_mock.call_count == catalog_id
        resource, resource_id = self._get_important_parameters(
            self.get_data_mock)

        assert resource == "catalogs/{}/courses/".format(catalog_id)
        assert resource_id is None
        assert actual_result == response_dict

    @ddt.data(*EMPTY_RESPONSES)
    def test_get_paginated_catalog_courses_empty_response(self, response):
        """
        Verify get_paginated_catalog_courses of CourseCatalogApiClient works as expected.
        """
        catalog_id = 1
        self.get_data_mock.return_value = response

        assert self.api.get_paginated_catalog_courses(
            catalog_id=catalog_id) == []

    @ddt.data(
        "course-v1:JediAcademy+AppliedTelekinesis+T1",
        "course-v1:TrantorAcademy+Psychohistory101+T1",
        "course-v1:StarfleetAcademy+WarpspeedNavigation+T2337",
        "course-v1:SinhonCompanionAcademy+Calligraphy+TermUnknown",
        "course-v1:CampArthurCurrie+HeavyWeapons+T2245_5",
    )
    def test_get_course_run(self, course_run_id):
        response_dict = {"very": "complex", "json": {"with": " nested object"}}
        self.get_data_mock.return_value = response_dict

        actual_result = self.api.get_course_run(course_run_id)

        assert self.get_data_mock.call_count == 1
        resource, resource_id = self._get_important_parameters(
            self.get_data_mock)

        assert resource == "course_runs"
        assert resource_id is course_run_id
        assert actual_result == response_dict

    @ddt.data(*EMPTY_RESPONSES)
    def test_get_course_run_empty_response(self, response):
        self.get_data_mock.return_value = response

        assert self.api.get_course_run("any") == {}

    @ddt.data("Apollo", "Star Wars", "mk Ultra")
    def test_get_program_by_uuid(self, program_id):
        response_dict = {"very": "complex", "json": {"with": " nested object"}}
        self.get_data_mock.return_value = response_dict

        actual_result = self.api.get_program_by_uuid(program_id)

        assert self.get_data_mock.call_count == 1
        resource, resource_id = self._get_important_parameters(
            self.get_data_mock)

        assert resource == "programs"
        assert resource_id is program_id
        assert actual_result == response_dict

    @ddt.data(*EMPTY_RESPONSES)
    def test_get_program_by_uuid_empty_response(self, response):
        self.get_data_mock.return_value = response

        assert self.api.get_program_by_uuid("any") is None

    @ddt.unpack
    @ddt.data(
        ("mk Ultra", [{
            'title': "Star Wars"
        }], None),
        ("Star Wars", [{
            'title': "Star Wars"
        }], {
            'title': "Star Wars"
        }),
        ("Apollo", [{
            'title': "Star Wars"
        }, {
            'title': "Apollo",
            "uuid": "Apollo11"
        }], {
            'title': "Apollo",
            "uuid": "Apollo11"
        }),
    )
    def test_get_program_by_title(self, program_title, response,
                                  expected_result):
        self.get_data_mock.return_value = response

        actual_result = self.api.get_program_by_title(program_title)

        assert self.get_data_mock.call_count == 1
        resource, _ = self._get_important_parameters(self.get_data_mock)

        assert resource == "programs"
        assert actual_result == expected_result

    @ddt.data(*EMPTY_RESPONSES)
    def test_get_program_by_title_empty_response(self, response):
        self.get_data_mock.return_value = response

        assert self.api.get_program_by_title("any") is None

    def test_get_program_by_title_raise_multiple_match(self):
        self.get_data_mock.return_value = [{
            'title': "Apollo",
            "uuid": "Apollo11"
        }, {
            'title': "Apollo",
            "uuid": "Apollo12"
        }]

        with raises(CourseCatalogApiError):
            self.api.get_program_by_title("Apollo")

    @ddt.unpack
    @ddt.data(
        # single run
        (("c1", ), [_make_run("c1", "prof", "audit")], {"prof", "audit"}),
        # multiple runs - intersection
        (("c1", "c2"),
         [_make_run("c1", "prof", "audit"),
          _make_run("c2", "prof")], {"prof"}),
        # multiple runs, one of which is empty
        (("c1", "c2"), [_make_run("c1"),
                        _make_run("c2", "prof")], set()),
        # multiple runs, one of which is empty - other way around
        (("c1", "c2"), [_make_run("c2", "prof"),
                        _make_run("c1")], set()),
        # run(s) not found
        (("c1", "c3", "c4"), [_make_run("c1"),
                              _make_run("c2", "prof")], set()),
    )
    def test_get_common_course_modes(self, course_runs, response,
                                     expected_result):
        def get_course_run(*args, **kwargs):  # pylint: disable=unused-argument
            """
            Return course run data from `reponse` argument by key.
            """
            resource_id = kwargs.get("resource_id")
            try:
                return next(item for item in response
                            if item["key"] == resource_id)
            except StopIteration:
                return {}

        self.get_data_mock.side_effect = get_course_run

        actual_result = self.api.get_common_course_modes(course_runs)
        assert actual_result == expected_result
예제 #12
0
 def test_raise_error_missing_get_edx_api_data(self, *args):  # pylint: disable=unused-argument
     message = 'To parse a Catalog API response, this package must be installed in an Open edX environment.'
     with raises(NotConnectedToOpenEdX) as excinfo:
         CourseCatalogApiClient(mock.Mock(spec=User))
     assert message == str(excinfo.value)
예제 #13
0
 def test_raise_error_missing_catalog_integration(self, *args):  # pylint: disable=unused-argument
     message = 'To get a CatalogIntegration object, this package must be installed in an Open edX environment.'
     with raises(NotConnectedToOpenEdX) as excinfo:
         CourseCatalogApiClient(mock.Mock(spec=User))
     assert message == str(excinfo.value)
예제 #14
0
    def get_enterprise_course_enrollment_page(self, request,
                                              enterprise_customer,
                                              course_details, course_modes):
        """
        Render enterprise specific course track selection page.
        """
        platform_name = configuration_helpers.get_value(
            'PLATFORM_NAME', settings.PLATFORM_NAME)
        course_start_date = ''
        if course_details['start']:
            course_start_date = parse(
                course_details['start']).strftime('%B %d, %Y')

        try:
            effort_hours = int(course_details['effort'].split(':')[0])
        except (AttributeError, ValueError):
            course_effort = ''
        else:
            course_effort = self.context_data['effort_hours_text'].format(
                hours=effort_hours)
        course_run = CourseCatalogApiClient(request.user).get_course_run(
            course_details['course_id'])

        course_modes = self.set_final_prices(course_modes, request)
        premium_modes = [mode for mode in course_modes if mode['premium']]

        try:
            organization = organizations_helpers.get_organization(
                course_details['org'])
            organization_logo = organization['logo'].url
            organization_name = organization['name']
        except (TypeError, ValidationError, ValueError):
            organization_logo = None
            organization_name = None

        context_data = {
            'page_title':
            self.context_data['page_title'],
            'LANGUAGE_CODE':
            get_language_from_request(request),
            'platform_name':
            platform_name,
            'course_id':
            course_details['course_id'],
            'course_name':
            course_details['name'],
            'course_organization':
            course_details['org'],
            'course_short_description':
            course_details['short_description'] or '',
            'course_pacing':
            self.pacing_options.get(course_details['pacing'], ''),
            'course_start_date':
            course_start_date,
            'course_image_uri':
            course_details['media']['course_image']['uri'],
            'enterprise_customer':
            enterprise_customer,
            'enterprise_welcome_text':
            self.context_data['enterprise_welcome_text'].format(
                enterprise_customer_name=enterprise_customer.name,
                platform_name=platform_name,
                strong_start='<strong>',
                strong_end='</strong>',
            ),
            'confirmation_text':
            self.context_data['confirmation_text'],
            'starts_at_text':
            self.context_data['starts_at_text'],
            'view_course_details_text':
            self.context_data['view_course_details_text'],
            'select_mode_text':
            self.context_data['select_mode_text'],
            'price_text':
            self.context_data['price_text'],
            'continue_link_text':
            self.context_data['continue_link_text'],
            'course_modes':
            filter_audit_course_modes(enterprise_customer, course_modes),
            'course_effort':
            course_effort,
            'level_text':
            self.context_data['level_text'],
            'effort_text':
            self.context_data['effort_text'],
            'course_overview':
            course_details['overview'],
            'organization_logo':
            organization_logo,
            'organization_name':
            organization_name,
            'course_level_type':
            course_run.get('level_type', ''),
            'close_modal_button_text':
            self.context_data['close_modal_button_text'],
            'premium_modes':
            premium_modes,
        }
        return render(request,
                      'enterprise/enterprise_course_enrollment_page.html',
                      context=context_data)