Repository: box/box-python-sdk
Pull request #329 (2018-08-21 19:06:54)

collaboration endpoint

boxcla: Hi @carycheng, thanks for the pull request. Before we can merge it, we need you to sign our Contributor License Agreement. You can do so electronically here: http://opensource.box.com/cla

Once you have signed, just add a comment to this pull request saying, "CLA signed". Thanks!

Patch:
diff --git a/boxsdk/client/client.py b/boxsdk/client/client.py
index 2779e8e..b3abb81 100644
--- a/boxsdk/client/client.py
+++ b/boxsdk/client/client.py
@@ -538,6 +538,37 @@ def create_user(self, name, login=None, **user_attributes):
             response_object=response,
         )
 
+    def get_pending_collaborations(self, limit=None, offset=None, fields=None):
+        """
+        Get the entries in the pending collaborations using limit-offset paging.
+
+        :param limit:
+            The maximum number of entries to return per page. If not specified, then will use the server-side default.
+        :type limit:
+            `int` or None
+        :param offset:
+            The offset of the item at which to begin the response.
+        :type offset:
+            `int` or None
+        :param fields:
+            List of fields to request.
+        :type fields:
+            `Iterable` of `unicode`
+        :returns:
+            An iterator of the entries in the pending collaborations
+        :rtype:
+            :class:`BoxObjectCollection`
+        """
+        return LimitOffsetBasedObjectCollection(
+            session=self._session,
+            url=self.get_url('collaborations'),
+            additional_params={'status': 'pending'},
+            limit=limit,
+            offset=offset,
+            fields=fields,
+            return_full_pages=False,
+        )
+
     def downscope_token(self, scopes, item=None, additional_data=None):
         """
         Generate a downscoped token for the provided file or folder with the provided scopes.
diff --git a/boxsdk/object/base_api_json_object.py b/boxsdk/object/base_api_json_object.py
index 00b52dc..eecbdd4 100644
--- a/boxsdk/object/base_api_json_object.py
+++ b/boxsdk/object/base_api_json_object.py
@@ -125,6 +125,15 @@ def _description(self):
         """Return a description of the object if one exists."""
         return ""
 
+    @property
+    def object_type(self):
+        """Return the Box type for the object.
+
+        :rtype:
+            `unicode`
+        """
+        return self._item_type
+
     @property
     def response_object(self):
         """
diff --git a/boxsdk/object/collaboration.py b/boxsdk/object/collaboration.py
index 97c7b85..7c14470 100644
--- a/boxsdk/object/collaboration.py
+++ b/boxsdk/object/collaboration.py
@@ -59,3 +59,11 @@ def update_info(self, role=None, status=None):
         if status:
             data['status'] = status
         return super(Collaboration, self).update_info(data=data)
+
+    def accept(self):
+        """Accepts a pending collaboration"""
+        return self.update_info(status='accepted')
+
+    def reject(self):
+        """Rejects a pending collaboration"""
+        return self.update_info(status='rejected')
diff --git a/boxsdk/object/item.py b/boxsdk/object/item.py
index 1050d77..7c8268e 100644
--- a/boxsdk/object/item.py
+++ b/boxsdk/object/item.py
@@ -7,6 +7,7 @@
 from ..exception import BoxAPIException
 from .metadata import Metadata
 from ..util.api_call_decorator import api_call
+from ..pagination.marker_based_object_collection import MarkerBasedObjectCollection
 
 
 class Item(BaseObject):
@@ -398,3 +399,141 @@ def remove_from_collection(self, collection):
             'collections': updated_collections
         }
         return self.update_info(data)
