Commit 561c57db by Andy Armstrong

Extend preview to support cohorted courseware

TNL-651
parent c4e8673d
......@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
Studio/LMS: Implement cohorted courseware. TNL-648
LMS: Student Notes: Eventing for Student Notes. TNL-931
LMS: Student Notes: Add course structure view. TNL-762
......@@ -26,6 +28,9 @@ LMS: Student Notes: Toggle single note visibility. TNL-660
LMS: Student Notes: Add Notes page. TNL-797
LMS: Student Notes: Add possibility to add/edit/remove notes. TNL-655
=======
LMS: Extend preview to support cohorted courseware. TNL-651
>>>>>>> Extend preview to support cohorted courseware
Platform: Add group_access field to all xblocks. TNL-670
......
......@@ -11,25 +11,26 @@ class StaffPage(CoursewarePage):
"""
url = None
STAFF_STATUS_CSS = '#staffstatus'
PREVIEW_MENU_CSS = '.preview-menu'
VIEW_MODE_OPTIONS_CSS = '.preview-menu .action-preview-select option'
def is_browser_on_page(self):
if not super(StaffPage, self).is_browser_on_page():
return False
return self.q(css=self.STAFF_STATUS_CSS).present
return self.q(css=self.PREVIEW_MENU_CSS).present
@property
def staff_status(self):
def staff_view_mode(self):
"""
Return the current status, either Staff view or Student view
Return the currently chosen view mode, e.g. "Staff", "Student" or a content group.
"""
return self.q(css=self.STAFF_STATUS_CSS).text[0]
return self.q(css=self.VIEW_MODE_OPTIONS_CSS).filter(lambda el: el.is_selected()).first.text[0]
def toggle_staff_view(self):
def set_staff_view_mode(self, view_mode):
"""
Toggle between staff view and student view.
Set the current view mode, e.g. "Staff", "Student" or a content group.
"""
self.q(css=self.STAFF_STATUS_CSS).first.click()
self.q(css=self.VIEW_MODE_OPTIONS_CSS).filter(lambda el: el.text == view_mode).first.click()
self.wait_for_ajax()
def open_staff_debug_info(self):
......
......@@ -7,9 +7,11 @@ import functools
import requests
import os
from path import path
from bok_choy.javascript import js_defined
from bok_choy.web_app_test import WebAppTest
from opaque_keys.edx.locator import CourseLocator
from bok_choy.javascript import js_defined
from xmodule.partitions.tests.test_partitions import MockUserPartitionScheme
from xmodule.partitions.partitions import UserPartition
def skip_if_browser(browser):
......@@ -279,3 +281,12 @@ class YouTubeStubConfig(object):
return json.loads(response.content)
else:
return {}
def create_user_partition_json(partition_id, name, description, groups, scheme="random"):
"""
Helper method to create user partition JSON. If scheme is not supplied, "random" is used.
"""
return UserPartition(
partition_id, name, description, groups, MockUserPartitionScheme(scheme)
).to_json()
# -*- coding: utf-8 -*-
"""
End-to-end tests for the LMS.
Tests the "preview" selector in the LMS that allows changing between Staff, Student, and Content Groups.
"""
from ..helpers import UniqueCourseTest
from ..helpers import UniqueCourseTest, create_user_partition_json
from ...pages.studio.auto_auth import AutoAuthPage
from ...pages.lms.courseware import CoursewarePage
from ...pages.lms.staff_view import StaffPage
from ...pages.lms.course_nav import CourseNavPage
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
from xmodule.partitions.partitions import Group
from textwrap import dedent
......@@ -24,11 +26,39 @@ class StaffViewTest(UniqueCourseTest):
self.courseware_page = CoursewarePage(self.browser, self.course_id)
# Install a course with sections/problems, tabs, updates, and handouts
course_fix = CourseFixture(
self.course_fixture = CourseFixture(
self.course_info['org'], self.course_info['number'],
self.course_info['run'], self.course_info['display_name']
)
self.populate_course_fixture(self.course_fixture) # pylint: disable=no-member
self.course_fixture.install()
# Auto-auth register for the course.
# Do this as global staff so that you will see the Staff View
AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL,
course_id=self.course_id, staff=True).visit()
def _goto_staff_page(self):
"""
Open staff page with assertion
"""
self.courseware_page.visit()
staff_page = StaffPage(self.browser, self.course_id)
self.assertEqual(staff_page.staff_view_mode, 'Staff')
return staff_page
class CourseWithoutContentGroupsTest(StaffViewTest):
"""
Setup for tests that have no content restricted to specific content groups.
"""
def populate_course_fixture(self, course_fixture):
"""
Populates test course with chapter, sequential, and 2 problems.
"""
problem_data = dedent("""
<problem markdown="Simple Problem" max_attempts="" weight="">
<p>Choose Yes.</p>
......@@ -40,31 +70,17 @@ class StaffViewTest(UniqueCourseTest):
</problem>
""")
course_fix.add_children(
course_fixture.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('problem', 'Test Problem 1', data=problem_data),
XBlockFixtureDesc('problem', 'Test Problem 2', data=problem_data)
)
)
).install()
# Auto-auth register for the course.
# Do this as global staff so that you will see the Staff View
AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL,
course_id=self.course_id, staff=True).visit()
def _goto_staff_page(self):
"""
Open staff page with assertion
"""
self.courseware_page.visit()
staff_page = StaffPage(self.browser, self.course_id)
self.assertEqual(staff_page.staff_status, 'Staff view')
return staff_page
)
class StaffViewToggleTest(StaffViewTest):
class StaffViewToggleTest(CourseWithoutContentGroupsTest):
"""
Tests for the staff view toggle button.
"""
......@@ -75,12 +91,12 @@ class StaffViewToggleTest(StaffViewTest):
course_page = self._goto_staff_page()
self.assertTrue(course_page.has_tab('Instructor'))
course_page.toggle_staff_view()
self.assertEqual(course_page.staff_status, 'Student view')
course_page.set_staff_view_mode('Student')
self.assertEqual(course_page.staff_view_mode, 'Student')
self.assertFalse(course_page.has_tab('Instructor'))
class StaffDebugTest(StaffViewTest):
class StaffDebugTest(CourseWithoutContentGroupsTest):
"""
Tests that verify the staff debug info.
"""
......@@ -209,3 +225,115 @@ class StaffDebugTest(StaffViewTest):
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully deleted student state '
'for user {}'.format(self.USERNAME), msg)
class CourseWithContentGroupsTest(StaffViewTest):
"""
Verifies that changing the "previewing as" selector works properly for cohorted content.
"""
def setUp(self):
super(CourseWithContentGroupsTest, self).setUp()
# pylint: disable=protected-access
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
create_user_partition_json(
0,
'Configuration alpha,beta',
'Content Group Partition',
[Group("0", 'alpha'), Group("1", 'beta')],
scheme="cohort"
)
],
},
})
def populate_course_fixture(self, course_fixture):
"""
Populates test course with chapter, sequential, and 3 problems.
One problem is visible to all, one problem is visible only to Group "alpha", and
one problem is visible only to Group "beta".
"""
problem_data = dedent("""
<problem markdown="Simple Problem" max_attempts="" weight="">
<p>Choose Yes.</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<choice correct="true">Yes</choice>
</checkboxgroup>
</choiceresponse>
</problem>
""")
course_fixture.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc(
'problem', 'Visible to alpha', data=problem_data, metadata={"group_access": {0: [0]}}
),
XBlockFixtureDesc(
'problem', 'Visible to beta', data=problem_data, metadata={"group_access": {0: [1]}}
),
XBlockFixtureDesc('problem', 'Visible to everyone', data=problem_data)
)
)
)
def _verify_visible_problems(self, expected_items):
"""
Verify that the expected problems are visible.
"""
course_nav = CourseNavPage(self.browser)
actual_items = course_nav.sequence_items
self.assertItemsEqual(expected_items, actual_items)
def test_staff_sees_all_problems(self):
"""
Scenario: Staff see all problems
Given I have a course with a cohort user partition
And problems that are associated with specific groups in the user partition
When I view the courseware in the LMS with staff access
Then I see all the problems, regardless of their group_access property
"""
self._goto_staff_page()
self._verify_visible_problems(['Visible to alpha', 'Visible to beta', 'Visible to everyone'])
def test_student_not_in_content_group(self):
"""
Scenario: When previewing as a student, only content visible to all is shown
Given I have a course with a cohort user partition
And problems that are associated with specific groups in the user partition
When I view the courseware in the LMS with staff access
And I change to previewing as a Student
Then I see only problems visible to all users
"""
course_page = self._goto_staff_page()
course_page.set_staff_view_mode('Student')
self._verify_visible_problems(['Visible to everyone'])
def test_as_student_in_alpha(self):
"""
Scenario: When previewing as a student in group alpha, only content visible to alpha is shown
Given I have a course with a cohort user partition
And problems that are associated with specific groups in the user partition
When I view the courseware in the LMS with staff access
And I change to previewing as a Student in group alpha
Then I see only problems visible to group alpha
"""
course_page = self._goto_staff_page()
course_page.set_staff_view_mode('Student in alpha')
self._verify_visible_problems(['Visible to alpha', 'Visible to everyone'])
def test_as_student_in_beta(self):
"""
Scenario: When previewing as a student in group beta, only content visible to beta is shown
Given I have a course with a cohort user partition
And problems that are associated with specific groups in the user partition
When I view the courseware in the LMS with staff access
And I change to previewing as a Student in group beta
Then I see only problems visible to group beta
"""
course_page = self._goto_staff_page()
course_page.set_staff_view_mode('Student in beta')
self._verify_visible_problems(['Visible to beta', 'Visible to everyone'])
......@@ -698,14 +698,14 @@ class UnitPublishingTest(ContainerBase):
"""
Verifies no component is visible when viewing as a student.
"""
self._verify_and_return_staff_page().toggle_staff_view()
self._verify_and_return_staff_page().set_staff_view_mode('Student')
self.assertEqual(0, self.courseware.num_xblock_components)
def _verify_student_view_visible(self, expected_components):
"""
Verifies expected components are visible when viewing as a student.
"""
self._verify_and_return_staff_page().toggle_staff_view()
self._verify_and_return_staff_page().set_staff_view_mode('Student')
self._verify_components_visible(expected_components)
def _verify_components_visible(self, expected_components):
......
......@@ -708,7 +708,7 @@ class StaffLockTest(CourseOutlineTest):
When I enable explicit staff lock on one section
And I click the View Live button to switch to staff view
Then I see two sections in the sidebar
And when I click to toggle to student view
And when I switch the view mode to student view
Then I see one section in the sidebar
"""
self.course_outline_page.visit()
......@@ -718,7 +718,7 @@ class StaffLockTest(CourseOutlineTest):
courseware = CoursewarePage(self.browser, self.course_id)
courseware.wait_for_page()
self.assertEqual(courseware.num_sections, 2)
StaffPage(self.browser, self.course_id).toggle_staff_view()
StaffPage(self.browser, self.course_id).set_staff_view_mode('Student')
self.assertEqual(courseware.num_sections, 1)
def test_locked_subsections_do_not_appear_in_lms(self):
......@@ -728,7 +728,7 @@ class StaffLockTest(CourseOutlineTest):
When I enable explicit staff lock on one subsection
And I click the View Live button to switch to staff view
Then I see two subsections in the sidebar
And when I click to toggle to student view
And when I switch the view mode to student view
Then I see one section in the sidebar
"""
self.course_outline_page.visit()
......@@ -737,7 +737,7 @@ class StaffLockTest(CourseOutlineTest):
courseware = CoursewarePage(self.browser, self.course_id)
courseware.wait_for_page()
self.assertEqual(courseware.num_subsections, 2)
StaffPage(self.browser, self.course_id).toggle_staff_view()
StaffPage(self.browser, self.course_id).set_staff_view_mode('Student')
self.assertEqual(courseware.num_subsections, 1)
def test_toggling_staff_lock_on_section_does_not_publish_draft_units(self):
......
......@@ -9,7 +9,6 @@ from nose.plugins.attrib import attr
from selenium.webdriver.support.ui import Select
from xmodule.partitions.partitions import Group, UserPartition
from xmodule.partitions.tests.test_partitions import MockUserPartitionScheme
from bok_choy.promise import Promise, EmptyPromise
from ...fixtures.course import XBlockFixtureDesc
......@@ -21,6 +20,7 @@ from ...pages.studio.settings_group_configurations import GroupConfigurationsPag
from ...pages.studio.utils import add_advanced_component
from ...pages.xblock.utils import wait_for_xblock_initialization
from ...pages.lms.courseware import CoursewarePage
from ..helpers import create_user_partition_json
from base_studio_test import StudioCourseTest
......@@ -31,15 +31,6 @@ class SplitTestMixin(object):
"""
Mixin that contains useful methods for split_test module testing.
"""
@staticmethod
def create_user_partition_json(partition_id, name, description, groups):
"""
Helper method to create user partition JSON.
"""
return UserPartition(
partition_id, name, description, groups, MockUserPartitionScheme("random")
).to_json()
def verify_groups(self, container, active_groups, inactive_groups, verify_missing_groups_not_present=True):
"""
Check that the groups appear and are correctly categorized as to active and inactive.
......@@ -90,13 +81,13 @@ class SplitTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
self.create_user_partition_json(
create_user_partition_json(
0,
'Configuration alpha,beta',
'first',
[Group("0", 'alpha'), Group("1", 'beta')]
),
self.create_user_partition_json(
create_user_partition_json(
1,
'Configuration 0,1,2',
'second',
......@@ -144,7 +135,7 @@ class SplitTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
self.create_user_partition_json(
create_user_partition_json(
0,
'Configuration alpha,beta',
'first',
......@@ -348,7 +339,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
self.create_user_partition_json(0, "Name", "Description.", groups),
create_user_partition_json(0, "Name", "Description.", groups),
],
},
})
......@@ -420,13 +411,13 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
self.create_user_partition_json(
create_user_partition_json(
0,
'Name of the Group Configuration',
'Description of the group configuration.',
[Group("0", 'Group 0'), Group("1", 'Group 1')]
),
self.create_user_partition_json(
create_user_partition_json(
1,
'Name of second Group Configuration',
'Second group configuration.',
......@@ -565,7 +556,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
self.create_user_partition_json(
create_user_partition_json(
0,
'Name of the Group Configuration',
'Description of the group configuration.',
......@@ -649,13 +640,13 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
self.create_user_partition_json(
create_user_partition_json(
0,
'Name of the Group Configuration',
'Description of the group configuration.',
[Group("0", 'Group 0'), Group("1", 'Group 1')]
),
self.create_user_partition_json(
create_user_partition_json(
1,
'Name of second Group Configuration',
'Second group configuration.',
......@@ -745,7 +736,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
self.create_user_partition_json(
create_user_partition_json(
0,
"Name",
"Description.",
......@@ -782,7 +773,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
self.create_user_partition_json(
create_user_partition_json(
0,
"Name",
"Description.",
......@@ -830,13 +821,13 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
self.create_user_partition_json(
create_user_partition_json(
0,
'Configuration 1',
'Description of the group configuration.',
[Group("0", 'Group 0'), Group("1", 'Group 1')]
),
self.create_user_partition_json(
create_user_partition_json(
1,
'Configuration 2',
'Second group configuration.',
......@@ -873,7 +864,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
self.create_user_partition_json(
create_user_partition_json(
0,
"Name",
"Description.",
......@@ -914,13 +905,13 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
self.create_user_partition_json(
create_user_partition_json(
0,
"Name",
"Description.",
[Group("0", "Group A"), Group("1", "Group B")]
),
self.create_user_partition_json(
create_user_partition_json(
1,
'Name of second Group Configuration',
'Second group configuration.',
......
......@@ -18,7 +18,7 @@ from xblock.core import XBlock
from xmodule.partitions.partitions import NoSuchUserPartitionError, NoSuchUserPartitionGroupError
from external_auth.models import ExternalAuthMap
from courseware.masquerade import is_masquerading_as_student
from courseware.masquerade import get_masquerade_role, is_masquerading_as_student
from django.utils.timezone import UTC
from student import auth
from student.roles import (
......@@ -396,7 +396,7 @@ def _has_access_descriptor(user, action, descriptor, course_key=None):
return _has_staff_access_to_descriptor(user, descriptor, course_key)
# If start dates are off, can always load
if settings.FEATURES['DISABLE_START_DATES'] and not is_masquerading_as_student(user):
if settings.FEATURES['DISABLE_START_DATES'] and not is_masquerading_as_student(user, course_key):
debug("Allow: DISABLE_START_DATES")
return True
......@@ -579,7 +579,7 @@ def _has_access_to_course(user, access_level, course_key):
debug("Deny: no user or anon user")
return False
if is_masquerading_as_student(user):
if is_masquerading_as_student(user, course_key):
return False
if GlobalStaff().has_user(user):
......@@ -636,8 +636,9 @@ def get_user_role(user, course_key):
Return corresponding string if user has staff, instructor or student
course role in LMS.
"""
if is_masquerading_as_student(user):
return 'student'
role = get_masquerade_role(user, course_key)
if role:
return role
elif has_access(user, 'instructor', course_key):
return 'instructor'
elif has_access(user, 'staff', course_key):
......
......@@ -64,7 +64,7 @@ Feature: LMS.LTI component
| False | True |
And I view the LTI and it is rendered in iframe
And I see in iframe that LTI role is Instructor
And I switch to Student view
And I switch to student
Then I see in iframe that LTI role is Student
#8
......
......@@ -387,9 +387,9 @@ def check_role(_step, role):
@step('I switch to (.*)$')
def switch_view(_step, view):
staff_status = world.css_find('#staffstatus').first
if staff_status.text != view:
world.css_click('#staffstatus')
staff_status = world.css_find('#action-preview-select').first.value
if staff_status != view:
world.browser.select("select", view)
world.wait_for_ajax_complete()
......
'''
---------------------------------------- Masequerade ----------------------------------------
---------------------------------------- Masquerade ----------------------------------------
Allow course staff to see a student or staff view of courseware.
Which kind of view has been selected is stored in the session state.
'''
import json
import logging
from django.http import HttpResponse
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST
from util.json_request import expect_json, JsonResponse
log = logging.getLogger(__name__)
MASQ_KEY = 'masquerade_identity'
from opaque_keys.edx.keys import CourseKey
def handle_ajax(request, marg):
'''
Handle ajax call from "staff view" / "student view" toggle button
'''
if marg == 'toggle':
status = request.session.get(MASQ_KEY, '')
if status is None or status in ['', 'staff']:
status = 'student'
else:
status = 'staff'
request.session[MASQ_KEY] = status
return HttpResponse(json.dumps({'status': status}))
def setup_masquerade(request, staff_access=False):
'''
Setup masquerade identity (allows staff to view courseware as either staff or student)
log = logging.getLogger(__name__)
Uses request.session[MASQ_KEY] to store status of masquerading.
Adds masquerade status to request.user, if masquerading active.
Return string version of status of view (either 'staff' or 'student')
'''
# The key used to store a user's course-level masquerade information in the Django session.
# The value is a dict from course keys to CourseMasquerade objects.
MASQUERADE_SETTINGS_KEY = 'masquerade_settings'
class CourseMasquerade(object):
"""
Masquerade settings for a particular course.
"""
def __init__(self, course_key, role='student', user_partition_id=None, group_id=None):
self.course_key = course_key
self.role = role
self.user_partition_id = user_partition_id
self.group_id = group_id
@require_POST
@login_required
@expect_json
def handle_ajax(request, course_key_string):
"""
Handle AJAX posts to update the current user's masquerade for the specified course.
The masquerade settings are stored in the Django session as a dict from course keys
to CourseMasquerade objects.
"""
course_key = CourseKey.from_string(course_key_string)
masquerade_settings = request.session.get(MASQUERADE_SETTINGS_KEY, {})
request_json = request.json
role = request_json.get('role', 'student')
user_partition_id = request_json.get('user_partition_id', None)
group_id = request_json.get('group_id', None)
masquerade_settings[course_key] = CourseMasquerade(
course_key,
role=role,
user_partition_id=user_partition_id,
group_id=group_id
)
request.session[MASQUERADE_SETTINGS_KEY] = masquerade_settings
return JsonResponse()
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.
"""
if request.user is None:
return None
......@@ -46,20 +73,46 @@ def setup_masquerade(request, staff_access=False):
if not staff_access: # can masquerade only if user has staff access to course
return None
usertype = request.session.get(MASQ_KEY, '')
if usertype is None or not usertype:
request.session[MASQ_KEY] = 'staff'
usertype = 'staff'
masquerade_settings = request.session.get(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)
def get_course_masquerade(user, course_key):
"""
Returns the masquerade for the current user for the specified course. If no masquerade has
been installed, then a default no-op masquerade is returned.
"""
masquerade_settings = getattr(user, 'masquerade_settings', {})
return masquerade_settings.get(course_key, None)
def get_masquerade_role(user, course_key):
"""
Returns the role that the user is masquerading as, or None if no masquerade is in effect.
"""
course_masquerade = get_course_masquerade(user, course_key)
return course_masquerade.role if course_masquerade else None
if usertype == 'student':
request.user.masquerade_as_student = True
return usertype
def is_masquerading_as_student(user, course_key):
"""
Returns true if the user is a staff member masquerading as a student.
"""
return get_masquerade_role(user, course_key) == 'student'
def is_masquerading_as_student(user):
'''
Return True if user is masquerading as a student, False otherwise
'''
masq = getattr(user, 'masquerade_as_student', False)
return masq is True
def get_masquerading_group_info(user, course_key):
"""
If the user is masquerading as belonging to a group, then this method returns
two values: the id of the group, and the id of the user partition that the group
belongs to. If the user is not masquerading as a group, then None is returned.
"""
course_masquerade = get_course_masquerade(user, course_key)
if not course_masquerade:
return None, None
return course_masquerade.group_id, course_masquerade.user_partition_id
......@@ -213,7 +213,7 @@ def get_xqueue_callback_url_prefix(request):
return settings.XQUEUE_INTERFACE.get('callback_url', prefix)
def get_module_for_descriptor(user, request, descriptor, field_data_cache, course_id,
def get_module_for_descriptor(user, request, descriptor, field_data_cache, course_key,
position=None, wrap_xmodule_display=True, grade_bucket_type=None,
static_asset_path=''):
"""
......@@ -221,10 +221,6 @@ def get_module_for_descriptor(user, request, descriptor, field_data_cache, cours
See get_module() docstring for further details.
"""
# allow course staff to masquerade as student
if has_access(user, 'staff', descriptor, course_id):
setup_masquerade(request, True)
track_function = make_track_function(request)
xqueue_callback_url_prefix = get_xqueue_callback_url_prefix(request)
......@@ -234,7 +230,7 @@ def get_module_for_descriptor(user, request, descriptor, field_data_cache, cours
user=user,
descriptor=descriptor,
field_data_cache=field_data_cache,
course_id=course_id,
course_id=course_key,
track_function=track_function,
xqueue_callback_url_prefix=xqueue_callback_url_prefix,
position=position,
......@@ -782,6 +778,7 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
user,
descriptor
)
setup_masquerade(request, course_id, has_access(user, 'staff', descriptor, course_id))
instance = get_module(user, request, usage_key, field_data_cache, grade_bucket_type='ajax')
if instance is None:
# Either permissions just changed, or someone is trying to be clever
......
......@@ -6,6 +6,7 @@ from mock import Mock, patch
from opaque_keys.edx.locations import SlashSeparatedCourseKey
import courseware.access as access
from courseware.masquerade import CourseMasquerade
from courseware.tests.factories import UserFactory, StaffFactory, InstructorFactory
from student.tests.factories import AnonymousUserFactory, CourseEnrollmentAllowedFactory
from xmodule.course_module import (
......@@ -255,6 +256,14 @@ class UserRoleTestCase(TestCase):
self.course_staff = StaffFactory(course_key=self.course_key)
self.course_instructor = InstructorFactory(course_key=self.course_key)
def _install_masquerade(self, user, role='student'):
"""
Installs a masquerade for the specified user.
"""
user.masquerade_settings = {
self.course_key: CourseMasquerade(self.course_key, role=role)
}
def test_user_role_staff(self):
"""Ensure that user role is student for staff masqueraded as student."""
self.assertEqual(
......@@ -262,7 +271,7 @@ class UserRoleTestCase(TestCase):
access.get_user_role(self.course_staff, self.course_key)
)
# Masquerade staff
self.course_staff.masquerade_as_student = True
self._install_masquerade(self.course_staff)
self.assertEqual(
'student',
access.get_user_role(self.course_staff, self.course_key)
......@@ -275,7 +284,7 @@ class UserRoleTestCase(TestCase):
access.get_user_role(self.course_instructor, self.course_key)
)
# Masquerade instructor
self.course_instructor.masquerade_as_student = True
self._install_masquerade(self.course_instructor)
self.assertEqual(
'student',
access.get_user_role(self.course_instructor, self.course_key)
......
......@@ -354,7 +354,7 @@ 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)]))
masq = setup_masquerade(request, staff_access)
masquerade = setup_masquerade(request, course_key, staff_access)
try:
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
......@@ -377,7 +377,7 @@ def _index_bulk_op(request, course_key, chapter, section, position):
'fragment': Fragment(),
'staff_access': staff_access,
'studio_url': studio_url,
'masquerade': masq,
'masquerade': masquerade,
'xqa_server': settings.FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa'),
'reverifications': fetch_reverify_banner_info(request, course_key),
}
......@@ -419,8 +419,8 @@ def _index_bulk_op(request, course_key, chapter, section, position):
chapter_module = course_module.get_child_by(lambda m: m.location.name == chapter)
if chapter_module is None:
# User may be trying to access a chapter that isn't live yet
if masq == 'student': # if staff is masquerading as student be kinder, don't 404
log.debug('staff masq as student: no chapter %s' % chapter)
if masquerade and masquerade.role == 'student': # if staff is masquerading as student be kinder, don't 404
log.debug('staff masquerading as student: no chapter %s', chapter)
return redirect(reverse('courseware', args=[course.id.to_deprecated_string()]))
raise Http404
......@@ -429,8 +429,8 @@ def _index_bulk_op(request, course_key, chapter, section, position):
if section_descriptor is None:
# Specifically asked-for section doesn't exist
if masq == 'student': # if staff is masquerading as student be kinder, don't 404
log.debug('staff masq as student: no section %s' % section)
if masquerade and masquerade.role == 'student': # don't 404 if staff is masquerading as student
log.debug('staff masquerading as student: no section %s', section)
return redirect(reverse('courseware', args=[course.id.to_deprecated_string()]))
raise Http404
......@@ -625,7 +625,7 @@ def course_info(request, course_id):
return redirect(reverse('course_survey', args=[unicode(course.id)]))
staff_access = has_access(request.user, 'staff', course)
masq = setup_masquerade(request, staff_access) # allow staff to toggle masquerade on info page
masquerade = setup_masquerade(request, course_key, staff_access) # allow staff to masquerade on the info page
reverifications = fetch_reverify_banner_info(request, course_key)
studio_url = get_studio_url(course, 'course_info')
......@@ -643,7 +643,7 @@ def course_info(request, course_id):
'cache': None,
'course': course,
'staff_access': staff_access,
'masquerade': masq,
'masquerade': masquerade,
'studio_url': studio_url,
'reverifications': reverifications,
'show_enroll_banner': show_enroll_banner,
......
......@@ -97,44 +97,6 @@ class XBlockValidationTest(LmsXBlockMixinTestCase):
)
class XBlockGroupAccessTest(LmsXBlockMixinTestCase):
"""
Unit tests for XBlock group access.
"""
def setUp(self):
super(XBlockGroupAccessTest, self).setUp()
self.build_course()
def test_is_visible_to_group(self):
"""
Test the behavior of is_visible_to_group.
"""
# All groups are visible for an unrestricted xblock
self.assertTrue(self.video.is_visible_to_group(self.user_partition, self.group1))
self.assertTrue(self.video.is_visible_to_group(self.user_partition, self.group2))
# Verify that all groups are visible if the set of group ids is empty
self.video.group_access[self.user_partition.id] = [] # pylint: disable=no-member
self.assertTrue(self.video.is_visible_to_group(self.user_partition, self.group1))
self.assertTrue(self.video.is_visible_to_group(self.user_partition, self.group2))
# Verify that only specified groups are visible
self.video.group_access[self.user_partition.id] = [self.group1.id] # pylint: disable=no-member
self.assertTrue(self.video.is_visible_to_group(self.user_partition, self.group1))
self.assertFalse(self.video.is_visible_to_group(self.user_partition, self.group2))
# Verify that having an invalid user partition does not affect group visibility of other partitions
self.video.group_access[999] = [self.group1.id]
self.assertTrue(self.video.is_visible_to_group(self.user_partition, self.group1))
self.assertFalse(self.video.is_visible_to_group(self.user_partition, self.group2))
# Verify that group access is still correct even with invalid group ids
self.video.group_access.clear()
self.video.group_access[self.user_partition.id] = [self.group2.id, 999] # pylint: disable=no-member
self.assertFalse(self.video.is_visible_to_group(self.user_partition, self.group1))
self.assertTrue(self.video.is_visible_to_group(self.user_partition, self.group2))
class OpenAssessmentBlockMixinTestCase(ModuleStoreTestCase):
"""
Tests for OpenAssessmentBlock mixin.
......
......@@ -14,13 +14,19 @@ def url_class(is_active):
%>
<%! from xmodule.tabs import CourseTabList %>
<%! from courseware.access import has_access %>
<%! from courseware.masquerade import get_course_masquerade %>
<%! from courseware.views import notification_image_for_tab %>
<%! from django.conf import settings %>
<%! from django.core.urlresolvers import reverse %>
<%! from django.utils.translation import ugettext as _ %>
<%! from courseware.views import notification_image_for_tab %>
<%! from student.models import CourseEnrollment%>
<%! from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition %>
<%! from student.models import CourseEnrollment %>
<%
user_is_enrolled = user.is_authenticated() and CourseEnrollment.is_enrolled(user, course.id)
cohorted_user_partition = get_cohorted_user_partition(course.id)
show_preview_menu = 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
%>
% if show_preview_menu:
......@@ -72,43 +78,38 @@ def url_class(is_active):
</a>
</li>
% endfor
<%block name="extratabs" />
% if masquerade is not UNDEFINED:
% if staff_access and masquerade is not None:
<li style="float:right"><a href="#" id="staffstatus">${_("Staff view")}</a></li>
% endif
% endif
</ol>
</div>
</nav>
%endif
% if masquerade is not UNDEFINED:
% if staff_access and masquerade is not None:
% if show_preview_menu:
<script type="text/javascript">
masq = (function(){
var el = $('#staffstatus');
(function() {
var element = $('.action-preview-select');
% if disable_student_access:
el.attr("disabled", true);
el.attr("title", "${_("Course is not yet visible to students.")}");
element.attr("disabled", true);
element.attr("title", "${_("Course is not yet visible to students.")}");
% endif
var setstat = function(status){
if (status=='student'){
el.html('<font color="green">${_("Student view")}</font>');
}else{
el.html('<font color="red">${_("Staff view")}</font>');
}
}
setstat('${masquerade}');
el.click(function(){
if (el.attr("disabled")) {
element.change(function() {
var selectedOption, data;
if (element.attr("disabled")) {
return alert("${_("You cannot view the course as a student or beta tester before the course release date.")}");
}
$.ajax({ url: '/masquerade/toggle',
type: 'GET',
success: function(result){
setstat(result.status);
selectedOption = element.find('option:selected');
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')
};
$.ajax({
url: '/courses/${course.id}/masquerade',
type: 'POST',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify(data),
success: function(result) {
location.reload();
},
error: function() {
......@@ -116,7 +117,6 @@ masq = (function(){
}
});
});
}() );
}());
</script>
% endif
% endif
......@@ -47,13 +47,11 @@ $(document).ready(function(){
<div class="info-wrapper">
% if user.is_authenticated():
<section class="updates">
% if staff_access and masquerade is not UNDEFINED and studio_url is not None:
% if masquerade == 'staff':
% if studio_url is not None and masquerade and masquerade.role == 'staff':
<div class="wrap-instructor-info studio-view">
<a class="instructor-info-action" href="${studio_url}">${_("View Updates in Studio")}</a>
</div>
% endif
% endif
<h1>${_("Course Updates &amp; News")}</h1>
${get_course_info_section(request, course, 'updates')}
......
......@@ -389,7 +389,8 @@ if settings.COURSEWARE_ENABLED:
# allow course staff to change to student view of courseware
if settings.FEATURES.get('ENABLE_MASQUERADE'):
urlpatterns += (
url(r'^masquerade/(?P<marg>.*)$', 'courseware.masquerade.handle_ajax', name="masquerade-switch"),
url(r'^courses/{}/masquerade$'.format(settings.COURSE_KEY_PATTERN),
'courseware.masquerade.handle_ajax', name="masquerade_update"),
)
# discussion forums live within courseware, so courseware must be enabled first
......
......@@ -16,6 +16,7 @@ from eventtracking import tracker
from student.models import get_user_by_username_or_email
from .models import CourseUserGroup, CourseUserGroupPartitionGroup
log = logging.getLogger(__name__)
......@@ -381,15 +382,15 @@ def add_user_to_cohort(cohort, username_or_email):
return (user, previous_cohort_name)
def get_partition_group_id_for_cohort(cohort):
def get_group_info_for_cohort(cohort):
"""
Get the ids of the partition and group to which this cohort has been linked
Get the ids of the group and partition to which this cohort has been linked
as a tuple of (int, int).
If the cohort has not been linked to any partition/group, both values in the
If the cohort has not been linked to any group/partition, both values in the
tuple will be None.
"""
res = CourseUserGroupPartitionGroup.objects.filter(course_user_group=cohort)
if len(res):
return res[0].partition_id, res[0].group_id
return res[0].group_id, res[0].partition_id
return None, None
......@@ -3,9 +3,12 @@ Provides a UserPartition driver for cohorts.
"""
import logging
from courseware import courses
from courseware.masquerade import get_masquerading_group_info
from xmodule.partitions.partitions import NoSuchUserPartitionGroupError
from .cohorts import get_cohort, get_partition_group_id_for_cohort
from .cohorts import get_cohort, get_group_info_for_cohort
log = logging.getLogger(__name__)
......@@ -17,8 +20,9 @@ class CohortPartitionScheme(object):
Groups.
"""
# pylint: disable=unused-argument
@classmethod
def get_group_for_user(cls, course_id, user, user_partition, track_function=None):
def get_group_for_user(cls, course_key, user, user_partition, track_function=None):
"""
Returns the Group from the specified user partition to which the user
is assigned, via their cohort membership and any mappings from cohorts
......@@ -32,12 +36,22 @@ class CohortPartitionScheme(object):
If the user has no cohort mapping, or there is no (valid) cohort ->
partition group mapping found, the function returns None.
"""
cohort = get_cohort(user, course_id)
# If the current user is masquerading as being in a group belonging to the
# specified user partition then return the masquerading group.
group_id, user_partition_id = get_masquerading_group_info(user, course_key)
if group_id is not None and user_partition_id == user_partition.id:
try:
return user_partition.get_group(group_id)
except NoSuchUserPartitionGroupError:
# If the group no longer exists then the masquerade is not in effect
pass
cohort = get_cohort(user, course_key)
if cohort is None:
# student doesn't have a cohort
return None
partition_id, group_id = get_partition_group_id_for_cohort(cohort)
group_id, partition_id = get_group_info_for_cohort(cohort)
if partition_id is None:
# cohort isn't mapped to any partition group.
return None
......@@ -75,3 +89,17 @@ class CohortPartitionScheme(object):
)
# fail silently
return None
def get_cohorted_user_partition(course_key):
"""
Returns the first user partition from the specified course which uses the CohortPartitionScheme,
or None if one is not found. Note that it is currently recommended that each course have only
one cohorted user partition.
"""
course = courses.get_course_by_id(course_key)
for user_partition in course.user_partitions:
if user_partition.scheme == CohortPartitionScheme:
return user_partition
return None
......@@ -623,13 +623,13 @@ class TestCohortsAndPartitionGroups(TestCase):
link.save()
return link
def test_get_partition_group_id_for_cohort(self):
def test_get_group_info_for_cohort(self):
"""
Basic test of the partition_group_id accessor function
Basic test of the partition_group_info accessor function
"""
# api should return nothing for an unmapped cohort
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
cohorts.get_group_info_for_cohort(self.first_cohort),
(None, None),
)
# create a link for the cohort in the db
......@@ -640,14 +640,14 @@ class TestCohortsAndPartitionGroups(TestCase):
)
# api should return the specified partition and group
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
(self.partition_id, self.group1_id)
cohorts.get_group_info_for_cohort(self.first_cohort),
(self.group1_id, self.partition_id)
)
# delete the link in the db
link.delete()
# api should return nothing again
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
cohorts.get_group_info_for_cohort(self.first_cohort),
(None, None),
)
......@@ -666,12 +666,12 @@ class TestCohortsAndPartitionGroups(TestCase):
self.group1_id,
)
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
(self.partition_id, self.group1_id),
cohorts.get_group_info_for_cohort(self.first_cohort),
(self.group1_id, self.partition_id),
)
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.second_cohort),
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
cohorts.get_group_info_for_cohort(self.second_cohort),
cohorts.get_group_info_for_cohort(self.first_cohort),
)
def test_multiple_partition_groups(self):
......@@ -701,14 +701,14 @@ class TestCohortsAndPartitionGroups(TestCase):
self.group1_id
)
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
(self.partition_id, self.group1_id)
cohorts.get_group_info_for_cohort(self.first_cohort),
(self.group1_id, self.partition_id)
)
# delete the link
self.first_cohort.delete()
# api should return nothing at that point
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
cohorts.get_group_info_for_cohort(self.first_cohort),
(None, None),
)
# link should no longer exist because of delete cascade
......
......@@ -3,18 +3,23 @@ Test the partitions and partitions service
"""
import json
from django.conf import settings
import django.test
from django.test.utils import override_settings
from mock import patch
from unittest import skipUnless
from courseware.masquerade import handle_ajax, setup_masquerade
from courseware.tests.test_masquerade import StaffMasqueradeTestCase
from student.tests.factories import UserFactory
from xmodule.partitions.partitions import Group, UserPartition, UserPartitionError
from xmodule.modulestore.django import modulestore, clear_existing_modulestores
from xmodule.modulestore.tests.django_utils import mixed_store_config
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from ..partition_scheme import CohortPartitionScheme
from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme
from ..partition_scheme import CohortPartitionScheme, get_cohorted_user_partition
from ..models import CourseUserGroupPartitionGroup
from ..cohorts import add_user_to_cohort, get_course_cohorts
from .helpers import CohortFactory, config_course_cohorts
......@@ -280,3 +285,111 @@ class TestExtension(django.test.TestCase):
self.assertEqual(UserPartition.get_scheme('cohort'), CohortPartitionScheme)
with self.assertRaisesRegexp(UserPartitionError, 'Unrecognized scheme'):
UserPartition.get_scheme('other')
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class TestGetCohortedUserPartition(django.test.TestCase):
"""
Test that `get_cohorted_user_partition` returns the first user_partition with scheme `CohortPartitionScheme`.
"""
def setUp(self):
"""
Regenerate a course with cohort configuration, partition and groups,
and a student for each test.
"""
self.course_key = SlashSeparatedCourseKey("edX", "toy", "2012_Fall")
self.course = modulestore().get_course(self.course_key)
self.student = UserFactory.create()
self.random_user_partition = UserPartition(
1,
'Random Partition',
'Should not be returned',
[Group(0, 'Group 0'), Group(1, 'Group 1')],
scheme=RandomUserPartitionScheme
)
self.cohort_user_partition = UserPartition(
0,
'Cohort Partition 1',
'Should be returned',
[Group(10, 'Group 10'), Group(20, 'Group 20')],
scheme=CohortPartitionScheme
)
self.second_cohort_user_partition = UserPartition(
2,
'Cohort Partition 2',
'Should not be returned',
[Group(10, 'Group 10'), Group(1, 'Group 1')],
scheme=CohortPartitionScheme
)
def test_returns_first_cohort_user_partition(self):
"""
Test get_cohorted_user_partition returns first user_partition with scheme `CohortPartitionScheme`.
"""
self.course.user_partitions.append(self.random_user_partition)
self.course.user_partitions.append(self.cohort_user_partition)
self.course.user_partitions.append(self.second_cohort_user_partition)
self.assertEqual(self.cohort_user_partition, get_cohorted_user_partition(self.course_key))
def test_no_cohort_user_partitions(self):
"""
Test get_cohorted_user_partition returns None when there are no cohorted user partitions.
"""
self.course.user_partitions.append(self.random_user_partition)
self.assertIsNone(get_cohorted_user_partition(self.course_key))
class TestMasqueradedGroup(StaffMasqueradeTestCase):
"""
Check for staff being able to masquerade as belonging to a group.
"""
def setUp(self):
super(TestMasqueradedGroup, self).setUp()
self.user_partition = UserPartition(
0, 'Test User Partition', '',
[Group(0, 'Group 1'), Group(1, 'Group 2')],
scheme_id='cohort'
)
self.course.user_partitions.append(self.user_partition)
self.session = {}
modulestore().update_item(self.course, self.test_user.id)
def _verify_masquerade_for_group(self, group):
"""
Verify that the masquerade works for the specified group id.
"""
# Send the request to set the masquerade
request_json = {
"role": "student",
}
if group and self.user_partition:
request_json['user_partition_id'] = self.user_partition.id
request_json['group_id'] = group.id
request = self._create_mock_json_request(
self.test_user,
body=json.dumps(request_json),
session=self.session
)
handle_ajax(request, unicode(self.course.id))
# Now setup the masquerade for the test user
setup_masquerade(request, self.test_user, True)
scheme = self.user_partition.scheme # pylint: disable=no-member
self.assertEqual(
scheme.get_group_for_user(self.course.id, self.test_user, self.user_partition),
group
)
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in LMS')
@patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False})
def test_group_masquerade(self):
"""
Tests that a staff member can masquerade as being in a particular group.
"""
self._verify_masquerade_for_group(self.user_partition.groups[0])
self._verify_masquerade_for_group(self.user_partition.groups[1])
self._verify_masquerade_for_group(None)
......@@ -17,13 +17,13 @@ class RandomUserPartitionScheme(object):
RANDOM = random.Random()
@classmethod
def get_group_for_user(cls, course_id, user, user_partition, assign=True, track_function=None):
def get_group_for_user(cls, course_key, user, user_partition, assign=True, track_function=None):
"""
Returns the group from the specified user position to which the user is assigned.
If the user has not yet been assigned, a group will be randomly chosen for them if assign flag is True.
"""
partition_key = cls._key_for_partition(user_partition)
group_id = course_tag_api.get_course_tag(user, course_id, partition_key)
group_id = course_tag_api.get_course_tag(user, course_key, partition_key)
group = None
if group_id is not None:
......@@ -52,7 +52,7 @@ class RandomUserPartitionScheme(object):
group = cls.RANDOM.choice(user_partition.groups)
# persist the value as a course tag
course_tag_api.set_course_tag(user, course_id, partition_key, group.id)
course_tag_api.set_course_tag(user, course_key, partition_key, group.id)
if track_function:
# emit event for analytics
......
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