* feat!: Remove all trivial mentions of PREVIEW_LMS_BASE There are a few more mentions but these are all the ones that don't need major further followup. BREAKING CHANGE: The learning MFE now supports preview functionality natively and it is no longer necessary to use a different domain on the LMS to render a preview of course content. See https://github.com/openedx/frontend-app-learning/issues/1455 for more details. * feat: Drop the `in_preview_mode` function. Since we're no longer using a separate domain, that check always returned false. Remove it and update any places/tests where it is used. * feat: Drop courseware_mfe_is_active function. With the removal of the preview check this function is also a no-op now so drop calls to it and update the places where it is called to not change other behavior. * feat!: Drop redirect to preview from the legacy courseware index. The CoursewareIndex view is going to be removed eventually but for now we're focusing on removing the PREVIEW_LMS_BASE setting. With this change, if someone tries to load the legacy courseware URL from the preview domain it will no longer redirect them to the MFE preview. This is not a problem that will occur for users coming from existing studio links because those links have already been updated to go directly to the new urls. The only way this path could execute is if someone goes directly to the old Preview URL that they saved off platform somewhere. eg. If they bookmarked it for some reason. BREAKING CHANGE: Saved links (including bookmarks) to the legacy preview URLs will no longer redirect to the MFE preview URLs. * test: Drop the set_preview_mode test helper. This test helper was setting the preview mode for tests by changing the hostname that was set while tests were running. This was mostly not being used to test preview but to run a bunch of legacy courseware tests while defaulting to the new learning MFE for the courseware. This commit updates various tests in the `courseware` app to not rely on the fact that we're in preview to test legacy courseware behavior and instead directly patches either the `_redirect_to_learning_mfe` function or uses the `_get_legacy_courseware_url` or both to be able to have the tests continue to test the legacy coursewary. This will hopefully make the tests more accuarte even though hopefully we'll just be removing many of them soon as a part of the legacy courseware cleanup. We're just doing the preview removal separately to reduce the number of things that are changing at once. * test: Drop the `_get_urls_function` With the other recent cleanup, this function is no longer being referenced by anything so we can just drop it. * test: Test student access to unpublihsed content. Ensure that students can't get access to unpublished content.
968 lines
42 KiB
Python
968 lines
42 KiB
Python
"""
|
|
Test the access control framework
|
|
"""
|
|
|
|
|
|
import datetime
|
|
import itertools
|
|
|
|
from unittest.mock import Mock, patch
|
|
import pytest
|
|
import ddt
|
|
import pytz
|
|
from ccx_keys.locator import CCXLocator
|
|
from django.conf import settings
|
|
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
|
from django.test import TestCase
|
|
from django.test.client import RequestFactory
|
|
from django.test.utils import override_settings
|
|
from django.urls import reverse
|
|
from milestones.tests.utils import MilestonesTestCaseMixin
|
|
from opaque_keys.edx.locator import CourseLocator
|
|
|
|
import lms.djangoapps.courseware.access as access
|
|
import lms.djangoapps.courseware.access_response as access_response
|
|
from lms.djangoapps.courseware.masquerade import CourseMasquerade
|
|
from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase, masquerade_as_group_member
|
|
from lms.djangoapps.courseware.toggles import course_is_invitation_only
|
|
from lms.djangoapps.ccx.models import CustomCourseForEdX
|
|
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
|
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
|
from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES
|
|
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
|
|
from common.djangoapps.student.models import CourseEnrollment
|
|
from common.djangoapps.student.roles import CourseCcxCoachRole, CourseStaffRole
|
|
from common.djangoapps.student.tests.factories import (
|
|
AdminFactory,
|
|
AnonymousUserFactory,
|
|
CourseEnrollmentAllowedFactory,
|
|
CourseEnrollmentFactory
|
|
)
|
|
from common.djangoapps.student.tests.factories import BetaTesterFactory
|
|
from common.djangoapps.student.tests.factories import GlobalStaffFactory
|
|
from common.djangoapps.student.tests.factories import InstructorFactory
|
|
from common.djangoapps.student.tests.factories import StaffFactory
|
|
from common.djangoapps.student.tests.factories import UserFactory
|
|
from common.djangoapps.util.milestones_helpers import fulfill_course_milestone, set_prerequisite_courses
|
|
from xmodule.course_block import ( # lint-amnesty, pylint: disable=wrong-import-order
|
|
CATALOG_VISIBILITY_ABOUT,
|
|
CATALOG_VISIBILITY_CATALOG_AND_ABOUT,
|
|
CATALOG_VISIBILITY_NONE
|
|
)
|
|
|
|
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
|
|
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
|
from xmodule.modulestore.exceptions import ItemNotFoundError
|
|
from xmodule.modulestore.tests.django_utils import ( # lint-amnesty, pylint: disable=wrong-import-order
|
|
ModuleStoreTestCase,
|
|
SharedModuleStoreTestCase
|
|
)
|
|
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order
|
|
from xmodule.partitions.partitions import MINIMUM_UNUSED_PARTITION_ID, Group, UserPartition # lint-amnesty, pylint: disable=wrong-import-order
|
|
from openedx.features.enterprise_support.api import add_enterprise_customer_to_session
|
|
from enterprise.api.v1.serializers import EnterpriseCustomerSerializer
|
|
from openedx.features.enterprise_support.tests.factories import (
|
|
EnterpriseCourseEnrollmentFactory,
|
|
EnterpriseCustomerUserFactory,
|
|
EnterpriseCustomerFactory
|
|
)
|
|
from crum import set_current_request
|
|
|
|
QUERY_COUNT_TABLE_IGNORELIST = WAFFLE_TABLES
|
|
|
|
# pylint: disable=protected-access
|
|
|
|
|
|
class CoachAccessTestCaseCCX(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
|
|
"""
|
|
Test if user is coach on ccx.
|
|
"""
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
"""
|
|
Set up course for tests
|
|
"""
|
|
super().setUpClass()
|
|
cls.course = CourseFactory.create()
|
|
|
|
def setUp(self):
|
|
"""
|
|
Set up tests
|
|
"""
|
|
super().setUp()
|
|
|
|
# Create ccx coach account
|
|
self.coach = AdminFactory.create(password=self.TEST_PASSWORD)
|
|
self.client.login(username=self.coach.username, password=self.TEST_PASSWORD)
|
|
|
|
# assign role to coach
|
|
role = CourseCcxCoachRole(self.course.id)
|
|
role.add_users(self.coach)
|
|
self.request_factory = RequestFactory()
|
|
|
|
def make_ccx(self):
|
|
"""
|
|
create ccx
|
|
"""
|
|
ccx = CustomCourseForEdX(
|
|
course_id=self.course.id,
|
|
coach=self.coach,
|
|
display_name="Test CCX"
|
|
)
|
|
ccx.save()
|
|
|
|
ccx_locator = CCXLocator.from_course_locator(self.course.id, str(ccx.id))
|
|
role = CourseCcxCoachRole(ccx_locator)
|
|
role.add_users(self.coach)
|
|
CourseEnrollment.enroll(self.coach, ccx_locator)
|
|
return ccx_locator
|
|
|
|
def test_has_ccx_coach_role(self):
|
|
"""
|
|
Assert that user has coach access on ccx.
|
|
"""
|
|
ccx_locator = self.make_ccx()
|
|
|
|
# user have access as coach on ccx
|
|
assert access.has_ccx_coach_role(self.coach, ccx_locator)
|
|
|
|
# user dont have access as coach on ccx
|
|
self.setup_user()
|
|
assert not access.has_ccx_coach_role(self.user, ccx_locator)
|
|
|
|
def test_ccx_coach_has_staff_role(self):
|
|
"""
|
|
Assert that user has staff access on ccx.
|
|
"""
|
|
ccx_locator = self.make_ccx()
|
|
|
|
# coach user has access as staff on ccx
|
|
assert access.has_access(self.coach, 'staff', ccx_locator)
|
|
|
|
# basic user doesn't have staff access on ccx..
|
|
self.setup_user()
|
|
assert not access.has_access(self.user, 'staff', ccx_locator)
|
|
|
|
# until we give her a staff role.
|
|
CourseStaffRole(ccx_locator).add_users(self.user)
|
|
assert access.has_access(self.user, 'staff', ccx_locator)
|
|
|
|
def test_access_student_progress_ccx(self):
|
|
"""
|
|
Assert that only a coach can see progress of student.
|
|
"""
|
|
ccx_locator = self.make_ccx()
|
|
student = UserFactory()
|
|
|
|
# Enroll user
|
|
CourseEnrollment.enroll(student, ccx_locator)
|
|
|
|
# Test for access of a coach
|
|
resp = self.client.get(reverse('student_progress', args=[str(ccx_locator), student.id]))
|
|
assert resp.status_code == 200
|
|
|
|
# Assert access of a student
|
|
self.client.login(username=student.username, password=self.TEST_PASSWORD)
|
|
resp = self.client.get(reverse('student_progress', args=[str(ccx_locator), self.coach.id]))
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@ddt.ddt
|
|
class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTestCaseMixin):
|
|
"""
|
|
Tests for the various access controls on the student dashboard
|
|
"""
|
|
TOMORROW = 'tomorrow'
|
|
YESTERDAY = 'yesterday'
|
|
DATES = {
|
|
TOMORROW: datetime.datetime.now(pytz.utc) + datetime.timedelta(days=1),
|
|
YESTERDAY: datetime.datetime.now(pytz.utc) - datetime.timedelta(days=1),
|
|
None: None,
|
|
}
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.course = CourseFactory.create(org='edX', course='toy', run='test_run')
|
|
self.anonymous_user = AnonymousUserFactory()
|
|
self.beta_user = BetaTesterFactory(course_key=self.course.id)
|
|
self.student = UserFactory()
|
|
self.global_staff = UserFactory(is_staff=True)
|
|
self.course_staff = StaffFactory(course_key=self.course.id)
|
|
self.course_instructor = InstructorFactory(course_key=self.course.id)
|
|
self.staff = GlobalStaffFactory()
|
|
|
|
def verify_access(self, mock_unit, student_should_have_access, expected_error_type=None):
|
|
""" Verify the expected result from _has_access_to_block """
|
|
response = access._has_access_to_block(self.anonymous_user, 'load', mock_unit, course_key=self.course.id)
|
|
assert student_should_have_access == bool(response)
|
|
|
|
if expected_error_type is not None:
|
|
assert isinstance(response, expected_error_type)
|
|
assert response.to_json()['error_code'] is not None
|
|
|
|
assert access._has_access_to_block(self.course_staff, 'load', mock_unit, course_key=self.course.id)
|
|
|
|
def test_has_staff_access_to_preview_mode(self):
|
|
"""
|
|
Test that preview mode is only accessible by staff users.
|
|
"""
|
|
course_key = self.course.id
|
|
CourseEnrollmentFactory(user=self.student, course_id=self.course.id)
|
|
|
|
for user in [self.global_staff, self.course_staff, self.course_instructor]:
|
|
assert access.has_staff_access_to_preview_mode(user, course_key)
|
|
|
|
assert not access.has_staff_access_to_preview_mode(self.student, course_key)
|
|
|
|
# we don't want to restrict a staff user, masquerading as student,
|
|
# to access preview mode.
|
|
|
|
# Note that self.student now have access to preview mode,
|
|
# `is_masquerading_as_student == True` means user is staff and is
|
|
# masquerading as a student.
|
|
with patch('lms.djangoapps.courseware.access.is_masquerading_as_student') as mock_masquerade:
|
|
mock_masquerade.return_value = True
|
|
for user in [self.global_staff, self.course_staff, self.course_instructor, self.student]:
|
|
assert access.has_staff_access_to_preview_mode(user, course_key)
|
|
|
|
def test_administrative_accesses_to_course_for_user(self):
|
|
"""
|
|
Test types of admin accesses to a course
|
|
"""
|
|
course_key = self.course.id
|
|
|
|
# `administrative_accesses_to_course_for_user` returns accesses in tuple as
|
|
# (`global_staff`, `course_staff`, `course_instructor`).
|
|
# Order matters here, for example `True` at first index in tuple essentially means
|
|
# given user is a global staff.
|
|
for count, user in enumerate([self.global_staff, self.course_staff, self.course_instructor]):
|
|
assert access.administrative_accesses_to_course_for_user(user, course_key)[count]
|
|
|
|
assert not any(access.administrative_accesses_to_course_for_user(self.student, course_key))
|
|
|
|
def test_student_has_access(self):
|
|
"""
|
|
Tests course student have right access to content w/o preview.
|
|
"""
|
|
course_key = self.course.id
|
|
chapter = BlockFactory.create(category="chapter", parent_location=self.course.location)
|
|
overview = CourseOverview.get_from_id(course_key)
|
|
subsection = BlockFactory.create(category="sequential", parent_location=chapter.location)
|
|
unit = BlockFactory.create(category="vertical", parent_location=subsection.location)
|
|
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
|
|
html_block = BlockFactory.create(
|
|
category="html",
|
|
parent_location=unit.location,
|
|
display_name="Unpublished Block",
|
|
data='<p>This block should not be published.</p>',
|
|
publish_item=False,
|
|
)
|
|
|
|
# Enroll student to the course
|
|
CourseEnrollmentFactory(user=self.student, course_id=self.course.id)
|
|
|
|
modules = [
|
|
self.course,
|
|
chapter,
|
|
overview,
|
|
subsection,
|
|
unit,
|
|
|
|
]
|
|
# Student should have access to modules they're enrolled in
|
|
for obj in modules:
|
|
assert bool(access.has_access(
|
|
self.student,
|
|
'load',
|
|
self.store.get_item(obj.location),
|
|
course_key=self.course.id)
|
|
)
|
|
|
|
# If the document is not published yet, it should return an error when we try to fetch
|
|
# it from the store. This check confirms that the student would not be able to access it.
|
|
with pytest.raises(ItemNotFoundError):
|
|
self.store.get_item(html_block.location)
|
|
|
|
def test_has_access_based_on_roles(self):
|
|
"""
|
|
Tests user access to content based on their role.
|
|
"""
|
|
assert bool(access.has_access(self.global_staff, 'staff', self.course, course_key=self.course.id))
|
|
assert bool(access.has_access(self.course_staff, 'staff', self.course, course_key=self.course.id))
|
|
assert bool(access.has_access(self.course_instructor, 'staff', self.course, course_key=self.course.id))
|
|
assert not bool(access.has_access(self.student, 'staff', self.course, course_key=self.course.id))
|
|
|
|
# Student should be able to load the course even if they don't have staff access.
|
|
assert bool(access.has_access(self.student, 'load', self.course, course_key=self.course.id))
|
|
|
|
# When masquerading is true, user should not be able to access staff content
|
|
with patch('lms.djangoapps.courseware.access.is_masquerading_as_student') as mock_masquerade:
|
|
mock_masquerade.return_value = True
|
|
assert not bool(access.has_access(self.global_staff, 'staff', self.course, course_key=self.course.id))
|
|
assert not bool(access.has_access(self.student, 'staff', self.course, course_key=self.course.id))
|
|
|
|
def test_has_access_with_content_groups(self):
|
|
"""
|
|
Test that a user masquerading as a member of a group sees appropriate content.
|
|
"""
|
|
# Note about UserPartition and UserPartition Group IDs: these must not conflict with IDs used
|
|
# by dynamic user partitions.
|
|
partition_id = MINIMUM_UNUSED_PARTITION_ID
|
|
group_0_id = MINIMUM_UNUSED_PARTITION_ID + 1
|
|
group_1_id = MINIMUM_UNUSED_PARTITION_ID + 2
|
|
user_partition = UserPartition(
|
|
partition_id, 'Test User Partition', '',
|
|
[Group(group_0_id, 'Group 1'), Group(group_1_id, 'Group 2')],
|
|
scheme_id='cohort'
|
|
)
|
|
self.course.user_partitions.append(user_partition)
|
|
self.course.cohort_config = {'cohorted': True}
|
|
|
|
chapter = BlockFactory.create(category="chapter", parent_location=self.course.location)
|
|
chapter.group_access = {partition_id: [group_0_id]}
|
|
|
|
modulestore().update_item(self.course, ModuleStoreEnum.UserID.test)
|
|
|
|
# User should not be able to preview when masquerading as student (and not in the group above).
|
|
with patch('lms.djangoapps.courseware.access.get_user_role') as mock_user_role:
|
|
mock_user_role.return_value = 'student'
|
|
assert not bool(access.has_access(self.global_staff, 'load', chapter, course_key=self.course.id))
|
|
|
|
# Should be able to preview when in staff or instructor role.
|
|
for mocked_role in ['staff', 'instructor']:
|
|
with patch('lms.djangoapps.courseware.access.get_user_role') as mock_user_role:
|
|
mock_user_role.return_value = mocked_role
|
|
assert bool(access.has_access(self.global_staff, 'load', chapter, course_key=self.course.id))
|
|
|
|
# Now install masquerade group and set staff as a member of that.
|
|
assert 200 == masquerade_as_group_member(self.global_staff, self.course, partition_id, group_0_id)
|
|
# Can load the chapter since user is in the group.
|
|
assert bool(access.has_access(self.global_staff, 'load', chapter, course_key=self.course.id))
|
|
|
|
# Move the user to be a part of the second group.
|
|
assert 200 == masquerade_as_group_member(self.global_staff, self.course, partition_id, group_1_id)
|
|
# Cannot load the chapter since user is in a different group.
|
|
assert not bool(access.has_access(self.global_staff, 'load', chapter, course_key=self.course.id))
|
|
|
|
def test_has_access_to_course(self):
|
|
assert not access._has_access_to_course(None, 'staff', self.course.id)
|
|
|
|
assert not access._has_access_to_course(self.anonymous_user, 'staff', self.course.id)
|
|
assert not access._has_access_to_course(self.anonymous_user, 'instructor', self.course.id)
|
|
|
|
assert access._has_access_to_course(self.global_staff, 'staff', self.course.id)
|
|
assert access._has_access_to_course(self.global_staff, 'instructor', self.course.id)
|
|
|
|
# A user has staff access if they are in the staff group
|
|
assert access._has_access_to_course(self.course_staff, 'staff', self.course.id)
|
|
assert not access._has_access_to_course(self.course_staff, 'instructor', self.course.id)
|
|
|
|
# A user has staff and instructor access if they are in the instructor group
|
|
assert access._has_access_to_course(self.course_instructor, 'staff', self.course.id)
|
|
assert access._has_access_to_course(self.course_instructor, 'instructor', self.course.id)
|
|
|
|
# A user does not have staff or instructor access if they are
|
|
# not in either the staff or the the instructor group
|
|
assert not access._has_access_to_course(self.student, 'staff', self.course.id)
|
|
assert not access._has_access_to_course(self.student, 'instructor', self.course.id)
|
|
|
|
assert not access._has_access_to_course(self.student, 'not_staff_or_instructor', self.course.id)
|
|
|
|
def test__has_access_string(self):
|
|
user = Mock(is_staff=True)
|
|
assert not access._has_access_string(user, 'staff', 'not_global')
|
|
|
|
user._has_global_staff_access.return_value = True
|
|
assert access._has_access_string(user, 'staff', 'global')
|
|
|
|
self.assertRaises(ValueError, access._has_access_string, user, 'not_staff', 'global')
|
|
|
|
@ddt.data(
|
|
('load', False, True, True),
|
|
('staff', False, True, True),
|
|
('instructor', False, False, True)
|
|
)
|
|
@ddt.unpack
|
|
def test__has_access_error_block(self, action, expected_student, expected_staff, expected_instructor):
|
|
block = Mock()
|
|
|
|
for (user, expected_response) in (
|
|
(self.student, expected_student),
|
|
(self.course_staff, expected_staff),
|
|
(self.course_instructor, expected_instructor)
|
|
):
|
|
assert bool(access._has_access_error_block(user, action, block, self.course.id)) == expected_response
|
|
|
|
with pytest.raises(ValueError):
|
|
access._has_access_error_block(self.course_instructor, 'not_load_or_staff', block, self.course.id)
|
|
|
|
def test__has_access_to_block(self):
|
|
# TODO: override DISABLE_START_DATES and test the start date branch of the method
|
|
user = Mock()
|
|
block = Mock(user_partitions=[])
|
|
block._class_tags = {}
|
|
block.merged_group_access = {}
|
|
|
|
# Always returns true because DISABLE_START_DATES is set in test.py
|
|
assert access._has_access_to_block(user, 'load', block)
|
|
assert access._has_access_to_block(user, 'instructor', block)
|
|
with pytest.raises(ValueError):
|
|
access._has_access_to_block(user, 'not_load_or_staff', block)
|
|
|
|
@ddt.data(
|
|
(True, None, access_response.VisibilityError),
|
|
(False, None),
|
|
(True, YESTERDAY, access_response.VisibilityError),
|
|
(False, YESTERDAY),
|
|
(True, TOMORROW, access_response.VisibilityError),
|
|
(False, TOMORROW, access_response.StartDateError)
|
|
)
|
|
@ddt.unpack
|
|
@patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False})
|
|
def test__has_access_to_block_staff_lock(self, visible_to_staff_only, start, expected_error_type=None):
|
|
"""
|
|
Tests that "visible_to_staff_only" overrides start date.
|
|
"""
|
|
expected_access = expected_error_type is None
|
|
mock_unit = Mock(location=self.course.location, user_partitions=[])
|
|
mock_unit._class_tags = {} # Needed for detached check in _has_access_to_block
|
|
mock_unit.visible_to_staff_only = visible_to_staff_only
|
|
mock_unit.start = self.DATES[start]
|
|
mock_unit.merged_group_access = {}
|
|
|
|
self.verify_access(mock_unit, expected_access, expected_error_type)
|
|
|
|
def test__has_access_to_block_beta_user(self):
|
|
mock_unit = Mock(user_partitions=[])
|
|
mock_unit._class_tags = {}
|
|
mock_unit.days_early_for_beta = 2
|
|
mock_unit.start = self.DATES[self.TOMORROW]
|
|
mock_unit.visible_to_staff_only = False
|
|
mock_unit.merged_group_access = {}
|
|
|
|
assert bool(access._has_access_to_block(self.beta_user, 'load', mock_unit, course_key=self.course.id))
|
|
|
|
@ddt.data(
|
|
(TOMORROW, access_response.StartDateError),
|
|
(None, None),
|
|
(YESTERDAY, None)
|
|
) # ddt throws an error if I don't put the None argument there
|
|
@ddt.unpack
|
|
@patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False})
|
|
def test__has_access_to_block_with_start_date(self, start, expected_error_type):
|
|
"""
|
|
Tests that block access follows start date rules.
|
|
Access should be denied when start date is in the future and granted when
|
|
start date is in the past or not set.
|
|
"""
|
|
expected_access = expected_error_type is None
|
|
mock_unit = Mock(location=self.course.location, user_partitions=[])
|
|
mock_unit._class_tags = {} # Needed for detached check in _has_access_to_block
|
|
mock_unit.visible_to_staff_only = False
|
|
mock_unit.start = self.DATES[start]
|
|
mock_unit.merged_group_access = {}
|
|
|
|
self.verify_access(mock_unit, expected_access, expected_error_type)
|
|
|
|
def test__has_access_course_can_enroll(self):
|
|
yesterday = datetime.datetime.now(pytz.utc) - datetime.timedelta(days=1)
|
|
tomorrow = datetime.datetime.now(pytz.utc) + datetime.timedelta(days=1)
|
|
|
|
# Non-staff can enroll if authenticated and specifically allowed for that course
|
|
# even outside the open enrollment period
|
|
user = UserFactory.create()
|
|
course = CourseOverviewFactory.create(id=CourseLocator('edX', 'test', '2012_Fall'))
|
|
course.enrollment_start = tomorrow
|
|
course.enrollment_end = tomorrow
|
|
course.enrollment_domain = ''
|
|
course.save()
|
|
CourseEnrollmentAllowedFactory(email=user.email, course_id=course.id)
|
|
assert access._has_access_course(user, 'enroll', course)
|
|
|
|
# Staff can always enroll even outside the open enrollment period
|
|
user = StaffFactory.create(course_key=course.id)
|
|
assert access._has_access_course(user, 'enroll', course)
|
|
|
|
# Non-staff cannot enroll if it is between the start and end dates and invitation only
|
|
# and not specifically allowed
|
|
course.enrollment_start = yesterday
|
|
course.enrollment_end = tomorrow
|
|
course.invitation_only = True
|
|
course.save()
|
|
user = UserFactory.create()
|
|
assert not access._has_access_course(user, 'enroll', course)
|
|
|
|
# Non-staff can enroll if it is between the start and end dates and not invitation only
|
|
course.invitation_only = False
|
|
course.save()
|
|
assert access._has_access_course(user, 'enroll', course)
|
|
|
|
# Non-staff cannot enroll outside the open enrollment period if not specifically allowed
|
|
course.enrollment_start = tomorrow
|
|
course.enrollment_end = tomorrow
|
|
course.invitation_only = False
|
|
course.save()
|
|
assert not access._has_access_course(user, 'enroll', course)
|
|
|
|
@override_settings(FEATURES={**settings.FEATURES, 'DISABLE_ALLOWED_ENROLLMENT_IF_ENROLLMENT_CLOSED': True})
|
|
def test__has_access_course_with_disable_allowed_enrollment_flag(self):
|
|
yesterday = datetime.datetime.now(pytz.utc) - datetime.timedelta(days=1)
|
|
tomorrow = datetime.datetime.now(pytz.utc) + datetime.timedelta(days=1)
|
|
|
|
# Non-staff user invited to course, cannot enroll outside the open enrollment period
|
|
# if DISABLE_ALLOWED_ENROLLMENT_IF_ENROLLMENT_CLOSED is True
|
|
user = UserFactory.create()
|
|
course = CourseOverviewFactory.create(id=CourseLocator('edX', 'test', '2012_Fall'))
|
|
course.enrollment_start = tomorrow
|
|
course.enrollment_end = tomorrow
|
|
course.enrollment_domain = ''
|
|
course.save()
|
|
CourseEnrollmentAllowedFactory(email=user.email, course_id=course.id)
|
|
assert not access._has_access_course(user, 'enroll', course)
|
|
|
|
# Staff can always enroll even outside the open enrollment period
|
|
user = StaffFactory.create(course_key=course.id)
|
|
assert access._has_access_course(user, 'enroll', course)
|
|
|
|
# Non-staff cannot enroll outside the open enrollment period if not specifically allowed
|
|
user = UserFactory.create()
|
|
assert not access._has_access_course(user, 'enroll', course)
|
|
|
|
# Non-staff cannot enroll if it is between the start and end dates and invitation only
|
|
# and not specifically allowed
|
|
course.enrollment_start = yesterday
|
|
course.enrollment_end = tomorrow
|
|
course.invitation_only = True
|
|
course.save()
|
|
assert not access._has_access_course(user, 'enroll', course)
|
|
|
|
# Non-staff can enroll if it is between the start and end dates and not invitation only
|
|
course.invitation_only = False
|
|
course.save()
|
|
assert access._has_access_course(user, 'enroll', course)
|
|
|
|
@override_settings(COURSES_INVITE_ONLY=False)
|
|
def test__course_default_invite_only_flag_false(self):
|
|
"""
|
|
Ensure that COURSES_INVITE_ONLY does not take precedence,
|
|
if it is not set over the course invitation_only settings.
|
|
"""
|
|
|
|
user = UserFactory.create()
|
|
|
|
# User cannot enroll in the course if it is just invitation only.
|
|
course = self._mock_course_with_invitation(invitation=True)
|
|
self.assertFalse(access._has_access_course(user, 'enroll', course))
|
|
|
|
# User can enroll in the course if it is not just invitation only.
|
|
course = self._mock_course_with_invitation(invitation=False)
|
|
self.assertTrue(access._has_access_course(user, 'enroll', course))
|
|
|
|
@override_settings(COURSES_INVITE_ONLY=True)
|
|
def test__course_default_invite_only_flag_true(self):
|
|
"""
|
|
Ensure that COURSES_INVITE_ONLY takes precedence over the course invitation_only settings.
|
|
"""
|
|
|
|
user = UserFactory.create()
|
|
|
|
# User cannot enroll in the course if it is just invitation only and COURSES_INVITE_ONLY is also set.
|
|
course = self._mock_course_with_invitation(invitation=True)
|
|
self.assertFalse(access._has_access_course(user, 'enroll', course))
|
|
|
|
# User cannot enroll in the course if COURSES_INVITE_ONLY is set despite of the course invitation_only value.
|
|
course = self._mock_course_with_invitation(invitation=False)
|
|
self.assertFalse(access._has_access_course(user, 'enroll', course))
|
|
|
|
@ddt.data(True, False)
|
|
def test_old_mongo_is_invite_only(self, old_mongo):
|
|
"""
|
|
Ensure that Old Mongo courses are marked as invite only and don't allow enrollment
|
|
"""
|
|
user = UserFactory.create()
|
|
course = self._mock_course_with_invitation(invitation=False, deprecated=old_mongo)
|
|
self.assertEqual(course_is_invitation_only(course), old_mongo)
|
|
self.assertEqual(access._has_access_course(user, 'enroll', course).has_access, not old_mongo)
|
|
|
|
def _mock_course_with_invitation(self, invitation, deprecated=False):
|
|
yesterday = datetime.datetime.now(pytz.utc) - datetime.timedelta(days=1)
|
|
tomorrow = datetime.datetime.now(pytz.utc) + datetime.timedelta(days=1)
|
|
return Mock(
|
|
enrollment_start=yesterday, enrollment_end=tomorrow,
|
|
id=CourseLocator('edX', 'test', '2012_Fall', deprecated=deprecated), enrollment_domain='',
|
|
invitation_only=invitation
|
|
)
|
|
|
|
def test__user_passed_as_none(self):
|
|
"""Ensure has_access handles a user being passed as null"""
|
|
access.has_access(None, 'staff', 'global', None)
|
|
|
|
def test__catalog_visibility(self):
|
|
"""
|
|
Tests the catalog visibility tri-states
|
|
"""
|
|
user = UserFactory.create()
|
|
course_id = CourseLocator('edX', 'test', '2012_Fall')
|
|
staff = StaffFactory.create(course_key=course_id)
|
|
|
|
course = Mock(
|
|
id=course_id,
|
|
catalog_visibility=CATALOG_VISIBILITY_CATALOG_AND_ABOUT
|
|
)
|
|
assert access._has_access_course(user, 'see_in_catalog', course)
|
|
assert access._has_access_course(user, 'see_about_page', course)
|
|
assert access._has_access_course(staff, 'see_in_catalog', course)
|
|
assert access._has_access_course(staff, 'see_about_page', course)
|
|
|
|
# Now set visibility to just about page
|
|
course = Mock(
|
|
id=CourseLocator('edX', 'test', '2012_Fall'),
|
|
catalog_visibility=CATALOG_VISIBILITY_ABOUT
|
|
)
|
|
assert not access._has_access_course(user, 'see_in_catalog', course)
|
|
assert access._has_access_course(user, 'see_about_page', course)
|
|
assert access._has_access_course(staff, 'see_in_catalog', course)
|
|
assert access._has_access_course(staff, 'see_about_page', course)
|
|
|
|
# Now set visibility to none, which means neither in catalog nor about pages
|
|
course = Mock(
|
|
id=CourseLocator('edX', 'test', '2012_Fall'),
|
|
catalog_visibility=CATALOG_VISIBILITY_NONE
|
|
)
|
|
assert not access._has_access_course(user, 'see_in_catalog', course)
|
|
assert not access._has_access_course(user, 'see_about_page', course)
|
|
assert access._has_access_course(staff, 'see_in_catalog', course)
|
|
assert access._has_access_course(staff, 'see_about_page', course)
|
|
|
|
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
|
|
def test_access_on_course_with_pre_requisites(self):
|
|
"""
|
|
Test course access when a course has pre-requisite course yet to be completed
|
|
"""
|
|
user = UserFactory.create()
|
|
|
|
pre_requisite_course = CourseFactory.create(
|
|
org='test_org', number='788', run='test_run'
|
|
)
|
|
|
|
pre_requisite_courses = [str(pre_requisite_course.id)]
|
|
course = CourseFactory.create(
|
|
org='test_org', number='786', run='test_run', pre_requisite_courses=pre_requisite_courses
|
|
)
|
|
set_prerequisite_courses(course.id, pre_requisite_courses)
|
|
|
|
# user should not be able to load course even if enrolled
|
|
CourseEnrollmentFactory(user=user, course_id=course.id)
|
|
response = access._has_access_course(user, 'load', course)
|
|
assert not response
|
|
assert isinstance(response, access_response.MilestoneAccessError)
|
|
# Staff can always access course
|
|
staff = StaffFactory.create(course_key=course.id)
|
|
assert access._has_access_course(staff, 'load', course)
|
|
|
|
# User should be able access after completing required course
|
|
fulfill_course_milestone(pre_requisite_course.id, user)
|
|
assert access._has_access_course(user, 'load', course)
|
|
|
|
@ddt.data(
|
|
(True, True, True),
|
|
(False, False, True)
|
|
)
|
|
@ddt.unpack
|
|
def test__access_on_mobile(self, mobile_available, student_expected, staff_expected):
|
|
"""
|
|
Test course access on mobile for staff and students.
|
|
"""
|
|
course = CourseFactory()
|
|
course.visible_to_staff_only = False
|
|
course.mobile_available = mobile_available
|
|
|
|
assert bool(access._has_access_course(self.student, 'load_mobile', course)) == student_expected
|
|
assert bool(access._has_access_course(self.staff, 'load_mobile', course)) == staff_expected
|
|
|
|
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
|
|
def test_courseware_page_unfulfilled_prereqs(self):
|
|
"""
|
|
Test courseware access when a course has pre-requisite course yet to be completed
|
|
"""
|
|
pre_requisite_course = CourseFactory.create(
|
|
org='edX',
|
|
course='900',
|
|
run='test_run',
|
|
)
|
|
|
|
pre_requisite_courses = [str(pre_requisite_course.id)]
|
|
course = CourseFactory.create(
|
|
org='edX',
|
|
course='1000',
|
|
run='test_run',
|
|
pre_requisite_courses=pre_requisite_courses,
|
|
)
|
|
set_prerequisite_courses(course.id, pre_requisite_courses)
|
|
|
|
test_password = 't3stp4ss.!'
|
|
user = UserFactory.create()
|
|
user.set_password(test_password)
|
|
user.save()
|
|
self.login(user.email, test_password)
|
|
CourseEnrollmentFactory(user=user, course_id=course.id)
|
|
|
|
url = reverse('courseware', args=[str(course.id)])
|
|
response = self.client.get(url)
|
|
self.assertRedirects(
|
|
response,
|
|
reverse(
|
|
'dashboard'
|
|
)
|
|
)
|
|
assert response.status_code == 302
|
|
|
|
fulfill_course_milestone(pre_requisite_course.id, user)
|
|
response = self.client.get(url)
|
|
assert response.status_code == 200
|
|
|
|
|
|
class UserRoleTestCase(TestCase):
|
|
"""
|
|
Tests for user roles.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.course_key = CourseLocator('edX', 'toy', '2012_Fall')
|
|
self.anonymous_user = AnonymousUserFactory()
|
|
self.student = UserFactory()
|
|
self.global_staff = UserFactory(is_staff=True)
|
|
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."""
|
|
assert 'staff' == access.get_user_role(self.course_staff, self.course_key)
|
|
# Masquerade staff
|
|
self._install_masquerade(self.course_staff)
|
|
assert 'student' == access.get_user_role(self.course_staff, self.course_key)
|
|
|
|
def test_user_role_instructor(self):
|
|
"""Ensure that user role is student for instructor masqueraded as student."""
|
|
assert 'instructor' == access.get_user_role(self.course_instructor, self.course_key)
|
|
# Masquerade instructor
|
|
self._install_masquerade(self.course_instructor)
|
|
assert 'student' == access.get_user_role(self.course_instructor, self.course_key)
|
|
|
|
def test_user_role_anonymous(self):
|
|
"""Ensure that user role is student for anonymous user."""
|
|
assert 'student' == access.get_user_role(self.anonymous_user, self.course_key)
|
|
|
|
|
|
@ddt.ddt
|
|
class CourseOverviewAccessTestCase(ModuleStoreTestCase):
|
|
"""
|
|
Tests confirming that has_access works equally on CourseBlocks and
|
|
CourseOverviews.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
|
|
today = datetime.datetime.now(pytz.UTC)
|
|
last_week = today - datetime.timedelta(days=7)
|
|
next_week = today + datetime.timedelta(days=7)
|
|
|
|
self.course_default = CourseFactory.create()
|
|
self.course_started = CourseFactory.create(start=last_week)
|
|
self.course_not_started = CourseFactory.create(start=next_week, days_early_for_beta=10)
|
|
self.course_staff_only = CourseFactory.create(visible_to_staff_only=True)
|
|
self.course_mobile_available = CourseFactory.create(mobile_available=True)
|
|
self.course_with_pre_requisite = CourseFactory.create(
|
|
pre_requisite_courses=[str(self.course_started.id)]
|
|
)
|
|
self.course_with_pre_requisites = CourseFactory.create(
|
|
pre_requisite_courses=[str(self.course_started.id), str(self.course_not_started.id)]
|
|
)
|
|
|
|
self.user_normal = UserFactory.create()
|
|
self.user_beta_tester = BetaTesterFactory.create(course_key=self.course_not_started.id)
|
|
self.user_completed_pre_requisite = UserFactory.create()
|
|
fulfill_course_milestone(self.course_started.id, self.user_completed_pre_requisite)
|
|
self.user_staff = UserFactory.create(is_staff=True)
|
|
self.user_anonymous = AnonymousUserFactory.create()
|
|
|
|
COURSE_TEST_DATA = list(itertools.product(
|
|
['user_normal', 'user_staff', 'user_anonymous'],
|
|
['enroll', 'load', 'staff', 'instructor', 'see_exists', 'see_in_catalog', 'see_about_page'],
|
|
['course_default', 'course_started', 'course_not_started', 'course_staff_only'],
|
|
))
|
|
|
|
LOAD_MOBILE_TEST_DATA = list(itertools.product(
|
|
['user_normal', 'user_staff'],
|
|
['load_mobile'],
|
|
['course_default', 'course_mobile_available'],
|
|
))
|
|
|
|
PREREQUISITES_TEST_DATA = list(itertools.product(
|
|
['user_normal', 'user_completed_pre_requisite', 'user_staff', 'user_anonymous'],
|
|
['load'],
|
|
['course_default', 'course_with_pre_requisite', 'course_with_pre_requisites'],
|
|
))
|
|
|
|
@ddt.data(*(COURSE_TEST_DATA + LOAD_MOBILE_TEST_DATA + PREREQUISITES_TEST_DATA))
|
|
@ddt.unpack
|
|
@patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False})
|
|
def test_course_overview_access(self, user_attr_name, action, course_attr_name):
|
|
"""
|
|
Check that a user's access to a course is equal to the user's access to
|
|
the corresponding course overview.
|
|
|
|
Instead of taking a user and course directly as arguments, we have to
|
|
take their attribute names, as ddt doesn't allow us to reference self.
|
|
|
|
Arguments:
|
|
user_attr_name (str): the name of the attribute on self that is the
|
|
User to test with.
|
|
action (str): action to test with.
|
|
course_attr_name (str): the name of the attribute on self that is
|
|
the CourseBlock to test with.
|
|
"""
|
|
user = getattr(self, user_attr_name)
|
|
course = getattr(self, course_attr_name)
|
|
|
|
course_overview = CourseOverview.get_from_id(course.id)
|
|
assert bool(access.has_access(user, action, course, course_key=course.id)) ==\
|
|
bool(access.has_access(user, action, course_overview, course_key=course.id))
|
|
|
|
def test_course_overview_unsupported_action(self):
|
|
"""
|
|
Check that calling has_access with an unsupported action raises a
|
|
ValueError.
|
|
"""
|
|
overview = CourseOverview.get_from_id(self.course_default.id)
|
|
with pytest.raises(ValueError):
|
|
access.has_access(self.user, '_non_existent_action', overview)
|
|
|
|
@ddt.data(
|
|
*itertools.product(
|
|
['user_normal', 'user_staff', 'user_anonymous'],
|
|
['see_exists', 'see_in_catalog', 'see_about_page'],
|
|
['course_default', 'course_started', 'course_not_started'],
|
|
)
|
|
)
|
|
@ddt.unpack
|
|
@patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False})
|
|
def test_course_catalog_access_num_queries_no_enterprise(self, user_attr_name, action, course_attr_name):
|
|
ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime.datetime(2018, 1, 1))
|
|
|
|
course = getattr(self, course_attr_name)
|
|
|
|
# get a fresh user object that won't have any cached role information
|
|
if user_attr_name == 'user_anonymous':
|
|
user = AnonymousUserFactory()
|
|
else:
|
|
user = getattr(self, user_attr_name)
|
|
user = User.objects.get(id=user.id)
|
|
|
|
if user_attr_name == 'user_staff' and action == 'see_exists':
|
|
# always checks staff role, and if the course has started, check the duration configuration
|
|
if course_attr_name == 'course_started':
|
|
num_queries = 2
|
|
else:
|
|
num_queries = 1
|
|
elif user_attr_name == 'user_normal' and action == 'see_exists':
|
|
if course_attr_name == 'course_started':
|
|
num_queries = 4
|
|
else:
|
|
# checks staff role and enrollment data
|
|
num_queries = 2
|
|
elif user_attr_name == 'user_anonymous' and action == 'see_exists':
|
|
if course_attr_name == 'course_started':
|
|
num_queries = 1
|
|
else:
|
|
num_queries = 0
|
|
else:
|
|
# if the course has started, check the duration configuration
|
|
if action == 'see_exists' and course_attr_name == 'course_started':
|
|
num_queries = 3
|
|
else:
|
|
num_queries = 0
|
|
|
|
course_overview = CourseOverview.get_from_id(course.id)
|
|
with self.assertNumQueries(num_queries, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST):
|
|
bool(access.has_access(user, action, course_overview, course_key=course.id))
|
|
|
|
@ddt.data(
|
|
*itertools.product(
|
|
['user_normal', 'user_staff', 'user_anonymous'],
|
|
['course_started', 'course_not_started'],
|
|
)
|
|
)
|
|
@ddt.unpack
|
|
@patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False, 'ENABLE_ENTERPRISE_INTEGRATION': True})
|
|
def test_course_catalog_access_num_queries_enterprise(self, user_attr_name, course_attr_name):
|
|
"""
|
|
Similar to test_course_catalog_access_num_queries_no_enterprise, except enable enterprise features and make the
|
|
basic enrollment look like an enterprise-subsidized enrollment, setting up one of each:
|
|
|
|
* EnterpriseCustomer
|
|
* EnterpriseCustomerUser
|
|
* EnterpriseCourseEnrollment
|
|
* A mock request session to pre-cache the enterprise customer data.
|
|
"""
|
|
ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime.datetime(2018, 1, 1))
|
|
|
|
course = getattr(self, course_attr_name)
|
|
|
|
request = RequestFactory().get('/')
|
|
request.session = {}
|
|
|
|
# get a fresh user object that won't have any cached role information
|
|
if user_attr_name == 'user_anonymous':
|
|
user = AnonymousUserFactory()
|
|
request.user = user
|
|
else:
|
|
user = getattr(self, user_attr_name)
|
|
user = User.objects.get(id=user.id)
|
|
request.user = user
|
|
course_enrollment = CourseEnrollmentFactory(user=user, course_id=course.id)
|
|
enterprise_customer = EnterpriseCustomerFactory(enable_learner_portal=True)
|
|
add_enterprise_customer_to_session(request, EnterpriseCustomerSerializer(enterprise_customer).data)
|
|
enterprise_customer_user = EnterpriseCustomerUserFactory(
|
|
user_id=user.id,
|
|
enterprise_customer=enterprise_customer,
|
|
)
|
|
EnterpriseCourseEnrollmentFactory(enterprise_customer_user=enterprise_customer_user, course_id=course.id)
|
|
set_current_request(request)
|
|
|
|
if user_attr_name == 'user_staff':
|
|
if course_attr_name == 'course_started':
|
|
# read: CourseAccessRole + django_comment_client.Role
|
|
num_queries = 2
|
|
else:
|
|
# read: CourseAccessRole + EnterpriseCourseEnrollment
|
|
num_queries = 2
|
|
elif user_attr_name == 'user_normal':
|
|
if course_attr_name == 'course_started':
|
|
# read: CourseAccessRole + django_comment_client.Role + FBEEnrollmentExclusion + CourseMode
|
|
num_queries = 4
|
|
else:
|
|
# read: CourseAccessRole + CourseEnrollmentAllowed + EnterpriseCourseEnrollment
|
|
num_queries = 3
|
|
elif user_attr_name == 'user_anonymous':
|
|
if course_attr_name == 'course_started':
|
|
# read: CourseMode
|
|
num_queries = 1
|
|
else:
|
|
num_queries = 0
|
|
|
|
course_overview = CourseOverview.get_from_id(course.id)
|
|
with self.assertNumQueries(num_queries, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST):
|
|
bool(access.has_access(user, 'see_exists', course_overview, course_key=course.id))
|