def _delete_entrance_exam(request, course_key): """ Internal workflow operation to remove an entrance exam """ store = modulestore() course = store.get_course(course_key) if course is None: return HttpResponse(status=400) remove_entrance_exam_milestone_reference(request, course_key) # Reset the entrance exam flags on the course # Reload the course so we have the latest state course = store.get_course(course_key) if course.entrance_exam_id: metadata = { 'entrance_exam_enabled': False, 'entrance_exam_minimum_score_pct': None, 'entrance_exam_id': None, } CourseMetadata.update_from_dict(metadata, course, request.user) # Clean up any pre-existing entrance exam graders remove_entrance_exam_graders(course_key, request.user) return HttpResponse(status=204)
def update_entrance_exam(request, course_key, exam_data): """ Operation to update course fields pertaining to entrance exams The update operation is not currently exposed directly via the API Because the operation is not exposed directly, we do not return a 200 response But we do return a 400 in the error case because the workflow is executed in a request context """ course = modulestore().get_course(course_key) if course: metadata = exam_data CourseMetadata.update_from_dict(metadata, course, request.user)
def test_import_delete_pre_exiting_entrance_exam(self): """ Check that pre existed entrance exam content should be overwrite with the imported course. """ exam_url = '/course/{}/entrance_exam/'.format(six.text_type(self.course.id)) resp = self.client.post(exam_url, {'entrance_exam_minimum_score_pct': 0.5}, http_accept='application/json') self.assertEqual(resp.status_code, 201) # Reload the test course now that the exam module has been added self.course = modulestore().get_course(self.course.id) metadata = CourseMetadata.fetch_all(self.course) self.assertTrue(metadata['entrance_exam_enabled']) self.assertIsNotNone(metadata['entrance_exam_minimum_score_pct']) self.assertEqual(metadata['entrance_exam_minimum_score_pct']['value'], 0.5) self.assertTrue(len(milestones_helpers.get_course_milestones(six.text_type(self.course.id)))) content_milestones = milestones_helpers.get_course_content_milestones( six.text_type(self.course.id), metadata['entrance_exam_id']['value'], milestones_helpers.get_milestone_relationship_types()['FULFILLS'] ) self.assertTrue(len(content_milestones)) # Now import entrance exam course with open(self.entrance_exam_tar, 'rb') as gtar: # pylint: disable=open-builtin args = {"name": self.entrance_exam_tar, "course-data": [gtar]} resp = self.client.post(self.url, args) self.assertEqual(resp.status_code, 200) course = self.store.get_course(self.course.id) self.assertIsNotNone(course) self.assertEqual(course.entrance_exam_enabled, True) self.assertEqual(course.entrance_exam_minimum_score_pct, 0.7)
def test_contentstore_views_entrance_exam_post(self): """ Unit Test: test_contentstore_views_entrance_exam_post """ resp = self.client.post(self.exam_url, {}, http_accept='application/json') self.assertEqual(resp.status_code, 201) resp = self.client.get(self.exam_url) self.assertEqual(resp.status_code, 200) # Reload the test course now that the exam module has been added self.course = modulestore().get_course(self.course.id) metadata = CourseMetadata.fetch_all(self.course) self.assertTrue(metadata['entrance_exam_enabled']) self.assertIsNotNone(metadata['entrance_exam_minimum_score_pct']) self.assertIsNotNone(metadata['entrance_exam_id']['value']) self.assertTrue( len( milestones_helpers.get_course_milestones( six.text_type(self.course.id)))) content_milestones = milestones_helpers.get_course_content_milestones( six.text_type(self.course.id), metadata['entrance_exam_id']['value'], self.milestone_relationship_types['FULFILLS']) self.assertTrue(len(content_milestones))
def post(self, request, course_id): """ POST handler """ serializer = ProctoredExamSettingsSerializer if request.user.is_staff \ else LimitedProctoredExamSettingsSerializer exam_config = serializer(data=request.data.get('proctored_exam_settings', {})) valid_request = exam_config.is_valid() if not request.user.is_staff and valid_request and ProctoredExamSettingsSerializer( data=request.data.get('proctored_exam_settings', {}) ).is_valid(): return Response(status=status.HTTP_403_FORBIDDEN) with modulestore().bulk_operations(CourseKey.from_string(course_id)): course_module = self._get_and_validate_course_access(request.user, course_id) course_metadata = CourseMetadata().fetch_all(course_module) models_to_update = {} for setting_key, value in exam_config.data.items(): model = course_metadata.get(setting_key) if model: models_to_update[setting_key] = copy.deepcopy(model) models_to_update[setting_key]['value'] = value # validate data formats and update the course module object is_valid, errors, updated_data = CourseMetadata.validate_and_update_from_json( course_module, models_to_update, user=request.user, ) if not is_valid: error_messages = [{error.get('key'): error.get('message')} for error in errors] return Response( {'detail': error_messages}, status=status.HTTP_400_BAD_REQUEST ) # save to mongo modulestore().update_item(course_module, request.user.id) # merge updated settings with all existing settings. # do this because fields that could not be modified are excluded from the result course_metadata = {**course_metadata, **updated_data} updated_setttings = self._get_proctored_exam_setting_values(course_metadata) serializer = ProctoredExamSettingsSerializer(updated_setttings) return Response({ 'proctored_exam_settings': serializer.data })
def _create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct=None): """ Internal workflow operation to create an entrance exam """ # Provide a default value for the minimum score percent if nothing specified if entrance_exam_minimum_score_pct is None: entrance_exam_minimum_score_pct = _get_default_entrance_exam_minimum_pct() # Confirm the course exists course = modulestore().get_course(course_key) if course is None: return HttpResponse(status=400) # Create the entrance exam item (currently it's just a chapter) parent_locator = str(course.location) created_block = create_xblock( parent_locator=parent_locator, user=request.user, category='chapter', display_name=_('Entrance Exam'), is_entrance_exam=True ) # Set the entrance exam metadata flags for this course # Reload the course so we don't overwrite the new child reference course = modulestore().get_course(course_key) metadata = { 'entrance_exam_enabled': True, 'entrance_exam_minimum_score_pct': entrance_exam_minimum_score_pct, 'entrance_exam_id': str(created_block.location), } CourseMetadata.update_from_dict(metadata, course, request.user) # Create the entrance exam section item. create_xblock( parent_locator=str(created_block.location), user=request.user, category='sequential', display_name=_('Entrance Exam - Subsection') ) add_entrance_exam_milestone(course.id, created_block) return HttpResponse(status=201)
def test_contentstore_views_entrance_exam_get_bogus_exam(self): """ Unit Test: test_contentstore_views_entrance_exam_get_bogus_exam """ resp = self.client.post(self.exam_url, {'entrance_exam_minimum_score_pct': '50'}, http_accept='application/json') self.assertEqual(resp.status_code, 201) resp = self.client.get(self.exam_url) self.assertEqual(resp.status_code, 200) self.course = modulestore().get_course(self.course.id) # Should raise an ItemNotFoundError and return a 404 updated_metadata = { 'entrance_exam_id': 'i4x://org.4/course_4/chapter/ed7c4c6a4d68409998e2c8554c4629d1' } CourseMetadata.update_from_dict( updated_metadata, self.course, self.user, ) self.course = modulestore().get_course(self.course.id) resp = self.client.get(self.exam_url) self.assertEqual(resp.status_code, 404) # Should raise an InvalidKeyError and return a 404 updated_metadata = {'entrance_exam_id': '123afsdfsad90f87'} CourseMetadata.update_from_dict( updated_metadata, self.course, self.user, ) self.course = modulestore().get_course(self.course.id) resp = self.client.get(self.exam_url) self.assertEqual(resp.status_code, 404)
def get(self, request, course_id): """ GET handler """ with modulestore().bulk_operations(CourseKey.from_string(course_id)): course_module = self._get_and_validate_course_access(request.user, course_id) course_metadata = CourseMetadata().fetch_all(course_module) proctored_exam_settings = self._get_proctored_exam_setting_values(course_metadata) data = {} data['proctored_exam_settings'] = proctored_exam_settings data['available_proctoring_providers'] = get_available_providers() data['course_start_date'] = course_metadata['start'].get('value') serializer = ProctoredExamConfigurationSerializer(data) return Response(serializer.data)
def migrate_course_settings(*args, **kwargs): # pylint: disable=unused-argument """ Migrates all course settings to OtherCourseSettings table in edxapp database. delay_seconds -> sleep time in seconds. group_length -> number of courses to be migrated by groups. """ migrated_courses = [] try: group_counter = 0 for course in modulestore().get_courses(): group_counter += 1 other_course_settings = CourseMetadata.fetch(course).get( 'other_course_settings', {}).get('value', {}) # Only save or update courses that have other_course_settings configurations. if other_course_settings and other_course_settings.get( 'external_platform_target'): migrated_courses.append(str(course.id)) OtherCourseSettings.objects.update_or_create( # pylint: disable=no-member course_id=course.id, defaults={ 'external_course_id': other_course_settings.get('external_course_run_id'), 'external_platform': other_course_settings.get('external_platform_target'), 'other_course_settings': other_course_settings, }, ) # Sleeps in every group_length reach to avoid database from crashing. if group_counter % kwargs.get('group_length') == 0: print("Waiting for %s seconds..." % kwargs.get('delay_seconds')) time.sleep(kwargs.get('delay_seconds')) print("=" * 80) print("=" * 30 + "> Migration group") print("Total number of courses migrated or updated: %s\n" % len(migrated_courses)) print("\n".join(migrated_courses)) print("=" * 80) except Exception as error: # pylint: disable=broad-except print("=" * 80) print( "This command should be run via cms. Example: python manage.py cms migrate_other_course_settings 5 6." ) print("ERROR: %s" % error)
def import_olx(self, user_id, course_key_string, archive_path, archive_name, language): """ Import a course or library from a provided OLX .tar.gz archive. """ set_code_owner_attribute_from_module(__name__) courselike_key = CourseKey.from_string(course_key_string) try: user = User.objects.get(pk=user_id) except User.DoesNotExist: with translation_language(language): self.status.fail(_(u'Unknown User ID: {0}').format(user_id)) return if not has_course_author_access(user, courselike_key): with translation_language(language): self.status.fail(_(u'Permission denied')) return is_library = isinstance(courselike_key, LibraryLocator) is_course = not is_library if is_library: root_name = LIBRARY_ROOT courselike_module = modulestore().get_library(courselike_key) import_func = import_library_from_xml else: root_name = COURSE_ROOT courselike_module = modulestore().get_course(courselike_key) import_func = import_course_from_xml # Locate the uploaded OLX archive (and download it from S3 if necessary) # Do everything in a try-except block to make sure everything is properly cleaned up. data_root = path(settings.GITHUB_REPO_ROOT) subdir = base64.urlsafe_b64encode( repr(courselike_key).encode('utf-8')).decode('utf-8') course_dir = data_root / subdir try: self.status.set_state(u'Unpacking') if not archive_name.endswith(u'.tar.gz'): with translation_language(language): self.status.fail( _(u'We only support uploading a .tar.gz file.')) return temp_filepath = course_dir / get_valid_filename(archive_name) if not course_dir.isdir(): os.mkdir(course_dir) LOGGER.debug(u'importing course to {0}'.format(temp_filepath)) # Copy the OLX archive from where it was uploaded to (S3, Swift, file system, etc.) if not course_import_export_storage.exists(archive_path): LOGGER.info(u'Course import %s: Uploaded file %s not found', courselike_key, archive_path) with translation_language(language): self.status.fail(_(u'Tar file not found')) return with course_import_export_storage.open(archive_path, 'rb') as source: with open(temp_filepath, 'wb') as destination: def read_chunk(): """ Read and return a sequence of bytes from the source file. """ return source.read(FILE_READ_CHUNK) for chunk in iter(read_chunk, b''): destination.write(chunk) LOGGER.info(u'Course import %s: Download from storage complete', courselike_key) # Delete from source location course_import_export_storage.delete(archive_path) # If the course has an entrance exam then remove it and its corresponding milestone. # current course state before import. if is_course: if courselike_module.entrance_exam_enabled: fake_request = RequestFactory().get(u'/') fake_request.user = user from .views.entrance_exam import remove_entrance_exam_milestone_reference # TODO: Is this really ok? Seems dangerous for a live course remove_entrance_exam_milestone_reference( fake_request, courselike_key) LOGGER.info( u'entrance exam milestone content reference for course %s has been removed', courselike_module.id) # Send errors to client with stage at which error occurred. except Exception as exception: # pylint: disable=broad-except if course_dir.isdir(): shutil.rmtree(course_dir) LOGGER.info(u'Course import %s: Temp data cleared', courselike_key) LOGGER.exception(u'Error importing course %s', courselike_key, exc_info=True) self.status.fail(text_type(exception)) return # try-finally block for proper clean up after receiving file. try: tar_file = tarfile.open(temp_filepath) try: safetar_extractall(tar_file, (course_dir + u'/')) except SuspiciousOperation as exc: LOGGER.info(u'Course import %s: Unsafe tar file - %s', courselike_key, exc.args[0]) with translation_language(language): self.status.fail(_(u'Unsafe tar file. Aborting import.')) return finally: tar_file.close() LOGGER.info(u'Course import %s: Uploaded file extracted', courselike_key) self.status.set_state(u'Verifying') self.status.increment_completed_steps() # find the 'course.xml' file def get_all_files(directory): """ For each file in the directory, yield a 2-tuple of (file-name, directory-path) """ for directory_path, _dirnames, filenames in os.walk(directory): for filename in filenames: yield (filename, directory_path) def get_dir_for_filename(directory, filename): """ Returns the directory path for the first file found in the directory with the given name. If there is no file in the directory with the specified name, return None. """ for name, directory_path in get_all_files(directory): if name == filename: return directory_path return None dirpath = get_dir_for_filename(course_dir, root_name) if not dirpath: with translation_language(language): self.status.fail( _(u'Could not find the {0} file in the package.').format( root_name)) return dirpath = os.path.relpath(dirpath, data_root) LOGGER.debug(u'found %s at %s', root_name, dirpath) LOGGER.info(u'Course import %s: Extracted file verified', courselike_key) self.status.set_state(u'Updating') self.status.increment_completed_steps() courselike_items = import_func(modulestore(), user.id, settings.GITHUB_REPO_ROOT, [dirpath], load_error_modules=False, static_content_store=contentstore(), target_id=courselike_key) new_location = courselike_items[0].location LOGGER.debug(u'new course at %s', new_location) LOGGER.info(u'Course import %s: Course import successful', courselike_key) except Exception as exception: # pylint: disable=broad-except LOGGER.exception(u'error importing course', exc_info=True) self.status.fail(text_type(exception)) finally: if course_dir.isdir(): shutil.rmtree(course_dir) LOGGER.info(u'Course import %s: Temp data cleared', courselike_key) if self.status.state == u'Updating' and is_course: # Reload the course so we have the latest state course = modulestore().get_course(courselike_key) if course.entrance_exam_enabled: entrance_exam_chapter = modulestore().get_items( course.id, qualifiers={u'category': u'chapter'}, settings={u'is_entrance_exam': True})[0] metadata = { u'entrance_exam_id': text_type(entrance_exam_chapter.location) } CourseMetadata.update_from_dict(metadata, course, user) from .views.entrance_exam import add_entrance_exam_milestone add_entrance_exam_milestone(course.id, entrance_exam_chapter) LOGGER.info(u'Course %s Entrance exam imported', course.id)
def test_team_settings(self, config_block, error_message): result = CourseMetadata.validate_team_settings(config_block) self.assertEqual(len(result), len(error_message)) if len(error_message) > 0: for res in result: self.assertIn(res['message'], error_message)
def import_olx(self, user_id, course_key_string, archive_path, archive_name, language): """ Import a course or library from a provided OLX .tar.gz archive. """ current_step = 'Unpacking' courselike_key = CourseKey.from_string(course_key_string) set_code_owner_attribute_from_module(__name__) set_custom_attributes_for_course_key(courselike_key) log_prefix = f'Course import {courselike_key}' self.status.set_state(current_step) data_root = path(settings.GITHUB_REPO_ROOT) subdir = base64.urlsafe_b64encode( repr(courselike_key).encode('utf-8')).decode('utf-8') course_dir = data_root / subdir def validate_user(): """Validate if the user exists otherwise log error. """ try: return User.objects.get(pk=user_id) except User.DoesNotExist as exc: with translation_language(language): self.status.fail(UserErrors.USER_PERMISSION_DENIED) LOGGER.error(f'{log_prefix}: Unknown User: {user_id}') monitor_import_failure(courselike_key, current_step, exception=exc) return def user_has_access(user): """Return True if user has studio write access to the given course.""" has_access = has_course_author_access(user, courselike_key) if not has_access: message = f'User permission denied: {user.username}' with translation_language(language): self.status.fail(UserErrors.COURSE_PERMISSION_DENIED) LOGGER.error(f'{log_prefix}: {message}') monitor_import_failure(courselike_key, current_step, message=message) return has_access def file_is_supported(): """Check if it is a supported file.""" file_is_valid = archive_name.endswith('.tar.gz') if not file_is_valid: message = f'Unsupported file {archive_name}' with translation_language(language): self.status.fail(UserErrors.INVALID_FILE_TYPE) LOGGER.error(f'{log_prefix}: {message}') monitor_import_failure(courselike_key, current_step, message=message) return file_is_valid def file_exists_in_storage(): """Verify archive path exists in storage.""" archive_path_exists = course_import_export_storage.exists(archive_path) if not archive_path_exists: message = f'Uploaded file {archive_path} not found' with translation_language(language): self.status.fail(UserErrors.FILE_NOT_FOUND) LOGGER.error(f'{log_prefix}: {message}') monitor_import_failure(courselike_key, current_step, message=message) return archive_path_exists def verify_root_name_exists(course_dir, root_name): """Verify root xml file exists.""" def get_all_files(directory): """ For each file in the directory, yield a 2-tuple of (file-name, directory-path) """ for directory_path, _dirnames, filenames in os.walk(directory): for filename in filenames: yield (filename, directory_path) def get_dir_for_filename(directory, filename): """ Returns the directory path for the first file found in the directory with the given name. If there is no file in the directory with the specified name, return None. """ for name, directory_path in get_all_files(directory): if name == filename: return directory_path return None dirpath = get_dir_for_filename(course_dir, root_name) if not dirpath: message = UserErrors.FILE_MISSING.format(root_name) with translation_language(language): self.status.fail(message) LOGGER.error(f'{log_prefix}: {message}') monitor_import_failure(courselike_key, current_step, message=message) return return dirpath user = validate_user() if not user: return if not user_has_access(user): return if not file_is_supported(): return is_library = isinstance(courselike_key, LibraryLocator) is_course = not is_library if is_library: root_name = LIBRARY_ROOT courselike_module = modulestore().get_library(courselike_key) import_func = import_library_from_xml else: root_name = COURSE_ROOT courselike_module = modulestore().get_course(courselike_key) import_func = import_course_from_xml # Locate the uploaded OLX archive (and download it from S3 if necessary) # Do everything in a try-except block to make sure everything is properly cleaned up. try: LOGGER.info(f'{log_prefix}: unpacking step started') temp_filepath = course_dir / get_valid_filename(archive_name) if not course_dir.isdir(): os.mkdir(course_dir) LOGGER.info(f'{log_prefix}: importing course to {temp_filepath}') # Copy the OLX archive from where it was uploaded to (S3, Swift, file system, etc.) if not file_exists_in_storage(): return with course_import_export_storage.open(archive_path, 'rb') as source: with open(temp_filepath, 'wb') as destination: def read_chunk(): """ Read and return a sequence of bytes from the source file. """ return source.read(FILE_READ_CHUNK) for chunk in iter(read_chunk, b''): destination.write(chunk) LOGGER.info(f'{log_prefix}: Download from storage complete') # Delete from source location course_import_export_storage.delete(archive_path) # If the course has an entrance exam then remove it and its corresponding milestone. # current course state before import. if is_course: if courselike_module.entrance_exam_enabled: fake_request = RequestFactory().get('/') fake_request.user = user from .views.entrance_exam import remove_entrance_exam_milestone_reference # TODO: Is this really ok? Seems dangerous for a live course remove_entrance_exam_milestone_reference( fake_request, courselike_key) LOGGER.info( f'{log_prefix}: entrance exam milestone content reference has been removed' ) # Send errors to client with stage at which error occurred. except Exception as exception: # pylint: disable=broad-except if course_dir.isdir(): shutil.rmtree(course_dir) LOGGER.info(f'{log_prefix}: Temp data cleared') self.status.fail(UserErrors.UNKNOWN_ERROR_IN_UNPACKING) LOGGER.exception(f'{log_prefix}: Unknown error while unpacking', exc_info=True) monitor_import_failure(courselike_key, current_step, exception=exception) return # try-finally block for proper clean up after receiving file. try: tar_file = tarfile.open(temp_filepath) try: safetar_extractall(tar_file, (course_dir + '/')) except SuspiciousOperation as exc: with translation_language(language): self.status.fail(UserErrors.UNSAFE_TAR_FILE) LOGGER.error(f'{log_prefix}: Unsafe tar file') monitor_import_failure(courselike_key, current_step, exception=exc) return finally: tar_file.close() current_step = 'Verifying' self.status.set_state(current_step) self.status.increment_completed_steps() LOGGER.info( f'{log_prefix}: Uploaded file extracted. Verification step started' ) dirpath = verify_root_name_exists(course_dir, root_name) if not dirpath: return if not validate_course_olx(courselike_key, dirpath, self.status): return dirpath = os.path.relpath(dirpath, data_root) current_step = 'Updating' self.status.set_state(current_step) self.status.increment_completed_steps() LOGGER.info( f'{log_prefix}: Extracted file verified. Updating course started') courselike_items = import_func( modulestore(), user.id, settings.GITHUB_REPO_ROOT, [dirpath], load_error_modules=False, static_content_store=contentstore(), target_id=courselike_key, verbose=True, ) new_location = courselike_items[0].location LOGGER.debug('new course at %s', new_location) LOGGER.info(f'{log_prefix}: Course import successful') set_custom_attribute('course_import_completed', True) except (CourseImportException, InvalidProctoringProvider, DuplicateCourseError) as known_exe: handle_course_import_exception(courselike_key, known_exe, self.status) except Exception as exception: # pylint: disable=broad-except handle_course_import_exception(courselike_key, exception, self.status, known=False) finally: if course_dir.isdir(): shutil.rmtree(course_dir) LOGGER.info(f'{log_prefix}: Temp data cleared') if self.status.state == 'Updating' and is_course: # Reload the course so we have the latest state course = modulestore().get_course(courselike_key) if course.entrance_exam_enabled: entrance_exam_chapter = modulestore().get_items( course.id, qualifiers={'category': 'chapter'}, settings={'is_entrance_exam': True})[0] metadata = { 'entrance_exam_id': str(entrance_exam_chapter.location) } CourseMetadata.update_from_dict(metadata, course, user) from .views.entrance_exam import add_entrance_exam_milestone add_entrance_exam_milestone(course.id, entrance_exam_chapter) LOGGER.info( f'Course import {course.id}: Entrance exam imported')
def get(self, request: Request, course_id: str): """ Get an object containing all the advanced settings in a course. **Example Request** GET /api/contentstore/v0/advanced_settings/{course_id} **Response Values** If the request is successful, an HTTP 200 "OK" response is returned. The HTTP 200 response contains a single dict that contains keys that are the course's advanced settings. For each setting a dictionary is returned that contains the following fields: * **deprecated**: This is true for settings that are deprecated. * **display_name**: This is a friendly name for the setting. * **help**: Contains help text that explains how the setting works. * **value**: Contains the value of the setting. The exact format depends on the setting and is often explained in the ``help`` field above. There may be other fields returned by the response. **Example Response** ```json { "display_name": { "value": "Demonstration Course", "display_name": "Course Display Name", "help": "Enter the name of the course as it should appear in the course list.", "deprecated": false, "hide_on_enabled_publisher": false }, "course_edit_method": { "value": "Studio", "display_name": "Course Editor", "help": "Enter the method by which this course is edited (\"XML\" or \"Studio\").", "deprecated": true, "hide_on_enabled_publisher": false }, "days_early_for_beta": { "value": null, "display_name": "Days Early for Beta Users", "help": "Enter the number of days before the start date that beta users can access the course.", "deprecated": false, "hide_on_enabled_publisher": false }, ... } ``` """ filter_query_data = AdvancedCourseSettingsView.FilterQuery( request.query_params) if not filter_query_data.is_valid(): raise ValidationError(filter_query_data.errors) course_key = CourseKey.from_string(course_id) if not has_studio_read_access(request.user, course_key): self.permission_denied(request) course_module = modulestore().get_course(course_key) return Response( CourseMetadata.fetch_all( course_module, filter_fields=filter_query_data.cleaned_data['filter_fields'], ))