diff --git a/cms/envs/common.py b/cms/envs/common.py index c95feee1c9..b6b0067d48 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -114,6 +114,7 @@ from lms.envs.common import ( FILE_UPLOAD_STORAGE_PREFIX, COURSE_ENROLLMENT_MODES, + CONTENT_TYPE_GATE_GROUP_IDS, HELP_TOKENS_BOOKS, diff --git a/common/lib/xmodule/xmodule/partitions/partitions.py b/common/lib/xmodule/xmodule/partitions/partitions.py index c0271ad5d0..f17437ccae 100644 --- a/common/lib/xmodule/xmodule/partitions/partitions.py +++ b/common/lib/xmodule/xmodule/partitions/partitions.py @@ -255,7 +255,7 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche """ return None - def access_denied_fragment(self, block, user, course_key, user_group, allowed_groups): + def access_denied_fragment(self, block, user, user_group, allowed_groups): """ Return an html fragment that should be displayed to the user when they are not allowed to access content managed by this partition, or None if there is no applicable message. diff --git a/common/lib/xmodule/xmodule/partitions/partitions_service.py b/common/lib/xmodule/xmodule/partitions/partitions_service.py index 90e711029f..d732077363 100644 --- a/common/lib/xmodule/xmodule/partitions/partitions_service.py +++ b/common/lib/xmodule/xmodule/partitions/partitions_service.py @@ -8,10 +8,7 @@ from django.utils.translation import ugettext_lazy as _ import logging from openedx.core.lib.cache_utils import request_cached -from openedx.features.content_type_gating.partitions import ( - CONTENT_GATING_PARTITION_ID, - create_content_gating_partition, -) +from openedx.features.content_type_gating.partitions import create_content_gating_partition from xmodule.partitions.partitions import ( UserPartition, UserPartitionError, diff --git a/lms/djangoapps/course_blocks/api.py b/lms/djangoapps/course_blocks/api.py index 85e2ca1dc0..27b161354e 100644 --- a/lms/djangoapps/course_blocks/api.py +++ b/lms/djangoapps/course_blocks/api.py @@ -6,6 +6,8 @@ from django.conf import settings from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager from openedx.core.djangoapps.content.block_structure.transformers import BlockStructureTransformers +from openedx.features.content_type_gating.block_transformers import ContentTypeGateTransformer +from openedx.features.course_duration_limits.config import CONTENT_TYPE_GATING_FLAG from .transformers import ( library_content, @@ -39,12 +41,23 @@ def get_course_block_access_transformers(user): which the block structure is to be transformed. """ - course_block_access_transformers = [ - library_content.ContentLibraryTransformer(), - start_date.StartDateTransformer(), - user_partitions.UserPartitionTransformer(), - visibility.VisibilityTransformer(), - ] + if CONTENT_TYPE_GATING_FLAG.is_enabled(): + # [REV/Revisit] remove this duplicated code when flag is removed + course_block_access_transformers = [ + library_content.ContentLibraryTransformer(), + start_date.StartDateTransformer(), + ContentTypeGateTransformer(), + user_partitions.UserPartitionTransformer(), + visibility.VisibilityTransformer(), + ] + else: + course_block_access_transformers = [ + library_content.ContentLibraryTransformer(), + start_date.StartDateTransformer(), + user_partitions.UserPartitionTransformer(), + visibility.VisibilityTransformer(), + ] + if has_individual_student_override_provider(): course_block_access_transformers += [load_override_data.OverrideDataTransformer(user)] diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 3d2acc1402..6c43c1511c 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -34,6 +34,10 @@ from courseware.access_utils import ( in_preview_mode, check_course_open_for_learner, ) +from courseware.access_response import ( + NoAllowedPartitionGroupsError, + IncorrectPartitionGroupError, +) from courseware.masquerade import get_masquerade_role, is_masquerading_as_student from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException from lms.djangoapps.ccx.models import CustomCourseForEdX @@ -450,11 +454,6 @@ def _has_group_access(descriptor, user, course_key): # use merged_group_access which takes group access on the block's # parents / ancestors into account merged_access = descriptor.merged_group_access - # check for False in merged_access, which indicates that at least one - # partition's group list excludes all students. - if False in merged_access.values(): - log.warning("Group access check excludes all students, access will be denied.", exc_info=True) - return ACCESS_DENIED # resolve the partition IDs in group_access to actual # partition objects, skipping those which contain empty group directives. @@ -465,6 +464,13 @@ def _has_group_access(descriptor, user, course_key): for partition_id, group_ids in merged_access.items(): try: partition = descriptor._get_user_partition(partition_id) # pylint: disable=protected-access + + # check for False in merged_access, which indicates that at least one + # partition's group list excludes all students. + if group_ids is False: + log.warning("Group access check excludes all students, access will be denied.", exc_info=True) + return NoAllowedPartitionGroupsError(partition) + if partition.active: if group_ids is not None: partitions.append(partition) @@ -491,19 +497,34 @@ def _has_group_access(descriptor, user, course_key): log.warning("Error looking up referenced user partition group, access will be denied.", exc_info=True) return ACCESS_DENIED - # look up the user's group for each partition - user_groups = {} + # finally: check that the user has a satisfactory group assignment + # for each partition. + + # missing_groups is the list of groups that the user is NOT in but would NEED to be in order to be granted access. + # For each partition there are group(s) of users that are granted access to this content. + # Below, we loop through each partition and check if the user belongs to one of the appropriate group(s). If they do + # not that group is added to their list of missing_groups. + # If missing_groups is empty, the user is granted access. + # If missing_groups is NOT empty, we generate an error based on one of the particular groups they are missing. + missing_groups = [] for partition, groups in partition_groups: - user_groups[partition.id] = partition.scheme.get_group_for_user( + user_group = partition.scheme.get_group_for_user( course_key, user, partition, ) + if user_group not in groups: + missing_groups.append((partition, user_group, groups)) - # finally: check that the user has a satisfactory group assignment - # for each partition. - if not all(user_groups.get(partition.id) in groups for partition, groups in partition_groups): - return ACCESS_DENIED + if missing_groups: + partition, user_group, allowed_groups = missing_groups[0] + return IncorrectPartitionGroupError( + partition=partition, + user_group=user_group, + allowed_groups=allowed_groups, + user_message=partition.access_denied_message(descriptor, user, user_group, allowed_groups), + user_fragment=partition.access_denied_fragment(descriptor, user, user_group, allowed_groups), + ) # all checks passed. return ACCESS_GRANTED @@ -532,12 +553,14 @@ def _has_access_descriptor(user, action, descriptor, course_key=None): # access to this content, then deny access. The problem with calling _has_staff_access_to_descriptor # before this method is that _has_staff_access_to_descriptor short-circuits and returns True # for staff users in preview mode. - if not _has_group_access(descriptor, user, course_key): - return ACCESS_DENIED + group_access_response = _has_group_access(descriptor, user, course_key) + if not group_access_response: + return group_access_response # If the user has staff access, they can load the module and checks below are not needed. - if _has_staff_access_to_descriptor(user, descriptor, course_key): - return ACCESS_GRANTED + staff_access_response = _has_staff_access_to_descriptor(user, descriptor, course_key) + if staff_access_response: + return staff_access_response return ( _visible_to_nonstaff_users(descriptor) and diff --git a/lms/djangoapps/courseware/access_response.py b/lms/djangoapps/courseware/access_response.py index 3b1502aab3..e3bdfeb8d0 100644 --- a/lms/djangoapps/courseware/access_response.py +++ b/lms/djangoapps/courseware/access_response.py @@ -165,3 +165,32 @@ class MobileAvailabilityError(AccessError): developer_message = "Course is not available on mobile for this user" user_message = _("You do not have access to this course on a mobile device") super(MobileAvailabilityError, self).__init__(error_code, developer_message, user_message) + + +class IncorrectPartitionGroupError(AccessError): + """ + Access denied because the user is not in the correct user subset. + """ + def __init__(self, partition, user_group, allowed_groups, user_message=None, user_fragment=None): + error_code = "incorrect_user_group" + developer_message = "In partition {}, user was in group {}, but only {} are allowed access".format( + partition.name, + user_group.name if user_group is not None else user_group, + ", ".join(group.name for group in allowed_groups), + ) + super(IncorrectPartitionGroupError, self).__init__( + error_code=error_code, + developer_message=developer_message, + user_message=user_message, + user_fragment=user_fragment + ) + + +class NoAllowedPartitionGroupsError(AccessError): + """ + Access denied because the content is not allowed to any group in a partition. + """ + def __init__(self, partition, user_message=None, user_fragment=None): + error_code = "no_allowed_user_groups" + developer_message = "Group access for {} excludes all students".format(partition.name) + super(NoAllowedPartitionGroupsError, self).__init__(error_code, developer_message, user_message) diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index f9ca31b1ac..285262eaa4 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -38,7 +38,7 @@ from capa.tests.response_xml_factory import OptionResponseXMLFactory from course_modes.models import CourseMode from courseware import module_render as render from courseware.courses import get_course_info_section, get_course_with_access -from lms.djangoapps.courseware.field_overrides import OverrideFieldData +from courseware.access_response import AccessResponse from courseware.masquerade import CourseMasquerade from courseware.model_data import FieldDataCache from courseware.models import StudentModule @@ -47,6 +47,7 @@ from courseware.tests.factories import GlobalStaffFactory, StudentModuleFactory, from courseware.tests.test_submitting_problems import TestSubmittingProblems from courseware.tests.tests import LoginEnrollmentTestCase from lms.djangoapps.lms_xblock.field_data import LmsFieldData +from lms.djangoapps.courseware.field_overrides import OverrideFieldData from openedx.core.djangoapps.credit.api import set_credit_requirement_status, set_credit_requirements from openedx.core.djangoapps.credit.models import CreditCourse from openedx.core.lib.courses import course_image_url @@ -2362,10 +2363,9 @@ class TestFilteredChildren(SharedModuleStoreTestCase): key = obj.scope_ids.usage_id elif isinstance(obj, UsageKey): key = obj - if key == self.parent.scope_ids.usage_id: - return True - return key in self.children_for_user[user] + return AccessResponse(True) + return AccessResponse(key in self.children_for_user[user]) def assertBoundChildren(self, block, user): """ diff --git a/lms/envs/common.py b/lms/envs/common.py index 1e22da7ba5..6f6697c754 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3081,7 +3081,7 @@ FIELD_OVERRIDE_PROVIDERS = () # Modulestore-level field override providers. These field override providers don't # require student context. -MODULESTORE_FIELD_OVERRIDE_PROVIDERS = () +MODULESTORE_FIELD_OVERRIDE_PROVIDERS = ('openedx.features.content_type_gating.field_override.ContentTypeGatingFieldOverride',) # pylint: disable=line-too-long # PROFILE IMAGE CONFIG # WARNING: Certain django storage backends do not support atomic @@ -3417,6 +3417,11 @@ COURSE_ENROLLMENT_MODES = { }, } +CONTENT_TYPE_GATE_GROUP_IDS = { + 'limited_access': 1, + 'full_access': 2, +} + ############## Settings for the Discovery App ###################### COURSES_API_CACHE_TIMEOUT = 3600 # Value is in seconds diff --git a/lms/templates/vert_module.html b/lms/templates/vert_module.html index 1ac7a09736..8165169a7f 100644 --- a/lms/templates/vert_module.html +++ b/lms/templates/vert_module.html @@ -10,8 +10,10 @@