* feat: adds SearchAccess model Stores a numeric ID for each course + library, which will generally be shorter than the full context_key, so we can pack more of them into the the Meilisearch search filter. Also: * Adds data migration pre-populates the SearchAccess model from the existing CourseOverview and ContentLibrary records * Adds signal handlers to add/remove SearchAccess entries when content is created or deleted. * Adds get_access_ids_for_request() helper method for use in views. * Adds tests. * test: can't import content.search in lms tests * feat: use SearchAccess in documents and views * Adds an access_id field to the document, which stores the SearchAccess.id for the block's context. * Use the requesting user's allowed access_ids to filter search results to documents with those access_ids. * Since some users have a lot of individual access granted, limit the number of access_ids in the filter to a large number (1_000) * Updates tests to demonstrate. * test: can't import content.search or content_staging in lms tests * fix: make access_id field filterable * fix: use SearchAccess.get_or_create in signal handlers In theory, we shouldn't have to do this, because the CREATE and DELETE events should keep the SearchAccess table up-to-date. But in practice, signals can be missed (or in tests, they may be disabled). So we assume that it's ok to re-use a SearchAccess.id created for a given course or library context_key. * refactor: refactors the view tests to make them clearer Uses helper methods and decorators to wrap the settings and patches used by multiple view tests. * feat: adds org filters to meilisearch filter * Uses content_tagging.rules.get_user_orgs to fetch the user's content-related orgs for use in the meilisearch filter. * Limits the number of orgs used to 1_000 to keep token size down * refactor: removes data migration Users should use the reindex_studio management command to populate SearchAccess. * refactor: adds functions to common.djangoapps.student.role_helpers to allow general access to the user's RoleCache without having to access private attributes of User or RoleCache. Related changes: * Moves some functionality from openedx.core.djangoapps.enrollments.data.get_user_roles to this new helper method. * Use these new helper method in content_tagging.rules * fix: get_access_ids_for_request only returns individual access instead of all course keys that the user can read. Org- and GlobalStaff access checks will handle the rest. * fix: use org-level permissions when generating search filter Also refactors tests to demonstrate this change for OrgStaff and OrgInstructor users. * refactor: remove SearchAccess creation signal handlers Lets SearchAccess entries be created on demand during search indexing. * feat: omit access_ids from the search filter that are covered by the user's org roles --------- Co-authored-by: Rômulo Penido <romulo.penido@gmail.com>
236 lines
9.5 KiB
Python
236 lines
9.5 KiB
Python
"""
|
|
Tests of student.roles
|
|
"""
|
|
|
|
|
|
import ddt
|
|
from django.test import TestCase
|
|
from opaque_keys.edx.keys import CourseKey
|
|
|
|
from common.djangoapps.student.roles import (
|
|
CourseAccessRole,
|
|
CourseBetaTesterRole,
|
|
CourseInstructorRole,
|
|
CourseRole,
|
|
CourseLimitedStaffRole,
|
|
CourseStaffRole,
|
|
CourseFinanceAdminRole,
|
|
CourseSalesAdminRole,
|
|
LibraryUserRole,
|
|
CourseDataResearcherRole,
|
|
GlobalStaff,
|
|
OrgContentCreatorRole,
|
|
OrgInstructorRole,
|
|
OrgStaffRole,
|
|
RoleCache
|
|
)
|
|
from common.djangoapps.student.role_helpers import get_course_roles, has_staff_roles
|
|
from common.djangoapps.student.tests.factories import AnonymousUserFactory, InstructorFactory, StaffFactory, UserFactory
|
|
|
|
|
|
class RolesTestCase(TestCase):
|
|
"""
|
|
Tests of student.roles
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.course_key = CourseKey.from_string('edX/toy/2012_Fall')
|
|
self.course_loc = self.course_key.make_usage_key('course', '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)
|
|
self.orgs = ["Marvel", "DC"]
|
|
|
|
def test_global_staff(self):
|
|
assert not GlobalStaff().has_user(self.student)
|
|
assert not GlobalStaff().has_user(self.course_staff)
|
|
assert not GlobalStaff().has_user(self.course_instructor)
|
|
assert GlobalStaff().has_user(self.global_staff)
|
|
|
|
def test_has_staff_roles(self):
|
|
assert has_staff_roles(self.global_staff, self.course_key)
|
|
assert has_staff_roles(self.course_staff, self.course_key)
|
|
assert has_staff_roles(self.course_instructor, self.course_key)
|
|
assert not has_staff_roles(self.student, self.course_key)
|
|
|
|
def test_get_course_roles(self):
|
|
assert not list(get_course_roles(self.student))
|
|
assert not list(get_course_roles(self.global_staff))
|
|
assert list(get_course_roles(self.course_staff)) == [
|
|
CourseAccessRole(
|
|
user=self.course_staff,
|
|
course_id=self.course_key,
|
|
org=self.course_key.org,
|
|
role=CourseStaffRole.ROLE,
|
|
)
|
|
]
|
|
assert list(get_course_roles(self.course_instructor)) == [
|
|
CourseAccessRole(
|
|
user=self.course_instructor,
|
|
course_id=self.course_key,
|
|
org=self.course_key.org,
|
|
role=CourseInstructorRole.ROLE,
|
|
)
|
|
]
|
|
|
|
def test_group_name_case_sensitive(self):
|
|
uppercase_course_id = "ORG/COURSE/NAME"
|
|
lowercase_course_id = uppercase_course_id.lower()
|
|
uppercase_course_key = CourseKey.from_string(uppercase_course_id)
|
|
lowercase_course_key = CourseKey.from_string(lowercase_course_id)
|
|
|
|
role = "role"
|
|
|
|
lowercase_user = UserFactory()
|
|
CourseRole(role, lowercase_course_key).add_users(lowercase_user)
|
|
uppercase_user = UserFactory()
|
|
CourseRole(role, uppercase_course_key).add_users(uppercase_user)
|
|
|
|
assert CourseRole(role, lowercase_course_key).has_user(lowercase_user)
|
|
assert not CourseRole(role, uppercase_course_key).has_user(lowercase_user)
|
|
assert not CourseRole(role, lowercase_course_key).has_user(uppercase_user)
|
|
assert CourseRole(role, uppercase_course_key).has_user(uppercase_user)
|
|
|
|
def test_course_role(self):
|
|
"""
|
|
Test that giving a user a course role enables access appropriately
|
|
"""
|
|
assert not CourseStaffRole(self.course_key).has_user(self.student), \
|
|
f'Student has premature access to {self.course_key}'
|
|
CourseStaffRole(self.course_key).add_users(self.student)
|
|
assert CourseStaffRole(self.course_key).has_user(self.student), \
|
|
f"Student doesn't have access to {str(self.course_key)}"
|
|
|
|
# remove access and confirm
|
|
CourseStaffRole(self.course_key).remove_users(self.student)
|
|
assert not CourseStaffRole(self.course_key).has_user(self.student), \
|
|
f'Student still has access to {self.course_key}'
|
|
|
|
def test_org_role(self):
|
|
"""
|
|
Test that giving a user an org role enables access appropriately
|
|
"""
|
|
assert not OrgStaffRole(self.course_key.org).has_user(self.student), \
|
|
f'Student has premature access to {self.course_key.org}'
|
|
OrgStaffRole(self.course_key.org).add_users(self.student)
|
|
assert OrgStaffRole(self.course_key.org).has_user(self.student), \
|
|
f"Student doesn't have access to {str(self.course_key.org)}"
|
|
|
|
# remove access and confirm
|
|
OrgStaffRole(self.course_key.org).remove_users(self.student)
|
|
if hasattr(self.student, '_roles'):
|
|
del self.student._roles
|
|
assert not OrgStaffRole(self.course_key.org).has_user(self.student), \
|
|
f'Student still has access to {self.course_key.org}'
|
|
|
|
def test_org_and_course_roles(self):
|
|
"""
|
|
Test that Org and course roles don't interfere with course roles or vice versa
|
|
"""
|
|
OrgInstructorRole(self.course_key.org).add_users(self.student)
|
|
CourseInstructorRole(self.course_key).add_users(self.student)
|
|
assert OrgInstructorRole(self.course_key.org).has_user(self.student), \
|
|
f"Student doesn't have access to {str(self.course_key.org)}"
|
|
assert CourseInstructorRole(self.course_key).has_user(self.student), \
|
|
f"Student doesn't have access to {str(self.course_key)}"
|
|
|
|
# remove access and confirm
|
|
OrgInstructorRole(self.course_key.org).remove_users(self.student)
|
|
assert not OrgInstructorRole(self.course_key.org).has_user(self.student), \
|
|
f'Student still has access to {self.course_key.org}'
|
|
assert CourseInstructorRole(self.course_key).has_user(self.student), \
|
|
f"Student doesn't have access to {str(self.course_key)}"
|
|
|
|
# ok now keep org role and get rid of course one
|
|
OrgInstructorRole(self.course_key.org).add_users(self.student)
|
|
CourseInstructorRole(self.course_key).remove_users(self.student)
|
|
assert OrgInstructorRole(self.course_key.org).has_user(self.student), \
|
|
f'Student lost has access to {self.course_key.org}'
|
|
assert not CourseInstructorRole(self.course_key).has_user(self.student), \
|
|
f"Student doesn't have access to {str(self.course_key)}"
|
|
|
|
def test_get_user_for_role(self):
|
|
"""
|
|
test users_for_role
|
|
"""
|
|
role = CourseStaffRole(self.course_key)
|
|
role.add_users(self.student)
|
|
assert len(role.users_with_role()) > 0
|
|
|
|
def test_add_users_doesnt_add_duplicate_entry(self):
|
|
"""
|
|
Tests that calling add_users multiple times before a single call
|
|
to remove_users does not result in the user remaining in the group.
|
|
"""
|
|
role = CourseStaffRole(self.course_key)
|
|
role.add_users(self.student)
|
|
assert role.has_user(self.student)
|
|
# Call add_users a second time, then remove just once.
|
|
role.add_users(self.student)
|
|
role.remove_users(self.student)
|
|
assert not role.has_user(self.student)
|
|
|
|
def test_get_orgs_for_user(self):
|
|
"""
|
|
Test get_orgs_for_user
|
|
"""
|
|
role = OrgContentCreatorRole(org=self.orgs[0])
|
|
assert len(role.get_orgs_for_user(self.student)) == 0
|
|
role.add_users(self.student)
|
|
assert len(role.get_orgs_for_user(self.student)) == 1
|
|
role_second_org = OrgContentCreatorRole(org=self.orgs[1])
|
|
role_second_org.add_users(self.student)
|
|
assert len(role.get_orgs_for_user(self.student)) == 2
|
|
|
|
|
|
@ddt.ddt
|
|
class RoleCacheTestCase(TestCase): # lint-amnesty, pylint: disable=missing-class-docstring
|
|
|
|
IN_KEY = CourseKey.from_string('edX/toy/2012_Fall')
|
|
NOT_IN_KEY = CourseKey.from_string('edX/toy/2013_Fall')
|
|
|
|
ROLES = (
|
|
(CourseStaffRole(IN_KEY), ('staff', IN_KEY, 'edX')),
|
|
(CourseLimitedStaffRole(IN_KEY), ('limited_staff', IN_KEY, 'edX')),
|
|
(CourseInstructorRole(IN_KEY), ('instructor', IN_KEY, 'edX')),
|
|
(OrgStaffRole(IN_KEY.org), ('staff', None, 'edX')),
|
|
(CourseFinanceAdminRole(IN_KEY), ('finance_admin', IN_KEY, 'edX')),
|
|
(CourseSalesAdminRole(IN_KEY), ('sales_admin', IN_KEY, 'edX')),
|
|
(LibraryUserRole(IN_KEY), ('library_user', IN_KEY, 'edX')),
|
|
(CourseDataResearcherRole(IN_KEY), ('data_researcher', IN_KEY, 'edX')),
|
|
(OrgInstructorRole(IN_KEY.org), ('instructor', None, 'edX')),
|
|
(CourseBetaTesterRole(IN_KEY), ('beta_testers', IN_KEY, 'edX')),
|
|
)
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.user = UserFactory()
|
|
|
|
@ddt.data(*ROLES)
|
|
@ddt.unpack
|
|
def test_only_in_role(self, role, target):
|
|
role.add_users(self.user)
|
|
cache = RoleCache(self.user)
|
|
assert cache.has_role(*target)
|
|
|
|
for other_role, other_target in self.ROLES:
|
|
if other_role == role:
|
|
continue
|
|
|
|
role_base_id = getattr(role, "BASE_ROLE", None)
|
|
other_role_id = getattr(other_role, "ROLE", None)
|
|
|
|
if other_role_id and role_base_id == other_role_id:
|
|
assert cache.has_role(*other_target)
|
|
else:
|
|
assert not cache.has_role(*other_target)
|
|
|
|
@ddt.data(*ROLES)
|
|
@ddt.unpack
|
|
def test_empty_cache(self, role, target): # lint-amnesty, pylint: disable=unused-argument
|
|
cache = RoleCache(self.user)
|
|
assert not cache.has_role(*target)
|