diff --git a/cms/envs/common.py b/cms/envs/common.py index c95feee1c9bf17e117935974f0fd22999dd7e13c..b6b0067d489bafeb993109c94e73ecbb12442877 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 c0271ad5d0502b2ee7f5ba5fe69860fdc109677c..f17437ccaedfbf3b2fb5361c559ba8f5ffb6e49f 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 90e711029fceac3a0483c7c12869771b413bfd66..d73207736379eec80f3d0e18355d9d8d915619b4 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 85e2ca1dc003952233e3a97cf7c9ee4841d3a0e0..27b161354e16d834a343f99c1539a98ce0ad9b2c 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 3d2acc14022b2f7c8d43bfdaa328362724347283..6c43c1511cb5a0fc97509dd85d8b6a109332803a 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, ) - - # 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 user_group not in groups: + missing_groups.append((partition, user_group, groups)) + + 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 3b1502aab3f0569f76c86baa3700ffeb347f71cc..e3bdfeb8d0c03012ab8295cd7af3141c43afd995 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 f9ca31b1acc145a26bca9119a80bd51327b35435..285262eaa4ce7607c2a1f6c30c77cf2715084541 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 1e22da7ba589c4b53b57074c07dc1b5969696d82..6f6697c75445a3a39db06772783edef035cf083d 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 1ac7a0973679e11e02809a2c18b80b9c6c28c9a4..8165169a7ff0b1e17e99d9e15aa5142991ff22c6 100644 --- a/lms/templates/vert_module.html +++ b/lms/templates/vert_module.html @@ -10,8 +10,10 @@ <div class="vert-mod"> % for idx, item in enumerate(items): - <div class="vert vert-${idx}" data-id="${item['id']}"> - ${HTML(item['content'])} - </div> + % if item['content']: + <div class="vert vert-${idx}" data-id="${item['id']}"> + ${HTML(item['content'])} + </div> + %endif % endfor </div> diff --git a/openedx/features/content_type_gating/block_transformers.py b/openedx/features/content_type_gating/block_transformers.py new file mode 100644 index 0000000000000000000000000000000000000000..5f4243cc8e80fbdd31ed14749f0667de06c7db94 --- /dev/null +++ b/openedx/features/content_type_gating/block_transformers.py @@ -0,0 +1,49 @@ +""" +Content Type Gate Transformer implementation. +Limits access for certain users to certain types of content. +""" +from django.conf import settings + +from openedx.core.djangoapps.content.block_structure.transformer import ( + BlockStructureTransformer, +) +from openedx.features.content_type_gating.partitions import CONTENT_GATING_PARTITION_ID + + +class ContentTypeGateTransformer(BlockStructureTransformer): + """ + A transformer that adds a partition condition for all graded content + so that the content is only visible to verified users. + """ + WRITE_VERSION = 1 + READ_VERSION = 1 + + @classmethod + def name(cls): + """ + Unique identifier for the transformer's class; + same identifier used in setup.py. + """ + return "content_type_gate" + + @classmethod + def collect(cls, block_structure): + """ + Collects any information that's necessary to execute this + transformer's transform method. + """ + block_structure.request_xblock_fields('group_access', 'graded', 'has_score') + + def transform(self, usage_info, block_structure): + for block_key in block_structure.topological_traversal(): + graded = block_structure.get_xblock_field(block_key, 'graded') + has_score = block_structure.get_xblock_field(block_key, 'has_score') + if graded and has_score: + current_access = block_structure.get_xblock_field(block_key, 'group_access') + if current_access is None: + current_access = {} + current_access.setdefault( + CONTENT_GATING_PARTITION_ID, + [settings.CONTENT_TYPE_GATE_GROUP_IDS['full_access']] + ) + block_structure.override_xblock_field(block_key, 'group_access', current_access) diff --git a/openedx/features/content_type_gating/field_override.py b/openedx/features/content_type_gating/field_override.py new file mode 100644 index 0000000000000000000000000000000000000000..40a42149e9f0332c772362dbec771448d9daeb38 --- /dev/null +++ b/openedx/features/content_type_gating/field_override.py @@ -0,0 +1,40 @@ +""" +FieldOverride that forces graded components to be only accessible to +students in the Unlocked Group of the ContentTypeGating partition. +""" +from django.conf import settings + +from lms.djangoapps.courseware.field_overrides import FieldOverrideProvider, disable_overrides +from openedx.features.content_type_gating.partitions import CONTENT_GATING_PARTITION_ID + + +class ContentTypeGatingFieldOverride(FieldOverrideProvider): + """ + A concrete implementation of + :class:`~courseware.field_overrides.FieldOverrideProvider` which forces + graded content to only be accessible to the Full Access group + """ + def get(self, block, name, default): + if name != 'group_access': + return default + + if not (getattr(block, 'graded', False) and block.has_score): + return default + + # Read the group_access from the fallback field-data service + with disable_overrides(): + original_group_access = block.group_access + + if original_group_access is None: + original_group_access = {} + original_group_access.setdefault( + CONTENT_GATING_PARTITION_ID, + [settings.CONTENT_TYPE_GATE_GROUP_IDS['full_access']] + ) + + return original_group_access + + @classmethod + def enabled_for(cls, course): + """This simple override provider is always enabled""" + return True diff --git a/openedx/features/content_type_gating/partitions.py b/openedx/features/content_type_gating/partitions.py index 0f82e0500a775ed34082dac4efe39f9a19d3f4c9..f2a120758a1ec018578e9a0378f6255499f77d20 100644 --- a/openedx/features/content_type_gating/partitions.py +++ b/openedx/features/content_type_gating/partitions.py @@ -7,8 +7,11 @@ of audit learners. import logging +from course_modes.models import CourseMode + from django.utils.translation import ugettext_lazy as _ +from django.apps import apps from lms.djangoapps.courseware.masquerade import ( get_course_masquerade, is_masquerading_as_specific_student, @@ -20,13 +23,13 @@ from openedx.features.course_duration_limits.config import ( CONTENT_TYPE_GATING_STUDIO_UI_FLAG, ) - LOG = logging.getLogger(__name__) # Studio generates partition IDs starting at 100. There is already a manually generated # partition for Enrollment Track that uses ID 50, so we'll use 51. CONTENT_GATING_PARTITION_ID = 51 + CONTENT_TYPE_GATE_GROUP_IDS = { 'limited_access': 1, 'full_access': 2, @@ -102,7 +105,48 @@ class ContentTypeGatingPartitionScheme(object): # For now, treat everyone as a Full-access user, until we have the rest of the # feature gating logic in place. - return cls.FULL_ACCESS + + if not CONTENT_TYPE_GATING_FLAG.is_enabled(): + return cls.FULL_ACCESS + + # If CONTENT_TYPE_GATING is enabled use the following logic to determine whether a user should have FULL_ACCESS + # or LIMITED_ACCESS + + course_mode = apps.get_model('course_modes.CourseMode') + modes = course_mode.modes_for_course(course_key, include_expired=True, only_selectable=False) + modes_dict = {mode.slug: mode for mode in modes} + + # If there is no verified mode, all users are granted FULL_ACCESS + if not course_mode.has_verified_mode(modes_dict): + return cls.FULL_ACCESS + + course_enrollment = apps.get_model('student.CourseEnrollment') + + mode_slug, is_active = course_enrollment.enrollment_mode_for_user(user, course_key) + + if mode_slug and is_active: + course_mode = course_mode.mode_for_course( + course_key, + mode_slug, + modes=modes, + ) + if course_mode is None: + LOG.error( + "User %s is in an unknown CourseMode '%s'" + " for course %s. Granting full access to content for this user", + user.username, + mode_slug, + course_key, + ) + return cls.FULL_ACCESS + + if mode_slug == CourseMode.AUDIT: + return cls.LIMITED_ACCESS + else: + return cls.FULL_ACCESS + else: + # Unenrolled users don't get gated content + return cls.LIMITED_ACCESS @classmethod def create_user_partition(cls, id, name, description, groups=None, parameters=None, active=True): # pylint: disable=redefined-builtin, invalid-name, unused-argument diff --git a/setup.py b/setup.py index 4da1792d72bf5c3166f9c104e4314f8d9a51a7a1..446b43eb1c3fe57f64b0b8ab43380000ac744c6d 100644 --- a/setup.py +++ b/setup.py @@ -60,7 +60,8 @@ setup( "milestones = lms.djangoapps.course_api.blocks.transformers.milestones:MilestonesAndSpecialExamsTransformer", "grades = lms.djangoapps.grades.transformer:GradesTransformer", "completion = lms.djangoapps.course_api.blocks.transformers.block_completion:BlockCompletionTransformer", - "load_override_data = lms.djangoapps.course_blocks.transformers.load_override_data:OverrideDataTransformer" + "load_override_data = lms.djangoapps.course_blocks.transformers.load_override_data:OverrideDataTransformer", + "content_type_gate = openedx.features.content_type_gating.block_transformers:ContentTypeGateTransformer", ], "openedx.ace.policy": [ "bulk_email_optout = lms.djangoapps.bulk_email.policies:CourseEmailOptout"