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, ...@@ -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 in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. 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: Eventing for Student Notes. TNL-931
LMS: Student Notes: Add course structure view. TNL-762 LMS: Student Notes: Add course structure view. TNL-762
...@@ -26,6 +28,9 @@ LMS: Student Notes: Toggle single note visibility. TNL-660 ...@@ -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 Notes page. TNL-797
LMS: Student Notes: Add possibility to add/edit/remove notes. TNL-655 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 Platform: Add group_access field to all xblocks. TNL-670
......
...@@ -11,25 +11,26 @@ class StaffPage(CoursewarePage): ...@@ -11,25 +11,26 @@ class StaffPage(CoursewarePage):
""" """
url = None 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): def is_browser_on_page(self):
if not super(StaffPage, self).is_browser_on_page(): if not super(StaffPage, self).is_browser_on_page():
return False return False
return self.q(css=self.STAFF_STATUS_CSS).present return self.q(css=self.PREVIEW_MENU_CSS).present
@property @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() self.wait_for_ajax()
def open_staff_debug_info(self): def open_staff_debug_info(self):
......
...@@ -7,9 +7,11 @@ import functools ...@@ -7,9 +7,11 @@ import functools
import requests import requests
import os import os
from path import path from path import path
from bok_choy.javascript import js_defined
from bok_choy.web_app_test import WebAppTest from bok_choy.web_app_test import WebAppTest
from opaque_keys.edx.locator import CourseLocator 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): def skip_if_browser(browser):
...@@ -279,3 +281,12 @@ class YouTubeStubConfig(object): ...@@ -279,3 +281,12 @@ class YouTubeStubConfig(object):
return json.loads(response.content) return json.loads(response.content)
else: else:
return {} 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 -*- # -*- 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.studio.auto_auth import AutoAuthPage
from ...pages.lms.courseware import CoursewarePage from ...pages.lms.courseware import CoursewarePage
from ...pages.lms.staff_view import StaffPage from ...pages.lms.staff_view import StaffPage
from ...pages.lms.course_nav import CourseNavPage
from ...fixtures.course import CourseFixture, XBlockFixtureDesc from ...fixtures.course import CourseFixture, XBlockFixtureDesc
from xmodule.partitions.partitions import Group
from textwrap import dedent from textwrap import dedent
...@@ -24,11 +26,39 @@ class StaffViewTest(UniqueCourseTest): ...@@ -24,11 +26,39 @@ class StaffViewTest(UniqueCourseTest):
self.courseware_page = CoursewarePage(self.browser, self.course_id) self.courseware_page = CoursewarePage(self.browser, self.course_id)
# Install a course with sections/problems, tabs, updates, and handouts # 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['org'], self.course_info['number'],
self.course_info['run'], self.course_info['display_name'] 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_data = dedent("""
<problem markdown="Simple Problem" max_attempts="" weight=""> <problem markdown="Simple Problem" max_attempts="" weight="">
<p>Choose Yes.</p> <p>Choose Yes.</p>
...@@ -40,31 +70,17 @@ class StaffViewTest(UniqueCourseTest): ...@@ -40,31 +70,17 @@ class StaffViewTest(UniqueCourseTest):
</problem> </problem>
""") """)
course_fix.add_children( course_fixture.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children( XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children( XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('problem', 'Test Problem 1', data=problem_data), XBlockFixtureDesc('problem', 'Test Problem 1', data=problem_data),
XBlockFixtureDesc('problem', 'Test Problem 2', 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. Tests for the staff view toggle button.
""" """
...@@ -75,12 +91,12 @@ class StaffViewToggleTest(StaffViewTest): ...@@ -75,12 +91,12 @@ class StaffViewToggleTest(StaffViewTest):
course_page = self._goto_staff_page() course_page = self._goto_staff_page()
self.assertTrue(course_page.has_tab('Instructor')) self.assertTrue(course_page.has_tab('Instructor'))
course_page.toggle_staff_view() course_page.set_staff_view_mode('Student')
self.assertEqual(course_page.staff_status, 'Student view') self.assertEqual(course_page.staff_view_mode, 'Student')
self.assertFalse(course_page.has_tab('Instructor')) self.assertFalse(course_page.has_tab('Instructor'))
class StaffDebugTest(StaffViewTest): class StaffDebugTest(CourseWithoutContentGroupsTest):
""" """
Tests that verify the staff debug info. Tests that verify the staff debug info.
""" """
...@@ -209,3 +225,115 @@ class StaffDebugTest(StaffViewTest): ...@@ -209,3 +225,115 @@ class StaffDebugTest(StaffViewTest):
msg = staff_debug_page.idash_msg[0] msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully deleted student state ' self.assertEqual(u'Successfully deleted student state '
'for user {}'.format(self.USERNAME), msg) '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): ...@@ -698,14 +698,14 @@ class UnitPublishingTest(ContainerBase):
""" """
Verifies no component is visible when viewing as a student. 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) self.assertEqual(0, self.courseware.num_xblock_components)
def _verify_student_view_visible(self, expected_components): def _verify_student_view_visible(self, expected_components):
""" """
Verifies expected components are visible when viewing as a student. 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) self._verify_components_visible(expected_components)
def _verify_components_visible(self, expected_components): def _verify_components_visible(self, expected_components):
......
...@@ -708,7 +708,7 @@ class StaffLockTest(CourseOutlineTest): ...@@ -708,7 +708,7 @@ class StaffLockTest(CourseOutlineTest):
When I enable explicit staff lock on one section When I enable explicit staff lock on one section
And I click the View Live button to switch to staff view And I click the View Live button to switch to staff view
Then I see two sections in the sidebar 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 Then I see one section in the sidebar
""" """
self.course_outline_page.visit() self.course_outline_page.visit()
...@@ -718,7 +718,7 @@ class StaffLockTest(CourseOutlineTest): ...@@ -718,7 +718,7 @@ class StaffLockTest(CourseOutlineTest):
courseware = CoursewarePage(self.browser, self.course_id) courseware = CoursewarePage(self.browser, self.course_id)
courseware.wait_for_page() courseware.wait_for_page()
self.assertEqual(courseware.num_sections, 2) 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) self.assertEqual(courseware.num_sections, 1)
def test_locked_subsections_do_not_appear_in_lms(self): def test_locked_subsections_do_not_appear_in_lms(self):
...@@ -728,7 +728,7 @@ class StaffLockTest(CourseOutlineTest): ...@@ -728,7 +728,7 @@ class StaffLockTest(CourseOutlineTest):
When I enable explicit staff lock on one subsection When I enable explicit staff lock on one subsection
And I click the View Live button to switch to staff view And I click the View Live button to switch to staff view
Then I see two subsections in the sidebar 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 Then I see one section in the sidebar
""" """
self.course_outline_page.visit() self.course_outline_page.visit()
...@@ -737,7 +737,7 @@ class StaffLockTest(CourseOutlineTest): ...@@ -737,7 +737,7 @@ class StaffLockTest(CourseOutlineTest):
courseware = CoursewarePage(self.browser, self.course_id) courseware = CoursewarePage(self.browser, self.course_id)
courseware.wait_for_page() courseware.wait_for_page()
self.assertEqual(courseware.num_subsections, 2) 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) self.assertEqual(courseware.num_subsections, 1)
def test_toggling_staff_lock_on_section_does_not_publish_draft_units(self): def test_toggling_staff_lock_on_section_does_not_publish_draft_units(self):
......
...@@ -9,7 +9,6 @@ from nose.plugins.attrib import attr ...@@ -9,7 +9,6 @@ from nose.plugins.attrib import attr
from selenium.webdriver.support.ui import Select from selenium.webdriver.support.ui import Select
from xmodule.partitions.partitions import Group, UserPartition from xmodule.partitions.partitions import Group, UserPartition
from xmodule.partitions.tests.test_partitions import MockUserPartitionScheme
from bok_choy.promise import Promise, EmptyPromise from bok_choy.promise import Promise, EmptyPromise
from ...fixtures.course import XBlockFixtureDesc from ...fixtures.course import XBlockFixtureDesc
...@@ -21,6 +20,7 @@ from ...pages.studio.settings_group_configurations import GroupConfigurationsPag ...@@ -21,6 +20,7 @@ from ...pages.studio.settings_group_configurations import GroupConfigurationsPag
from ...pages.studio.utils import add_advanced_component from ...pages.studio.utils import add_advanced_component
from ...pages.xblock.utils import wait_for_xblock_initialization from ...pages.xblock.utils import wait_for_xblock_initialization
from ...pages.lms.courseware import CoursewarePage from ...pages.lms.courseware import CoursewarePage
from ..helpers import create_user_partition_json
from base_studio_test import StudioCourseTest from base_studio_test import StudioCourseTest
...@@ -31,15 +31,6 @@ class SplitTestMixin(object): ...@@ -31,15 +31,6 @@ class SplitTestMixin(object):
""" """
Mixin that contains useful methods for split_test module testing. 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): 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. Check that the groups appear and are correctly categorized as to active and inactive.
...@@ -90,13 +81,13 @@ class SplitTest(ContainerBase, SplitTestMixin): ...@@ -90,13 +81,13 @@ class SplitTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, { self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": { "metadata": {
u"user_partitions": [ u"user_partitions": [
self.create_user_partition_json( create_user_partition_json(
0, 0,
'Configuration alpha,beta', 'Configuration alpha,beta',
'first', 'first',
[Group("0", 'alpha'), Group("1", 'beta')] [Group("0", 'alpha'), Group("1", 'beta')]
), ),
self.create_user_partition_json( create_user_partition_json(
1, 1,
'Configuration 0,1,2', 'Configuration 0,1,2',
'second', 'second',
...@@ -144,7 +135,7 @@ class SplitTest(ContainerBase, SplitTestMixin): ...@@ -144,7 +135,7 @@ class SplitTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, { self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": { "metadata": {
u"user_partitions": [ u"user_partitions": [
self.create_user_partition_json( create_user_partition_json(
0, 0,
'Configuration alpha,beta', 'Configuration alpha,beta',
'first', 'first',
...@@ -348,7 +339,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -348,7 +339,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, { self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": { "metadata": {
u"user_partitions": [ 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): ...@@ -420,13 +411,13 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, { self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": { "metadata": {
u"user_partitions": [ u"user_partitions": [
self.create_user_partition_json( create_user_partition_json(
0, 0,
'Name of the Group Configuration', 'Name of the Group Configuration',
'Description of the group configuration.', 'Description of the group configuration.',
[Group("0", 'Group 0'), Group("1", 'Group 1')] [Group("0", 'Group 0'), Group("1", 'Group 1')]
), ),
self.create_user_partition_json( create_user_partition_json(
1, 1,
'Name of second Group Configuration', 'Name of second Group Configuration',
'Second group configuration.', 'Second group configuration.',
...@@ -565,7 +556,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -565,7 +556,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, { self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": { "metadata": {
u"user_partitions": [ u"user_partitions": [
self.create_user_partition_json( create_user_partition_json(
0, 0,
'Name of the Group Configuration', 'Name of the Group Configuration',
'Description of the group configuration.', 'Description of the group configuration.',
...@@ -649,13 +640,13 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -649,13 +640,13 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, { self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": { "metadata": {
u"user_partitions": [ u"user_partitions": [
self.create_user_partition_json( create_user_partition_json(
0, 0,
'Name of the Group Configuration', 'Name of the Group Configuration',
'Description of the group configuration.', 'Description of the group configuration.',
[Group("0", 'Group 0'), Group("1", 'Group 1')] [Group("0", 'Group 0'), Group("1", 'Group 1')]
), ),
self.create_user_partition_json( create_user_partition_json(
1, 1,
'Name of second Group Configuration', 'Name of second Group Configuration',
'Second group configuration.', 'Second group configuration.',
...@@ -745,7 +736,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -745,7 +736,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, { self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": { "metadata": {
u"user_partitions": [ u"user_partitions": [
self.create_user_partition_json( create_user_partition_json(
0, 0,
"Name", "Name",
"Description.", "Description.",
...@@ -782,7 +773,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -782,7 +773,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, { self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": { "metadata": {
u"user_partitions": [ u"user_partitions": [
self.create_user_partition_json( create_user_partition_json(
0, 0,
"Name", "Name",
"Description.", "Description.",
...@@ -830,13 +821,13 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -830,13 +821,13 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, { self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": { "metadata": {
u"user_partitions": [ u"user_partitions": [
self.create_user_partition_json( create_user_partition_json(
0, 0,
'Configuration 1', 'Configuration 1',
'Description of the group configuration.', 'Description of the group configuration.',
[Group("0", 'Group 0'), Group("1", 'Group 1')] [Group("0", 'Group 0'), Group("1", 'Group 1')]
), ),
self.create_user_partition_json( create_user_partition_json(
1, 1,
'Configuration 2', 'Configuration 2',
'Second group configuration.', 'Second group configuration.',
...@@ -873,7 +864,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -873,7 +864,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, { self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": { "metadata": {
u"user_partitions": [ u"user_partitions": [
self.create_user_partition_json( create_user_partition_json(
0, 0,
"Name", "Name",
"Description.", "Description.",
...@@ -914,13 +905,13 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -914,13 +905,13 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, { self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": { "metadata": {
u"user_partitions": [ u"user_partitions": [
self.create_user_partition_json( create_user_partition_json(
0, 0,
"Name", "Name",
"Description.", "Description.",
[Group("0", "Group A"), Group("1", "Group B")] [Group("0", "Group A"), Group("1", "Group B")]
), ),
self.create_user_partition_json( create_user_partition_json(
1, 1,
'Name of second Group Configuration', 'Name of second Group Configuration',
'Second group configuration.', 'Second group configuration.',
......
...@@ -18,7 +18,7 @@ from xblock.core import XBlock ...@@ -18,7 +18,7 @@ from xblock.core import XBlock
from xmodule.partitions.partitions import NoSuchUserPartitionError, NoSuchUserPartitionGroupError from xmodule.partitions.partitions import NoSuchUserPartitionError, NoSuchUserPartitionGroupError
from external_auth.models import ExternalAuthMap 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 django.utils.timezone import UTC
from student import auth from student import auth
from student.roles import ( from student.roles import (
...@@ -396,7 +396,7 @@ def _has_access_descriptor(user, action, descriptor, course_key=None): ...@@ -396,7 +396,7 @@ def _has_access_descriptor(user, action, descriptor, course_key=None):
return _has_staff_access_to_descriptor(user, descriptor, course_key) return _has_staff_access_to_descriptor(user, descriptor, course_key)
# If start dates are off, can always load # 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") debug("Allow: DISABLE_START_DATES")
return True return True
...@@ -579,7 +579,7 @@ def _has_access_to_course(user, access_level, course_key): ...@@ -579,7 +579,7 @@ def _has_access_to_course(user, access_level, course_key):
debug("Deny: no user or anon user") debug("Deny: no user or anon user")
return False return False
if is_masquerading_as_student(user): if is_masquerading_as_student(user, course_key):
return False return False
if GlobalStaff().has_user(user): if GlobalStaff().has_user(user):
...@@ -636,8 +636,9 @@ def get_user_role(user, course_key): ...@@ -636,8 +636,9 @@ def get_user_role(user, course_key):
Return corresponding string if user has staff, instructor or student Return corresponding string if user has staff, instructor or student
course role in LMS. course role in LMS.
""" """
if is_masquerading_as_student(user): role = get_masquerade_role(user, course_key)
return 'student' if role:
return role
elif has_access(user, 'instructor', course_key): elif has_access(user, 'instructor', course_key):
return 'instructor' return 'instructor'
elif has_access(user, 'staff', course_key): elif has_access(user, 'staff', course_key):
......
...@@ -64,7 +64,7 @@ Feature: LMS.LTI component ...@@ -64,7 +64,7 @@ Feature: LMS.LTI component
| False | True | | False | True |
And I view the LTI and it is rendered in iframe And I view the LTI and it is rendered in iframe
And I see in iframe that LTI role is Instructor 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 Then I see in iframe that LTI role is Student
#8 #8
...@@ -162,7 +162,7 @@ Feature: LMS.LTI component ...@@ -162,7 +162,7 @@ Feature: LMS.LTI component
| True | | True |
Then I view the permission alert Then I view the permission alert
Then I reject the permission alert and do not view the LTI Then I reject the permission alert and do not view the LTI
#16 #16
Scenario: LTI component requests permission for username and displays LTI when accepted Scenario: LTI component requests permission for username and displays LTI when accepted
Given the course has correct LTI credentials with registered Instructor Given the course has correct LTI credentials with registered Instructor
...@@ -180,7 +180,7 @@ Feature: LMS.LTI component ...@@ -180,7 +180,7 @@ Feature: LMS.LTI component
| True | | True |
Then I view the permission alert Then I view the permission alert
Then I reject the permission alert and do not view the LTI Then I reject the permission alert and do not view the LTI
#18 #18
Scenario: LTI component requests permission for email and displays LTI when accepted Scenario: LTI component requests permission for email and displays LTI when accepted
Given the course has correct LTI credentials with registered Instructor Given the course has correct LTI credentials with registered Instructor
...@@ -198,7 +198,7 @@ Feature: LMS.LTI component ...@@ -198,7 +198,7 @@ Feature: LMS.LTI component
| True | True | | True | True |
Then I view the permission alert Then I view the permission alert
Then I reject the permission alert and do not view the LTI Then I reject the permission alert and do not view the LTI
#20 #20
Scenario: LTI component requests permission for email and username and displays LTI when accepted Scenario: LTI component requests permission for email and username and displays LTI when accepted
Given the course has correct LTI credentials with registered Instructor Given the course has correct LTI credentials with registered Instructor
......
...@@ -387,9 +387,9 @@ def check_role(_step, role): ...@@ -387,9 +387,9 @@ def check_role(_step, role):
@step('I switch to (.*)$') @step('I switch to (.*)$')
def switch_view(_step, view): def switch_view(_step, view):
staff_status = world.css_find('#staffstatus').first staff_status = world.css_find('#action-preview-select').first.value
if staff_status.text != view: if staff_status != view:
world.css_click('#staffstatus') world.browser.select("select", view)
world.wait_for_ajax_complete() world.wait_for_ajax_complete()
......
''' '''
---------------------------------------- Masequerade ---------------------------------------- ---------------------------------------- Masquerade ----------------------------------------
Allow course staff to see a student or staff view of courseware. 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. Which kind of view has been selected is stored in the session state.
''' '''
import json
import logging import logging
from django.http import HttpResponse
from django.conf import settings 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__) from opaque_keys.edx.keys import CourseKey
MASQ_KEY = 'masquerade_identity'
def handle_ajax(request, marg): log = logging.getLogger(__name__)
'''
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)
Uses request.session[MASQ_KEY] to store status of masquerading. # The key used to store a user's course-level masquerade information in the Django session.
Adds masquerade status to request.user, if masquerading active. # The value is a dict from course keys to CourseMasquerade objects.
Return string version of status of view (either 'staff' or 'student') 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: if request.user is None:
return None return None
...@@ -46,20 +73,46 @@ def setup_masquerade(request, staff_access=False): ...@@ -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 if not staff_access: # can masquerade only if user has staff access to course
return None return None
usertype = request.session.get(MASQ_KEY, '') masquerade_settings = request.session.get(MASQUERADE_SETTINGS_KEY, {})
if usertype is None or not usertype:
request.session[MASQ_KEY] = 'staff' # Store the masquerade settings on the user so it can be accessed without the request
usertype = 'staff' 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): def get_masquerading_group_info(user, course_key):
''' """
Return True if user is masquerading as a student, False otherwise 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
masq = getattr(user, 'masquerade_as_student', False) belongs to. If the user is not masquerading as a group, then None is returned.
return masq is True """
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): ...@@ -213,7 +213,7 @@ def get_xqueue_callback_url_prefix(request):
return settings.XQUEUE_INTERFACE.get('callback_url', prefix) 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, position=None, wrap_xmodule_display=True, grade_bucket_type=None,
static_asset_path=''): static_asset_path=''):
""" """
...@@ -221,10 +221,6 @@ def get_module_for_descriptor(user, request, descriptor, field_data_cache, cours ...@@ -221,10 +221,6 @@ def get_module_for_descriptor(user, request, descriptor, field_data_cache, cours
See get_module() docstring for further details. 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) track_function = make_track_function(request)
xqueue_callback_url_prefix = get_xqueue_callback_url_prefix(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 ...@@ -234,7 +230,7 @@ def get_module_for_descriptor(user, request, descriptor, field_data_cache, cours
user=user, user=user,
descriptor=descriptor, descriptor=descriptor,
field_data_cache=field_data_cache, field_data_cache=field_data_cache,
course_id=course_id, course_id=course_key,
track_function=track_function, track_function=track_function,
xqueue_callback_url_prefix=xqueue_callback_url_prefix, xqueue_callback_url_prefix=xqueue_callback_url_prefix,
position=position, position=position,
...@@ -782,6 +778,7 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user): ...@@ -782,6 +778,7 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
user, user,
descriptor 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') instance = get_module(user, request, usage_key, field_data_cache, grade_bucket_type='ajax')
if instance is None: if instance is None:
# Either permissions just changed, or someone is trying to be clever # Either permissions just changed, or someone is trying to be clever
......
...@@ -6,6 +6,7 @@ from mock import Mock, patch ...@@ -6,6 +6,7 @@ from mock import Mock, patch
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
import courseware.access as access import courseware.access as access
from courseware.masquerade import CourseMasquerade
from courseware.tests.factories import UserFactory, StaffFactory, InstructorFactory from courseware.tests.factories import UserFactory, StaffFactory, InstructorFactory
from student.tests.factories import AnonymousUserFactory, CourseEnrollmentAllowedFactory from student.tests.factories import AnonymousUserFactory, CourseEnrollmentAllowedFactory
from xmodule.course_module import ( from xmodule.course_module import (
...@@ -255,6 +256,14 @@ class UserRoleTestCase(TestCase): ...@@ -255,6 +256,14 @@ class UserRoleTestCase(TestCase):
self.course_staff = StaffFactory(course_key=self.course_key) self.course_staff = StaffFactory(course_key=self.course_key)
self.course_instructor = InstructorFactory(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): def test_user_role_staff(self):
"""Ensure that user role is student for staff masqueraded as student.""" """Ensure that user role is student for staff masqueraded as student."""
self.assertEqual( self.assertEqual(
...@@ -262,7 +271,7 @@ class UserRoleTestCase(TestCase): ...@@ -262,7 +271,7 @@ class UserRoleTestCase(TestCase):
access.get_user_role(self.course_staff, self.course_key) access.get_user_role(self.course_staff, self.course_key)
) )
# Masquerade staff # Masquerade staff
self.course_staff.masquerade_as_student = True self._install_masquerade(self.course_staff)
self.assertEqual( self.assertEqual(
'student', 'student',
access.get_user_role(self.course_staff, self.course_key) access.get_user_role(self.course_staff, self.course_key)
...@@ -275,7 +284,7 @@ class UserRoleTestCase(TestCase): ...@@ -275,7 +284,7 @@ class UserRoleTestCase(TestCase):
access.get_user_role(self.course_instructor, self.course_key) access.get_user_role(self.course_instructor, self.course_key)
) )
# Masquerade instructor # Masquerade instructor
self.course_instructor.masquerade_as_student = True self._install_masquerade(self.course_instructor)
self.assertEqual( self.assertEqual(
'student', 'student',
access.get_user_role(self.course_instructor, self.course_key) access.get_user_role(self.course_instructor, self.course_key)
......
...@@ -354,7 +354,7 @@ def _index_bulk_op(request, course_key, chapter, section, position): ...@@ -354,7 +354,7 @@ def _index_bulk_op(request, course_key, chapter, section, position):
if survey.utils.must_answer_survey(course, user): if survey.utils.must_answer_survey(course, user):
return redirect(reverse('course_survey', args=[unicode(course.id)])) return redirect(reverse('course_survey', args=[unicode(course.id)]))
masq = setup_masquerade(request, staff_access) masquerade = setup_masquerade(request, course_key, staff_access)
try: try:
field_data_cache = FieldDataCache.cache_for_descriptor_descendents( field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
...@@ -377,7 +377,7 @@ def _index_bulk_op(request, course_key, chapter, section, position): ...@@ -377,7 +377,7 @@ def _index_bulk_op(request, course_key, chapter, section, position):
'fragment': Fragment(), 'fragment': Fragment(),
'staff_access': staff_access, 'staff_access': staff_access,
'studio_url': studio_url, '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'), '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), 'reverifications': fetch_reverify_banner_info(request, course_key),
} }
...@@ -419,8 +419,8 @@ def _index_bulk_op(request, course_key, chapter, section, position): ...@@ -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) chapter_module = course_module.get_child_by(lambda m: m.location.name == chapter)
if chapter_module is None: if chapter_module is None:
# User may be trying to access a chapter that isn't live yet # 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 if masquerade and masquerade.role == 'student': # if staff is masquerading as student be kinder, don't 404
log.debug('staff masq as student: no chapter %s' % chapter) log.debug('staff masquerading as student: no chapter %s', chapter)
return redirect(reverse('courseware', args=[course.id.to_deprecated_string()])) return redirect(reverse('courseware', args=[course.id.to_deprecated_string()]))
raise Http404 raise Http404
...@@ -429,8 +429,8 @@ def _index_bulk_op(request, course_key, chapter, section, position): ...@@ -429,8 +429,8 @@ def _index_bulk_op(request, course_key, chapter, section, position):
if section_descriptor is None: if section_descriptor is None:
# Specifically asked-for section doesn't exist # Specifically asked-for section doesn't exist
if masq == 'student': # if staff is masquerading as student be kinder, don't 404 if masquerade and masquerade.role == 'student': # don't 404 if staff is masquerading as student
log.debug('staff masq as student: no section %s' % section) log.debug('staff masquerading as student: no section %s', section)
return redirect(reverse('courseware', args=[course.id.to_deprecated_string()])) return redirect(reverse('courseware', args=[course.id.to_deprecated_string()]))
raise Http404 raise Http404
...@@ -625,7 +625,7 @@ def course_info(request, course_id): ...@@ -625,7 +625,7 @@ def course_info(request, course_id):
return redirect(reverse('course_survey', args=[unicode(course.id)])) return redirect(reverse('course_survey', args=[unicode(course.id)]))
staff_access = has_access(request.user, 'staff', course) 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) reverifications = fetch_reverify_banner_info(request, course_key)
studio_url = get_studio_url(course, 'course_info') studio_url = get_studio_url(course, 'course_info')
...@@ -643,7 +643,7 @@ def course_info(request, course_id): ...@@ -643,7 +643,7 @@ def course_info(request, course_id):
'cache': None, 'cache': None,
'course': course, 'course': course,
'staff_access': staff_access, 'staff_access': staff_access,
'masquerade': masq, 'masquerade': masquerade,
'studio_url': studio_url, 'studio_url': studio_url,
'reverifications': reverifications, 'reverifications': reverifications,
'show_enroll_banner': show_enroll_banner, 'show_enroll_banner': show_enroll_banner,
......
...@@ -97,44 +97,6 @@ class XBlockValidationTest(LmsXBlockMixinTestCase): ...@@ -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): class OpenAssessmentBlockMixinTestCase(ModuleStoreTestCase):
""" """
Tests for OpenAssessmentBlock mixin. Tests for OpenAssessmentBlock mixin.
......
...@@ -14,13 +14,19 @@ def url_class(is_active): ...@@ -14,13 +14,19 @@ def url_class(is_active):
%> %>
<%! from xmodule.tabs import CourseTabList %> <%! from xmodule.tabs import CourseTabList %>
<%! from courseware.access import has_access %> <%! 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.conf import settings %>
<%! from django.core.urlresolvers import reverse %> <%! from django.core.urlresolvers import reverse %>
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<%! from courseware.views import notification_image_for_tab %> <%! from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition %>
<%! from student.models import CourseEnrollment%> <%! from student.models import CourseEnrollment %>
<% <%
user_is_enrolled = user.is_authenticated() and CourseEnrollment.is_enrolled(user, course.id) 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: % if show_preview_menu:
...@@ -72,51 +78,45 @@ def url_class(is_active): ...@@ -72,51 +78,45 @@ def url_class(is_active):
</a> </a>
</li> </li>
% endfor % 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> </ol>
</div> </div>
</nav> </nav>
%endif %endif
% if masquerade is not UNDEFINED: % if show_preview_menu:
% if staff_access and masquerade is not None:
<script type="text/javascript"> <script type="text/javascript">
masq = (function(){ (function() {
var el = $('#staffstatus'); var element = $('.action-preview-select');
% if disable_student_access: % if disable_student_access:
el.attr("disabled", true); element.attr("disabled", true);
el.attr("title", "${_("Course is not yet visible to students.")}"); element.attr("title", "${_("Course is not yet visible to students.")}");
% endif % 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(){ element.change(function() {
if (el.attr("disabled")) { 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.")}"); return alert("${_("You cannot view the course as a student or beta tester before the course release date.")}");
} }
$.ajax({ url: '/masquerade/toggle', selectedOption = element.find('option:selected');
type: 'GET', data = {
success: function(result){ role: selectedOption.val() === 'staff' ? 'staff' : 'student',
setstat(result.status); user_partition_id: ${cohorted_user_partition.id if cohorted_user_partition else 'null'},
location.reload(); group_id: selectedOption.data('group-id')
}, };
error: function() { $.ajax({
alert('Error: cannot connect to server'); url: '/courses/${course.id}/masquerade',
} type: 'POST',
}); dataType: 'json',
contentType: 'application/json',
data: JSON.stringify(data),
success: function(result) {
location.reload();
},
error: function() {
alert('Error: cannot connect to server');
}
});
}); });
}() ); }());
</script> </script>
% endif
% endif % endif
...@@ -47,12 +47,10 @@ $(document).ready(function(){ ...@@ -47,12 +47,10 @@ $(document).ready(function(){
<div class="info-wrapper"> <div class="info-wrapper">
% if user.is_authenticated(): % if user.is_authenticated():
<section class="updates"> <section class="updates">
% if staff_access and masquerade is not UNDEFINED and studio_url is not None: % if studio_url is not None and masquerade and masquerade.role == 'staff':
% if masquerade == 'staff': <div class="wrap-instructor-info studio-view">
<div class="wrap-instructor-info studio-view"> <a class="instructor-info-action" href="${studio_url}">${_("View Updates in Studio")}</a>
<a class="instructor-info-action" href="${studio_url}">${_("View Updates in Studio")}</a> </div>
</div>
% endif
% endif % endif
<h1>${_("Course Updates &amp; News")}</h1> <h1>${_("Course Updates &amp; News")}</h1>
......
...@@ -389,7 +389,8 @@ if settings.COURSEWARE_ENABLED: ...@@ -389,7 +389,8 @@ if settings.COURSEWARE_ENABLED:
# allow course staff to change to student view of courseware # allow course staff to change to student view of courseware
if settings.FEATURES.get('ENABLE_MASQUERADE'): if settings.FEATURES.get('ENABLE_MASQUERADE'):
urlpatterns += ( 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 # discussion forums live within courseware, so courseware must be enabled first
......
...@@ -16,6 +16,7 @@ from eventtracking import tracker ...@@ -16,6 +16,7 @@ from eventtracking import tracker
from student.models import get_user_by_username_or_email from student.models import get_user_by_username_or_email
from .models import CourseUserGroup, CourseUserGroupPartitionGroup from .models import CourseUserGroup, CourseUserGroupPartitionGroup
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -381,15 +382,15 @@ def add_user_to_cohort(cohort, username_or_email): ...@@ -381,15 +382,15 @@ def add_user_to_cohort(cohort, username_or_email):
return (user, previous_cohort_name) 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). 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. tuple will be None.
""" """
res = CourseUserGroupPartitionGroup.objects.filter(course_user_group=cohort) res = CourseUserGroupPartitionGroup.objects.filter(course_user_group=cohort)
if len(res): if len(res):
return res[0].partition_id, res[0].group_id return res[0].group_id, res[0].partition_id
return None, None return None, None
...@@ -3,9 +3,12 @@ Provides a UserPartition driver for cohorts. ...@@ -3,9 +3,12 @@ Provides a UserPartition driver for cohorts.
""" """
import logging import logging
from courseware import courses
from courseware.masquerade import get_masquerading_group_info
from xmodule.partitions.partitions import NoSuchUserPartitionGroupError 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__) log = logging.getLogger(__name__)
...@@ -17,8 +20,9 @@ class CohortPartitionScheme(object): ...@@ -17,8 +20,9 @@ class CohortPartitionScheme(object):
Groups. Groups.
""" """
# pylint: disable=unused-argument
@classmethod @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 Returns the Group from the specified user partition to which the user
is assigned, via their cohort membership and any mappings from cohorts is assigned, via their cohort membership and any mappings from cohorts
...@@ -32,12 +36,22 @@ class CohortPartitionScheme(object): ...@@ -32,12 +36,22 @@ class CohortPartitionScheme(object):
If the user has no cohort mapping, or there is no (valid) cohort -> If the user has no cohort mapping, or there is no (valid) cohort ->
partition group mapping found, the function returns None. 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: if cohort is None:
# student doesn't have a cohort # student doesn't have a cohort
return None 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: if partition_id is None:
# cohort isn't mapped to any partition group. # cohort isn't mapped to any partition group.
return None return None
...@@ -75,3 +89,17 @@ class CohortPartitionScheme(object): ...@@ -75,3 +89,17 @@ class CohortPartitionScheme(object):
) )
# fail silently # fail silently
return None 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): ...@@ -623,13 +623,13 @@ class TestCohortsAndPartitionGroups(TestCase):
link.save() link.save()
return link 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 # api should return nothing for an unmapped cohort
self.assertEqual( self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort), cohorts.get_group_info_for_cohort(self.first_cohort),
(None, None), (None, None),
) )
# create a link for the cohort in the db # create a link for the cohort in the db
...@@ -640,14 +640,14 @@ class TestCohortsAndPartitionGroups(TestCase): ...@@ -640,14 +640,14 @@ class TestCohortsAndPartitionGroups(TestCase):
) )
# api should return the specified partition and group # api should return the specified partition and group
self.assertEqual( self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort), cohorts.get_group_info_for_cohort(self.first_cohort),
(self.partition_id, self.group1_id) (self.group1_id, self.partition_id)
) )
# delete the link in the db # delete the link in the db
link.delete() link.delete()
# api should return nothing again # api should return nothing again
self.assertEqual( self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort), cohorts.get_group_info_for_cohort(self.first_cohort),
(None, None), (None, None),
) )
...@@ -666,12 +666,12 @@ class TestCohortsAndPartitionGroups(TestCase): ...@@ -666,12 +666,12 @@ class TestCohortsAndPartitionGroups(TestCase):
self.group1_id, self.group1_id,
) )
self.assertEqual( self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort), cohorts.get_group_info_for_cohort(self.first_cohort),
(self.partition_id, self.group1_id), (self.group1_id, self.partition_id),
) )
self.assertEqual( self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.second_cohort), cohorts.get_group_info_for_cohort(self.second_cohort),
cohorts.get_partition_group_id_for_cohort(self.first_cohort), cohorts.get_group_info_for_cohort(self.first_cohort),
) )
def test_multiple_partition_groups(self): def test_multiple_partition_groups(self):
...@@ -701,14 +701,14 @@ class TestCohortsAndPartitionGroups(TestCase): ...@@ -701,14 +701,14 @@ class TestCohortsAndPartitionGroups(TestCase):
self.group1_id self.group1_id
) )
self.assertEqual( self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort), cohorts.get_group_info_for_cohort(self.first_cohort),
(self.partition_id, self.group1_id) (self.group1_id, self.partition_id)
) )
# delete the link # delete the link
self.first_cohort.delete() self.first_cohort.delete()
# api should return nothing at that point # api should return nothing at that point
self.assertEqual( self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort), cohorts.get_group_info_for_cohort(self.first_cohort),
(None, None), (None, None),
) )
# link should no longer exist because of delete cascade # link should no longer exist because of delete cascade
......
...@@ -3,18 +3,23 @@ Test the partitions and partitions service ...@@ -3,18 +3,23 @@ Test the partitions and partitions service
""" """
import json
from django.conf import settings from django.conf import settings
import django.test import django.test
from django.test.utils import override_settings from django.test.utils import override_settings
from mock import patch 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 student.tests.factories import UserFactory
from xmodule.partitions.partitions import Group, UserPartition, UserPartitionError from xmodule.partitions.partitions import Group, UserPartition, UserPartitionError
from xmodule.modulestore.django import modulestore, clear_existing_modulestores from xmodule.modulestore.django import modulestore, clear_existing_modulestores
from xmodule.modulestore.tests.django_utils import mixed_store_config from xmodule.modulestore.tests.django_utils import mixed_store_config
from opaque_keys.edx.locations import SlashSeparatedCourseKey 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 ..models import CourseUserGroupPartitionGroup
from ..cohorts import add_user_to_cohort, get_course_cohorts from ..cohorts import add_user_to_cohort, get_course_cohorts
from .helpers import CohortFactory, config_course_cohorts from .helpers import CohortFactory, config_course_cohorts
...@@ -280,3 +285,111 @@ class TestExtension(django.test.TestCase): ...@@ -280,3 +285,111 @@ class TestExtension(django.test.TestCase):
self.assertEqual(UserPartition.get_scheme('cohort'), CohortPartitionScheme) self.assertEqual(UserPartition.get_scheme('cohort'), CohortPartitionScheme)
with self.assertRaisesRegexp(UserPartitionError, 'Unrecognized scheme'): with self.assertRaisesRegexp(UserPartitionError, 'Unrecognized scheme'):
UserPartition.get_scheme('other') 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): ...@@ -17,13 +17,13 @@ class RandomUserPartitionScheme(object):
RANDOM = random.Random() RANDOM = random.Random()
@classmethod @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. 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. 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) 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 group = None
if group_id is not None: if group_id is not None:
...@@ -52,7 +52,7 @@ class RandomUserPartitionScheme(object): ...@@ -52,7 +52,7 @@ class RandomUserPartitionScheme(object):
group = cls.RANDOM.choice(user_partition.groups) group = cls.RANDOM.choice(user_partition.groups)
# persist the value as a course tag # 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: if track_function:
# emit event for analytics # 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