diff --git a/common/lib/xmodule/xmodule/abtest_module.py b/common/lib/xmodule/xmodule/abtest_module.py index 2a7bc5f0cb..a4b5ab48a6 100644 --- a/common/lib/xmodule/xmodule/abtest_module.py +++ b/common/lib/xmodule/xmodule/abtest_module.py @@ -80,6 +80,8 @@ class ABTestModule(ABTestFields, XModule): class ABTestDescriptor(ABTestFields, RawDescriptor, XmlDescriptor): module_class = ABTestModule + show_in_read_only_mode = True + @classmethod def definition_from_xml(cls, xml_object, system): """ diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index ed02acf261..26ac4a4d17 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -119,6 +119,7 @@ class CapaDescriptor(CapaFields, RawDescriptor): module_class = CapaModule has_score = True + show_in_read_only_mode = True template_dir_name = 'problem' mako_template = "widgets/problem-edit.html" js = {'coffee': [resource_string(__name__, 'js/src/problem/edit.coffee')]} diff --git a/common/lib/xmodule/xmodule/conditional_module.py b/common/lib/xmodule/xmodule/conditional_module.py index c887670a7a..a7453d3872 100644 --- a/common/lib/xmodule/xmodule/conditional_module.py +++ b/common/lib/xmodule/xmodule/conditional_module.py @@ -188,6 +188,8 @@ class ConditionalDescriptor(ConditionalFields, SequenceDescriptor): has_score = False + show_in_read_only_mode = True + def __init__(self, *args, **kwargs): """ Create an instance of the conditional module. diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index de5957db94..67f3c93791 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -188,6 +188,8 @@ class FolditDescriptor(FolditFields, XmlDescriptor, EditingDescriptor): has_score = True + show_in_read_only_mode = True + js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]} js_module_name = "HTMLEditingDescriptor" diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index d3e806eeb1..5bc086109b 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -96,6 +96,7 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): # pylint: d filename_extension = "xml" template_dir_name = "html" has_responsive_ui = True + show_in_read_only_mode = True js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]} js_module_name = "HTMLEditingDescriptor" diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index fb2723b880..cea8dd55b7 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -299,6 +299,8 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]} js_module_name = "VerticalDescriptor" + show_in_read_only_mode = True + @property def non_editable_metadata_fields(self): non_editable_fields = super(LibraryContentDescriptor, self).non_editable_metadata_fields diff --git a/common/lib/xmodule/xmodule/randomize_module.py b/common/lib/xmodule/xmodule/randomize_module.py index 3973bdffbf..a41cad983c 100644 --- a/common/lib/xmodule/xmodule/randomize_module.py +++ b/common/lib/xmodule/xmodule/randomize_module.py @@ -101,6 +101,8 @@ class RandomizeDescriptor(RandomizeFields, SequenceDescriptor): filename_extension = "xml" + show_in_read_only_mode = True + def definition_to_xml(self, resource_fs): xml_object = etree.Element('randomize') diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 0cbaaac589..e3fe56c646 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -157,6 +157,8 @@ class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor): mako_template = 'widgets/sequence-edit.html' module_class = SequenceModule + show_in_read_only_mode = True + js = { 'coffee': [resource_string(__name__, 'js/src/sequence/edit.coffee')], } diff --git a/common/lib/xmodule/xmodule/split_test_module.py b/common/lib/xmodule/xmodule/split_test_module.py index c862817f58..82127fdda8 100644 --- a/common/lib/xmodule/xmodule/split_test_module.py +++ b/common/lib/xmodule/xmodule/split_test_module.py @@ -375,6 +375,8 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor, StudioEditableDes mako_template = "widgets/metadata-only-edit.html" + show_in_read_only_mode = True + child_descriptor = module_attr('child_descriptor') log_child_render = module_attr('log_child_render') get_content_titles = module_attr('get_content_titles') diff --git a/common/lib/xmodule/xmodule/vertical_block.py b/common/lib/xmodule/xmodule/vertical_block.py index 1f2b4b874f..62af97c66c 100644 --- a/common/lib/xmodule/xmodule/vertical_block.py +++ b/common/lib/xmodule/xmodule/vertical_block.py @@ -29,6 +29,8 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse has_children = True + show_in_read_only_mode = True + def student_view(self, context): """ Renders the student view of the block in the LMS. diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py index f9134c820c..c045a493b4 100644 --- a/common/lib/xmodule/xmodule/video_module/video_module.py +++ b/common/lib/xmodule/xmodule/video_module/video_module.py @@ -339,6 +339,8 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler module_class = VideoModule transcript = module_attr('transcript') + show_in_read_only_mode = True + tabs = [ { 'name': _("Basic"), diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index f7179d0ec9..e12b1454f4 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -278,6 +278,10 @@ class XModuleMixin(XModuleFields, XBlock): # (like a practice problem). has_score = False + # Whether this module can be displayed in read-only mode. It is safe to set this to True if + # all user state is handled through the FieldData API. + show_in_read_only_mode = False + # Class level variable # True if this descriptor always requires recalculation of grades, for @@ -754,6 +758,7 @@ class XModule(HTMLSnippet, XModuleMixin): # pylint: disable=abstract-method entry_point = "xmodule.v1" has_score = descriptor_attr('has_score') + show_in_read_only_mode = descriptor_attr('show_in_read_only_mode') _field_data_cache = descriptor_attr('_field_data_cache') _field_data = descriptor_attr('_field_data') _dirty_fields = descriptor_attr('_dirty_fields') diff --git a/lms/djangoapps/courseware/masquerade.py b/lms/djangoapps/courseware/masquerade.py index 0ea3d6b6e1..b0bbba9ebe 100644 --- a/lms/djangoapps/courseware/masquerade.py +++ b/lms/djangoapps/courseware/masquerade.py @@ -8,10 +8,15 @@ import logging from django.conf import settings from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User +from django.utils.translation import ugettext as _ from django.views.decorators.http import require_POST +from student.models import CourseEnrollment from util.json_request import expect_json, JsonResponse from opaque_keys.edx.keys import CourseKey +from xblock.fragment import Fragment +from xblock.runtime import KeyValueStore log = logging.getLogger(__name__) @@ -19,16 +24,21 @@ log = logging.getLogger(__name__) # The value is a dict from course keys to CourseMasquerade objects. MASQUERADE_SETTINGS_KEY = 'masquerade_settings' +# The key used to store temporary XBlock field data in the Django session. This is where field +# data is stored to avoid modifying the state of the user we are masquerading as. +MASQUERADE_DATA_KEY = 'masquerade_data' + class CourseMasquerade(object): """ Masquerade settings for a particular course. """ - def __init__(self, course_key, role='student', user_partition_id=None, group_id=None): + def __init__(self, course_key, role='student', user_partition_id=None, group_id=None, user_name=None): self.course_key = course_key self.role = role self.user_partition_id = user_partition_id self.group_id = group_id + self.user_name = user_name @require_POST @@ -46,40 +56,73 @@ def handle_ajax(request, course_key_string): role = request_json.get('role', 'student') user_partition_id = request_json.get('user_partition_id', None) group_id = request_json.get('group_id', None) + user_name = request_json.get('user_name', None) + if user_name: + users_in_course = CourseEnrollment.objects.users_enrolled_in(course_key) + try: + if '@' in user_name: + user_name = users_in_course.get(email=user_name).username + else: + users_in_course.get(username=user_name) + except User.DoesNotExist: + return JsonResponse({ + 'success': False, + 'error': _( + 'There is no user with the username or email address {user_name} ' + 'enrolled in this course.' + ).format(user_name=user_name) + }) masquerade_settings[course_key] = CourseMasquerade( course_key, role=role, user_partition_id=user_partition_id, - group_id=group_id + group_id=group_id, + user_name=user_name, ) request.session[MASQUERADE_SETTINGS_KEY] = masquerade_settings - return JsonResponse() + return JsonResponse({'success': True}) -def setup_masquerade(request, course_key, staff_access=False): +def setup_masquerade(request, course_key, staff_access=False, reset_masquerade_data=False): """ - Sets up masquerading for the current user within the current request. The - request's user is updated to have a 'masquerade_settings' attribute with - the dict of all masqueraded settings if called from within a request context. - The function then returns the CourseMasquerade object for the specified - course key, or None if there isn't one. + Sets up masquerading for the current user within the current request. The request's user is + updated to have a 'masquerade_settings' attribute with the dict of all masqueraded settings if + called from within a request context. The function then returns a pair (CourseMasquerade, User) + with the masquerade settings for the specified course key or None if there isn't one, and the + user we are masquerading as or request.user if masquerading as a specific user is not active. + + If the reset_masquerade_data flag is set, the field data stored in the session will be cleared. """ - if request.user is None: - return None - - if not settings.FEATURES.get('ENABLE_MASQUERADE', False): - return None - - if not staff_access: # can masquerade only if user has staff access to course - return None - - masquerade_settings = request.session.get(MASQUERADE_SETTINGS_KEY, {}) - + if ( + request.user is None or + not settings.FEATURES.get('ENABLE_MASQUERADE', False) or + not staff_access + ): + return None, request.user + if reset_masquerade_data: + request.session.pop(MASQUERADE_DATA_KEY, None) + masquerade_settings = request.session.setdefault(MASQUERADE_SETTINGS_KEY, {}) # Store the masquerade settings on the user so it can be accessed without the request request.user.masquerade_settings = masquerade_settings - - # Return the masquerade for the current course, or none if there isn't one - return masquerade_settings.get(course_key, None) + course_masquerade = masquerade_settings.get(course_key, None) + masquerade_user = None + if course_masquerade and course_masquerade.user_name: + try: + masquerade_user = CourseEnrollment.objects.users_enrolled_in(course_key).get( + username=course_masquerade.user_name + ) + except User.DoesNotExist: + # This can only happen if the user was unenrolled from the course since masquerading + # was enabled. We silently reset the masquerading configuration in this case. + course_masquerade = None + del masquerade_settings[course_key] + request.session.modified = True + else: + # Store the masquerading settings on the masquerade_user as well, since this user will + # be used in some places instead of request.user. + masquerade_user.masquerade_settings = request.user.masquerade_settings + masquerade_user.real_user = request.user + return course_masquerade, masquerade_user or request.user def get_course_masquerade(user, course_key): @@ -106,6 +149,14 @@ def is_masquerading_as_student(user, course_key): return get_masquerade_role(user, course_key) == 'student' +def is_masquerading_as_specific_student(user, course_key): # pylint: disable=invalid-name + """ + Returns whether the user is a staff member masquerading as a specific student. + """ + course_masquerade = get_course_masquerade(user, course_key) + return bool(course_masquerade and course_masquerade.user_name) + + def get_masquerading_group_info(user, course_key): """ If the user is masquerading as belonging to a group, then this method returns @@ -116,3 +167,77 @@ def get_masquerading_group_info(user, course_key): if not course_masquerade: return None, None return course_masquerade.group_id, course_masquerade.user_partition_id + + +# Sentinel object to mark deleted objects in the session cache +_DELETED_SENTINEL = object() + + +class MasqueradingKeyValueStore(KeyValueStore): + """ + A `KeyValueStore` to avoid affecting the user state when masquerading. + + This `KeyValueStore` wraps an underlying `KeyValueStore`. Reads are forwarded to the underlying + store, but writes go to a Django session (or other dictionary-like object). + """ + def __init__(self, kvs, session): + """ + Arguments: + kvs: The KeyValueStore to wrap. + session: The Django session used to store temporary data in. + """ + self.kvs = kvs + self.session = session + self.session_data = session.setdefault(MASQUERADE_DATA_KEY, {}) + + def _serialize_key(self, key): + """ + Convert the key of Type KeyValueStore.Key to a string. + + Keys are not JSON-serializable, so we can't use them as keys for the Django session. + The implementation is taken from cms/djangoapps/contentstore/views/session_kv_store.py. + """ + return repr(tuple(key)) + + def get(self, key): + key_str = self._serialize_key(key) + try: + value = self.session_data[key_str] + except KeyError: + return self.kvs.get(key) + else: + if value is _DELETED_SENTINEL: + raise KeyError(key_str) + return value + + def set(self, key, value): + self.session_data[self._serialize_key(key)] = value + self.session.modified = True + + def delete(self, key): + # We can't simply delete the key from the session, since it might still exist in the kvs, + # which we are not allowed to modify, so we mark it as deleted by setting it to + # _DELETED_SENTINEL in the session. + self.set(key, _DELETED_SENTINEL) + + def has(self, key): + try: + value = self.session_data[self._serialize_key(key)] + except KeyError: + return self.kvs.has(key) + else: + return value != _DELETED_SENTINEL + + +def filter_displayed_blocks(block, unused_view, frag, unused_context): + """ + A wrapper to only show XBlocks that set `show_in_read_only_mode` when masquerading as a specific user. + + We don't want to modify the state of the user we are masquerading as, so we can't show XBlocks + that store information outside of the XBlock fields API. + """ + if getattr(block, 'show_in_read_only_mode', False): + return frag + return Fragment( + _(u'This type of component cannot be shown while viewing the course as a specific student.') + ) diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index faa5fdbad3..64d915a3e9 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -480,27 +480,6 @@ class UserStateCache(object): kvs_key.field_name in self._cache[cache_key] ) - # @contract(user_id=int, usage_key=UsageKey, score="number|None", max_score="number|None") - def set_score(self, user_id, usage_key, score, max_score): - """ - UNSUPPORTED METHOD - - Set the score and max_score for the specified user and xblock usage. - """ - student_module, created = StudentModule.objects.get_or_create( - student_id=user_id, - module_state_key=usage_key, - course_id=usage_key.course_key, - defaults={ - 'grade': score, - 'max_grade': max_score, - } - ) - if not created: - student_module.grade = score - student_module.max_grade = max_score - student_module.save() - def __len__(self): return len(self._cache) @@ -923,18 +902,6 @@ class FieldDataCache(object): return self.cache[key.scope].has(key) - # @contract(user_id=int, usage_key=UsageKey, score="number|None", max_score="number|None") - def set_score(self, user_id, usage_key, score, max_score): - """ - UNSUPPORTED METHOD - - Set the score and max_score for the specified user and xblock usage. - """ - assert not self.user.is_anonymous() - assert user_id == self.user.id - assert usage_key.course_key == self.course_id - self.cache[Scope.user_state].set_score(user_id, usage_key, score, max_score) - @contract(key=DjangoKeyValueStore.Key, returns="datetime|None") def last_modified(self, key): """ @@ -1017,3 +984,23 @@ class ScoresClient(object): client = cls(fd_cache.course_id, fd_cache.user.id) client.fetch_scores(fd_cache.scorable_locations) return client + + +# @contract(user_id=int, usage_key=UsageKey, score="number|None", max_score="number|None") +def set_score(user_id, usage_key, score, max_score): + """ + Set the score and max_score for the specified user and xblock usage. + """ + student_module, created = StudentModule.objects.get_or_create( + student_id=user_id, + module_state_key=usage_key, + course_id=usage_key.course_key, + defaults={ + 'grade': score, + 'max_grade': max_score, + } + ) + if not created: + student_module.grade = score + student_module.max_grade = max_score + student_module.save() diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index c87ad397ee..9418aa7605 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -28,8 +28,13 @@ import newrelic.agent from capa.xqueue_interface import XQueueInterface from courseware.access import has_access, get_user_role -from courseware.masquerade import setup_masquerade -from courseware.model_data import FieldDataCache, DjangoKeyValueStore +from courseware.masquerade import ( + MasqueradingKeyValueStore, + filter_displayed_blocks, + is_masquerading_as_specific_student, + setup_masquerade, +) +from courseware.model_data import DjangoKeyValueStore, FieldDataCache, set_score from courseware.models import SCORE_CHANGED from courseware.entrance_exams import ( get_entrance_exam_score, @@ -113,7 +118,7 @@ def make_track_function(request): return function -def toc_for_course(request, course, active_chapter, active_section, field_data_cache): +def toc_for_course(user, request, course, active_chapter, active_section, field_data_cache): ''' Create a table of contents from the module store @@ -139,7 +144,7 @@ def toc_for_course(request, course, active_chapter, active_section, field_data_c with modulestore().bulk_operations(course.id): course_module = get_module_for_descriptor( - request.user, request, course, field_data_cache, course.id, course=course + user, request, course, field_data_cache, course.id, course=course ) if course_module is None: return None @@ -148,10 +153,10 @@ def toc_for_course(request, course, active_chapter, active_section, field_data_c chapters = course_module.get_display_items() # See if the course is gated by one or more content milestones - required_content = milestones_helpers.get_required_content(course, request.user) + required_content = milestones_helpers.get_required_content(course, user) # The user may not actually have to complete the entrance exam, if one is required - if not user_must_complete_entrance_exam(request, request.user, course): + if not user_must_complete_entrance_exam(request, user, course): required_content = [content for content in required_content if not content == course.entrance_exam_id] for chapter in chapters: @@ -270,10 +275,15 @@ def get_module_for_descriptor(user, request, descriptor, field_data_cache, cours user_location = getattr(request, 'session', {}).get('country_code') + student_kvs = DjangoKeyValueStore(field_data_cache) + if is_masquerading_as_specific_student(user, course_key): + student_kvs = MasqueradingKeyValueStore(student_kvs, request.session) + student_data = KvsFieldData(student_kvs) + return get_module_for_descriptor_internal( user=user, descriptor=descriptor, - field_data_cache=field_data_cache, + student_data=student_data, course_id=course_key, track_function=track_function, xqueue_callback_url_prefix=xqueue_callback_url_prefix, @@ -288,7 +298,7 @@ def get_module_for_descriptor(user, request, descriptor, field_data_cache, cours ) -def get_module_system_for_user(user, field_data_cache, # TODO # pylint: disable=too-many-statements +def get_module_system_for_user(user, student_data, # TODO # pylint: disable=too-many-statements # Arguments preceding this comment have user binding, those following don't descriptor, course_id, track_function, xqueue_callback_url_prefix, request_token, position=None, wrap_xmodule_display=True, grade_bucket_type=None, @@ -302,7 +312,7 @@ def get_module_system_for_user(user, field_data_cache, # TODO # pylint: disabl closures that feed the instantiation of ModuleSystem. The arguments fall into two categories: those that have explicit or implicit user binding, which are user - and field_data_cache, and those don't and are just present so that ModuleSystem can be instantiated, which + and student_data, and those don't and are just present so that ModuleSystem can be instantiated, which are all the other arguments. Ultimately, this isn't too different than how get_module_for_descriptor_internal was before refactoring. @@ -313,7 +323,6 @@ def get_module_system_for_user(user, field_data_cache, # TODO # pylint: disabl Returns: (LmsModuleSystem, KvsFieldData): (module system, student_data) bound to, primarily, the user and descriptor """ - student_data = KvsFieldData(DjangoKeyValueStore(field_data_cache)) def make_xqueue_callback(dispatch='score_update'): """ @@ -378,7 +387,7 @@ def get_module_system_for_user(user, field_data_cache, # TODO # pylint: disabl return get_module_for_descriptor_internal( user=user, descriptor=descriptor, - field_data_cache=field_data_cache, + student_data=student_data, course_id=course_id, track_function=track_function, xqueue_callback_url_prefix=xqueue_callback_url_prefix, @@ -430,7 +439,7 @@ def get_module_system_for_user(user, field_data_cache, # TODO # pylint: disabl grade = event.get('value') max_grade = event.get('max_value') - field_data_cache.set_score( + set_score( user_id, descriptor.location, grade, @@ -472,7 +481,7 @@ def get_module_system_for_user(user, field_data_cache, # TODO # pylint: disabl def publish(block, event_type, event): """A function that allows XModules to publish events.""" - if event_type == 'grade': + if event_type == 'grade' and not is_masquerading_as_specific_student(user, course_id): handle_grade_event(block, event_type, event) else: track_function(event_type, event) @@ -502,10 +511,11 @@ def get_module_system_for_user(user, field_data_cache, # TODO # pylint: disabl module.descriptor, asides=XBlockAsidesConfig.possible_asides(), ) + student_data_real_user = KvsFieldData(DjangoKeyValueStore(field_data_cache_real_user)) (inner_system, inner_student_data) = get_module_system_for_user( user=real_user, - field_data_cache=field_data_cache_real_user, # These have implicit user bindings, rest of args considered not to + student_data=student_data_real_user, # These have implicit user bindings, rest of args considered not to descriptor=module.descriptor, course_id=course_id, track_function=track_function, @@ -540,6 +550,9 @@ def get_module_system_for_user(user, field_data_cache, # TODO # pylint: disabl # to the Fragment content coming out of the xblocks that are about to be rendered. block_wrappers = [] + if is_masquerading_as_specific_student(user, course_id): + block_wrappers.append(filter_displayed_blocks) + if settings.FEATURES.get("LICENSING", False): block_wrappers.append(wrap_with_license) @@ -582,9 +595,23 @@ def get_module_system_for_user(user, field_data_cache, # TODO # pylint: disabl )) if settings.FEATURES.get('DISPLAY_DEBUG_INFO_TO_STAFF'): - if has_access(user, 'staff', descriptor, course_id): - has_instructor_access = has_access(user, 'instructor', descriptor, course_id) - block_wrappers.append(partial(add_staff_markup, user, has_instructor_access, disable_staff_debug_info)) + if is_masquerading_as_specific_student(user, course_id): + # When masquerading as a specific student, we want to show the debug button + # unconditionally to enable resetting the state of the student we are masquerading as. + # We already know the user has staff access when masquerading is active. + staff_access = True + # To figure out whether the user has instructor access, we temporarily remove the + # masquerade_settings from the real_user. With the masquerading settings in place, + # the result would always be "False". + masquerade_settings = user.real_user.masquerade_settings + del user.real_user.masquerade_settings + instructor_access = has_access(user.real_user, 'instructor', descriptor, course_id) + user.real_user.masquerade_settings = masquerade_settings + else: + staff_access = has_access(user, 'staff', descriptor, course_id) + instructor_access = has_access(user, 'instructor', descriptor, course_id) + if staff_access: + block_wrappers.append(partial(add_staff_markup, user, instructor_access, disable_staff_debug_info)) # These modules store data using the anonymous_student_id as a key. # To prevent loss of data, we will continue to provide old modules with @@ -691,7 +718,7 @@ def get_module_system_for_user(user, field_data_cache, # TODO # pylint: disabl # TODO: Find all the places that this method is called and figure out how to # get a loaded course passed into it -def get_module_for_descriptor_internal(user, descriptor, field_data_cache, course_id, # pylint: disable=invalid-name +def get_module_for_descriptor_internal(user, descriptor, student_data, course_id, # pylint: disable=invalid-name track_function, xqueue_callback_url_prefix, request_token, position=None, wrap_xmodule_display=True, grade_bucket_type=None, static_asset_path='', user_location=None, disable_staff_debug_info=False, @@ -707,7 +734,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours (system, student_data) = get_module_system_for_user( user=user, - field_data_cache=field_data_cache, # These have implicit user bindings, the rest of args are considered not to + student_data=student_data, # These have implicit user bindings, the rest of args are considered not to descriptor=descriptor, course_id=course_id, track_function=track_function, @@ -908,12 +935,12 @@ def get_module_by_usage_id(request, course_id, usage_id, disable_staff_debug_inf tracking_context['module']['original_usage_key'] = unicode(descriptor_orig_usage_key) tracking_context['module']['original_usage_version'] = unicode(descriptor_orig_version) + unused_masquerade, user = setup_masquerade(request, course_id, has_access(user, 'staff', descriptor, course_id)) field_data_cache = FieldDataCache.cache_for_descriptor_descendents( course_id, user, descriptor ) - setup_masquerade(request, course_id, has_access(user, 'staff', descriptor, course_id)) instance = get_module_for_descriptor( user, request, diff --git a/lms/djangoapps/courseware/tests/test_entrance_exam.py b/lms/djangoapps/courseware/tests/test_entrance_exam.py index f249b1ef79..46dbe5413d 100644 --- a/lms/djangoapps/courseware/tests/test_entrance_exam.py +++ b/lms/djangoapps/courseware/tests/test_entrance_exam.py @@ -528,6 +528,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): self.entrance_exam ) return toc_for_course( + self.request.user, self.request, self.course, self.entrance_exam.url_name, diff --git a/lms/djangoapps/courseware/tests/test_masquerade.py b/lms/djangoapps/courseware/tests/test_masquerade.py index ce6dc156b7..da52b370b4 100644 --- a/lms/djangoapps/courseware/tests/test_masquerade.py +++ b/lms/djangoapps/courseware/tests/test_masquerade.py @@ -7,13 +7,21 @@ from nose.plugins.attrib import attr from datetime import datetime from django.core.urlresolvers import reverse +from django.test import TestCase from django.utils.timezone import UTC from capa.tests.response_xml_factory import OptionResponseXMLFactory -from courseware.masquerade import handle_ajax, setup_masquerade, get_masquerading_group_info +from courseware.masquerade import ( + MasqueradingKeyValueStore, + handle_ajax, + setup_masquerade, + get_masquerading_group_info +) from courseware.tests.factories import StaffFactory from courseware.tests.helpers import LoginEnrollmentTestCase, get_request_for_user +from courseware.tests.test_submitting_problems import ProblemSubmissionTestMixin from student.tests.factories import UserFactory +from xblock.runtime import DictKeyValueStore from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory @@ -54,7 +62,7 @@ class MasqueradeTestCase(ModuleStoreTestCase, LoginEnrollmentTestCase): options=['Correct', 'Incorrect'], correct_option='Correct' ) - self.problem_display_name = "Test Masquerade Problem" + self.problem_display_name = "TestMasqueradeProblem" self.problem = ItemFactory.create( parent_location=self.vertical.location, category='problem', @@ -158,7 +166,7 @@ class StaffMasqueradeTestCase(MasqueradeTestCase): """ return StaffFactory(course_key=self.course.id) - def update_masquerade(self, role, group_id=None): + def update_masquerade(self, role, group_id=None, user_name=None): """ Toggle masquerade state. """ @@ -170,10 +178,10 @@ class StaffMasqueradeTestCase(MasqueradeTestCase): ) response = self.client.post( masquerade_url, - json.dumps({"role": role, "group_id": group_id}), + json.dumps({"role": role, "group_id": group_id, "user_name": user_name}), "application/json" ) - self.assertEqual(response.status_code, 204) + self.assertEqual(response.status_code, 200) return response @@ -215,6 +223,80 @@ class TestStaffMasqueradeAsStudent(StaffMasqueradeTestCase): self.verify_show_answer_present(True) +@attr('shard_1') +class TestStaffMasqueradeAsSpecificStudent(StaffMasqueradeTestCase, ProblemSubmissionTestMixin): + """ + Check for staff being able to masquerade as a specific student. + """ + def setUp(self): + super(TestStaffMasqueradeAsSpecificStudent, self).setUp() + self.student_user = self.create_user() + self.login_student() + self.enroll(self.course, True) + + def login_staff(self): + """ Login as a staff user """ + self.login(self.test_user.email, 'test') + + def login_student(self): + """ Login as a student """ + self.login(self.student_user.email, 'test') + + def submit_answer(self, response1, response2): + """ + Submit an answer to the single problem in our test course. + """ + return self.submit_question_answer( + self.problem_display_name, + {'2_1': response1, '2_2': response2} + ) + + def get_progress_detail(self): + """ + Return the reported progress detail for the problem in our test course. + + The return value is a string like u'1/2'. + """ + return json.loads(self.look_at_question(self.problem_display_name).content)['progress_detail'] + + @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) + def test_masquerade_as_specific_student(self): + """ + Test masquerading as a specific user. + + We answer the problem in our test course as the student and as staff user, and we use the + progress as a proxy to determine who's state we currently see. + """ + # Answer correctly as the student, and check progress. + self.login_student() + self.submit_answer('Correct', 'Correct') + self.assertEqual(self.get_progress_detail(), u'2/2') + + # Log in as staff, and check the problem is unanswered. + self.login_staff() + self.assertEqual(self.get_progress_detail(), u'0/2') + + # Masquerade as the student, and check we can see the student state. + self.update_masquerade(role='student', user_name=self.student_user.username) + self.assertEqual(self.get_progress_detail(), u'2/2') + + # Temporarily override the student state. + self.submit_answer('Correct', 'Incorrect') + self.assertEqual(self.get_progress_detail(), u'1/2') + + # Reload the page and check we see the student state again. + self.get_courseware_page() + self.assertEqual(self.get_progress_detail(), u'2/2') + + # Become the staff user again, and check the problem is still unanswered. + self.update_masquerade(role='staff') + self.assertEqual(self.get_progress_detail(), u'0/2') + + # Verify the student state did not change. + self.login_student() + self.assertEqual(self.get_progress_detail(), u'2/2') + + @attr('shard_1') class TestGetMasqueradingGroupId(StaffMasqueradeTestCase): """ @@ -252,3 +334,63 @@ class TestGetMasqueradingGroupId(StaffMasqueradeTestCase): group_id, user_partition_id = get_masquerading_group_info(self.test_user, self.course.id) self.assertEqual(group_id, 1) self.assertEqual(user_partition_id, 0) + + +class ReadOnlyKeyValueStore(DictKeyValueStore): + """ + A KeyValueStore that raises an exception on attempts to modify it. + + Used to make sure MasqueradingKeyValueStore does not try to modify the underlying KeyValueStore. + """ + def set(self, key, value): + assert False, "ReadOnlyKeyValueStore may not be modified." + + def delete(self, key): + assert False, "ReadOnlyKeyValueStore may not be modified." + + def set_many(self, update_dict): # pylint: disable=unused-argument + assert False, "ReadOnlyKeyValueStore may not be modified." + + +class FakeSession(dict): + """ Mock for Django session object. """ + modified = False # We need dict semantics with a writable 'modified' property + + +class MasqueradingKeyValueStoreTest(TestCase): + """ + Unit tests for the MasqueradingKeyValueStore class. + """ + def setUp(self): + super(MasqueradingKeyValueStoreTest, self).setUp() + self.ro_kvs = ReadOnlyKeyValueStore({'a': 42, 'b': None, 'c': 'OpenCraft'}) + self.session = FakeSession() + self.kvs = MasqueradingKeyValueStore(self.ro_kvs, self.session) + + def test_all(self): + self.assertEqual(self.kvs.get('a'), 42) + self.assertEqual(self.kvs.get('b'), None) + self.assertEqual(self.kvs.get('c'), 'OpenCraft') + with self.assertRaises(KeyError): + self.kvs.get('d') + + self.assertTrue(self.kvs.has('a')) + self.assertTrue(self.kvs.has('b')) + self.assertTrue(self.kvs.has('c')) + self.assertFalse(self.kvs.has('d')) + + self.kvs.set_many({'a': 'Norwegian Blue', 'd': 'Giraffe'}) + self.kvs.set('b', 7) + + self.assertEqual(self.kvs.get('a'), 'Norwegian Blue') + self.assertEqual(self.kvs.get('b'), 7) + self.assertEqual(self.kvs.get('c'), 'OpenCraft') + self.assertEqual(self.kvs.get('d'), 'Giraffe') + + for key in 'abd': + self.assertTrue(self.kvs.has(key)) + self.kvs.delete(key) + with self.assertRaises(KeyError): + self.kvs.get(key) + + self.assertEqual(self.kvs.get('c'), 'OpenCraft') diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 1f22edee36..d5c9824cad 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -637,7 +637,7 @@ class TestTOC(ModuleStoreTestCase): course = self.store.get_course(self.toy_course.id, depth=2) with check_mongo_calls(toc_finds): actual = render.toc_for_course( - self.request, course, self.chapter, None, self.field_data_cache + self.request.user, self.request, course, self.chapter, None, self.field_data_cache ) for toc_section in expected: self.assertIn(toc_section, actual) @@ -676,7 +676,7 @@ class TestTOC(ModuleStoreTestCase): with check_mongo_calls(toc_finds): actual = render.toc_for_course( - self.request, self.toy_course, self.chapter, section, self.field_data_cache + self.request.user, self.request, self.toy_course, self.chapter, section, self.field_data_cache ) for toc_section in expected: self.assertIn(toc_section, actual) @@ -1173,7 +1173,7 @@ class TestAnonymousStudentId(ModuleStoreTestCase, LoginEnrollmentTestCase): return render.get_module_for_descriptor_internal( user=self.user, descriptor=descriptor, - field_data_cache=Mock(spec=FieldDataCache, name='field_data_cache'), + student_data=Mock(spec=FieldData, name='student_data'), course_id=course_id, track_function=Mock(name='track_function'), # Track Function xqueue_callback_url_prefix=Mock(name='xqueue_callback_url_prefix'), # XQueue Callback Url Prefix @@ -1468,7 +1468,7 @@ class LMSXBlockServiceBindingTest(ModuleStoreTestCase): """ super(LMSXBlockServiceBindingTest, self).setUp() self.user = UserFactory() - self.field_data_cache = Mock() + self.student_data = Mock() self.course = CourseFactory.create() self.track_function = Mock() self.xqueue_callback_url_prefix = Mock() @@ -1483,7 +1483,7 @@ class LMSXBlockServiceBindingTest(ModuleStoreTestCase): descriptor = ItemFactory(category="pure", parent=self.course) runtime, _ = render.get_module_system_for_user( self.user, - self.field_data_cache, + self.student_data, descriptor, self.course.id, self.track_function, @@ -1502,7 +1502,7 @@ class LMSXBlockServiceBindingTest(ModuleStoreTestCase): descriptor.days_early_for_beta = 5 runtime, _ = render.get_module_system_for_user( self.user, - self.field_data_cache, + self.student_data, descriptor, self.course.id, self.track_function, diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py index 3e24a5726d..5bb72b3a1e 100644 --- a/lms/djangoapps/courseware/tests/test_submitting_problems.py +++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py @@ -9,6 +9,7 @@ from textwrap import dedent from django.conf import settings from django.contrib.auth.models import User from django.core.urlresolvers import reverse +from django.test import TestCase from django.test.client import RequestFactory from mock import patch from nose.plugins.attrib import attr @@ -33,31 +34,10 @@ from openedx.core.djangoapps.credit.models import CreditCourse, CreditProvider from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory -class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase): +class ProblemSubmissionTestMixin(TestCase): """ - Check that a course gets graded properly. + TestCase mixin that provides functions to submit answers to problems. """ - - # arbitrary constant - COURSE_SLUG = "100" - COURSE_NAME = "test_course" - - def setUp(self): - - super(TestSubmittingProblems, self).setUp(create_user=False) - # Create course - self.course = CourseFactory.create(display_name=self.COURSE_NAME, number=self.COURSE_SLUG) - assert self.course, "Couldn't load course %r" % self.COURSE_NAME - - # create a test student - self.student = 'view@test.com' - self.password = 'foo' - self.create_account('u1', self.student, self.password) - self.activate_user(self.student) - self.enroll(self.course) - self.student_user = User.objects.get(email=self.student) - self.factory = RequestFactory() - def refresh_course(self): """ Re-fetch the course from the database so that the object being dealt with has everything added to it. @@ -68,7 +48,6 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Returns the url of the problem given the problem's name """ - return self.course.id.make_usage_key('problem', problem_url_name) def modx_url(self, problem_location, dispatch): @@ -136,6 +115,32 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase): resp = self.client.post(modx_url) return resp + +class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase, ProblemSubmissionTestMixin): + """ + Check that a course gets graded properly. + """ + + # arbitrary constant + COURSE_SLUG = "100" + COURSE_NAME = "test_course" + + def setUp(self): + + super(TestSubmittingProblems, self).setUp(create_user=False) + # Create course + self.course = CourseFactory.create(display_name=self.COURSE_NAME, number=self.COURSE_SLUG) + assert self.course, "Couldn't load course %r" % self.COURSE_NAME + + # create a test student + self.student = 'view@test.com' + self.password = 'foo' + self.create_account('u1', self.student, self.password) + self.activate_user(self.student) + self.enroll(self.course) + self.student_user = User.objects.get(email=self.student) + self.factory = RequestFactory() + def add_dropdown_to_section(self, section_location, name, num_inputs=2): """ Create and return a dropdown problem. diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 99a0f2e893..8dbfcb42d0 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -673,8 +673,8 @@ class TestAccordionDueDate(BaseDueDateTests): def get_text(self, course): """ Returns the HTML for the accordion """ return views.render_accordion( - self.request, course, course.get_children()[0].scope_ids.usage_id.to_deprecated_string(), - None, None + self.request.user, self.request, course, + unicode(course.get_children()[0].scope_ids.usage_id), None, None ) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 749d9e3195..403d47a2d9 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -139,7 +139,7 @@ def courses(request): ) -def render_accordion(request, course, chapter, section, field_data_cache): +def render_accordion(user, request, course, chapter, section, field_data_cache): """ Draws navigation bar. Takes current position in accordion as parameter. @@ -151,7 +151,7 @@ def render_accordion(request, course, chapter, section, field_data_cache): Returns the html string """ # grab the table of contents - toc = toc_for_course(request, course, chapter, section, field_data_cache) + toc = toc_for_course(user, request, course, chapter, section, field_data_cache) context = dict([ ('toc', toc), @@ -378,10 +378,10 @@ def _index_bulk_op(request, course_key, chapter, section, position): except ValueError: raise Http404(u"Position {} is not an integer!".format(position)) - user = request.user - course = get_course_with_access(user, 'load', course_key, depth=2) + course = get_course_with_access(request.user, 'load', course_key, depth=2) + staff_access = has_access(request.user, 'staff', course) + masquerade, user = setup_masquerade(request, course_key, staff_access, reset_masquerade_data=True) - staff_access = has_access(user, 'staff', course) registered = registered_for_course(course, user) if not registered: # TODO (vshnayder): do course instructors need to be registered to see course? @@ -413,8 +413,6 @@ def _index_bulk_op(request, course_key, chapter, section, position): if survey.utils.must_answer_survey(course, user): return redirect(reverse('course_survey', args=[unicode(course.id)])) - masquerade = setup_masquerade(request, course_key, staff_access) - try: field_data_cache = FieldDataCache.cache_for_descriptor_descendents( course_key, user, course, depth=2) @@ -431,7 +429,7 @@ def _index_bulk_op(request, course_key, chapter, section, position): context = { 'csrf': csrf(request)['csrf_token'], - 'accordion': render_accordion(request, course, chapter, section, field_data_cache), + 'accordion': render_accordion(user, request, course, chapter, section, field_data_cache), 'COURSE_TITLE': course.display_name_with_default, 'course': course, 'init': '', @@ -475,7 +473,7 @@ def _index_bulk_op(request, course_key, chapter, section, position): # settings. show_chat = course.show_chat and settings.FEATURES['ENABLE_CHAT'] if show_chat: - context['chat'] = chat_settings(course, user) + context['chat'] = chat_settings(course, request.user) # If we couldn't load the chat settings, then don't show # the widget in the courseware. if context['chat'] is None: @@ -536,7 +534,7 @@ def _index_bulk_op(request, course_key, chapter, section, position): ) section_module = get_module_for_descriptor( - request.user, + user, request, section_descriptor, field_data_cache, @@ -550,7 +548,7 @@ def _index_bulk_op(request, course_key, chapter, section, position): # they don't have access to. raise Http404 - # Save where we are in the chapter + # Save where we are in the chapter. save_child_position(chapter_module, section) context['fragment'] = section_module.render(STUDENT_VIEW) context['section_title'] = section_descriptor.display_name_with_default @@ -598,12 +596,8 @@ def _index_bulk_op(request, course_key, chapter, section, position): raise else: log.exception( - u"Error in index view: user=%s, course=%s, chapter=%s, section=%s, position=%s", - user, - course, - chapter, - section, - position + u"Error in index view: user=%s, effective_user=%s, course=%s, chapter=%s section=%s position=%s", + request.user, user, course, chapter, section, position ) try: result = render_to_response('courseware/courseware-error.html', { @@ -683,19 +677,19 @@ def course_info(request, course_id): with modulestore().bulk_operations(course_key): course = get_course_with_access(request.user, 'load', course_key) + staff_access = has_access(request.user, 'staff', course) + masquerade, user = setup_masquerade(request, course_key, staff_access, reset_masquerade_data=True) # If the user needs to take an entrance exam to access this course, then we'll need # to send them to that specific course module before allowing them into other areas - if user_must_complete_entrance_exam(request, request.user, course): + if user_must_complete_entrance_exam(request, user, course): return redirect(reverse('courseware', args=[unicode(course.id)])) # check to see if there is a required survey that must be taken before # the user can access the course. - if request.user.is_authenticated() and survey.utils.must_answer_survey(course, request.user): + if request.user.is_authenticated() and survey.utils.must_answer_survey(course, user): return redirect(reverse('course_survey', args=[unicode(course.id)])) - staff_access = has_access(request.user, 'staff', course) - masquerade = setup_masquerade(request, course_key, staff_access) # allow staff to masquerade on the info page studio_url = get_studio_url(course, 'course_info') # link to where the student should go to enroll in the course: @@ -704,7 +698,7 @@ def course_info(request, course_id): if settings.FEATURES.get('ENABLE_MKTG_SITE'): url_to_enroll = marketing_link('COURSES') - show_enroll_banner = request.user.is_authenticated() and not CourseEnrollment.is_enrolled(request.user, course.id) + show_enroll_banner = request.user.is_authenticated() and not CourseEnrollment.is_enrolled(user, course.id) context = { 'request': request, @@ -719,7 +713,7 @@ def course_info(request, course_id): } now = datetime.now(UTC()) - effective_start = _adjust_start_date_for_beta_testers(request.user, course, course_key) + effective_start = _adjust_start_date_for_beta_testers(user, course, course_key) if not in_preview_mode() and staff_access and now < effective_start: # Disable student view button if user is staff and # course is not yet visible to students. diff --git a/lms/djangoapps/instructor_task/tasks_helper.py b/lms/djangoapps/instructor_task/tasks_helper.py index 7fbca8c0a8..093f68fc40 100644 --- a/lms/djangoapps/instructor_task/tasks_helper.py +++ b/lms/djangoapps/instructor_task/tasks_helper.py @@ -31,6 +31,7 @@ from shoppingcart.models import ( from track.views import task_track from util.file import course_filename_prefix_generator, UniversalNewlineIterator +from xblock.runtime import KvsFieldData from xmodule.modulestore.django import modulestore from xmodule.split_test_module import get_split_user_partitions from django.utils.translation import ugettext as _ @@ -43,7 +44,7 @@ from certificates.api import generate_user_certificates from courseware.courses import get_course_by_id, get_problems_in_section from courseware.grades import iterate_grades_for from courseware.models import StudentModule -from courseware.model_data import FieldDataCache +from courseware.model_data import DjangoKeyValueStore, FieldDataCache from courseware.module_render import get_module_for_descriptor_internal from instructor_analytics.basic import enrolled_students_features, list_may_enroll from instructor_analytics.csvs import format_dictlist @@ -422,6 +423,7 @@ def _get_module_instance_for_task(course_id, student, module_descriptor, xmodule """ # reconstitute the problem's corresponding XModule: field_data_cache = FieldDataCache.cache_for_descriptor_descendents(course_id, student, module_descriptor) + student_data = KvsFieldData(DjangoKeyValueStore(field_data_cache)) # get request-related tracking information from args passthrough, and supplement with task-specific # information: @@ -444,7 +446,7 @@ def _get_module_instance_for_task(course_id, student, module_descriptor, xmodule return get_module_for_descriptor_internal( user=student, descriptor=module_descriptor, - field_data_cache=field_data_cache, + student_data=student_data, course_id=course_id, track_function=make_track_function(), xqueue_callback_url_prefix=xqueue_callback_url_prefix, diff --git a/lms/static/sass/course/layout/_courseware_preview.scss b/lms/static/sass/course/layout/_courseware_preview.scss index 7af61a275b..a3388c5328 100644 --- a/lms/static/sass/course/layout/_courseware_preview.scss +++ b/lms/static/sass/course/layout/_courseware_preview.scss @@ -22,6 +22,24 @@ margin-bottom: 0; vertical-align: middle; } + + .action-preview-select { + margin-right: $baseline; + } + + .action-preview-username-container { + display: none; + + .action-preview-username { + vertical-align: middle; + height: 25px; + } + } } } + + .preview-specific-student-notice { + margin-top: ($baseline/2); + font-size: 90%; + } } diff --git a/lms/templates/courseware/course_navigation.html b/lms/templates/courseware/course_navigation.html index 3efe49986f..9eadb892f3 100644 --- a/lms/templates/courseware/course_navigation.html +++ b/lms/templates/courseware/course_navigation.html @@ -18,12 +18,17 @@ def url_class(is_active): if is_active: return "active" return "" -%> -<% - cohorted_user_partition = get_cohorted_user_partition(course.id) - show_preview_menu = not disable_preview_menu and staff_access and active_page in ['courseware', 'info'] - is_student_masquerade = masquerade and masquerade.role == 'student' - masquerade_group_id = masquerade.group_id if masquerade else None + +def selected(is_selected): + return "selected" if is_selected else "" + +show_preview_menu = not disable_preview_menu and staff_access and active_page in ["courseware", "info"] +cohorted_user_partition = get_cohorted_user_partition(course.id) +masquerade_user_name = masquerade.user_name if masquerade else None +masquerade_group_id = masquerade.group_id if masquerade else None +staff_selected = selected(not masquerade or masquerade.role != "student") +specific_student_selected = selected(not staff_selected and masquerade.user_name) +student_selected = selected(not staff_selected and not specific_student_selected and not masquerade_group_id) %> % if show_preview_menu: @@ -34,20 +39,32 @@ def url_class(is_active):
- +
+ + +
+
+ % if specific_student_selected: +
+

+ ${_("You are now viewing the course as {user_name}.").format(user_name=masquerade_user_name)} +

+
+ % endif % endif @@ -84,22 +101,55 @@ def url_class(is_active): % if show_preview_menu: % endif