Commit 1091ea86 by Braden MacDonald

Merge pull request #8775 from open-craft/smarnach/masquerade

Allow staff to masquerade as a specific user in the LMS (SOL-816)
parents d494fe22 3d7246ec
......@@ -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):
"""
......
......@@ -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')]}
......
......@@ -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.
......
......@@ -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"
......
......@@ -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"
......
......@@ -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
......
......@@ -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')
......
......@@ -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')],
}
......
......@@ -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')
......
......@@ -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.
......
......@@ -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"),
......
......@@ -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')
......
......@@ -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):
"""
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.
def setup_masquerade(request, course_key, staff_access=False, reset_masquerade_data=False):
"""
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, {})
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 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.')
)
......@@ -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()
......@@ -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,
......
......@@ -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
......@@ -216,6 +224,80 @@ class TestStaffMasqueradeAsStudent(StaffMasqueradeTestCase):
@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):
"""
Check for staff being able to masquerade as belonging to a group.
......@@ -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')
......@@ -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,
......
......@@ -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.
......
......@@ -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
)
......
......@@ -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.
......
......@@ -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,
......
......@@ -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%;
}
}
......@@ -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):
<form action="#" class="action-preview-form" method="post">
<label for="action-preview-select" class="action-preview-label">${_("View this course as:")}</label>
<select class="action-preview-select" id="action-preview-select" name="select">
<option value="staff" ${"selected" if not is_student_masquerade else ""}>${_("Staff")}</option>
<option value="student" ${"selected" if is_student_masquerade and not masquerade_group_id else ""}>${_("Student")}</option>
<option value="staff" ${staff_selected}>${_("Staff")}</option>
<option value="student" ${student_selected}>${_("Student")}</option>
<option value="specific student" ${specific_student_selected}>${_("Specific student")}</option>
% if cohorted_user_partition:
% for group in sorted(cohorted_user_partition.groups, key=lambda group: group.name):
<option value="group.id" data-group-id="${group.id}" ${"selected" if masquerade_group_id == group.id else ""}>
<option value="group.id" data-group-id="${group.id}" ${selected(masquerade_group_id == group.id)}>
${_("Student in {content_group}").format(content_group=group.name)}
</option>
% endfor
% endif
</select>
<button type="submit" class="sr" name="submit" value="submit">${_("set preview mode")}</button>
<div class="action-preview-username-container">
<label for="action-preview-username" class="action-preview-label">${_("Username or email:")}</label>
<input type="text" class="action-preview-username" id="action-preview-username">
</div>
<button type="submit" class="sr" name="submit" value="submit">${_("Set preview mode")}</button>
</form>
</li>
</ol>
% if specific_student_selected:
<div class="preview-specific-student-notice">
<p>
${_("You are now viewing the course as <i>{user_name}</i>.").format(user_name=masquerade_user_name)}
</p>
</div>
% endif
</div>
</nav>
% endif
......@@ -84,22 +101,55 @@ def url_class(is_active):
% if show_preview_menu:
<script type="text/javascript">
(function() {
var element = $('.action-preview-select');
var selectElement = $('.action-preview-select');
var userNameElement = $('#action-preview-username');
var userNameContainer = $('.action-preview-username-container')
% if disable_student_access:
element.attr("disabled", true);
element.attr("title", "${_("Course is not yet visible to students.")}");
selectElement.attr("disabled", true);
selectElement.attr("title", "${_("Course is not yet visible to students.")}");
% endif
% if specific_student_selected:
userNameContainer.css('display', 'inline-block');
userNameElement.val('${masquerade_user_name}');
% endif
element.change(function() {
var selectedOption, data;
if (element.attr("disabled")) {
selectElement.change(function() {
var selectedOption;
if (selectElement.attr("disabled")) {
return alert("${_("You cannot view the course as a student or beta tester before the course release date.")}");
}
selectedOption = element.find('option:selected');
data = {
selectedOption = selectElement.find('option:selected');
if (selectedOption.val() === 'specific student') {
userNameContainer.css('display', 'inline-block');
} else {
userNameContainer.hide();
masquerade(selectedOption);
}
});
userNameElement.keypress(function(event) {
if (event.keyCode === 13) {
// Avoid submitting the form on enter, since the submit action isn't implemented. Instead, blur the
// element to trigger a change event in case the value was edited, which in turn will trigger an AJAX
// request to update the masquerading data.
userNameElement.blur();
return false;
}
return true;
});
userNameElement.change(function() {
masquerade(selectElement.find('option:selected'));
});
function masquerade(selectedOption) {
var data = {
role: selectedOption.val() === 'staff' ? 'staff' : 'student',
user_partition_id: ${cohorted_user_partition.id if cohorted_user_partition else 'null'},
group_id: selectedOption.data('group-id')
group_id: selectedOption.data('group-id'),
user_name: selectedOption.val() === 'specific student' ? userNameElement.val() : null
};
$.ajax({
url: '/courses/${course.id}/masquerade',
......@@ -108,13 +158,17 @@ def url_class(is_active):
contentType: 'application/json',
data: JSON.stringify(data),
success: function(result) {
location.reload();
if (result.success) {
location.reload();
} else {
alert(result.error);
}
},
error: function() {
alert('Error: cannot connect to server');
}
});
});
}
}());
</script>
% endif
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment