Repository: box/box-python-sdk
Pull request #326 (2018-08-20 23:26:16)

Group v2

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/HISTORY.rst b/HISTORY.rst
index 14dd6d2..d3d8f6f 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -10,6 +10,9 @@ Release History
 
 - Python 2.6 is no longer supported.
 - Python 3.3 is no longer supported.
+- ``client.get_memberships`` has a change in signature. The limit and offset parameters have swapped positions to keep
+  consistency with the rest of the SDK.
+- ``client.groups`` has been changed to ``client.get_groups``. The limit and offset parameters have swapped positions.
 - ``Events.get_events(...)`` now returns a list of ``Event`` instances rather than a list of ``dict``
   representing events.  ``Event`` inherits from ``Mapping`` but will not have all the same capabilities as
   ``dict``.
diff --git a/boxsdk/client/client.py b/boxsdk/client/client.py
index 94988b4..e511e50 100644
--- a/boxsdk/client/client.py
+++ b/boxsdk/client/client.py
@@ -473,7 +473,7 @@ def group_membership(self, group_membership_id):
         )
 
     @api_call
-    def groups(self, name=None, offset=0, limit=None, fields=None):
+    def get_groups(self, name=None, limit=None, offset=None, fields=None):
         """
         Get a list of all groups for the current user.
 
@@ -488,7 +488,7 @@ def groups(self, name=None, offset=0, limit=None, fields=None):
         :param offset:
             The group index at which to start the response.
         :type offset:
-            `int`
+            `int` or None.
         :param fields:
             List of fields to request on the :class:`Group` objects.
         :type fields:
@@ -512,7 +512,16 @@ def groups(self, name=None, offset=0, limit=None, fields=None):
         )
 
     @api_call
-    def create_group(self, name):
+    def create_group(
+            self,
+            name,
+            provenance=None,
+            external_sync_identifier=None,
+            description=None,
+            invitability_level=None,
+            member_viewability_level=None,
+            fields=None,
+    ):
         """
         Create a group with the given name.
 
@@ -520,6 +529,30 @@ def create_group(self, name):
             The name of the group.
         :type name:
             `unicode`
+        :param provenance:
+            Used to track the external source where the group is coming from.
+        :type provenance:
+            `unicode` or None
+        :param external_sync_identifier:
+            Used as a group identifier for groups coming from an external source.
+        :type external_sync_identifier:
+            `unicode` or None
+        :param description:
+            Description of the group.
+        :type description:
+            `unicode` or None
+        :param invitability_level:
+            Specifies who can invite this group to folders.
+        :type invitability_level:
+            `unicode`
+        :param member_viewability_level:
+            Specifies who can view the members of this group.
+        :type member_viewability_level:
+            `unicode`
+        :param fields:
+            List of fields to request on the :class:`Group` objects.
+        :type fields:
+            `Iterable` of `unicode`
         :return:
             The newly created Group.
         :rtype:
@@ -528,10 +561,23 @@ def create_group(self, name):
             :class:`BoxAPIException` if current user doesn't have permissions to create a group.
         """
         url = self.get_url('groups')
+        additional_params = {}
         body_attributes = {
             'name': name,
         }
-        box_response = self._session.post(url, data=json.dumps(body_attributes))
+        if provenance is not None:
+            body_attributes['provenance'] = provenance
+        if external_sync_identifier is not None:
+            body_attributes['external_sync_identifier'] = external_sync_identifier
+        if description is not None:
+            body_attributes['description'] = description
+        if invitability_level is not None:
+            body_attributes['invitability_level'] = invitability_level
+        if member_viewability_level is not None:
+            body_attributes['member_viewability_level'] = member_viewability_level
+        if fields is not None:
+            additional_params['fields'] = ','.join(fields)
+        box_response = self._session.post(url, data=json.dumps(body_attributes), params=additional_params)
         response = box_response.json()
         return self.translator.translate('group')(
             session=self._session,
diff --git a/boxsdk/object/group.py b/boxsdk/object/group.py
index 15ae8b8..04a911a 100644
--- a/boxsdk/object/group.py
+++ b/boxsdk/object/group.py
@@ -3,25 +3,32 @@
 from __future__ import unicode_literals, absolute_import
 import json
 
+from boxsdk.util.text_enum import TextEnum
 from .base_object import BaseObject
 from ..pagination.limit_offset_based_object_collection import LimitOffsetBasedObjectCollection
 from ..util.api_call_decorator import api_call
 
 
+class GroupRole(TextEnum):
+    """The role in the group."""
+    ADMIN = 'admin'
+    MEMBER = 'member'
+
+
 class Group(BaseObject):
     """Represents a Box group."""
 
     _item_type = 'group'
 
     @api_call
-    def get_memberships(self, offset=0, limit=None, fields=None):
+    def get_memberships(self, limit=None, offset=None, fields=None):
         """
         Get the membership records for the group, which indicate which users are included in the group.
 
         :param offset:
             The index at which to begin.
         :type offset:
