From c0b6260c3e8cd6bf452bedf9eaa060c01ff2699f Mon Sep 17 00:00:00 2001
From: Nizar Mahmoud <nizarmah@hotmail.com>
Date: Thu, 6 Aug 2020 20:55:08 +0300
Subject: [PATCH] Adds Custom Extra Fields to Course Blocks API through LMS
 Settings

---
 .../course_api/blocks/serializers.py          |  5 +-
 .../course_api/blocks/tests/test_views.py     | 59 ++++++++++++++++
 .../blocks/transformers/blocks_api.py         |  4 ++
 .../blocks/transformers/extra_fields.py       | 69 +++++++++++++++++++
 .../transformers/tests/test_extra_fields.py   | 61 ++++++++++++++++
 lms/djangoapps/course_api/blocks/views.py     |  4 ++
 lms/envs/common.py                            |  5 ++
 lms/envs/test.py                              |  5 ++
 8 files changed, 211 insertions(+), 1 deletion(-)
 create mode 100644 lms/djangoapps/course_api/blocks/transformers/extra_fields.py
 create mode 100644 lms/djangoapps/course_api/blocks/transformers/tests/test_extra_fields.py

diff --git a/lms/djangoapps/course_api/blocks/serializers.py b/lms/djangoapps/course_api/blocks/serializers.py
index eee1d5d4ed7..d8361fa81f6 100644
--- a/lms/djangoapps/course_api/blocks/serializers.py
+++ b/lms/djangoapps/course_api/blocks/serializers.py
@@ -15,6 +15,7 @@ from .transformers.block_counts import BlockCountsTransformer
 from .transformers.milestones import MilestonesAndSpecialExamsTransformer
 from .transformers.navigation import BlockNavigationTransformer
 from .transformers.student_view import StudentViewTransformer
+from .transformers.extra_fields import ExtraFieldsTransformer
 
 
 class SupportedFieldType(object):
@@ -80,7 +81,9 @@ SUPPORTED_FIELDS = [
         BlockCompletionTransformer.COMPLETION,
         BlockCompletionTransformer,
         'completion'
-    )
+    ),
+
+    *[SupportedFieldType(field_name) for field_name in ExtraFieldsTransformer.get_requested_extra_fields()],
 ]
 
 # This lists the names of all fields that are allowed
diff --git a/lms/djangoapps/course_api/blocks/tests/test_views.py b/lms/djangoapps/course_api/blocks/tests/test_views.py
index 99061b2cd7f..811cbc87c90 100644
--- a/lms/djangoapps/course_api/blocks/tests/test_views.py
+++ b/lms/djangoapps/course_api/blocks/tests/test_views.py
@@ -222,6 +222,65 @@ class TestBlocksView(SharedModuleStoreTestCase):
                 block_data['type'] in self.BLOCK_TYPES_WITH_STUDENT_VIEW_DATA
             )
 
+    def test_extra_field_when_requested(self):
+        """
+        Tests if all requested extra fields appear in output
+
+        Requests the fields specified under "COURSE_BLOCKS_API_EXTRA_FIELDS"
+        in the Test Django settings
+
+        Test setting "COURSE_BLOCKS_API_EXTRA_FIELDS" contains:
+            - other_course_settings
+            - course_visibility
+        """
+        response = self.verify_response(params={
+            'all_blocks': True,
+            'requested_fields': ['other_course_settings', 'course_visibility'],
+        })
+        self.verify_response_block_dict(response)
+        for block_data in six.itervalues(response.data['blocks']):
+            self.assert_in_iff(
+                'other_course_settings',
+                block_data,
+                block_data['type'] == 'course'
+            )
+
+            self.assert_in_iff(
+                'course_visibility',
+                block_data,
+                block_data['type'] == 'course'
+            )
+
+    def test_extra_field_when_not_requested(self):
+        """
+        Tests if fields that weren't requested would appear in output
+
+        Requests some of the fields specified under
+        "COURSE_BLOCKS_API_EXTRA_FIELDS" in the Test Django settings
+        The other extra fields specified in Test Django settings weren't
+        requested to see if they would show up in the output or not
+
+        Test setting "COURSE_BLOCKS_API_EXTRA_FIELDS" contains:
+            - other_course_settings
+            - course_visibility
+        """
+        response = self.verify_response(params={
+            'all_blocks': True,
+            'requested_fields': ['course_visibility'],
+        })
+        self.verify_response_block_dict(response)
+        for block_data in six.itervalues(response.data['blocks']):
+            self.assertNotIn(
+                'other_course_settings',
+                block_data
+            )
+
+            self.assert_in_iff(
+                'course_visibility',
+                block_data,
+                block_data['type'] == 'course'
+            )
+
     def test_navigation_param(self):
         response = self.verify_response(params={'nav_depth': 10})
         self.verify_response_block_dict(response)
