From a1c7c5f7e94f5ceb61b1dbe087226c6f2ffcac05 Mon Sep 17 00:00:00 2001
From: Nimisha Asthagiri <nasthagiri@edx.org>
Date: Wed, 28 Oct 2015 19:08:28 -0400
Subject: [PATCH] Transformer: UserPartitionTransformer

---
 lms/djangoapps/course_blocks/transformers/tests/test_user_partitions.py | 233 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 lms/djangoapps/course_blocks/transformers/user_partitions.py            | 263 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 496 insertions(+)
 create mode 100644 lms/djangoapps/course_blocks/transformers/tests/test_user_partitions.py
 create mode 100644 lms/djangoapps/course_blocks/transformers/user_partitions.py

diff --git a/lms/djangoapps/course_blocks/transformers/tests/test_user_partitions.py b/lms/djangoapps/course_blocks/transformers/tests/test_user_partitions.py
new file mode 100644
index 0000000..799a1c2
--- /dev/null
+++ b/lms/djangoapps/course_blocks/transformers/tests/test_user_partitions.py
@@ -0,0 +1,233 @@
+# pylint: disable=attribute-defined-outside-init, protected-access
+"""
+Tests for UserPartitionTransformer.
+"""
+import ddt
+
+from openedx.core.djangoapps.course_groups.partition_scheme import CohortPartitionScheme
+from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory, config_course_cohorts
+from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort
+from openedx.core.djangoapps.course_groups.views import link_cohort_to_partition_group
+from student.tests.factories import CourseEnrollmentFactory
+from xmodule.partitions.partitions import Group, UserPartition
+
+from ...api import get_course_blocks
+from ..user_partitions import UserPartitionTransformer, _MergedGroupAccess
+from .test_helpers import CourseStructureTestCase
+
+
+class UserPartitionTestMixin(object):
+    """
+    Helper Mixin for testing user partitions.
+    """
+    def setup_groups_partitions(self, num_user_partitions=1, num_groups=4):
+        """
+        Sets up groups and user partitions for testing.
+        """
+        # Set up groups
+        self.groups = []
+        for group_num in range(1, num_groups + 1):
+            self.groups.append(Group(group_num, 'Group ' + unicode(group_num)))
+
+        # Set up user partitions
+        self.user_partitions = []
+        for user_partition_num in range(1, num_user_partitions + 1):
+            user_partition = UserPartition(
+                id=user_partition_num,
+                name='Partition ' + unicode(user_partition_num),
+                description='This is partition ' + unicode(user_partition_num),
+                groups=self.groups,
+                scheme=CohortPartitionScheme
+            )
+            user_partition.scheme.name = "cohort"
+            self.user_partitions.append(user_partition)
+
+    def setup_chorts(self, course):
+        """
+        Sets up a cohort for each previously created user partition.
+        """
+        for user_partition in self.user_partitions:
+            config_course_cohorts(course, is_cohorted=True)
+            self.cohorts = []
+            for group in self.groups:
+                cohort = CohortFactory(course_id=course.id)
+                self.cohorts.append(cohort)
+                link_cohort_to_partition_group(
+                    cohort,
+                    user_partition.id,
+                    group.id,
+                )
+
+
+@ddt.ddt
+class UserPartitionTransformerTestCase(UserPartitionTestMixin, CourseStructureTestCase):
+    """
+    UserPartitionTransformer Test
+    """
+    def setUp(self):
+        """
+        Setup course structure and create user for user partition
+        transformer test.
+        """
+        super(UserPartitionTransformerTestCase, self).setUp()
+
+        # Set up user partitions and groups.
+        self.setup_groups_partitions()
+        self.user_partition = self.user_partitions[0]
+
+        # Build course.
+        self.course_hierarchy = self.get_course_hierarchy()
+        self.blocks = self.build_course(self.course_hierarchy)
+        self.course = self.blocks['course']
+
+        # Enroll user in course.
+        CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, is_active=True)
+
+        # Set up cohorts.
+        self.setup_chorts(self.course)
+
+        self.transformer = UserPartitionTransformer()
+
+    def get_course_hierarchy(self):
+        """
+        Returns a course hierarchy to test with.
+        """
+        #                                     course
+        #                                  /          \
+        #                                 /            \
+        #                      A[1, 2, 3]                B
+        #                     /  |      \                |
+        #                   /    |       \               |
+        #                 /      |        \              |
+        #           C[1, 2]    D[2, 3]     E            /
+        #         / |     \      |        / \          /
+        #        /  |     \      |       /   \        /
+        #       /   |     \      |      /     \      /
+        #     F   G[1]   H[2]    I     J     K[4]   /
+        #        /   \    /                  /  \  /
+        #       /    \   /                  /   \ /
+        #      /     \  /                  /    \/
+        #  L[1, 2]  M[1, 2, 3]            N     O
+        #
+        return [
+            {
+                'org': 'UserPartitionTransformer',
+                'course': 'UP101F',
+                'run': 'test_run',
+                'user_partitions': [self.user_partition],
+                '#type': 'course',
+                '#ref': 'course',
+                '#children': [
+                    {
+                        '#type': 'vertical',
+                        '#ref': 'A',
+                        'metadata': {'group_access': {self.user_partition.id: [0, 1, 2, 3]}},
+                    },
+                    {'#type': 'vertical', '#ref': 'B'},
+                ],
+            },
+            {
+                '#type': 'vertical',
+                '#ref': 'C',
+                '#parents': ['A'],
+                'metadata': {'group_access': {self.user_partition.id: [1, 2]}},
+                '#children': [
+                    {'#type': 'vertical', '#ref': 'F'},
+                    {
+                        '#type': 'vertical',
+                        '#ref': 'G',
+                        'metadata': {'group_access': {self.user_partition.id: [1]}},
+                    },
+                    {
+                        '#type': 'vertical',
+                        '#ref': 'H',
+                        'metadata': {'group_access': {self.user_partition.id: [2]}},
+                    },
+                ],
+            },
+            {
+                '#type': 'vertical',
+                '#ref': 'D',
+                '#parents': ['A'],
+                'metadata': {'group_access': {self.user_partition.id: [2, 3]}},
+                '#children': [{'#type': 'vertical', '#ref': 'I'}],
+            },
+            {
+                '#type': 'vertical',
+                '#ref': 'E',
+                '#parents': ['A'],
+                '#children': [{'#type': 'vertical', '#ref': 'J'}],
+            },
+            {
+                '#type': 'vertical',
+                '#ref': 'K',
+                '#parents': ['E'],
+                'metadata': {'group_access': {self.user_partition.id: [4]}},
+                '#children': [{'#type': 'vertical', '#ref': 'N'}],
+            },
+            {
+                '#type': 'vertical',
+                '#ref': 'L',
+                '#parents': ['G'],
+                'metadata': {'group_access': {self.user_partition.id: [1, 2]}},
+            },
+            {
+                '#type': 'vertical',
+                '#ref': 'M',
+                '#parents': ['G', 'H'],
+                'metadata': {'group_access': {self.user_partition.id: [1, 2, 3]}},
+            },
+            {
+                '#type': 'vertical',
+                '#ref': 'O',
+                '#parents': ['K', 'B'],
+            },
+        ]
+
+    @ddt.data(
+        (None, ('course', 'B', 'O')),
+        (1, ('course', 'A', 'B', 'C', 'E', 'F', 'G', 'J', 'L', 'M', 'O')),
+        (2, ('course', 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'I', 'J', 'M', 'O')),
+        (3, ('course', 'A', 'B', 'D', 'E', 'I', 'J', 'O')),
+        (4, ('course', 'B', 'O')),
+    )
+    @ddt.unpack
+    def test_transform(self, group_id, expected_blocks):
+        if group_id:
+            add_user_to_cohort(self.cohorts[group_id - 1], self.user.username)
+
+        trans_block_structure = get_course_blocks(
+            self.user,
+            self.course.location,
+            transformers={self.transformer}
+        )
+        self.assertSetEqual(
+            set(trans_block_structure.get_block_keys()),
+            self.get_block_key_set(self.blocks, *expected_blocks)
+        )
+
+
+@ddt.ddt
+class MergedGroupAccessTestCase(UserPartitionTestMixin, CourseStructureTestCase):
+    """
+    _MergedGroupAccess Test
+    """
+    # TODO Test Merged Group Access (MA-1624)
+
+    @ddt.data(
+        ([None], None),
+        ([{1}, None], {1}),
+        ([None, {1}], {1}),
+        ([None, {1}, {1, 2}], {1}),
+        ([None, {1, 2}, {1, 2}], {1, 2}),
+        ([{1, 2, 3}, {1, 2}, None], {1, 2}),
+        ([{1, 2}, {1, 2, 3, 4}, None], {1, 2}),
+        ([{1}, {2}, None], set()),
+        ([None, {1}, {2}, None], set()),
+    )
+    @ddt.unpack
+    def test_intersection_method(self, input_value, expected_result):
+        self.assertEquals(
+            _MergedGroupAccess._intersection(*input_value),
+            expected_result,
+        )
diff --git a/lms/djangoapps/course_blocks/transformers/user_partitions.py b/lms/djangoapps/course_blocks/transformers/user_partitions.py
new file mode 100644
index 0000000..337dc0e
--- /dev/null
+++ b/lms/djangoapps/course_blocks/transformers/user_partitions.py
@@ -0,0 +1,263 @@
+"""
+User Partitions Transformer
+"""
+from openedx.core.lib.block_cache.transformer import BlockStructureTransformer
+
+from .split_test import SplitTestTransformer
+from .utils import get_field_on_block
+
+
+class UserPartitionTransformer(BlockStructureTransformer):
+    """
+    A transformer that enforces the group access rules on course blocks,
+    by honoring their user_partitions and group_access fields, and
+    removing all blocks in the block structure to which the user does
+    not have group access.
+
+    Staff users are *not* exempted from user partition pathways.
+    """
+    VERSION = 1
+
+    @classmethod
+    def name(cls):
+        """
+        Unique identifier for the transformer's class;
+        same identifier used in setup.py.
+        """
+        return "user_partitions"
+
+    @classmethod
+    def collect(cls, block_structure):
+        """
+        Computes any information for each XBlock that's necessary to
+        execute this transformer's transform method.
+
+        Arguments:
+            block_structure (BlockStructureCollectedData)
+        """
+        # First have the split test transformer setup its group access
+        # data for each block.
+        SplitTestTransformer.collect(block_structure)
+
+        # Because user partitions are course-wide, only store data for
+        # them on the root block.
+        root_block = block_structure.get_xblock(block_structure.root_block_usage_key)
+        user_partitions = getattr(root_block, 'user_partitions', []) or []
+        block_structure.set_transformer_data(cls, 'user_partitions', user_partitions)
+
+        # If there are no user partitions, this transformation is a
+        # no-op, so there is nothing to collect.
+        if not user_partitions:
+            return
+
+        # For each block, compute merged group access. Because this is a
+        # topological sort, we know a block's parents are guaranteed to
+        # already have merged group access computed before the block
+        # itself.
+        for block_key in block_structure.topological_traversal():
+            xblock = block_structure.get_xblock(block_key)
+            parent_keys = block_structure.get_parents(block_key)
+            merged_parent_access_list = [
+                block_structure.get_transformer_block_field(parent_key, cls, 'merged_group_access')
+                for parent_key in parent_keys
+            ]
+            merged_group_access = _MergedGroupAccess(user_partitions, xblock, merged_parent_access_list)
+            block_structure.set_transformer_block_field(block_key, cls, 'merged_group_access', merged_group_access)
+
+    def transform(self, usage_info, block_structure):
+        """
+        Mutates block_structure and block_data based on the given
+        usage_info.
+
+        Arguments:
+            usage_info (object)
+            block_structure (BlockStructureCollectedData)
+        """
+        SplitTestTransformer().transform(usage_info, block_structure)
+
+        user_partitions = block_structure.get_transformer_data(self, 'user_partitions')
+
+        if not user_partitions:
+            return
+
+        user_groups = _get_user_partition_groups(
+            usage_info.course_key, user_partitions, usage_info.user
+        )
+        block_structure.remove_block_if(
+            lambda block_key: not block_structure.get_transformer_block_field(
+                block_key, self, 'merged_group_access'
+            ).check_group_access(user_groups)
+        )
+
+
+class _MergedGroupAccess(object):
+    """
+    A class object to represent the computed access value for a block,
+    merged from the inherited values from its ancestors.
+
+    Note: The implementation assumes that the block structure is
+    topologically traversed so that all parents' merged accesses are
+    computed before a block's.
+
+    How group access restrictions are represented within an XBlock:
+      - group_access not defined
+                    => No group access restrictions.
+      - For each partition:
+          - partition.id not in group_access
+                    => All groups have access for this partition
+          - group_access[partition_id] is None
+                    => All groups have access for this partition
+          - group_access[partition_id] == []
+                    => All groups have access for this partition
+          - group_access[partition_id] == [group1..groupN]
+                    => groups 1..N have access for this partition
+
+    We internally represent the restrictions in a simplified way:
+      - self._access == {}
+                    => No group access restrictions.
+      - For each partition:
+          - partition.id not in _access
+                    => All groups have access for this partition
+          - _access[partition_id] == set()
+                    => No groups have access for this partition
+          - _access[partition_id] == set(group1..groupN)
+                    => groups 1..N have access for this partition
+
+    Note that a user must have access to all partitions in group_access
+    or _access in order to access a block.
+    """
+    def __init__(self, user_partitions, xblock, merged_parent_access_list):
+        """
+        Arguments:
+            user_partitions (list[UserPartition])
+            xblock (XBlock)
+            merged_parent_access_list (list[_MergedGroupAccess])
+        """
+
+        # { partition.id: set(IDs of groups that can access partition) }
+        # If partition id is absent in this dict, no group access
+        # restrictions exist for that partition.
+        self._access = {}
+
+        # Get the group_access value that is directly set on the xblock.
+        # Do not get the inherited value since field inheritance doesn't
+        # take a union of them for DAGs.
+        xblock_group_access = get_field_on_block(xblock, 'group_access', default_value={})
+
+        for partition in user_partitions:
+            # Running list of all groups that have access to this
+            # block, computed as a "union" from all parent chains.
+            #
+            # Set the default to universal access, for the case when
+            # there are no parents.
+            merged_parent_group_ids = None
+
+            if merged_parent_access_list:
+                # Set the default to most restrictive as we iterate
+                # through all the parent chains.
+                merged_parent_group_ids = set()
+
+                # Loop through parent_access from each parent-chain
+                for merged_parent_access in merged_parent_access_list:
+                    # pylint: disable=protected-access
+                    if partition.id in merged_parent_access._access:
+                        # Since this parent has group access restrictions,
+                        # merge it with the running list of
+                        # parent-introduced restrictions.
+                        merged_parent_group_ids.update(merged_parent_access._access[partition.id])
+                    else:
+                        # Since at least one parent chain has no group
+                        # access restrictions for this partition, allow
+                        # unfettered group access or this partition.
+                        merged_parent_group_ids = None
+                        break
+
+            # Group access for this partition as stored on the xblock
+            xblock_partition_access = set(xblock_group_access.get(partition.id, [])) or None
+
+            # Compute this block's access by intersecting the block's
+            # own access with the merged access from its parent chains.
+            merged_group_ids = _MergedGroupAccess._intersection(xblock_partition_access, merged_parent_group_ids)
+
+            # Add this partition's access only if group restrictions
+            # exist.
+            if merged_group_ids is not None:
+                self._access[partition.id] = merged_group_ids
+
+    @staticmethod
+    def _intersection(*sets):
+        """
+        Compute an intersection of sets, interpreting None as the
+        Universe set.
+
+        This makes __init__ a bit more elegant.
+
+        Arguments:
+            sets (list[set or None]), where None represents the Universe
+            set.
+
+        Returns:
+            set or None, where None represents the Universe set.
+        """
+        non_universe_sets = [set_ for set_ in sets if set_ is not None]
+        if non_universe_sets:
+            first, rest = non_universe_sets[0], non_universe_sets[1:]
+            return first.intersection(*rest)
+        else:
+            return None
+
+    def check_group_access(self, user_groups):
+        """
+        Arguments:
+            dict[int: Group]: Given a user, a mapping from user
+                partition IDs to the group to which the user belongs in
+                each partition.
+
+        Returns:
+            bool: Whether said user has group access.
+        """
+        for partition_id, allowed_group_ids in self._access.iteritems():
+
+            # If the user is not assigned to a group for this partition,
+            # deny access.
+            if partition_id not in user_groups:
+                return False
+
+            # If the user belongs to one of the allowed groups for this
+            # partition, then move and check the next partition.
+            elif user_groups[partition_id].id in allowed_group_ids:
+                continue
+
+            # Else, deny access.
+            else:
+                return False
+
+        # The user has access for every partition, grant access.
+        return True
+
+
+def _get_user_partition_groups(course_key, user_partitions, user):
+    """
+    Collect group ID for each partition in this course for this user.
+
+    Arguments:
+        course_key (CourseKey)
+        user_partitions (list[UserPartition])
+        user (User)
+
+    Returns:
+        dict[int: Group]: Mapping from user partitions to the group to
+            which the user belongs in each partition. If the user isn't
+            in a group for a particular partition, then that partition's
+            ID will not be in the dict.
+    """
+    partition_groups = {}
+    for partition in user_partitions:
+        group = partition.scheme.get_group_for_user(
+            course_key,
+            user,
+            partition,
+        )
+        if group is not None:
+            partition_groups[partition.id] = group
+    return partition_groups
--
libgit2 0.26.0