+
+    def collaborate(self, accessible_by, role, can_view_path=None, notify=None, fields=None):
+        """Collaborate user or group onto a Box item.
+
+        :param accessible_by:
+            An object containing the collaborator.
+        :type accessible_by:
+            class:`User` or class:`Group`
+        :param role:
+            The permission level to grant the collaborator.
+        :type role:
+            `unicode`
+        :param can_view_path:
+            Indicates whether the user can view the path of the folder collaborated into.
+        :type can_view_path:
+            `bool` or None
+        :param notify:
+            Determines if the collaborator should receive a notification for the collaboration.
+        :type notify:
+            `bool` or None
+        :param fields:
+            List of fields to request.
+        :type fields:
+            `Iterable` of `unicode`
+        :return:
+            The new collaboration
+        :rtype:
+            :class:`Collaboration`
+        """
+        url = self._session.get_url('collaborations')
+        body = {
+            'item': {
+                'type': self.object_type,
+                'id': self.object_id,
+            },
+            'accessible_by': {
+                'type': accessible_by.object_type,
+                'id': accessible_by.object_id,
+            },
+            'role': role,
+        }
+        if can_view_path is not None:
+            body['can_view_path'] = can_view_path
+        params = {}
+        if fields is not None:
+            params['fields'] = ','.join(fields)
+        if notify is not None:
+            params['notify'] = notify
+        response = self._session.post(url, data=json.dumps(body), params=params).json()
+        return self.translator.translate(response['type'])(
+            session=self._session,
+            object_id=response['id'],
+            response_object=response,
+        )
+
+    def collaborate_with_login(self, login, role, can_view_path=None, notify=None, fields=None):
+        """Collaborate user onto a Box item with the user login.
+
+        :param login:
+            The email address of the person to grant access to.
+        :type login:
+            `unicode`
+        :param role:
+            The permission level to grant the collaborator.
+        :type role:
+            `unicode`
+        :param can_view_path:
+            Indicates whether the user can view the path of the folder collaborated into.
+        :type can_view_path:
+            `bool` or None
+        :param notify:
+            Determines if the collaborator should receive a notification for the collaboration.
+        :type notify:
+            `bool` or None
+        :param fields:
+            List of fields to request.
+        :type fields:
+            `Iterable` of `unicode`
+        :return:
+            The new collaboration with the user login
+        :rtype:
+            :class:`Collaboration`
+        """
+        url = self._session.get_url('collaborations')
+        body = {
+            'item': {
+                'type': self.object_type,
+                'id': self.object_id,
+            },
+            'accessible_by': {
+                'type': 'user',
+                'login': login,
+            },
+            'role': role,
+        }
+        if can_view_path is not None:
+            body['can_view_path'] = can_view_path
+        params = {}
+        if fields is not None:
+            params['fields'] = ','.join(fields)
+        if notify is not None:
+            params['notify'] = notify
+        response = self._session.post(url, data=json.dumps(body), params=params).json()
+        return self.translator.translate(response['type'])(
+            session=self._session,
+            object_id=response['id'],
+            response_object=response,
+        )
+
+    def get_collaborations(self, limit=None, marker=None, fields=None):
+        """
+        Get the entries in the collaboration.
+
+        :param limit:
+            The maximum number of items to return per page. If not specified, then will use the server-side default.
+        :type limit:
+            `int` or None
+        :param marker:
+            The paging marker to start returning items from when using marker-based paging.
+        :type marker:
+            `unicode` or None
+        :param fields:
+            List of fields to request.
+        :type fields:
+            `Iterable` of `unicode`
+        :returns:
+            An iterator of the entries in the collaboration.
+        :rtype:
+            :class:`BoxObjectCollection`
+        """
+        return MarkerBasedObjectCollection(
+            session=self._session,
+            url=self.get_url('collaborations'),
+            limit=limit,
+            marker=marker,
+            fields=fields,
+            return_full_pages=False,
+        )
diff --git a/docs/collaboration.md b/docs/collaboration.md
new file mode 100644
index 0000000..ef895b2
--- /dev/null
+++ b/docs/collaboration.md
@@ -0,0 +1,106 @@
+Collaborations
+==============
+
+Collaborations are used to share folders between users or groups. They also
+define what permissions a user has for a folder.
+
+
+Add a Collaboration
+-------------------
+
+You can add a collaboration on a folder or a file by calling `item.collaborate(accessible_by, role, can_view_path=None, notify=None, fields=None)`. The
+`role` parameter determines what permissions the collaborator will have on the folder.
+
+```python
+from boxsdk.object.collaboration import CollaborationRole
+folder_id = '1234'
+user_id = '1111'
+collaborator = client.user(user_id)
+collaboration = client.folder(folder_id).collaborate(collaborator, CollaborationRole.VIEWER)
+```
+
+Alternatively you can also invite a user with their email address.
+
+```python
+from boxsdk.object.collaboration import CollaborationRole
+folder_id = '1234'
+email_of_invitee = 'testuser@example.com'
+collaboration = client.folder(folder_id).collaborate_with_login(email_of_invitee, CollaborationRole.VIEWER)
+```
+
+Edit a Collaboration
+--------------------
+
+A collaboration can be edited by using `collaboration.update_info()`.
+
+```python
+from boxsdk.object.collaboration import CollaborationRole, CollaborationStatus
+updated_collaboration = client.collaboration('1111').update_info(CollaborationRole.EDITOR, CollaborationStatus.ACCEPTED)
+```
+
+Remove a Collaboration
+----------------------
+
+A collaboration can be removed by calling `collaboration.delete()`.
+
+```python
+collaboration_id = '1111'
+collaboration = client.collaboration(collaboration_id).delete()
+```
+
+Get a Collaboration's Information
+---------------------------------
+
+To get information about a specific collaboration use `collaboration.get()`.
+
+```python
+collaboration_id = '1111'
+collaboration_info = client.collaboration(collaboration_id).get()
+```
+
+List Collaborations on a Folder or File
+----------------------------------------
+
+To retrieve all collaborations on a specified folder you can use `folder.get_collaborations(limit=None, marker=None, fields=None)`. Or to retrieve an iterable of collaborations on a specified file you can use `file.get_collaborations(limit=None, marker=None, fields=None)`.
+
+```python
+folder_id = '1234'
+collaborations = client.folder(folder_id).get_collaborations()
+for collaboration in collaborations:
+    # Do something
+```
+
+```python
+file_id = '2222'
+collaborations = client.file(file_id).get_collaborations()
+for collaboration in collaborations
+    # Do something
+```
+
+List Pending Collaborations
+---------------------------
+
+To retrieve all pending collaborations for the user, use `client.get_pending_collaborations(limit=None, offset=None, fields=None)`.
+
+```python
+pending_collaborations = client.get_pending_collaborations()
+for pending_collaboration in pending_collaborations:
+    # Do something
+```
+
+Accept or Reject a Pending Collaboration
+-----------------------------------------
+
+To `accept` or `reject` a pending collaboration use `collaboration.accept()` or `collaboration.reject()`.
+
+```python
+collaboration_id = '3333'
+updated_collaboration = client.collaboration(collaboration_id).accept()
+```
+
+You can reject a pending collaboration with
+
+```python
+collaboration_id = '3333'
+updated_collaboration = client.collaboration(collaboration_id).reject()
+```