diff --git a/lms/djangoapps/course_api/blocks/transformers/blocks_api.py b/lms/djangoapps/course_api/blocks/transformers/blocks_api.py
index 5e42f277c34..bd733a9cfb3 100644
--- a/lms/djangoapps/course_api/blocks/transformers/blocks_api.py
+++ b/lms/djangoapps/course_api/blocks/transformers/blocks_api.py
@@ -10,6 +10,7 @@ from .block_depth import BlockDepthTransformer
 from .navigation import BlockNavigationTransformer
 from .student_view import StudentViewTransformer
 from .video_urls import VideoBlockURLTransformer
+from .extra_fields import ExtraFieldsTransformer
 
 
 class BlocksAPITransformer(BlockStructureTransformer):
@@ -22,6 +23,7 @@ class BlocksAPITransformer(BlockStructureTransformer):
         BlockCountsTransformer
         BlockDepthTransformer
         BlockNavigationTransformer
+        ExtraFieldsTransformer
 
     Note:
         * BlockDepthTransformer must be executed before BlockNavigationTransformer.
@@ -57,6 +59,7 @@ class BlocksAPITransformer(BlockStructureTransformer):
         BlockCountsTransformer.collect(block_structure)
         BlockDepthTransformer.collect(block_structure)
         BlockNavigationTransformer.collect(block_structure)
+        ExtraFieldsTransformer.collect(block_structure)
 
         # TODO support olx_data by calling export_to_xml(?)
 
@@ -69,3 +72,4 @@ class BlocksAPITransformer(BlockStructureTransformer):
         BlockDepthTransformer(self.depth).transform(usage_info, block_structure)
         BlockNavigationTransformer(self.nav_depth).transform(usage_info, block_structure)
         VideoBlockURLTransformer().transform(usage_info, block_structure)
+        ExtraFieldsTransformer().transform(usage_info, block_structure)
diff --git a/lms/djangoapps/course_api/blocks/transformers/extra_fields.py b/lms/djangoapps/course_api/blocks/transformers/extra_fields.py
new file mode 100644
index 00000000000..636adeb0eb7
--- /dev/null
+++ b/lms/djangoapps/course_api/blocks/transformers/extra_fields.py
@@ -0,0 +1,69 @@
+"""
+Extra Fields Transformer
+"""
+from django.conf import settings
+
+from openedx.core.djangoapps.content.block_structure.transformer import BlockStructureTransformer
+
+
+class ExtraFieldsTransformer(BlockStructureTransformer):
+    """
+    A configurable transformer that adds additional XBlock fields to the course blocks API.
+
+    Extra fields must be specified using the "COURSE_BLOCKS_API_EXTRA_FIELDS"
+    LMS Django settings variable. Open edX instances can use this to make
+    additional XBlock fields available via the course blocks API, that aren't
+    otherwise included by default.
+    """
+    WRITE_VERSION = 1
+    READ_VERSION = 1
+
+    @classmethod
+    def name(cls):
+        return "blocks_api:extra_fields"
+
+    @classmethod
+    def collect(cls, block_structure):
+        """
+        Collects any information that's necessary to execute this transformer's
+        transform method.
+        """
+        requested_field_names = cls.get_requested_extra_fields()
+
+        block_structure.request_xblock_fields('category', *requested_field_names)
+
+    @classmethod
+    def get_requested_extra_fields(cls):
+        """
+        Returns the names of the requested extra fields
+        """
+        try:
+            return [field_name for block_type, field_name in settings.COURSE_BLOCKS_API_EXTRA_FIELDS]
+        except AttributeError:
+            return []
+
+    def transform(self, usage_info, block_structure):
+        """
+        Mutates block_structure based on the given usage_info.
+        """
+        if len(self.get_requested_extra_fields()) == 0:
+            return
+
+        for block_key in block_structure.topological_traversal():
+            for requested_block_type, requested_field_name in settings.COURSE_BLOCKS_API_EXTRA_FIELDS:
+                block_type = block_structure.get_xblock_field(block_key, 'category')
+                if (requested_block_type == '*' or
+                        block_type in requested_block_type.split(',')):
+                    requested_field_data = block_structure.get_xblock_field(
+                        block_key,
+                        requested_field_name,
+                        None
+                    )
+
+                    if requested_field_data is not None:
+                        block_structure.set_transformer_block_field(
+                            block_key,
+                            self,
+                            requested_field_name,
+                            requested_field_data
+                        )
diff --git a/lms/djangoapps/course_api/blocks/transformers/tests/test_extra_fields.py b/lms/djangoapps/course_api/blocks/transformers/tests/test_extra_fields.py
new file mode 100644
index 00000000000..e820cdd18dc
--- /dev/null
+++ b/lms/djangoapps/course_api/blocks/transformers/tests/test_extra_fields.py
@@ -0,0 +1,61 @@
+"""
+Tests for ExtraFieldsTransformer.
+"""
+from django.test import override_settings
+
+# pylint: disable=protected-access
+from openedx.core.djangoapps.content.block_structure.factory import BlockStructureFactory
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import SampleCourseFactory
+
+from ..extra_fields import ExtraFieldsTransformer
+
+
+@override_settings(COURSE_BLOCKS_API_EXTRA_FIELDS=[('course', 'other_course_settings')])
+class TestExtraFieldsTransformer(ModuleStoreTestCase):
+    """
+    Test proper behavior for ExtraFieldsTransformer
+    """
+    shard = 4
+
+    OTHER_COURSE_SETTINGS_DEFAULT = {
+        'test key': 'test value',
+        'jackson 5': [
+            ['a', 'b', 'c'],
+            'it\'s easy as',
+            [1, 2, 3],
+            'as simple as',
+            ['do', 're', 'mi']
+        ]
+    }
+
+    def setUp(self):
+        super(TestExtraFieldsTransformer, self).setUp()
+
+        self.course = SampleCourseFactory.create(
+            other_course_settings=self.OTHER_COURSE_SETTINGS_DEFAULT
+        )
+        self.course_key = self.course.id
+
+        self.course_usage_key = self.store.make_course_usage_key(self.course_key)
+        self.block_structure = BlockStructureFactory.create_from_modulestore(self.course_usage_key, self.store)
+
+    def test_transform(self):
+        # collect phase
+        ExtraFieldsTransformer.collect(self.block_structure)
+        self.block_structure._collect_requested_xblock_fields()
+
+        # transform phase
+        ExtraFieldsTransformer().transform(
+            usage_info=None,
+            block_structure=self.block_structure,
+        )
+
+        block_data = self.block_structure.get_transformer_block_data(
+            self.course_usage_key, ExtraFieldsTransformer,
+        )
+
+        self.assertEqual(
+            block_data.other_course_settings,
+            self.OTHER_COURSE_SETTINGS_DEFAULT
+        )
diff --git a/lms/djangoapps/course_api/blocks/views.py b/lms/djangoapps/course_api/blocks/views.py
index 54ae7683834..b9f1b9eb2e8 100644
--- a/lms/djangoapps/course_api/blocks/views.py
+++ b/lms/djangoapps/course_api/blocks/views.py
@@ -187,6 +187,10 @@ class BlocksView(DeveloperErrorViewMixin, ListAPIView):
 
           * show_correctness: Whether to show scores/correctness to learners for the current sequence or problem.
             Returned only if "show_correctness" is included in the "requested_fields" parameter.
