def export_output_handler(request, course_key_string): """ Returns the OLX .tar.gz produced by a file export. Only used in environments such as devstack where the output is stored in a local filesystem instead of an external service like S3. """ course_key = CourseKey.from_string(course_key_string) if not has_course_author_access(request.user, course_key): raise PermissionDenied() task_status = _latest_task_status(request, course_key_string, export_output_handler) if task_status and task_status.state == UserTaskStatus.SUCCEEDED: artifact = None try: artifact = UserTaskArtifact.objects.get(status=task_status, name='Output') tarball = course_import_export_storage.open(artifact.file.name) return send_tarball(tarball, artifact.file.storage.size(artifact.file.name)) except UserTaskArtifact.DoesNotExist: raise Http404 # lint-amnesty, pylint: disable=raise-missing-from finally: if artifact: artifact.file.close() else: raise Http404
def export_olx(self, user_id, course_key_string, language): """ Export a course or library to an OLX .tar.gz archive and prepare it for download. """ 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(UserErrors.UNKNOWN_USER_ID.format(user_id)) return if not has_course_author_access(user, courselike_key): with translation_language(language): self.status.fail(UserErrors.PERMISSION_DENIED) return if isinstance(courselike_key, LibraryLocator): courselike_module = modulestore().get_library(courselike_key) else: courselike_module = modulestore().get_course(courselike_key) try: self.status.set_state('Exporting') tarball = create_export_tarball(courselike_module, courselike_key, {}, self.status) artifact = UserTaskArtifact(status=self.status, name='Output') artifact.file.save(name=os.path.basename(tarball.name), content=File(tarball)) artifact.save() # catch all exceptions so we can record useful error messages except Exception as exception: # pylint: disable=broad-except LOGGER.exception('Error exporting course %s', courselike_key, exc_info=True) if self.status.state != UserTaskStatus.FAILED: self.status.fail({'raw_error_msg': str(exception)}) return
def check_course_access(course_key, user=None, ip_address=None, url=None): """ Check is the user with this ip_address has access to the given course Arguments: course_key (CourseKey): Location of the course the user is trying to access. Keyword Arguments: user (User): The user making the request. Can be None, in which case the user's profile country will not be checked. ip_address (str): The IP address of the request. url (str): The URL the user is trying to access. Used in log messages. Returns: Boolean: True if the user has access to the course; False otherwise """ # No-op if the country access feature is not enabled if not settings.FEATURES.get('EMBARGO'): return True # First, check whether there are any restrictions on the course. # If not, then we do not need to do any further checks course_is_restricted = RestrictedCourse.is_restricted_course(course_key) if not course_is_restricted: return True # Always give global and course staff access, regardless of embargo settings. if user is not None and has_course_author_access(user, course_key): return True if ip_address is not None: # Retrieve the country code from the IP address # and check it against the allowed countries list for a course user_country_from_ip = _country_code_from_ip(ip_address) if not CountryAccessRule.check_country_access(course_key, user_country_from_ip): log.info((u"Blocking user %s from accessing course %s at %s " u"because the user's IP address %s appears to be " u"located in %s."), getattr(user, 'id', '<Not Authenticated>'), course_key, url, ip_address, user_country_from_ip) return False if user is not None: # Retrieve the country code from the user's profile # and check it against the allowed countries list for a course. user_country_from_profile = _get_user_country_from_profile(user) if not CountryAccessRule.check_country_access( course_key, user_country_from_profile): log.info((u"Blocking user %s from accessing course %s at %s " u"because the user's profile country is %s."), user.id, course_key, url, user_country_from_profile) return False return True
def checklists_handler(request, course_key_string=None): ''' The restful handler for course checklists. It allows retrieval of the checklists (as an HTML page). GET html: return an html page which will show course checklists. Note that only the checklists container is returned and that the actual data is determined with a client-side request. ''' course_key = CourseKey.from_string(course_key_string) if not has_course_author_access(request.user, course_key): raise PermissionDenied() course_module = modulestore().get_course(course_key) course_authoring_microfrontend_url = get_proctored_exam_settings_url(course_module) proctored_exam_settings_url = ( '{course_authoring_microfrontend_url}/proctored-exam-settings/{course_key_string}'.format( course_authoring_microfrontend_url=course_authoring_microfrontend_url, course_key_string=course_key_string, ) ) return render_to_response('checklists.html', { 'language_code': request.LANGUAGE_CODE, 'context_course': course_module, 'proctored_exam_settings_url': proctored_exam_settings_url, })
def export_git(request, course_key_string): """ This method serves up the 'Export to Git' page """ course_key = CourseKey.from_string(course_key_string) if not has_course_author_access(request.user, course_key): raise PermissionDenied() course_module = modulestore().get_course(course_key) failed = False log.debug('export_git course_module=%s', course_module) msg = "" if 'action' in request.GET and course_module.giturl: if request.GET['action'] == 'push': try: git_export_utils.export_to_git( course_module.id, course_module.giturl, request.user, ) msg = _('Course successfully exported to git repository') except git_export_utils.GitExportError as ex: failed = True msg = str(ex) return render_to_response('export_git.html', { 'context_course': course_module, 'msg': msg, 'failed': failed, })
def test_notifications_handler_dismiss(self): state = CourseRerunUIStateManager.State.FAILED should_display = True rerun_course_key = CourseLocator(org='testx', course='test_course', run='test_run') # add an instructor to this course user2 = UserFactory() add_instructor(rerun_course_key, self.user, user2) # create a test notification rerun_state = CourseRerunState.objects.update_state( course_key=rerun_course_key, new_state=state, allow_not_found=True) CourseRerunState.objects.update_should_display( entry_id=rerun_state.id, user=user2, should_display=should_display) # try to get information on this notification notification_dismiss_url = reverse_course_url( 'course_notifications_handler', self.course.id, kwargs={ 'action_state_id': rerun_state.id, }) resp = self.client.delete(notification_dismiss_url) self.assertEqual(resp.status_code, 200) with self.assertRaises(CourseRerunState.DoesNotExist): # delete nofications that are dismissed CourseRerunState.objects.get(id=rerun_state.id) self.assertFalse(has_course_author_access(user2, rerun_course_key))
def tabs_handler(request, course_key_string): """ The restful handler for static tabs. GET html: return page for editing static tabs json: not supported PUT or POST json: update the tab order. It is expected that the request body contains a JSON-encoded dict with entry "tabs". The value for "tabs" is an array of tab locators, indicating the desired order of the tabs. Creating a tab, deleting a tab, or changing its contents is not supported through this method. Instead use the general xblock URL (see item.xblock_handler). """ course_key = CourseKey.from_string(course_key_string) if not has_course_author_access(request.user, course_key): raise PermissionDenied() course_item = modulestore().get_course(course_key) if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): if request.method == 'GET': raise NotImplementedError('coming soon') else: if 'tabs' in request.json: return reorder_tabs_handler(course_item, request) elif 'tab_id_locator' in request.json: return edit_tab_handler(course_item, request) else: raise NotImplementedError( 'Creating or changing tab content is not supported.') elif request.method == 'GET': # assume html # get all tabs from the tabs list: static tabs (a.k.a. user-created tabs) and built-in tabs # present in the same order they are displayed in LMS tabs_to_render = [] for tab in CourseTabList.iterate_displayable(course_item, user=request.user, inline_collections=False, include_hidden=True): if isinstance(tab, StaticTab): # static tab needs its locator information to render itself as an xmodule static_tab_loc = course_key.make_usage_key( 'static_tab', tab.url_slug) tab.locator = static_tab_loc tabs_to_render.append(tab) return render_to_response( 'edit-tabs.html', { 'context_course': course_item, 'tabs_to_render': tabs_to_render, 'lms_link': get_lms_link_for_item(course_item.location), }) else: return HttpResponseNotFound()
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 test_rerun_course(self): """ Unit tests for :meth: `contentstore.tasks.rerun_course` """ mongo_course1_id = self.import_and_populate_course() # rerun from mongo into split split_course3_id = CourseLocator(org="edx3", course="split3", run="rerun_test") # Mark the action as initiated fields = {'display_name': 'rerun'} CourseRerunState.objects.initiated(mongo_course1_id, split_course3_id, self.user, fields['display_name']) result = rerun_course.delay(six.text_type(mongo_course1_id), six.text_type(split_course3_id), self.user.id, json.dumps(fields, cls=EdxJSONEncoder)) self.assertEqual(result.get(), "succeeded") self.assertTrue(has_course_author_access(self.user, split_course3_id), "Didn't grant access") rerun_state = CourseRerunState.objects.find_first( course_key=split_course3_id) self.assertEqual(rerun_state.state, CourseRerunUIStateManager.State.SUCCEEDED) # try creating rerunning again to same name and ensure it generates error result = rerun_course.delay(six.text_type(mongo_course1_id), six.text_type(split_course3_id), self.user.id) self.assertEqual(result.get(), "duplicate course") # the below will raise an exception if the record doesn't exist CourseRerunState.objects.find_first( course_key=split_course3_id, state=CourseRerunUIStateManager.State.FAILED) # try to hit the generic exception catch with patch('xmodule.modulestore.split_mongo.mongo_connection.MongoConnection.insert_course_index', Mock(side_effect=Exception)): # lint-amnesty, pylint: disable=line-too-long split_course4_id = CourseLocator(org="edx3", course="split3", run="rerun_fail") fields = {'display_name': 'total failure'} CourseRerunState.objects.initiated(split_course3_id, split_course4_id, self.user, fields['display_name']) result = rerun_course.delay(six.text_type(split_course3_id), six.text_type(split_course4_id), self.user.id, json.dumps(fields, cls=EdxJSONEncoder)) self.assertIn("exception: ", result.get()) self.assertIsNone(self.store.get_course(split_course4_id), "Didn't delete course after error") CourseRerunState.objects.find_first( course_key=split_course4_id, state=CourseRerunUIStateManager.State.FAILED)
def export_handler(request, course_key_string): """ The restful handler for exporting a course. GET html: return html page for import page json: not supported POST Start a Celery task to export the course The Studio UI uses a POST request to start the export asynchronously, with a link appearing on the page once it's ready. """ course_key = CourseKey.from_string(course_key_string) if not has_course_author_access(request.user, course_key): raise PermissionDenied() if isinstance(course_key, LibraryLocator): courselike_module = modulestore().get_library(course_key) context = { 'context_library': courselike_module, 'courselike_home_url': reverse_library_url("library_handler", course_key), 'library': True } else: courselike_module = modulestore().get_course(course_key) if courselike_module is None: raise Http404 context = { 'context_course': courselike_module, 'courselike_home_url': reverse_course_url("course_handler", course_key), 'library': False } context['status_url'] = reverse_course_url('export_status_handler', course_key) # an _accept URL parameter will be preferred over HTTP_ACCEPT in the header. requested_format = request.GET.get( '_accept', request.META.get('HTTP_ACCEPT', 'text/html')) if request.method == 'POST': export_olx.delay(request.user.id, course_key_string, request.LANGUAGE_CODE) return JsonResponse({'ExportStatus': 1}) elif 'text/html' in requested_format: return render_to_response('export.html', context) else: # Only HTML request format is supported (no JSON). return HttpResponse(status=406)
def _wrapper_view(self, request, course_id, *args, **kwargs): """ Checks for course author access for the given course by the requesting user. Calls the view function if has access, otherwise raises a 403. """ course_key = CourseKey.from_string(course_id) if not has_course_author_access(request.user, course_key): raise DeveloperErrorViewMixin.api_error( status_code=status.HTTP_403_FORBIDDEN, developer_message='The requesting user does not have course author permissions.', error_code='user_permissions', ) return view(self, request, course_key, *args, **kwargs)
def tabs_handler(request, course_key_string): """ The restful handler for static tabs. GET html: return page for editing static tabs json: not supported PUT or POST json: update the tab order. It is expected that the request body contains a JSON-encoded dict with entry "tabs". The value for "tabs" is an array of tab locators, indicating the desired order of the tabs. Creating a tab, deleting a tab, or changing its contents is not supported through this method. Instead use the general xblock URL (see item.xblock_handler). """ course_key = CourseKey.from_string(course_key_string) if not has_course_author_access(request.user, course_key): raise PermissionDenied() course_item = modulestore().get_course(course_key) if "application/json" in request.META.get("HTTP_ACCEPT", "application/json"): if request.method == "GET": # lint-amnesty, pylint: disable=no-else-raise raise NotImplementedError("coming soon") else: try: update_tabs_handler(course_item, request.json, request.user) except ValidationError as err: return JsonResponseBadRequest(err.detail) return JsonResponse() elif request.method == "GET": # assume html # get all tabs from the tabs list and select only static tabs (a.k.a. user-created tabs) # present in the same order they are displayed in LMS tabs_to_render = list(get_course_static_tabs(course_item, request.user)) return render_to_response( "edit-tabs.html", { "context_course": course_item, "tabs_to_render": tabs_to_render, "lms_link": get_lms_link_for_item(course_item.location), }, ) else: return HttpResponseNotFound()
def _get_item(request, data): """ Obtains from 'data' the locator for an item. Next, gets that item from the modulestore (allowing any errors to raise up). Finally, verifies that the user has access to the item. Returns the item. """ usage_key = UsageKey.from_string(data.get('locator')) # This is placed before has_course_author_access() to validate the location, # because has_course_author_access() raises r if location is invalid. item = modulestore().get_item(usage_key) # use the item's course_key, because the usage_key might not have the run if not has_course_author_access(request.user, item.location.course_key): raise PermissionDenied() return item
def entrance_exam(request, course_key_string): """ The restful handler for entrance exams. It allows retrieval of all the assets (as an HTML page), as well as uploading new assets, deleting assets, and changing the "locked" state of an asset. GET Retrieves the entrance exam module (metadata) for the specified course POST Adds an entrance exam module to the specified course. DELETE Removes the entrance exam from the course """ course_key = CourseKey.from_string(course_key_string) # Deny access if the user is valid, but they lack the proper object access privileges if not has_course_author_access(request.user, course_key): return HttpResponse(status=403) # Retrieve the entrance exam module for the specified course (returns 404 if none found) if request.method == 'GET': return _get_entrance_exam(request, course_key) # Create a new entrance exam for the specified course (returns 201 if created) elif request.method == 'POST': response_format = request.POST.get('format', 'html') http_accept = request.META.get('http_accept') if response_format == 'json' or 'application/json' in http_accept: ee_min_score = request.POST.get('entrance_exam_minimum_score_pct', None) # if request contains empty value or none then save the default one. entrance_exam_minimum_score_pct = _get_default_entrance_exam_minimum_pct() if ee_min_score != '' and ee_min_score is not None: entrance_exam_minimum_score_pct = float(ee_min_score) return create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct) return HttpResponse(status=400) # Remove the entrance exam module for the specified course (returns 204 regardless of existence) elif request.method == 'DELETE': return delete_entrance_exam(request, course_key) # No other HTTP verbs/methods are supported at this time else: return HttpResponse(status=405)
def import_status_handler(request, course_key_string, filename=None): """ Returns an integer corresponding to the status of a file import. These are: -X : Import unsuccessful due to some error with X as stage [0-3] 0 : No status info found (import done or upload still in progress) 1 : Unpacking 2 : Verifying 3 : Updating 4 : Import successful """ course_key = CourseKey.from_string(course_key_string) if not has_course_author_access(request.user, course_key): raise PermissionDenied() # The task status record is authoritative once it's been created args = {'course_key_string': course_key_string, 'archive_name': filename} name = CourseImportTask.generate_name(args) task_status = UserTaskStatus.objects.filter(name=name) message = '' for status_filter in STATUS_FILTERS: task_status = status_filter().filter_queryset(request, task_status, import_status_handler) task_status = task_status.order_by('-created').first() if task_status is None: # The task hasn't been initialized yet; did we store info in the session already? try: session_status = request.session["import_status"] status = session_status[course_key_string + filename] except KeyError: status = 0 elif task_status.state == UserTaskStatus.SUCCEEDED: status = 4 elif task_status.state in (UserTaskStatus.FAILED, UserTaskStatus.CANCELED): status = max(-(task_status.completed_steps + 1), -3) artifact = UserTaskArtifact.objects.filter( name='Error', status=task_status).order_by('-created').first() if artifact: message = artifact.text else: status = min(task_status.completed_steps + 1, 3) return JsonResponse({"ImportStatus": status, "Message": message})
def import_handler(request, course_key_string): """ The restful handler for importing a course. GET html: return html page for import page json: not supported POST or PUT json: import a course via the .tar.gz file specified in request.FILES """ courselike_key = CourseKey.from_string(course_key_string) library = isinstance(courselike_key, LibraryLocator) if library: successful_url = reverse_library_url('library_handler', courselike_key) context_name = 'context_library' courselike_module = modulestore().get_library(courselike_key) else: successful_url = reverse_course_url('course_handler', courselike_key) context_name = 'context_course' courselike_module = modulestore().get_course(courselike_key) if not has_course_author_access(request.user, courselike_key): raise PermissionDenied() if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): if request.method == 'GET': # lint-amnesty, pylint: disable=no-else-raise raise NotImplementedError('coming soon') else: return _write_chunk(request, courselike_key) elif request.method == 'GET': # assume html status_url = reverse_course_url("import_status_handler", courselike_key, kwargs={'filename': "fillerName"}) return render_to_response( 'import.html', { context_name: courselike_module, 'successful_import_redirect_url': successful_url, 'import_status_url': status_url, 'library': isinstance(courselike_key, LibraryLocator) }) else: return HttpResponseNotFound()
def _get_item_in_course(request, usage_key): """ Helper method for getting the old location, containing course, item, lms_link, and preview_lms_link for a given locator. Verifies that the caller has permission to access this item. """ # usage_key's course_key may have an empty run property usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key)) course_key = usage_key.course_key if not has_course_author_access(request.user, course_key): raise PermissionDenied() course = modulestore().get_course(course_key) item = modulestore().get_item(usage_key, depth=1) lms_link = get_lms_link_for_item(item.location) preview_lms_link = get_lms_link_for_item(item.location, preview=True) return course, item, lms_link, preview_lms_link
def assets_handler(request, course_key_string=None, asset_key_string=None): ''' The restful handler for assets. It allows retrieval of all the assets (as an HTML page), as well as uploading new assets, deleting assets, and changing the 'locked' state of an asset. GET html: return an html page which will show all course assets. Note that only the asset container is returned and that the actual assets are filled in with a client-side request. json: returns a page of assets. The following parameters are supported: page: the desired page of results (defaults to 0) page_size: the number of items per page (defaults to 50) sort: the asset field to sort by (defaults to 'date_added') direction: the sort direction (defaults to 'descending') asset_type: the file type to filter items to (defaults to All) text_search: string to filter results by file name (defaults to '') POST json: create (or update?) an asset. The only updating that can be done is changing the lock state. PUT json: update the locked state of an asset DELETE json: delete an asset ''' course_key = CourseKey.from_string(course_key_string) if not has_course_author_access(request.user, course_key): raise PermissionDenied() response_format = _get_response_format(request) if _request_response_format_is_json(request, response_format): if request.method == 'GET': return _assets_json(request, course_key) asset_key = AssetKey.from_string( asset_key_string) if asset_key_string else None return _update_asset(request, course_key, asset_key) elif request.method == 'GET': # assume html return _asset_index(request, course_key) return HttpResponseNotFound()
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 export_status_handler(request, course_key_string): """ Returns an integer corresponding to the status of a file export. These are: -X : Export unsuccessful due to some error with X as stage [0-3] 0 : No status info found (export done or task not yet created) 1 : Exporting 2 : Compressing 3 : Export successful If the export was successful, a URL for the generated .tar.gz file is also returned. """ course_key = CourseKey.from_string(course_key_string) if not has_course_author_access(request.user, course_key): raise PermissionDenied() # The task status record is authoritative once it's been created task_status = _latest_task_status(request, course_key_string, export_status_handler) output_url = None error = None if task_status is None: # The task hasn't been initialized yet; did we store info in the session already? try: session_status = request.session["export_status"] status = session_status[course_key_string] except KeyError: status = 0 elif task_status.state == UserTaskStatus.SUCCEEDED: status = 3 artifact = UserTaskArtifact.objects.get(status=task_status, name='Output') if isinstance(artifact.file.storage, FileSystemStorage): output_url = reverse_course_url('export_output_handler', course_key) elif isinstance(artifact.file.storage, S3BotoStorage): filename = os.path.basename(artifact.file.name) disposition = f'attachment; filename="{filename}"' output_url = artifact.file.storage.url(artifact.file.name, response_headers={ 'response-content-disposition': disposition, 'response-content-encoding': 'application/octet-stream', 'response-content-type': 'application/x-tgz' }) elif isinstance(artifact.file.storage, S3Boto3Storage): filename = os.path.basename(artifact.file.name) disposition = f'attachment; filename="{filename}"' output_url = artifact.file.storage.url(artifact.file.name, parameters={ 'ResponseContentDisposition': disposition, 'ResponseContentEncoding': 'application/octet-stream', 'ResponseContentType': 'application/x-tgz' }) else: output_url = artifact.file.storage.url(artifact.file.name) elif task_status.state in (UserTaskStatus.FAILED, UserTaskStatus.CANCELED): status = max(-(task_status.completed_steps + 1), -2) errors = UserTaskArtifact.objects.filter(status=task_status, name='Error') if errors: error = errors[0].text try: error = json.loads(error) except ValueError: # Wasn't JSON, just use the value as a string pass else: status = min(task_status.completed_steps + 1, 2) response = {"ExportStatus": status} if output_url: response['ExportOutput'] = output_url elif error: response['ExportError'] = error return JsonResponse(response)
def cohort_handler(request, course_key_string, cohort_id=None): """ The restful handler for cohort requests. Requires JSON. GET If a cohort ID is specified, returns a JSON representation of the cohort (name, id, user_count, assignment_type, user_partition_id, group_id). If no cohort ID is specified, returns the JSON representation of all cohorts. This is returned as a dict with the list of cohort information stored under the key `cohorts`. PUT or POST or PATCH If a cohort ID is specified, updates the cohort with the specified ID. Currently the only properties that can be updated are `name`, `user_partition_id` and `group_id`. Returns the JSON representation of the updated cohort. If no cohort ID is specified, creates a new cohort and returns the JSON representation of the updated cohort. """ course_key = CourseKey.from_string(course_key_string) if not has_course_author_access(request.user, course_key): raise Http404( 'The requesting user does not have course author permissions.') course = get_course(course_key) if request.method == 'GET': if not cohort_id: all_cohorts = [ _get_cohort_representation(c, course) for c in cohorts.get_course_cohorts(course) ] return JsonResponse({'cohorts': all_cohorts}) else: cohort = cohorts.get_cohort_by_id(course_key, cohort_id) return JsonResponse(_get_cohort_representation(cohort, course)) else: name = request.json.get('name') assignment_type = request.json.get('assignment_type') if not name: # Note: error message not translated because it is not exposed to the user (UI prevents this state). return JsonResponse({"error": "Cohort name must be specified."}, 400) if not assignment_type: # Note: error message not translated because it is not exposed to the user (UI prevents this state). return JsonResponse( {"error": "Assignment type must be specified."}, 400) # If cohort_id is specified, update the existing cohort. Otherwise, create a new cohort. if cohort_id: cohort = cohorts.get_cohort_by_id(course_key, cohort_id) if name != cohort.name: if cohorts.is_cohort_exists(course_key, name): err_msg = gettext( "A cohort with the same name already exists.") return JsonResponse({"error": str(err_msg)}, 400) cohort.name = name cohort.save() try: cohorts.set_assignment_type(cohort, assignment_type) except ValueError as err: return JsonResponse({"error": str(err)}, 400) else: try: cohort = cohorts.add_cohort(course_key, name, assignment_type) except ValueError as err: return JsonResponse({"error": str(err)}, 400) group_id = request.json.get('group_id') if group_id is not None: user_partition_id = request.json.get('user_partition_id') if user_partition_id is None: # Note: error message not translated because it is not exposed to the user (UI prevents this state). return JsonResponse( { "error": "If group_id is specified, user_partition_id must also be specified." }, 400) existing_group_id, existing_partition_id = cohorts.get_group_info_for_cohort( cohort) if group_id != existing_group_id or user_partition_id != existing_partition_id: unlink_cohort_partition_group(cohort) link_cohort_to_partition_group(cohort, user_partition_id, group_id) else: # If group_id was specified as None, unlink the cohort if it previously was associated with a group. existing_group_id, _ = cohorts.get_group_info_for_cohort(cohort) if existing_group_id is not None: unlink_cohort_partition_group(cohort) return JsonResponse(_get_cohort_representation(cohort, course))
def check_course_access( course_key: CourseKey, user: Optional[types.User] = None, ip_addresses: Optional[List[str]] = None, url: Optional[str] = None, ) -> bool: """ Check is the user with this ip_addresses chain has access to the given course Arguments: course_key: Location of the course the user is trying to access. user: The user making the request. Can be None, in which case the user's profile country will not be checked. ip_addresses: The full external chain of IP addresses of the request. url: The URL the user is trying to access. Used in log messages. Returns: True if the user has access to the course; False otherwise """ # No-op if the country access feature is not enabled if not settings.FEATURES.get('EMBARGO'): return True # First, check whether there are any restrictions on the course. # If not, then we do not need to do any further checks course_is_restricted = RestrictedCourse.is_restricted_course(course_key) if not course_is_restricted: return True # Always give global and course staff access, regardless of embargo settings. if user is not None and has_course_author_access(user, course_key): return True if ip_addresses is not None: # Check every IP address provided and deny access if ANY of them fail our country checks for ip_address in ip_addresses: # Retrieve the country code from the IP address # and check it against the allowed countries list for a course user_country_from_ip = country_code_from_ip(ip_address) if not CountryAccessRule.check_country_access(course_key, user_country_from_ip): log.info( ( "Blocking user %s from accessing course %s at %s " "because the user's IP address %s appears to be " "located in %s." ), getattr(user, 'id', '<Not Authenticated>'), course_key, url, ip_address, user_country_from_ip ) return False if user is not None: # Retrieve the country code from the user's profile # and check it against the allowed countries list for a course. user_country_from_profile = _get_user_country_from_profile(user) if not CountryAccessRule.check_country_access(course_key, user_country_from_profile): log.info( ( "Blocking user %s from accessing course %s at %s " "because the user's profile country is %s." ), user.id, course_key, url, user_country_from_profile ) return False return True
def test_get_all_users(self): """ Test getting all authors for a course where their permissions run the gamut of allowed group types. """ # first check the course creator.has explicit access (don't use has_access as is_staff # will trump the actual test) self.assertTrue( CourseInstructorRole(self.course_key).has_user(self.user), "Didn't add creator as instructor.") users = copy.copy(self.users) # doesn't use role.users_with_role b/c it's verifying the roles.py behavior user_by_role = {} # add the misc users to the course in different groups for role in [ CourseInstructorRole, CourseStaffRole, OrgStaffRole, OrgInstructorRole ]: user_by_role[role] = [] # Org-based roles are created via org name, rather than course_key if (role is OrgStaffRole) or (role is OrgInstructorRole): group = role(self.course_key.org) else: group = role(self.course_key) # NOTE: this loop breaks the roles.py abstraction by purposely assigning # users to one of each possible groupname in order to test that has_course_author_access # and remove_user work user = users.pop() group.add_users(user) user_by_role[role].append(user) self.assertTrue(auth.has_course_author_access(user, self.course_key), f"{user} does not have access") # lint-amnesty, pylint: disable=line-too-long course_team_url = reverse_course_url('course_team_handler', self.course_key) response = self.client.get_html(course_team_url) for role in [CourseInstructorRole, CourseStaffRole ]: # Global and org-based roles don't appear on this page for user in user_by_role[role]: self.assertContains(response, user.email) # test copying course permissions copy_course_key = self.store.make_course_key('copyu', 'copydept.mycourse', 'myrun') for role in [ CourseInstructorRole, CourseStaffRole, OrgStaffRole, OrgInstructorRole ]: if (role is OrgStaffRole) or (role is OrgInstructorRole): auth.add_users(self.user, role(copy_course_key.org), *role(self.course_key.org).users_with_role()) else: auth.add_users(self.user, role(copy_course_key), *role(self.course_key).users_with_role()) # verify access in copy course and verify that removal from source course w/ the various # groupnames works for role in [ CourseInstructorRole, CourseStaffRole, OrgStaffRole, OrgInstructorRole ]: for user in user_by_role[role]: # forcefully decache the groups: premise is that any real request will not have # multiple objects repr the same user but this test somehow uses different instance # in above add_users call if hasattr(user, '_roles'): del user._roles self.assertTrue( auth.has_course_author_access(user, copy_course_key), f"{user} no copy access") if (role is OrgStaffRole) or (role is OrgInstructorRole): auth.remove_users(self.user, role(self.course_key.org), user) else: auth.remove_users(self.user, role(self.course_key), user) self.assertFalse(auth.has_course_author_access(user, self.course_key), f"{user} remove didn't work") # lint-amnesty, pylint: disable=line-too-long
def get(self, request, subsection_id): """ Returns subection grade data, override grade data and a history of changes made to a specific users specific subsection grade. Args: subsection_id: String representation of a usage_key, which is an opaque key of a persistant subection grade. user_id: An integer represenation of a user """ try: usage_key = UsageKey.from_string(subsection_id) except InvalidKeyError: raise self.api_error(status_code=status.HTTP_404_NOT_FOUND, developer_message='Invalid UsageKey', error_code='invalid_usage_key') if not has_course_author_access(request.user, usage_key.course_key): raise DeveloperErrorViewMixin.api_error( status_code=status.HTTP_403_FORBIDDEN, developer_message= 'The requesting user does not have course author permissions.', error_code='user_permissions', ) try: user_id = int(request.GET.get('user_id')) except ValueError: raise self.api_error(status_code=status.HTTP_404_NOT_FOUND, developer_message='Invalid UserID', error_code='invalid_user_id') success = True err_msg = "" override = None history = [] history_record_limit = request.GET.get('history_record_limit') if history_record_limit is not None: try: history_record_limit = int(history_record_limit) except ValueError: history_record_limit = 0 try: original_grade = PersistentSubsectionGrade.read_grade( user_id, usage_key) if original_grade is not None and hasattr(original_grade, 'override'): override = original_grade.override # pylint: disable=no-member history = list( PersistentSubsectionGradeOverride.history.filter( grade_id=original_grade.id).order_by('history_date') [:history_record_limit]) grade_data = { 'earned_all': original_grade.earned_all, 'possible_all': original_grade.possible_all, 'earned_graded': original_grade.earned_graded, 'possible_graded': original_grade.possible_graded, } except PersistentSubsectionGrade.DoesNotExist: try: grade_data = self._get_grade_data_for_not_attempted_assignment( user_id, usage_key) except SubsectionUnavailableToUserException as exc: success = False err_msg = str(exc) grade_data = { 'earned_all': 0, 'possible_all': 0, 'earned_graded': 0, 'possible_graded': 0, } response_data = { 'success': success, 'original_grade': grade_data, 'override': override, 'history': history, 'subsection_id': usage_key, 'user_id': user_id, 'course_id': usage_key.course_key, } if not success: response_data['error_message'] = err_msg results = SubsectionGradeResponseSerializer(response_data) return Response(results.data)