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
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)))
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)
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)
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)
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 )
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)
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 ]
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)
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)
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
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)
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)
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)