diff --git a/common/lib/xmodule/xmodule/partitions/partitions_service.py b/common/lib/xmodule/xmodule/partitions/partitions_service.py index 51a2b79cec..90e711029f 100644 --- a/common/lib/xmodule/xmodule/partitions/partitions_service.py +++ b/common/lib/xmodule/xmodule/partitions/partitions_service.py @@ -8,7 +8,15 @@ from django.utils.translation import ugettext_lazy as _ import logging from openedx.core.lib.cache_utils import request_cached -from xmodule.partitions.partitions import UserPartition, UserPartitionError, ENROLLMENT_TRACK_PARTITION_ID +from openedx.features.content_type_gating.partitions import ( + CONTENT_GATING_PARTITION_ID, + create_content_gating_partition, +) +from xmodule.partitions.partitions import ( + UserPartition, + UserPartitionError, + ENROLLMENT_TRACK_PARTITION_ID, +) from xmodule.modulestore.django import modulestore @@ -42,8 +50,14 @@ def _get_dynamic_partitions(course): Return the dynamic user partitions for this course. If none exists, returns an empty array. """ - enrollment_partition = _create_enrollment_track_partition(course) - return [enrollment_partition] if enrollment_partition else [] + return [ + partition + for partition in [ + _create_enrollment_track_partition(course), + create_content_gating_partition(course), + ] + if partition + ] def _create_enrollment_track_partition(course): diff --git a/common/lib/xmodule/xmodule/tests/test_split_test_module.py b/common/lib/xmodule/xmodule/tests/test_split_test_module.py index d592de4f4c..c9706d9f9e 100644 --- a/common/lib/xmodule/xmodule/tests/test_split_test_module.py +++ b/common/lib/xmodule/xmodule/tests/test_split_test_module.py @@ -127,6 +127,19 @@ class SplitTestModuleLMSTest(SplitTestModuleTest): """ shard = 1 + def setUp(self): + super(SplitTestModuleLMSTest, self).setUp() + content_gating_flag_patcher = patch( + 'openedx.features.content_type_gating.partitions.CONTENT_TYPE_GATING_FLAG.is_enabled', + return_value=False, + ).start() + self.addCleanup(content_gating_flag_patcher.stop) + content_gating_ui_flag_patcher = patch( + 'openedx.features.content_type_gating.partitions.CONTENT_TYPE_GATING_STUDIO_UI_FLAG.is_enabled', + return_value=False, + ).start() + self.addCleanup(content_gating_ui_flag_patcher.stop) + @ddt.data((0, 'split_test_cond0'), (1, 'split_test_cond1')) @ddt.unpack def test_child(self, user_tag, child_url_name): diff --git a/lms/djangoapps/course_blocks/api.py b/lms/djangoapps/course_blocks/api.py index f63592e741..85e2ca1dc0 100644 --- a/lms/djangoapps/course_blocks/api.py +++ b/lms/djangoapps/course_blocks/api.py @@ -7,7 +7,13 @@ 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 .transformers import library_content, start_date, user_partitions, visibility, load_override_data +from .transformers import ( + library_content, + start_date, + user_partitions, + visibility, + load_override_data, +) from .usage_info import CourseUsageInfo INDIVIDUAL_STUDENT_OVERRIDE_PROVIDER = ( diff --git a/openedx/features/content_type_gating/apps.py b/openedx/features/content_type_gating/apps.py index c1230f73e4..00f3730cfb 100644 --- a/openedx/features/content_type_gating/apps.py +++ b/openedx/features/content_type_gating/apps.py @@ -1,3 +1,7 @@ +""" +Define the content_type_gating Django App. +""" + # -*- coding: utf-8 -*- from __future__ import unicode_literals diff --git a/openedx/features/content_type_gating/partitions.py b/openedx/features/content_type_gating/partitions.py new file mode 100644 index 0000000000..b19b60dd76 --- /dev/null +++ b/openedx/features/content_type_gating/partitions.py @@ -0,0 +1,144 @@ +""" +Define the ContentTypeGatingPartition and ContentTypeGatingPartitionScheme. + +These are used together to allow course content to be blocked for a subset +of audit learners. +""" + +import logging + +from django.utils.translation import ugettext_lazy as _ + +from lms.djangoapps.courseware.masquerade import ( + get_course_masquerade, + is_masquerading_as_specific_student, + get_masquerading_user_group, +) +from xmodule.partitions.partitions import Group, UserPartition, UserPartitionError + + +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, +} + + +def create_content_gating_partition(course): + """ + Create and return the Content Gating user partition. + """ + + try: + content_gate_scheme = UserPartition.get_scheme("content_type_gate") + except UserPartitionError: + LOG.warning("No 'content_type_gate' scheme registered, ContentTypeGatingPartitionScheme will not be created.") + return None + + used_ids = set(p.id for p in course.user_partitions) + if CONTENT_GATING_PARTITION_ID in used_ids: + # It's possible for course authors to add arbitrary partitions via XML import. If they do, and create a + # partition with id 51, it will collide with the Content Gating Partition. We'll catch that here, and + # then fix the course content as needed (or get the course team to). + LOG.warning( + "Can't add 'content_type_gate' partition, as ID {id} is assigned to {partition} in course {course}.".format( + id=CONTENT_GATING_PARTITION_ID, + partition=_get_partition_from_id(course.user_partitions, CONTENT_GATING_PARTITION_ID).name, + course=unicode(course.id) + ) + ) + return None + + partition = content_gate_scheme.create_user_partition( + id=CONTENT_GATING_PARTITION_ID, + name=_(u"Content Type Gating"), + description=_(u"Partition for segmenting users by access to gated content types"), + parameters={"course_id": unicode(course.id)} + ) + return partition + + +class ContentTypeGatingPartition(UserPartition): + pass + + +class ContentTypeGatingPartitionScheme(object): + """ + This scheme implements the Content Type Gating permission partitioning. + + This partitioning is roughly the same as the verified/audit split, but also allows for individual + schools or courses to specify particular learner subsets by email that are allowed to access + the gated content despite not being verified users. + """ + + LIMITED_ACCESS = Group(CONTENT_TYPE_GATE_GROUP_IDS['limited_access'], 'Limited-access Users') + FULL_ACCESS = Group(CONTENT_TYPE_GATE_GROUP_IDS['full_access'], 'Full-access Users') + + @classmethod + def get_group_for_user(cls, course_key, user, user_partition, **kwargs): # pylint: disable=unused-argument + """ + Returns the Group for the specified user. + """ + + # First, check if we have to deal with masquerading. + # If the current user is masquerading as a specific student, use the + # same logic as normal to return that student's group. If the current + # user is masquerading as a generic student in a specific group, then + # return that group. + if get_course_masquerade(user, course_key) and not is_masquerading_as_specific_student(user, course_key): + return get_masquerading_user_group(course_key, user, user_partition) + + # 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 + + @classmethod + def create_user_partition(cls, id, name, description, groups=None, parameters=None, active=True): # pylint: disable=redefined-builtin, invalid-name, unused-argument + """ + Create a custom UserPartition to support dynamic groups. + + A Partition has an id, name, scheme, description, parameters, and a list + of groups. The id is intended to be unique within the context where these + are used. (e.g., for partitions of users within a course, the ids should + be unique per-course). The scheme is used to assign users into groups. + The parameters field is used to save extra parameters e.g., location of + the course ID for this partition scheme. + + Partitions can be marked as inactive by setting the "active" flag to False. + Any group access rule referencing inactive partitions will be ignored + when performing access checks. + """ + return ContentTypeGatingPartition( + id, + unicode(name), + unicode(description), + [ + cls.LIMITED_ACCESS, + cls.FULL_ACCESS, + ], + cls, + parameters, + # N.B. This forces Content Type Gating partitioning to always be active on every course, + # no matter how the course xml content is set. We will manage enabling/disabling + # as a policy in the LMS. + active=True, + ) + + +def _get_partition_from_id(partitions, user_partition_id): + """ + Look for a user partition with a matching id in the provided list of partitions. + + Returns: + A UserPartition, or None if not found. + """ + for partition in partitions: + if partition.id == user_partition_id: + return partition + + return None diff --git a/setup.py b/setup.py index 80a0abadef..4da1792d72 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ setup( "cohort = openedx.core.djangoapps.course_groups.partition_scheme:CohortPartitionScheme", "verification = openedx.core.djangoapps.user_api.partition_schemes:ReturnGroup1PartitionScheme", "enrollment_track = openedx.core.djangoapps.verified_track_content.partition_scheme:EnrollmentTrackPartitionScheme", + "content_type_gate = openedx.features.content_type_gating.partitions:ContentTypeGatingPartitionScheme", ], "openedx.block_structure_transformer": [ "library_content = lms.djangoapps.course_blocks.transformers.library_content:ContentLibraryTransformer",