From 0adb654724f89d70c02cb9c82509e6a94b38c907 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 15 Oct 2018 13:20:38 -0400 Subject: [PATCH 1/3] Add a shell app for content_type_gating --- cms/envs/common.py | 1 + lms/envs/common.py | 1 + openedx/features/content_type_gating/__init__.py | 0 openedx/features/content_type_gating/apps.py | 8 ++++++++ .../features/content_type_gating/migrations/__init__.py | 0 5 files changed, 10 insertions(+) create mode 100644 openedx/features/content_type_gating/__init__.py create mode 100644 openedx/features/content_type_gating/apps.py create mode 100644 openedx/features/content_type_gating/migrations/__init__.py diff --git a/cms/envs/common.py b/cms/envs/common.py index 980e7adb93..55dd9981b3 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1163,6 +1163,7 @@ INSTALLED_APPS = [ 'rest_framework_swagger', 'openedx.features.course_duration_limits', + 'openedx.features.content_type_gating', ] diff --git a/lms/envs/common.py b/lms/envs/common.py index 6cd6d0ae54..f43be3d826 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2287,6 +2287,7 @@ INSTALLED_APPS = [ 'openedx.features.learner_analytics', 'openedx.features.portfolio_project', 'openedx.features.course_duration_limits', + 'openedx.features.content_type_gating', 'experiments', diff --git a/openedx/features/content_type_gating/__init__.py b/openedx/features/content_type_gating/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/content_type_gating/apps.py b/openedx/features/content_type_gating/apps.py new file mode 100644 index 0000000000..c1230f73e4 --- /dev/null +++ b/openedx/features/content_type_gating/apps.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class ContentTypeGatingConfig(AppConfig): + name = 'openedx.features.content_type_gating' diff --git a/openedx/features/content_type_gating/migrations/__init__.py b/openedx/features/content_type_gating/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 From 99f8918b74cabed1af64695fb430e5e991c2198c Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 2 Oct 2018 13:45:22 -0400 Subject: [PATCH 2/3] Add an automatic UserPartition and Scheme for Content Type Gating. For now, this UserPartition groups all users as Full-access users. --- .../xmodule/partitions/partitions_service.py | 20 ++- .../xmodule/tests/test_split_test_module.py | 13 ++ lms/djangoapps/course_blocks/api.py | 8 +- openedx/features/content_type_gating/apps.py | 4 + .../content_type_gating/partitions.py | 144 ++++++++++++++++++ setup.py | 1 + 6 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 openedx/features/content_type_gating/partitions.py 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", From f9dd7fe22341ad39d9605c4bee23f731c17d321e Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Thu, 18 Oct 2018 09:34:04 -0400 Subject: [PATCH 3/3] Allow Studio override of Feature Based Enrollment status for individual xblocks --- cms/djangoapps/contentstore/utils.py | 4 ++++ .../xmodule/partitions/tests/test_partitions.py | 14 +++++++++++++- .../grades/tests/test_course_grade_factory.py | 2 +- .../instructor_task/tests/test_tasks_helper.py | 2 +- openedx/features/content_type_gating/partitions.py | 9 ++++++++- openedx/features/course_duration_limits/config.py | 6 ++++++ 6 files changed, 33 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index af2faecfb5..2c5c6c82f0 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -15,6 +15,7 @@ from six import text_type from django_comment_common.models import assign_default_role from django_comment_common.utils import seed_permissions_roles from openedx.core.djangoapps.site_configuration.models import SiteConfiguration +from openedx.features.course_duration_limits.config import CONTENT_TYPE_GATING_STUDIO_UI_FLAG from student import auth from student.models import CourseEnrollment from student.roles import CourseInstructorRole, CourseStaffRole @@ -457,6 +458,9 @@ def get_visibility_partition_info(xblock, course=None): if len(partition["groups"]) > 1 or any(group["selected"] for group in partition["groups"]): selectable_partitions.append(partition) + if CONTENT_TYPE_GATING_STUDIO_UI_FLAG.is_enabled(): + selectable_partitions += get_user_partition_info(xblock, schemes=["content_type_gate"], course=course) + # Now add the cohort user partitions. selectable_partitions = selectable_partitions + get_user_partition_info(xblock, schemes=["cohort"], course=course) diff --git a/common/lib/xmodule/xmodule/partitions/tests/test_partitions.py b/common/lib/xmodule/xmodule/partitions/tests/test_partitions.py index 601799a0a5..6db47039d8 100644 --- a/common/lib/xmodule/xmodule/partitions/tests/test_partitions.py +++ b/common/lib/xmodule/xmodule/partitions/tests/test_partitions.py @@ -4,7 +4,7 @@ Test the partitions and partitions service """ from unittest import TestCase -from mock import Mock +from mock import Mock, patch from opaque_keys.edx.locator import CourseLocator from stevedore.extension import Extension, ExtensionManager @@ -434,6 +434,18 @@ class PartitionServiceBaseClass(PartitionTestCase): def setUp(self): super(PartitionServiceBaseClass, self).setUp() + + content_gating_flag_patcher = patch( + 'openedx.features.content_type_gating.partitions.CONTENT_TYPE_GATING_FLAG.is_enabled', + return_value=True, + ).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=True, + ).start() + self.addCleanup(content_gating_ui_flag_patcher.stop) + self.course = Mock(id=CourseLocator('org_0', 'course_0', 'run_0')) self.partition_service = self._create_service("ma") diff --git a/lms/djangoapps/grades/tests/test_course_grade_factory.py b/lms/djangoapps/grades/tests/test_course_grade_factory.py index 1df89359f7..6aa15af834 100644 --- a/lms/djangoapps/grades/tests/test_course_grade_factory.py +++ b/lms/djangoapps/grades/tests/test_course_grade_factory.py @@ -311,7 +311,7 @@ class TestGradeIteration(SharedModuleStoreTestCase): else mock_course_grade.return_value for student in self.students ] - with self.assertNumQueries(4): + with self.assertNumQueries(6): all_course_grades, all_errors = self._course_grades_and_errors_for(self.course, self.students) self.assertEqual( {student: text_type(all_errors[student]) for student in all_errors}, diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py index 654d97dd28..de07b194e4 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py @@ -413,7 +413,7 @@ class TestInstructorGradeReport(InstructorGradeReportTestCase): RequestCache.clear_all_namespaces() - expected_query_count = 43 + expected_query_count = 45 with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'): with check_mongo_calls(mongo_count): with self.assertNumQueries(expected_query_count): diff --git a/openedx/features/content_type_gating/partitions.py b/openedx/features/content_type_gating/partitions.py index b19b60dd76..0f82e0500a 100644 --- a/openedx/features/content_type_gating/partitions.py +++ b/openedx/features/content_type_gating/partitions.py @@ -15,6 +15,10 @@ from lms.djangoapps.courseware.masquerade import ( get_masquerading_user_group, ) from xmodule.partitions.partitions import Group, UserPartition, UserPartitionError +from openedx.features.course_duration_limits.config import ( + CONTENT_TYPE_GATING_FLAG, + CONTENT_TYPE_GATING_STUDIO_UI_FLAG, +) LOG = logging.getLogger(__name__) @@ -34,6 +38,9 @@ def create_content_gating_partition(course): Create and return the Content Gating user partition. """ + if not (CONTENT_TYPE_GATING_FLAG.is_enabled() or CONTENT_TYPE_GATING_STUDIO_UI_FLAG.is_enabled()): + return None + try: content_gate_scheme = UserPartition.get_scheme("content_type_gate") except UserPartitionError: @@ -56,7 +63,7 @@ def create_content_gating_partition(course): partition = content_gate_scheme.create_user_partition( id=CONTENT_GATING_PARTITION_ID, - name=_(u"Content Type Gating"), + name=_(u"Feature-based Enrollments"), description=_(u"Partition for segmenting users by access to gated content types"), parameters={"course_id": unicode(course.id)} ) diff --git a/openedx/features/course_duration_limits/config.py b/openedx/features/course_duration_limits/config.py index eff4f675e7..23836c1133 100644 --- a/openedx/features/course_duration_limits/config.py +++ b/openedx/features/course_duration_limits/config.py @@ -11,3 +11,9 @@ CONTENT_TYPE_GATING_FLAG = WaffleFlag( flag_name=u'debug', flag_undefined_default=False ) + +CONTENT_TYPE_GATING_STUDIO_UI_FLAG = WaffleFlag( + waffle_namespace=WAFFLE_FLAG_NAMESPACE, + flag_name=u'studio_ui', + flag_undefined_default=False +)