Repository: box/box-python-sdk
Pull request #336 (2018-08-22 01:12:46)

Tasks 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/boxsdk/client/client.py b/boxsdk/client/client.py
index 36f9c46..b304231 100644
--- a/boxsdk/client/client.py
+++ b/boxsdk/client/client.py
@@ -585,6 +585,36 @@ def create_group(
             response_object=response,
         )
 
+    def task(self, task_id):
+        """
+        Initialize a :class:`Task` object, whose box id is task_id.
+
+        :param task_id:
+            The box ID of the :class:`Task` object.
+        :type task_id:
+            `unicode`
+        :return:
+            A :class:`Task` object with the given entry ID.
+        :rtype:
+            :class:`Task`
+        """
+        return self.translator.get('task')(session=self._session, object_id=task_id)
+
+    def task_assignment(self, assignment_id):
+        """
+        Initialize a :class:`TaskAssignment` object, whose box id is assignment_id.
+
+        :param assignment_id:
+            The box ID of the :class:`TaskAssignment` object.
+        :type assignment_id:
+            `unicode`
+        :return:
+            A :class:`TaskAssignment` object with the given entry ID.
+        :rtype:
+            :class:`TaskAssignment`
+        """
+        return self.translator.get('task_assignment')(session=self._session, object_id=assignment_id)
+
     def retention_policy(self, retention_id):
         """
         Initialize a :class:`RetentionPolicy` object, whose box id is retention_id.
diff --git a/boxsdk/object/__init__.py b/boxsdk/object/__init__.py
index 912f37c..ebdcd6c 100644
--- a/boxsdk/object/__init__.py
+++ b/boxsdk/object/__init__.py
@@ -24,6 +24,8 @@
     'retention_policy',
     'retention_policy_assignment',
     'search',
+    'task',
+    'task_assignment',
     'user',
     'watermark',
     'web_link',
diff --git a/boxsdk/object/base_api_json_object.py b/boxsdk/object/base_api_json_object.py
index 44bc019..eecbdd4 100644
--- a/boxsdk/object/base_api_json_object.py
+++ b/boxsdk/object/base_api_json_object.py
@@ -128,6 +128,7 @@ def _description(self):
     @property
     def object_type(self):
         """Return the Box type for the object.