-            `int`
+            `int` or None
         :param limit:
             The maximum number of items to return in a page.
         :type limit:
@@ -31,18 +38,17 @@ def get_memberships(self, offset=0, limit=None, fields=None):
         :rtype:
             `Iterable` of :class:`GroupMembership`
         """
-        url = self.get_url('memberships')
         return LimitOffsetBasedObjectCollection(
             self._session,
-            url,
+            url=self.get_url('memberships'),
             limit=limit,
             offset=offset,
             fields=fields,
-            return_full_pages=False
+            return_full_pages=False,
         )
 
     @api_call
-    def add_member(self, user, role):
+    def add_member(self, user, role=GroupRole.MEMBER, configurable_permissions=None):
         """
         Add the given user to this group under the given role
 
@@ -51,9 +57,14 @@ def add_member(self, user, role):
         :type user:
             :class:`User`
         :param role:
-            The role for the user. TODO: determine valid options and create an Enum.
+            The role for the user.
         :type role:
             `unicode`
+        :param configurable_permissions:
+            This is a group level permission that is configured for Group members with
+            admin role only.
+        :type configurable_permissons:
+            `unicode` or None
         :returns:
             The new GroupMembership instance.
         :rtype:
@@ -65,7 +76,41 @@ def add_member(self, user, role):
             'group': {'id': self.object_id},
             'role': role,
         }
+        if configurable_permissions is not None:
+            body_attributes['configurable_permissions'] = configurable_permissions
         box_response = self._session.post(url, data=json.dumps(body_attributes))
         response = box_response.json()
-
         return self.translator.translate(response['type'])(self._session, response['id'], response, user=user, group=self)
+
+    def get_collaborations(self, limit=None, offset=None, fields=None):
+        """
+        Get the entries in the collaboration for the group 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 collaboration for the group.
+        :rtype:
+            :class:`BoxObjectCollection`
+        """
+        additional_params = {}
+        if fields is not None:
+            additional_params['fields'] = ','.join(fields)
+        return LimitOffsetBasedObjectCollection(
+            session=self._session,
+            url=self.get_url('collaborations'),
+            additional_params=additional_params,
+            limit=limit,
+            offset=offset,
+            return_full_pages=False,
+        )
diff --git a/boxsdk/object/user.py b/boxsdk/object/user.py
index 8746bf9..cd75354 100644
--- a/boxsdk/object/user.py
+++ b/boxsdk/object/user.py
@@ -3,9 +3,43 @@
 from __future__ import unicode_literals
 
 from .base_object import BaseObject
+from ..pagination.limit_offset_based_object_collection import LimitOffsetBasedObjectCollection
 
 
 class User(BaseObject):
     """Represents a Box user."""
 
     _item_type = 'user'