+
+          * Additional XBlock fields can be included in the response if they are
+            configured via the COURSE_BLOCKS_API_EXTRA_FIELDS Django setting and
+            requested via the "requested_fields" parameter.
     """
 
     def list(self, request, usage_key_string, hide_access_denials=False):  # pylint: disable=arguments-differ
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 09cc70b8cd1..d29e173f412 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -488,6 +488,11 @@ FEATURES = {
     'ENABLE_ORA_USERNAMES_ON_DATA_EXPORT': False,
 }
 
+# Specifies extra XBlock fields that should available when requested via the Course Blocks API
+# Should be a list of tuples of (block_type, field_name), where block_type can also be "*" for all block types.
+# e.g. COURSE_BLOCKS_API_EXTRA_FIELDS = [  ('course', 'other_course_settings'), ("problem", "weight")  ]
+COURSE_BLOCKS_API_EXTRA_FIELDS = []
+
 # Settings for the course reviews tool template and identification key, set either to None to disable course reviews
 COURSE_REVIEWS_TOOL_PROVIDER_FRAGMENT_NAME = 'coursetalk-reviews-fragment.html'
 COURSE_REVIEWS_TOOL_PROVIDER_PLATFORM_KEY = 'edx'
diff --git a/lms/envs/test.py b/lms/envs/test.py
index 04eef4ee131..55553f765d4 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -486,6 +486,11 @@ FEATURES['ORGANIZATIONS_APP'] = True
 # Financial assistance page
 FEATURES['ENABLE_FINANCIAL_ASSISTANCE_FORM'] = True
 
+COURSE_BLOCKS_API_EXTRA_FIELDS = [
+    ('course', 'course_visibility'),
+    ('course', 'other_course_settings'),
+]
+
 COURSE_CATALOG_URL_ROOT = 'https://catalog.example.com'
 COURSE_CATALOG_API_URL = '{}/api/v1'.format(COURSE_CATALOG_URL_ROOT)
 
-- 
GitLab