+
         :rtype:
             `unicode`
         """
diff --git a/boxsdk/object/file.py b/boxsdk/object/file.py
index 4cc4440..72bfe5f 100644
--- a/boxsdk/object/file.py
+++ b/boxsdk/object/file.py
@@ -6,6 +6,7 @@
 
 from .item import Item
 from ..util.api_call_decorator import api_call
+from ..pagination.marker_based_object_collection import MarkerBasedObjectCollection
 from ..pagination.limit_offset_based_object_collection import LimitOffsetBasedObjectCollection
 
 
@@ -340,3 +341,62 @@ def add_comment(self, message):
             object_id=response['id'],
             response_object=response,
         )
+
+    def create_task(self, message=None, due_at=None):
+        """
+        Create a task on the given file.
+
+        :param message:
+            An optional message to include in the task.
+        :type message:
+            `unicode` or None
+        :param due_at:
+            When this task is due.
+        :type due_at:
+            `unicode` or None
+        :return:
+            The newly created task
+        :rtype:
+            :class:`Task`
+        """
+        url = self._session.get_url('tasks')
+        task_attributes = {
+            'item': {
+                'type': 'file',
+                'id': self.object_id
+            },
+            'action': 'review',
+        }
+        if message is not None:
+            task_attributes['message'] = message
+        if due_at is not None:
+            task_attributes['due_at'] = due_at
+        box_response = self._session.post(url, data=json.dumps(task_attributes))
+        response = box_response.json()
+        return self.translator.translate(response['type'])(
+            session=self._session,
+            object_id=response['id'],
+            response_object=response,
+        )
+
+    def get_tasks(self, fields=None):
+        """
+        Get the entries in the file tasks.
+
+        :param fields:
+            List of fields to request.
+        :type fields:
+            `Iterable` of `unicode`
+        :returns:
+            An iterator of the entries in the file tasks
+        :rtype:
+            :class:`BoxObjectCollection`
+        """
+        return MarkerBasedObjectCollection(
+            session=self._session,
+            url=self.get_url('tasks'),
+            limit=None,
+            marker=None,
+            fields=fields,
+            return_full_pages=False,
+        )
diff --git a/boxsdk/object/task.py b/boxsdk/object/task.py
new file mode 100644
index 0000000..5bcea4c
--- /dev/null
+++ b/boxsdk/object/task.py
@@ -0,0 +1,94 @@
+# coding: utf-8
+from __future__ import unicode_literals
+
+import json
+
+from boxsdk.object.base_object import BaseObject
+from ..pagination.marker_based_object_collection import MarkerBasedObjectCollection
+
+
+class Task(BaseObject):
+    """Represents a Box task."""
+    _item_type = 'task'
+
+    def assign(self, assignee):
+        """
+        Assign a task to a single user on a single file.
+
+        :param assignee:
+            The :class:`User` to assign the task to.
+        :type assignee:
+            :class:`User`
+        :returns:
+            A task assignment object.
+        :rtype:
+            :class:`TaskAssignment`
+        """
+        url = self._session.get_url('task_assignments')
+        body = {
+            'task': {
+                'type': 'task',
+                'id': self.object_id,
+            },
+            'assign_to': {
+                'id': assignee.object_id,
+            },
+        }
+        response = self._session.post(url, data=json.dumps(body)).json()
+        return self.translator.translate(response['type'])(
+            session=self._session,
+            object_id=response['id'],
+            response_object=response,
+        )
+
+    def assign_with_login(self, assignee_login):
+        """
+        Used to assign a task to a single user with the login email address of the assignee.
+
+        :param assign_to_login:
+            The login of the user to assign the task to.
+        :type assign_to_login:
+            `unicode` or None
+        :returns:
+            A task assignment object.
+        :rtype:
+            :class:`TaskAssignment`
+        """
+        url = self._session.get_url('task_assignments')
+        body = {
+            'task': {
+                'type': 'task',
+                'id': self.object_id,
+            },
+            'assign_to': {
+                'login': assignee_login,
+            },
+        }
+        response = self._session.post(url, data=json.dumps(body)).json()
+        return self.translator.translate(response['type'])(
+            session=self._session,
+            object_id=response['id'],
+            response_object=response,
+        )
+
+    def get_assignments(self, fields=None):
+        """
+        Get the entries in the file task assignment.
+
+        :param fields:
+            List of fields to request.
+        :type fields:
+            `Iterable` of `unicode`
+        :returns:
+            An iterator of the entries in the file task assignment.
+        :rtype:
+            :class:`BoxObjectCollection`
+        """
+        return MarkerBasedObjectCollection(
+            session=self._session,
+            url=self.get_url('assignments'),
+            limit=None,
+            marker=None,
+            fields=fields,
+            return_full_pages=False,
+        )
diff --git a/boxsdk/object/task_assignment.py b/boxsdk/object/task_assignment.py
new file mode 100644
index 0000000..ee10e92
--- /dev/null
+++ b/boxsdk/object/task_assignment.py
@@ -0,0 +1,20 @@
+# coding: utf-8
+
+from __future__ import unicode_literals, absolute_import
+
+from boxsdk.util.text_enum import TextEnum
+from .base_object import BaseObject
+
+
+class ResolutionState(TextEnum):
+    """An enum of possible resolution states"""
+    COMPLETED = 'completed'
+    INCOMPLETE = 'incomplete'
+    APPROVED = 'approved'
+    REJECTED = 'rejected'
+
+
+class TaskAssignment(BaseObject):
+    """Represents a Box task."""
+
+    _item_type = 'task_assignment'
diff --git a/boxsdk/pagination/box_object_collection.py b/boxsdk/pagination/box_object_collection.py
index b4c36f3..c040019 100644
--- a/boxsdk/pagination/box_object_collection.py
+++ b/boxsdk/pagination/box_object_collection.py
@@ -99,10 +99,6 @@ def _items_generator(self):
         while not self._has_retrieved_all_items:
             response_object = self._load_next_page()
 
-            # If the limit was not specified, then it should default to whatever the server tells us.
-            if self._limit is None:
-                self._limit = response_object['limit']
-
             self._update_pointer_to_next_page(response_object)
             self._has_retrieved_all_items = not self._has_more_pages(response_object)
             page = Page(self._session, response_object)
diff --git a/docs/task.md b/docs/task.md
new file mode 100644
index 0000000..27fb605
--- /dev/null
+++ b/docs/task.md
@@ -0,0 +1,115 @@
+Tasks
+=====
+
+Task objects represent a user-created task on a file.
+
+
+Get a Task's Information
+------------------------
+
+Calling `task.get(fields=None, headers=None)` will return information about the specified task.
+
+```python
+task_info = client.task('1234').get()
+```
+
+List Tasks on File
+------------------
+
+To retrieve all tasks for a given file use, `file.get_tasks(fields=None)`.
+
+```python
+tasks = client.file('1111').get_tasks()
+for task in tasks:
+    # Do something
+```
+
+Add Task to File
+----------------
+
+To create a single task for a single user on a single file use, `file.create_task(message=None, due_at=None)`
+
+```python
+message = 'Please review this'
+due_at = "2014-04-03T11:09:43-07:00"
+task = client.file('11111').create_task(message, due_at)
+```
+
+Update Task Info
+----------------
+
+To update the task information use, `task.update_info(data)`
+
+```python
+task_update = {'message': 'New Message', 'due_at': '2014-04-03T11:09:43-10:00',}
+updated_task = client.task('1234').update_info(task_update)
+```
+
+Delete a Task
+-------------
+
+To delete a task, use `task.delete()`
+
+```python
+client.client.task('1234').delete()
+```
+
+Assign a Task
+--------------
+
+To assign a task to a user on a file, use `task.assign(assignee)`
+
+```python
+user = client.user('1111')
+assignment = client.task('1234').assign(user)
+```
+
+Assign a Task with User Login
+-----------------------------
+
+To assign a task to a user on a file, use `task.assign_with_login(assignee_login)`
+
+```python
+assignment = client.task('1234').assign_with_login('test_user@example.com')
+```
+
+List Task Assignments
+---------------------
+
+To retrieve all task assignments for a given task use `task.get_assignments(fields=None)`
+
+```python
+assignments = client.task('1234').get_assignments()
+for assignment in assignments:
+    # Do something
+```
+
+Get Information about Task Assignment
+-------------------------------------
+
+To retrieve information about a task assignment use, `task_assignment.get(fields=None, headers=None)`
+
+```python
+task_assignment_id = '2222'
+assignment_info = client.task_assignment(task_assignment_id).get()
+```
+
+Update Task Assignment
+----------------------
+
+To update information about a task assignment use, `task_assignment.update_info(data)`
+
+```python
+from boxsdk.object.task_assignment import ResolutionState
+updated_task = {'resolution_state': ResolutionState.APPROVED}
+updated_assignment = client.task_assignment('5678').update_info(updated_task)
+```
+
+Delete Task Assignment
+----------------------
+
+To delete a task assignment use, `task_assignment.delete()`
+
+```python
+client.task_assignment('2222').delete()
+```


Test patch:
diff --git a/test/unit/object/conftest.py b/test/unit/object/conftest.py
index 9e5e973..e2f1259 100644
--- a/test/unit/object/conftest.py
+++ b/test/unit/object/conftest.py
@@ -22,6 +22,8 @@
 from boxsdk.object.retention_policy import RetentionPolicy
 from boxsdk.object.retention_policy_assignment import RetentionPolicyAssignment
 from boxsdk.object.search import Search
+from boxsdk.object.task import Task
+from boxsdk.object.task_assignment import TaskAssignment
 from boxsdk.object.web_link import WebLink
 
 
@@ -139,6 +141,16 @@ def test_search(mock_box_session):
     return Search(mock_box_session)
 
 
+@pytest.fixture()
+def test_task(mock_box_session, mock_object_id):
+    return Task(mock_box_session, mock_object_id)
+
+
+@pytest.fixture()
+def test_task_assignment(mock_box_session, mock_object_id):
+    return TaskAssignment(mock_box_session, mock_object_id)
+
+
 @pytest.fixture()
 def test_web_link(mock_box_session, mock_object_id):
     return WebLink(mock_box_session, mock_object_id)
diff --git a/test/unit/object/test_file.py b/test/unit/object/test_file.py
index 8952f62..8c93f3f 100644
--- a/test/unit/object/test_file.py
+++ b/test/unit/object/test_file.py
@@ -9,6 +9,7 @@
 from boxsdk.exception import BoxAPIException
 from boxsdk.object.comment import Comment
 from boxsdk.object.file import File
+from boxsdk.object.task import Task
 
 
 # pylint:disable=protected-access
@@ -41,6 +42,62 @@ def test_delete_file(test_file, mock_box_session, etag, if_match_header):
     )
 
 
+def test_create_task(test_file, test_task, mock_box_session):
+    # pylint:disable=redefined-outer-name
+    expected_url = "{0}/tasks".format(API.BASE_API_URL)
+    due_at = '2014-04-03T11:09:43-07:00'
+    action = 'review'
+    message = 'Test Message'
+    expected_body = {
+        'item': {
+            'type': 'file',
+            'id': '42',
+        },
+        'action': action,
+        'message': message,
+        'due_at': due_at,
+    }
+    mock_box_session.post.return_value.json.return_value = {
+        'type': test_task.object_type,
+        'id': test_task.object_id,
+        'due_at': due_at,
+        'action': action,
+        'message': message,
+    }
+    value = json.dumps(expected_body)
+    new_task = test_file.create_task(message=message, due_at=due_at)
+    mock_box_session.post.assert_called_once_with(expected_url, data=value)
+    assert isinstance(new_task, Task)
+    assert new_task.object_type == test_task.object_type
+    assert new_task.object_id == test_task.object_id
+    assert new_task.action == action
+    assert new_task.message == message
+    assert new_task.due_at == due_at
+
+
+def test_get_tasks(test_file, mock_box_session):
+    expected_url = test_file.get_url('tasks')
+    task_body = {
+        'type': 'task',
+        'id': '12345',
+        'item': {
+            'type': 'file',
+            'id': '33333',
+        },
+    }
+    mock_box_session.get.return_value.json.return_value = {
+        'limit': 100,
+        'entries': [task_body],
+    }
+    tasks = test_file.get_tasks()
+    task = tasks.next()
+    mock_box_session.get.assert_called_once_with(expected_url, params={})
+    assert isinstance(task, Task)
+    assert task.id == task_body['id']
+    assert task.object_type == task_body['type']
+    assert task.item['id'] == task_body['item']['id']
+
+
 def test_download_to(test_file, mock_box_session, mock_content_response):
     expected_url = test_file.get_url('content')
     mock_box_session.get.return_value = mock_content_response
diff --git a/test/unit/object/test_task.py b/test/unit/object/test_task.py
new file mode 100644
index 0000000..41bc183
--- /dev/null
+++ b/test/unit/object/test_task.py
@@ -0,0 +1,154 @@
+from __future__ import unicode_literals
+
+import json
+import pytest
+
+from mock import Mock
+from boxsdk.config import API
+from boxsdk.object.task import Task
+from boxsdk.object.task_assignment import TaskAssignment
+from boxsdk.network.default_network import DefaultNetworkResponse
+
+
+@pytest.fixture(scope='module')
+def delete_task_response():
+    # pylint:disable=redefined-outer-name
+    mock_network_response = Mock(DefaultNetworkResponse)
+    mock_network_response.ok = True
+    return mock_network_response
+
+
+def test_get(test_task, mock_box_session):
+    expected_url = '{0}/tasks/{1}'.format(API.BASE_API_URL, test_task.object_id)
+    due_at = '2014-04-03T11:09:43-07:00'
+    action = 'review'
+    message = 'Test Message'
+    mock_box_session.get.return_value.json.return_value = {
+        'type': test_task.object_type,
+        'id': test_task.object_id,
+        'due_at': due_at,
+        'action': action,
+        'message': message,
+    }
+    retrieved_task = test_task.get()
+    mock_box_session.get.assert_called_once_with(expected_url, headers=None, params=None)
+    assert isinstance(retrieved_task, Task)
+    assert retrieved_task.object_type == test_task.object_type
+    assert retrieved_task.object_id == test_task.object_id
+    assert retrieved_task.due_at == due_at
+    assert retrieved_task.action == action
+    assert retrieved_task.message == message
+
+
+def test_update(test_task, mock_box_session):
+    new_message = 'New Message'
+    expected_url = '{0}/tasks/{1}'.format(API.BASE_API_URL, test_task.object_id)
+    mock_box_session.put.return_value.json.return_value = {
+        'type': test_task.object_type,
+        'id': test_task.object_id,
+        'message': new_message,
+    }
+    expected_body = {
+        'message': new_message,
+    }
+    updated_task = test_task.update_info({'message': new_message})
+    mock_box_session.put.assert_called_once_with(expected_url, data=json.dumps(expected_body), headers=None, params=None)
+    assert isinstance(updated_task, Task)
+    assert updated_task.message == new_message
+    assert updated_task.object_type == test_task.object_type
+    assert updated_task.object_id == test_task.object_id
+
+
+def test_delete_policy_return_the_correct_response(
+        test_task,
+        mock_box_session,
+        delete_task_response,
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_session.delete.return_value = delete_task_response
+    response = test_task.delete()
+    # pylint:disable=protected-access
+    expected_url = '{0}/tasks/{1}'.format(API.BASE_API_URL, test_task.object_id)
+    # pylint:enable = protected-access
+    mock_box_session.delete.assert_called_once_with(expected_url, params={}, expect_json_response=False, headers=None)
+    assert response is True
+
+
+def test_assign(test_task, mock_user, mock_box_session):
+    expected_url = '{0}/task_assignments'.format(API.BASE_API_URL)
+    expected_body = {
+        'task': {
+            'type': test_task.object_type,
+            'id': test_task.object_id,
+        },
+        'assign_to': {
+            'id': mock_user.object_id,
+        },
+    }
+    mock_box_session.post.return_value.json.return_value = {
+        'type': 'task_assignment',
+        'id': '42',
+        'assigned_to': {
+            'type': 'user',
+            'id': '1234',
+        },
+        'assigned_at': '2013-05-10T11:43:41-07:00',
+    }
+    new_task_assignment = test_task.assign(assignee=mock_user)
+    mock_box_session.post.assert_called_once_with(expected_url, data=json.dumps(expected_body))
+    assert isinstance(new_task_assignment, TaskAssignment)
+    assert new_task_assignment.object_type == 'task_assignment'
+    assert new_task_assignment.object_id == '42'
+    assert new_task_assignment.assigned_to['type'] == 'user'
+    assert new_task_assignment.assigned_at == '2013-05-10T11:43:41-07:00'
+
+
+def test_assign_with_login(test_task, mock_box_session):
+    expected_url = '{0}/task_assignments'.format(API.BASE_API_URL)
+    expected_body = {
+        'task': {
+            'type': test_task.object_type,
+            'id': test_task.object_id,
+        },
+        'assign_to': {
+            'login': 'test_user@example.com',
+        },
+    }
+    mock_box_session.post.return_value.json.return_value = {
+        'type': 'task_assignment',
+        'id': '42',
+        'assigned_to': {
+            'type': 'user',
+            'id': '1234',
+        },
+        'assigned_at': '2013-05-10T11:43:41-07:00',
+    }
+    new_task_assignment = test_task.assign_with_login(assignee_login='test_user@example.com')
+    mock_box_session.post.assert_called_once_with(expected_url, data=json.dumps(expected_body))
+    assert isinstance(new_task_assignment, TaskAssignment)
+    assert new_task_assignment.object_type == 'task_assignment'
+    assert new_task_assignment.object_id == '42'
+    assert new_task_assignment.assigned_to['type'] == 'user'
+    assert new_task_assignment.assigned_at == '2013-05-10T11:43:41-07:00'
+
+
+def test_get_assignments(test_task, mock_box_session):
+    expected_url = '{0}/tasks/{1}/assignments'.format(API.BASE_API_URL, test_task.object_id)
+    mock_assignment = {
+        'type': 'task_assignment',
+        'id': '12345',
+        'item': {
+            'type': 'file',
+            'id': '33333'
+        }
+    }
+    mock_box_session.get.return_value.json.return_value = {
+        'limit': 100,
+        'entries': [mock_assignment]
+    }
+    assignments = test_task.get_assignments()
+    assignment = assignments.next()
+    mock_box_session.get.assert_called_once_with(expected_url, params={})
+    assert isinstance(assignment, TaskAssignment)
+    assert assignment.id == mock_assignment['id']
+    assert assignment.item['id'] == mock_assignment['item']['id']
diff --git a/test/unit/object/test_task_assignment.py b/test/unit/object/test_task_assignment.py
new file mode 100644
index 0000000..273d289
--- /dev/null
+++ b/test/unit/object/test_task_assignment.py
@@ -0,0 +1,74 @@
+from __future__ import unicode_literals
+
+import json
+import pytest
+
+from mock import Mock
+from boxsdk.config import API
+from boxsdk.object.task_assignment import TaskAssignment, ResolutionState
+from boxsdk.network.default_network import DefaultNetworkResponse
+
+
+@pytest.fixture(scope='module')
+def delete_task_assignment_response():
+    # pylint:disable=redefined-outer-name
+    mock_network_response = Mock(DefaultNetworkResponse)
+    mock_network_response.ok = True
+    return mock_network_response
+
+
+def test_get_assignment(test_task_assignment, mock_box_session):
+    expected_url = '{0}/task_assignments/{1}'.format(API.BASE_API_URL, test_task_assignment.object_id)
+    mock_box_session.get.return_value.json.return_value = {
+        'type': test_task_assignment.object_type,
+        'id': test_task_assignment.object_id,
+        'assigned_to': {
+            'type': 'user',
+            'id': '11111',
+        },
+    }
+    retrieved_task = test_task_assignment.get()
+    mock_box_session.get.assert_called_once_with(expected_url, headers=None, params=None)
+    assert isinstance(retrieved_task, TaskAssignment)
+    assert retrieved_task.object_type == test_task_assignment.object_type
+    assert retrieved_task.object_id == test_task_assignment.object_id
+    assert retrieved_task.assigned_to['type'] == 'user'
+    assert retrieved_task.assigned_to['id'] == '11111'
+
+
+def test_delete_policy_return_the_correct_response(
+        test_task_assignment,
+        mock_box_session,
+        delete_task_assignment_response,
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_session.delete.return_value = delete_task_assignment_response
+    response = test_task_assignment.delete()
+    # pylint:disable=protected-access
+    expected_url = '{0}/task_assignments/{1}'.format(API.BASE_API_URL, test_task_assignment.object_id)
+    # pylint:enable = protected-access
+    mock_box_session.delete.assert_called_once_with(expected_url, params={}, expect_json_response=False, headers=None)
+    assert response is True
+
+
+def test_update(test_task_assignment, mock_box_session):
+    new_message = 'New Message'
+    resolution_state = ResolutionState.APPROVED
+    expected_url = '{0}/task_assignments/{1}'.format(API.BASE_API_URL, test_task_assignment.object_id)
+    mock_box_session.put.return_value.json.return_value = {
+        'type': 'task_assignment',
+        'id': test_task_assignment.object_id,
+        'message': new_message,
+        'resolution_state': resolution_state,
+    }
+    expected_body = {
+        'message': new_message,
+        'resolution_state': resolution_state,
+    }
+    updated_task_assignment = test_task_assignment.update_info({'message': new_message, 'resolution_state': resolution_state})
+    mock_box_session.put.assert_called_once_with(expected_url, data=json.dumps(expected_body), headers=None, params=None)
+    assert isinstance(updated_task_assignment, TaskAssignment)
+    assert updated_task_assignment.message == new_message
+    assert updated_task_assignment.object_type == test_task_assignment.object_type
+    assert updated_task_assignment.object_id == test_task_assignment.object_id
+    assert updated_task_assignment.resolution_state == resolution_state