+
+    def get_group_memberships(self, limit=None, offset=None, fields=None):
+        """
+        Get the entries in the user group membership 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 groups
+        :rtype:
+            :class:`BoxObjectCollection`
+        """
+        additional_params = {}
+        if fields is not None:
+            additional_params['fields'] = ','.join(fields)
+        return LimitOffsetBasedObjectCollection(
+            session=self._session,
+            url=self.get_url('memberships'),
+            additional_params=additional_params,
+            limit=limit,
+            offset=offset,
+            return_full_pages=False,
+        )
diff --git a/docs/group.md b/docs/group.md
new file mode 100644
index 0000000..e1cd74d
--- /dev/null
+++ b/docs/group.md
@@ -0,0 +1,162 @@
+Groups
+======
+Groups are sets of users that can be used in collaborations.
+
+<!-- START doctoc generated TOC please keep comment here to allow auto update -->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+
+
+- [List Groups](#list-groups)
+- [Create a Group](#create-a-group)
+- [Get Information about a Group](#get-information-about-a-group)
+- [Update a Group](#update-a-group)
+- [Delete a Group](#delete-a-group)
+- [Get a Group's Collaborations](#get-a-groups-collaborations)
+- [Add User to Group](#add-user-to-group)
+- [Get Information about a Group Membership](#get-information-about-a-group-membership)
+- [Update Membership](#update-membership)
+- [Remove User from Group](#remove-user-from-group)
+- [List Group Members](#list-group-members)
+- [List Memberships for User](#list-memberships-for-user)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+List Groups
+-----------
+
+Calling `client.get_groups(name=None, limit=None, offset=None, fields=None)` will return an iterable of all groups in the enterprise.
+
+```python
+groups = client.get_groups()
+for group in groups:
+    # Do something
+```
+
+Alternatively, you can set a filter on the name of the groups to return from the enterprise by using `client.get_groups(name)`.
+
+```python
+group_name = 'Example Group'
+groups = client.get_groups(group_name)
+for group in groups:
+    # Do something
+```
+
+Create a Group
+--------------
+
+To create a group, use `client.create_group(name, provenance=None, external_sync_identifier=None, description=None, invitability_level=None, member_viewability_level=None, fields=None)`.
+
+```python
+created_group = client.create_group('Example Group')
+```
+
+You can read more about the optional parameters [here](https://developer.box.com/v2.0/reference#create-a-group)
+
+Get Information about a Group
+-----------------------------
+
+To retrieve information about a group use `group.get()`.
+
+```python
+group_id = '1111'
+group_information = client.group(group_id).get()
+```
+
+Update a Group
+--------------
+
+To update a group call `group.update_info()`.
+
+```python
+group_id = '1234'
+group_update = {'name': 'New Group Name'}
+updated_group = client.group(group_id).update_info(group_update)
+```
+
+Delete a Group
+--------------
+
+To delete a specified group call `group.delete()`.
+
+```python
+group_id = '1234'
+client.group(group_id).delete()
+```
+
+Get a Group's Collaborations
+----------------------------
+
+To retrieve all collaborations for a specified group call `group.get_collaborations(limit=None, offset=None, fields=None)`.
+
+```python
+group_id = '1234'
+collaborations = client.group(group_id).get_collaborations()
+for collaboration in collaborations:
+    # Do something
+```
+
+Add User to Group
+-----------------
+
+To add a new member to the group use `group.add_member(user, role='Member', configurable_permissions=None)`.
+
+```python
+group_id = '1234'
+user = client.user('1111')
+group_membership = client.group(group_id).add_member(user)
+```
+
+Get Information about a Group Membership
+----------------------------------------
+
+To retrieve information about a group_membership, use `group_membership.get()`.
+
+```python
+membership_id = '1111'
+membership_info = client.group_membership(membership_id).get()
+```
+
+Update Membership
+-----------------
+
+To update a group membership, use `group_membership.update_info()`.
+
+```python
+membership_id = '1234'
+membership_update = {'role': 'admin'}
+updated_membership_info = client.group_membership.update_info(membership_update)
+```
+
+Remove User from Group
+----------------------
+
+To delete a group membership use, `group_membership.delete()`. This removes a specific user from a group.
+
+```python
+membership_id = '1234'
+client.group_membership(membership_id).delete() 
+```
+
+List Group Members
+------------------
+
+To retrieve all of the members for a given group use `group.get_memberships(limit=None, offset=0, fields=None)`.
+
+```python
+group_id = '1111'
+group_memberships = client.group(group_id).get_memberships()
+for group_membership in group_memberships:
+    # Do something
+```
+
+List Memberships for User
+-------------------------
+
+To retrieve all of the members for a give user use `user.get_group_memberships(limit=None, offset=0, fields=None)`.
+
+```python
+user_id = '2222'
+user_memberships = client.user(user_id).get_group_memberships()
+for user_membership in user_memberships:
+    # Do something
+```


Test patch:
diff --git a/test/unit/client/test_client.py b/test/unit/client/test_client.py
index 9fc9392..2e957ed 100644
--- a/test/unit/client/test_client.py
+++ b/test/unit/client/test_client.py
@@ -327,7 +327,7 @@ def test_events_returns_event_object(mock_client):
     assert isinstance(mock_client.events(), Events)
 
 
-def test_groups_return_the_correct_group_objects(
+def test_get_groups_return_the_correct_group_objects(
         mock_client,
         mock_box_session,
         groups_response,
@@ -335,26 +335,42 @@ def test_groups_return_the_correct_group_objects(
         group_id_2,
 ):
     # pylint:disable=redefined-outer-name
+    expected_url = '{0}/groups'.format(API.BASE_API_URL)
     mock_box_session.get.return_value = groups_response
-    groups = mock_client.groups()
+    groups = mock_client.get_groups()
     for group, expected_id in zip(groups, [group_id_1, group_id_2]):
         assert group.object_id == expected_id
         assert group.name == str(expected_id)
         # pylint:disable=protected-access
         assert group._session == mock_box_session
+    mock_box_session.get.assert_called_once_with(expected_url, params={'offset': None})
 
 
 def test_create_group_returns_the_correct_group_object(mock_client, mock_box_session, create_group_response):
     # pylint:disable=redefined-outer-name
+    expected_url = "{0}/groups".format(API.BASE_API_URL)
     test_group_name = 'test_group_name'
-    value = json.dumps({'name': test_group_name})
+    value = json.dumps({
+        'name': test_group_name,
+        'provenance': 'Example',
+        'external_sync_identifier': 'Example-User',
+        'description': 'Description of group',
+        'invitability_level': 'admins_and_members',
+        'member_viewability_level': 'admins_only',
+    })
     mock_box_session.post.return_value = create_group_response
-    new_group = mock_client.create_group(name=test_group_name)
+    new_group = mock_client.create_group(
+        name=test_group_name,
+        provenance='Example',
+        external_sync_identifier='Example-User',
+        description='Description of group',
+        invitability_level='admins_and_members',
+        member_viewability_level='admins_only',
+    )
 
     assert len(mock_box_session.post.call_args_list) == 1
 
-    assert mock_box_session.post.call_args[0] == ("{0}/groups".format(API.BASE_API_URL),)
-    assert mock_box_session.post.call_args[1] == {'data': value}
+    mock_box_session.post.assert_called_once_with(expected_url, data=value, params={})
     assert isinstance(new_group, Group)
     assert new_group.object_id == 1234
     assert new_group.name == test_group_name
diff --git a/test/unit/object/conftest.py b/test/unit/object/conftest.py
index cabf113..5416afc 100644
--- a/test/unit/object/conftest.py
+++ b/test/unit/object/conftest.py
@@ -14,6 +14,7 @@
 from boxsdk.object.legal_hold import LegalHold
 from boxsdk.object.folder import Folder
 from boxsdk.object.group import Group
+from boxsdk.object.group_membership import GroupMembership
 from boxsdk.object.legal_hold_policy import LegalHoldPolicy
 from boxsdk.object.legal_hold_policy_assignment import LegalHoldPolicyAssignment
 from boxsdk.object.user import User
@@ -96,6 +97,11 @@ def test_group(mock_box_session, mock_group_id):
     return Group(mock_box_session, mock_group_id)
 
 
+@pytest.fixture()
+def test_group_membership(mock_box_session, mock_object_id):
+    return GroupMembership(mock_box_session, mock_object_id)
+
+
 @pytest.fixture()
 def test_legal_hold_policy(mock_box_session, mock_object_id):
     return LegalHoldPolicy(mock_box_session, mock_object_id)
diff --git a/test/unit/object/test_group.py b/test/unit/object/test_group.py
index 84d8f61..8998314 100644
--- a/test/unit/object/test_group.py
+++ b/test/unit/object/test_group.py
@@ -11,6 +11,7 @@
 from six.moves import map  # pylint:disable=redefined-builtin,import-error
 
 from boxsdk.network.default_network import DefaultNetworkResponse
+from boxsdk.object.collaboration import Collaboration
 from boxsdk.object.group_membership import GroupMembership
 from boxsdk.config import API
 from boxsdk.session.box_response import BoxResponse
@@ -45,11 +46,12 @@ def test_delete_group_return_the_correct_response(
 def test_add_member(test_group, mock_box_session, mock_add_member_response, mock_user, role):
     expected_url = '{0}/group_memberships'.format(API.BASE_API_URL)
     mock_box_session.post.return_value = mock_add_member_response
-    new_group_membership = test_group.add_member(mock_user, role)
+    new_group_membership = test_group.add_member(mock_user, role, configurable_permissions={'can_run_reports': True})
     data = json.dumps({
         'user': {'id': mock_user.object_id},
         'group': {'id': test_group.object_id},
         'role': role,
+        'configurable_permissions': {'can_run_reports': True}
     })
     mock_box_session.post.assert_called_once_with(expected_url, data=data)
     assert isinstance(new_group_membership, GroupMembership)
@@ -162,3 +164,27 @@ def test_get_memberships_with_hidden_results(test_group, mock_box_session, mock_
         count += 1
         assert isinstance(membership, GroupMembership)
     assert count == total - total_hidden
+
+
+def test_get_group_collaborations(test_group, mock_box_session):
+    expected_url = '{0}/groups/{1}/collaborations'.format(API.BASE_API_URL, test_group.object_id)
+    mock_collaboration = {
+        'type': 'collaboration',
+        'id': '12345',
+        'created_by': {
+            'type': 'user',
+            'id': '33333'
+        }
+    }
+    mock_box_session.get.return_value.json.return_value = {
+        'limit': 100,
+        'entries': [mock_collaboration],
+        'offset': 0,
+        'total_count': 1
+    }
+    collaborations = test_group.get_collaborations()
+    collaboration = collaborations.next()
+    mock_box_session.get.assert_called_once_with(expected_url, params={'offset': None})
+    assert isinstance(collaboration, Collaboration)
+    assert collaboration.id == mock_collaboration['id']
+    assert collaboration.created_by['id'] == mock_collaboration['created_by']['id']
diff --git a/test/unit/object/test_user.py b/test/unit/object/test_user.py
index 6e0858f..2ea14a3 100644
--- a/test/unit/object/test_user.py
+++ b/test/unit/object/test_user.py
@@ -1,10 +1,45 @@
 # coding: utf-8
 
 from __future__ import unicode_literals
+import pytest
 
+from mock import Mock
 from boxsdk.config import API
+from boxsdk.network.default_network import DefaultNetworkResponse
 
 
 def test_user_url(mock_user):
     # pylint:disable=redefined-outer-name, protected-access
     assert mock_user.get_url() == '{0}/{1}/{2}'.format(API.BASE_API_URL, 'users', mock_user.object_id)
+
+
+@pytest.fixture(scope='module')
+def memberships_response():
+    # pylint disable=redefined-outer-name
+    mock_network_response = Mock(DefaultNetworkResponse)
+    mock_network_response.json.return_value = {
+        'entries': [
+            {'type': 'group_membership', 'id': 101, 'user': {'type': 'user', 'id': 100}, 'group': {'type': 'group', 'id': 300}},
+            {'type': 'group_membership', 'id': 202, 'user': {'type': 'user', 'id': 200}, 'group': {'type': 'group', 'id': 400}}
+        ],
+        'limit': 2,
+        'total_count': 2,
+        'offset': 0,
+    }
+    return mock_network_response
+
+
+def test_get_group_memberships(
+        mock_user,
+        mock_box_session,
+        memberships_response,
+):
+    # pylint:disable=redefined-outer-name
+    expected_url = '{0}/users/{1}/memberships'.format(API.BASE_API_URL, mock_user.object_id)
+    mock_box_session.get.return_value = memberships_response
+    memberships = mock_user.get_group_memberships()
+    for membership, expected_id in zip(memberships, [101, 202]):
+        assert membership.object_id == expected_id
+        # pylint:disable=protected-access
+        assert membership._session == mock_box_session
+    mock_box_session.get.assert_called_once_with(expected_url, params={'offset': None})