Test patch:
diff --git a/test/unit/client/test_client.py b/test/unit/client/test_client.py
index 039715d..90b0274 100644
--- a/test/unit/client/test_client.py
+++ b/test/unit/client/test_client.py
@@ -17,6 +17,7 @@
 from boxsdk.client import Client, DeveloperTokenClient, DevelopmentClient, LoggingClient
 from boxsdk.config import API
 from boxsdk.network.default_network import DefaultNetworkResponse
+from boxsdk.object.collaboration import Collaboration
 from boxsdk.object.events import Events
 from boxsdk.object.folder import Folder
 from boxsdk.object.file import File
@@ -411,6 +412,33 @@ def test_create_enterprise_user_returns_the_correct_user_object(mock_client, moc
     assert new_user.name == test_user_name
 
 
+def test_get_pending_collaborations(mock_client, mock_box_session):
+    # pylint:disable=redefined-outer-name, protected-access
+    expected_url = '{0}/collaborations'.format(API.BASE_API_URL)
+    mock_collaboration = {
+        'type': 'collaboration',
+        'id': '12345',
+        'created_by': {
+            'type': 'user',
+            'id': '33333',
+        },
+    }
+    mock_box_session.get.return_value.json.return_value = {
+        'total_count': 1,
+        'limit': 2,
+        'offset': 0,
+        'entries': [mock_collaboration],
+    }
+    pending_collaborations = mock_client.get_pending_collaborations(limit=2)
+    pending_collaboration = pending_collaborations.next()
+    mock_box_session.get.assert_called_once_with(expected_url, params={'limit': 2, 'status': 'pending', 'offset': None})
+    assert isinstance(pending_collaboration, Collaboration)
+    assert pending_collaboration.id == mock_collaboration['id']
+    assert pending_collaboration.type == mock_collaboration['type']
+    assert pending_collaboration['created_by']['type'] == 'user'
+    assert pending_collaboration['created_by']['id'] == '33333'
+
+
 @pytest.fixture
 def check_downscope_token_request(
         mock_client,
diff --git a/test/unit/object/test_collaboration.py b/test/unit/object/test_collaboration.py
index 800e3fd..84ec5e4 100644
--- a/test/unit/object/test_collaboration.py
+++ b/test/unit/object/test_collaboration.py
@@ -6,6 +6,7 @@
 
 import pytest
 
+from boxsdk.config import API
 from boxsdk.object.collaboration import CollaborationRole, CollaborationStatus
 
 
@@ -34,3 +35,51 @@ def test_update_info_returns_the_correct_response(
     )
     assert isinstance(update_response, test_collaboration.__class__)
     assert update_response.object_id == test_collaboration.object_id
+
+
+def test_accept_pending_collaboration(test_collaboration, mock_box_session):
+    # pylint:disable=protected-access
+    new_status = 'accepted'
+    expected_url = '{0}/collaborations/{1}'.format(API.BASE_API_URL, test_collaboration.object_id)
+    mock_collab_response = {
+        'type': 'collaboration',
+        'id': '1234',
+        'status': 'accepted',
+    }
+    mock_box_session.put.return_value.json.return_value = mock_collab_response
+    response = test_collaboration.accept()
+    update_body = {
+        'status': 'accepted'
+    }
+    mock_box_session.put.assert_called_once_with(
+        expected_url,
+        data=json.dumps(update_body),
+        headers=None,
+        params=None,
+    )
+    assert isinstance(response, test_collaboration.__class__)
+    assert response.status == new_status
+
+
+def test_reject_pending_collaboration(test_collaboration, mock_box_session):
+    # pylint:disable=protected-access
+    new_status = 'rejected'
+    expected_url = '{0}/collaborations/{1}'.format(API.BASE_API_URL, test_collaboration.object_id)
+    mock_collab_response = {
+        'type': 'collaboration',
+        'id': '1234',
+        'status': 'rejected',
+    }
+    mock_box_session.put.return_value.json.return_value = mock_collab_response
+    response = test_collaboration.reject()
+    update_body = {
+        'status': 'rejected'
+    }
+    mock_box_session.put.assert_called_once_with(
+        expected_url,
+        data=json.dumps(update_body),
+        headers=None,
+        params=None,
+    )
+    assert isinstance(response, test_collaboration.__class__)
+    assert response.status == new_status
diff --git a/test/unit/object/test_item.py b/test/unit/object/test_item.py
index 2bed02f..1bf2c3a 100644
--- a/test/unit/object/test_item.py
+++ b/test/unit/object/test_item.py
@@ -4,6 +4,9 @@
 import json
 import pytest
 
+from boxsdk.config import API
+from boxsdk.object.collaboration import Collaboration
+
 
 @pytest.fixture(params=('file', 'folder'))
 def test_item_and_response(test_file, test_folder, mock_file_response, mock_folder_response, request):
@@ -215,3 +218,122 @@ def test_remove_from_collection(test_item_and_response, mock_box_session, mock_c
 
     mock_box_session.get.assert_called_once_with(expected_url, headers=None, params=expected_params)
     mock_box_session.put.assert_called_once_with(expected_url, data=json.dumps(expected_data), headers=None, params=None)
+
+
+def test_collaborate_with_group(test_item_and_response, test_group, mock_box_session):
+    # pylint:disable=redefined-outer-name, protected-access
+    test_item, _ = test_item_and_response
+    expected_url = '{0}/collaborations'.format(API.BASE_API_URL)
+    expected_data = {
+        'item': {
+            'type': test_item.object_type,
+            'id': test_item.object_id,
+        },
+        'accessible_by': {
+            'type': test_group.object_type,
+            'id': test_group.object_id,
+        },
+        'role': 'editor',
+    }
+    mock_collaboration = {
+        'type': 'collaboration',
+        'id': '1234',
+        'created_by': {
+            'type': 'user',
+            'id': '1111',
+        }
+    }
+    mock_box_session.post.return_value.json.return_value = mock_collaboration
+    collaboration = test_item.collaborate(test_group, 'editor')
+    mock_box_session.post.assert_called_once_with(expected_url, data=json.dumps(expected_data), params={})
+    assert collaboration.id == mock_collaboration['id']
+    assert collaboration['type'] == mock_collaboration['type']
+    assert collaboration['created_by']['id'] == mock_collaboration['created_by']['id']
+
+
+def test_collaborate_with_user(test_item_and_response, mock_user, mock_box_session):
+    # pylint:disable=redefined-outer-name, protected-access
+    test_item, _ = test_item_and_response
+    expected_url = '{0}/collaborations'.format(API.BASE_API_URL)
+    expected_data = {
+        'item': {
+            'type': test_item.object_type,
+            'id': test_item.object_id,
+        },
+        'accessible_by': {
+            'type': mock_user.object_type,
+            'id': mock_user.object_id,
+        },
+        'role': 'editor',
+    }
+    mock_collaboration = {
+        'type': 'collaboration',
+        'id': '1234',
+        'created_by': {
+            'type': 'user',
+            'id': '1111',
+        }
+    }
+    mock_box_session.post.return_value.json.return_value = mock_collaboration
+    collaboration = test_item.collaborate(mock_user, 'editor')
+    mock_box_session.post.assert_called_once_with(expected_url, data=json.dumps(expected_data), params={})
+    assert collaboration.id == mock_collaboration['id']
+    assert collaboration['type'] == mock_collaboration['type']
+    assert collaboration['created_by']['id'] == mock_collaboration['created_by']['id']
+
+
+def test_collaborate_with_login(test_item_and_response, mock_box_session):
+    # pylint:disable=redefined-outer-name, protected-access
+    test_item, _ = test_item_and_response
+    expected_url = '{0}/collaborations'.format(API.BASE_API_URL)
+    expected_data = {
+        'item': {
+            'type': test_item.object_type,
+            'id': test_item.object_id,
+        },
+        'accessible_by': {
+            'type': 'user',
+            'login': 'test@example.com',
+        },
+        'role': 'editor',
+    }
+    mock_collaboration = {
+        'type': 'collaboration',
+        'id': '1234',
+        'created_by': {
+            'type': 'user',
+            'id': '1111',
+        }
+    }
+    mock_box_session.post.return_value.json.return_value = mock_collaboration
+    collaboration = test_item.collaborate_with_login('test@example.com', 'editor')
+    mock_box_session.post.assert_called_once_with(expected_url, data=json.dumps(expected_data), params={})
+    assert collaboration.id == mock_collaboration['id']
+    assert collaboration['type'] == mock_collaboration['type']
+    assert collaboration['created_by']['id'] == mock_collaboration['created_by']['id']
+
+
+def test_collaborations(test_item_and_response, mock_box_session):
+    # pylint:disable=redefined-outer-name, protected-access
+    test_item, _ = test_item_and_response
+    expected_url = '{0}/{1}s/{2}/collaborations'.format(API.BASE_API_URL, test_item.object_type, test_item.object_id)
+    mock_collaboration = {
+        'type': 'collaboration',
+        'id': '12345',
+        'created_by': {
+            'type': 'user',
+            'id': '33333',
+        },
+    }
+    mock_box_session.get.return_value.json.return_value = {
+        'limit': 500,
+        'entries': [mock_collaboration]
+    }
+    collaborations = test_item.get_collaborations(limit=500)
+    collaboration = collaborations.next()
+    mock_box_session.get.assert_called_once_with(expected_url, params={'limit': 500})
+    assert isinstance(collaboration, Collaboration)
+    assert collaboration.id == mock_collaboration['id']
+    assert collaboration.type == mock_collaboration['type']
+    assert collaboration['created_by']['type'] == 'user'
+    assert collaboration['created_by']['id'] == '33333'