Repository: box/box-python-sdk
Pull request #332 (2018-08-21 22:50:40)

trash 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!
coveralls: ## Pull Request Test Coverage Report for [Build 1087](https://coveralls.io/builds/19356945)

* **32** of **35**   **(91.43%)**  changed or added relevant lines in **2** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.05%**) to **94.474%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [boxsdk/client/client.py](https://coveralls.io/builds/19356945/source?filename=boxsdk%2Fclient%2Fclient.py#L172) | 2 | 3 | 66.67%
| [boxsdk/object/trash.py](https://coveralls.io/builds/19356945/source?filename=boxsdk%2Fobject%2Ftrash.py#L33) | 30 | 32 | 93.75%
<!-- | **Total:** | **32** | **35** | **91.43%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/19356945/badge)](https://coveralls.io/builds/19356945) |
| :-- | --: |
| Change from base [Build 1084](https://coveralls.io/builds/19316485): |  -0.05% |
| Covered Lines: | 2103 |
| Relevant Lines: | 2226 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/boxsdk/client/client.py b/boxsdk/client/client.py
index 177887e..2c29d00 100644
--- a/boxsdk/client/client.py
+++ b/boxsdk/client/client.py
@@ -9,6 +9,7 @@
 from ..util.api_call_decorator import api_call
 from ..object.search import Search
 from ..object.events import Events
+from ..object.trash import Trash
 from ..pagination.limit_offset_based_object_collection import LimitOffsetBasedObjectCollection
 from ..pagination.marker_based_object_collection import MarkerBasedObjectCollection
 from ..util.shared_link import get_shared_link_header
@@ -159,6 +160,17 @@ def collaboration(self, collab_id):
         """
         return self.translator.translate('collaboration')(session=self._session, object_id=collab_id)
 
+    def trash(self):
+        """
+        Initialize a :class:`Trash` object.
+
+        :return:
+            A :class:`Trash` object.
+        :rtype:
+            :class:`Trash`
+        """
+        return Trash(self._session)
+
     def legal_hold_policy(self, policy_id):
         """
         Initialize a :class:`LegalHoldPolicy` object, whose box id is policy_id.
diff --git a/boxsdk/object/__init__.py b/boxsdk/object/__init__.py
index a58ca02..a4b467f 100644
--- a/boxsdk/object/__init__.py
+++ b/boxsdk/object/__init__.py
@@ -24,6 +24,7 @@
     'retention_policy',
     'retention_policy_assignment',
     'search',
+    'trash',
     'task',
     'task_assignment',
     'user',
diff --git a/boxsdk/object/trash.py b/boxsdk/object/trash.py
new file mode 100644
index 0000000..39563af
--- /dev/null
+++ b/boxsdk/object/trash.py
@@ -0,0 +1,129 @@
+# coding: utf-8
+from __future__ import unicode_literals, absolute_import
+
+import json
+
+from .base_endpoint import BaseEndpoint
+from ..pagination.limit_offset_based_object_collection import LimitOffsetBasedObjectCollection
+
+
+class Trash(BaseEndpoint):
+    """Box API endpoint for performing trash related actions in Box."""
+
+    def get_item(self, item, fields=None):
+        """
+        Get item from trash.
+
+        :param item:
+            The :class:`Item` object to get info on.
+        :type item:
+            :class:`Item`
+        :param fields:
+            List of fields to request
+        :type fields:
+            `Iterable` of `unicode`
+        :returns:
+            Information for a trashed :class:`Item` object.
+        :rtype:
+            :class:`Item`
+        """
+        url = item.get_url('trash')
+        params = {}
+        if fields:
+            params['fields'] = ','.join(fields)
+        box_response = self._session.get(url, params=params)
+        response = box_response.json()
+        return self.translator.translate(response['type'])(
+            session=self._session,
+            object_id=response['id'],
+            response_object=response,
+        )
+
+    def restore_item(self, item, name=None, parent_folder=None, fields=None):
+        """
+        Restores an item from the trash. Could be files, folders, or weblinks.
+
+        :param item:
+            The :class:`Item` object to restore from trash.
+        :type item:
+            :class:`Item`.
+        :param name:
+            The new name for this item. Only used if the item can't be restored due to name conflict.
+        :type name:
+            `unicode` or None
+        :param parent_folder:
+            The new parent folder. Only used if the previous parent folder no longer exists.
+        :type parent_folder:
+            :class:`Item` or None
+        :param fields:
+            List of fields to request
+        :type fields:
+            `Iterable` of `unicode`
+        :returns:
+            A restored :class:`Item`.
+        :rtype:
+            :class:`Item`.
+        """
+        url = item.get_url()
+        body = {}
+        if name is not None:
+            body['name'] = name
+        if parent_folder is not None:
+            body['parent'] = {'id': parent_folder.object_id}
+        params = {}
+        if fields:
+            params['fields'] = ','.join(fields)
+        box_response = self._session.post(url, data=json.dumps(body), params=params)
+        response = box_response.json()
+        return self.translator.translate(response['type'])(
+            session=self._session,
+            object_id=response['id'],
+            response_object=response,
+        )
+
+    def permanently_delete_item(self, item):
+        """
+        Permanently delete an item that is in the trash. The item will no longer exist in Box.
+
+        :param item:
+            The :class:`Item` to delete from trash.
+        :type item:
+            :class:`Item`
+        :returns:
+            Whether or not the delete was successful.
+        :rtype:
+            `bool`
+        """
+        url = item.get_url('trash')
+        box_response = self._session.delete(url, expect_json_response=False)
+        return box_response.ok
+
+    def get_items(self, limit=None, offset=None, fields=None):
+        """
+        Using limit-offset paging, get the files, folders and web links that are in the user's trash.
+
+        :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 trash
+        :rtype:
+            :class:`BoxObjectCollection`
+        """
+        return LimitOffsetBasedObjectCollection(
+            session=self._session,
+            url=self._session.get_url('folders', 'trash', 'items'),
+            limit=limit,
+            offset=offset,
+            fields=fields,
+            return_full_pages=False,
+        )
diff --git a/docs/trash.md b/docs/trash.md
new file mode 100644
index 0000000..41e1d82
--- /dev/null
+++ b/docs/trash.md
@@ -0,0 +1,97 @@
+Trash
+=====
+
+Under normal circumstances, when an item in Box is deleted, it is not actually erased immediately. Instead, it is
+moved to the Trash. The Trash allows you to recover files and folders that have been deleted. By default, items in
+the Trash will be purged after 30 days.
+
+<!-- START doctoc generated TOC please keep comment here to allow auto update -->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+
+
+- [List Trashed Items](#list-trashed-items)
+- [Get Trashed Items](#get-trashed-items)
+- [Restore Item from Trash](#restore-item-from-trash)
+- [Permanently Delete Item](#permanently-delete-item)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+List Trashed Items
+------------------
+
+To retrieve a list of all trashed items, you can use `client.trash().get__items(limit=None, offset=None, fields=None)`
+
+```python
+trashed_items = client.trash().get_items()
+for trashed_item in trashed_items:
+    # Do something
+```
+
+Get Trashed Items
+-----------------
+
+To retrieve a file, folder, weblink from the trash, use `client.trash.get_item(item, fields=None)`. 
+
+```python
+file_info_to_retrieve = client.file('11111')
+file_from_trash = client.trash().get_item(file_info_to_retrieve)
+```
+
+```python
+folder = client.folder('22222')
+folder_from_trash = client.trash().get_item(folder)
+```
+
+```python
+web_link = client.web_link('33333')
+web_link_from_trash = client.trash().get_item(web_link)
+```
+
+Restore Item from Trash
+-----------------------
+
+To retore a trashed item, effectively undeleting it, call `client.trash().restore_item(item, name=None, parent_folder=None, fields=None)`.
+
+```python
+file_to_restore = client.file('11111')
+restored_file = client.trash().restore_item(file_to_restore)
+```
+
+```python
+folder_to_restore = client.folder('22222')
+restored_folder = client.trash().restore_item(folder_to_restore)
+```
+
+```python
+web_link_to_restore = client.web_link('33333')
+restored_web_link = client.trash().restore_item(web_link_to_restore)
+```
+
+In order to avoid conflicts, you can set a new name and new parent folder for the item you wish to restore.
+
+```python
+file_to_restore = client.file('11111')
+new_name = 'New File Name'
+new_parent_folder = client.folder('22222')
+restored_file = client.trash().restore_item(file_to_restore, new_name, new_parent_folder)
+```
+
+Permanently Delete Item
+-----------------------
+
+To delete an item from trash, use `client.trash().permanently_delete_item(item)`.
+
+```python
+file_to_delete = client.file('11111')
+client.trash().permanently_delete_item(file_to_delete)
+```
+
+```python
+folder = client.folder('22222')
+client.trash().permanently_delete_item(folder)
+```
+
+```python
+web_link = client.web_link('33333')
+client.trash().permanently_delete_item(web_link)
+```


Test patch:
diff --git a/test/unit/client/test_client.py b/test/unit/client/test_client.py
index 3735297..3cf4cef 100644
--- a/test/unit/client/test_client.py
+++ b/test/unit/client/test_client.py
@@ -23,6 +23,7 @@
 from boxsdk.object.file import File
 from boxsdk.object.group import Group
 from boxsdk.object.user import User
+from boxsdk.object.trash import Trash
 from boxsdk.object.group_membership import GroupMembership
 from boxsdk.object.retention_policy import RetentionPolicy
 from boxsdk.object.file_version_retention import FileVersionRetention
@@ -495,6 +496,11 @@ def test_legal_hold_policies_return_the_correct_policy_objects(
         assert policy._session == mock_box_session
 
 
+def test_trash_initializer(mock_client):
+    trash = mock_client.trash()
+    assert isinstance(trash, Trash)
+
+
 def test_get_recent_items_returns_the_correct_items(mock_client, mock_box_session, recent_items_response, file_id):
     mock_box_session.get.return_value = recent_items_response
     recent_items = mock_client.get_recent_items()
diff --git a/test/unit/conftest.py b/test/unit/conftest.py
index aacf18a..b2402e8 100644
--- a/test/unit/conftest.py
+++ b/test/unit/conftest.py
@@ -129,3 +129,12 @@ def mock_folder_response(mock_object_id, make_mock_box_request):
         response={'type': 'folder', 'id': mock_object_id},
     )
     return mock_box_response
+
+
+@pytest.fixture(scope='function')
+def mock_web_link_response(mock_object_id, make_mock_box_request):
+    # pylint:disable=redefined-outer-name
+    mock_box_response, _ = make_mock_box_request(
+        response={'type': 'web_link', 'id': mock_object_id},
+    )
+    return mock_box_response
diff --git a/test/unit/object/test_trash.py b/test/unit/object/test_trash.py
new file mode 100644
index 0000000..ae4c0bd
--- /dev/null
+++ b/test/unit/object/test_trash.py
@@ -0,0 +1,121 @@
+
+from __future__ import unicode_literals
+
+import json
+import pytest
+
+from boxsdk.config import API
+from boxsdk.object.trash import Trash
+
+
+@pytest.fixture()
+def test_trash(mock_box_session):
+    return Trash(mock_box_session)
+
+
+@pytest.fixture(params=('file', 'folder', 'web_link'))
+def test_item_and_response(
+        test_file,
+        mock_file_response,
+        test_folder,
+        mock_folder_response,
+        test_web_link,
+        mock_web_link_response,
+        request,
+):
+    if request.param == 'file':
+        return test_file, mock_file_response
+    elif request.param == 'web_link':
+        return test_web_link, mock_web_link_response
+    return test_folder, mock_folder_response
+
+
+def test_get_from_trash(test_item_and_response, test_trash, mock_box_session):
+    # pylint:disable=redefined-outer-name, protected-access
+    test_item, _ = test_item_and_response
+    expected_url = '{0}/{1}/{2}/trash'.format(API.BASE_API_URL, test_item.object_type + 's', test_item.object_id)
+    mock_box_session.get.return_value.json.return_value = {
+        'type': test_item.object_type,
+        'id': test_item.object_id,
+        'created_at': '2015-05-07T14:31:16-07:00',
+        'modified_at': '2015-05-07T14:31:16-07:00',
+        'created_by': {
+            'type': 'user',
+            'id': '11111',
+        },
+    }
+    trashed_item_info = test_trash.get_item(item=test_item, fields=['created_at', 'modified_at'])
+    mock_box_session.get.assert_called_once_with(expected_url, params={'fields': 'created_at,modified_at'})
+    assert isinstance(trashed_item_info, test_item.__class__)
+    assert trashed_item_info.object_type == test_item.object_type
+    assert trashed_item_info.object_id == test_item.object_id
+    assert trashed_item_info.created_at == '2015-05-07T14:31:16-07:00'
+    assert trashed_item_info.modified_at == '2015-05-07T14:31:16-07:00'
+    assert trashed_item_info.created_by['type'] == 'user'
+    assert trashed_item_info.created_by['id'] == '11111'
+
+
+def test_restore_from_trash(test_item_and_response, test_trash, test_folder, mock_box_session):
+    # pylint:disable=redefined-outer-name, protected-access
+    test_item, _ = test_item_and_response
+    new_name = 'New Name'
+    expected_url = '{0}/{1}/{2}'.format(API.BASE_API_URL, test_item.object_type + 's', test_item.object_id)
+    value = json.dumps({
+        'name': new_name,
+        'parent': {
+            'id': test_folder.object_id,
+        },
+    })
+    mock_box_session.post.return_value.json.return_value = {
+        'type': test_item.object_type,
+        'id': test_item.object_id,
+        'created_at': '2015-05-07T14:31:16-07:00',
+        'modified_at': '2015-05-07T14:31:16-07:00',
+        'created_by': {
+            'type': 'user',
+            'id': '11111',
+        },
+    }
+    restored_item = test_trash.restore_item(test_item, new_name, test_folder, ['created_at', 'modified_at'])
+    mock_box_session.post.assert_called_once_with(expected_url, data=value, params={'fields': 'created_at,modified_at'})
+    assert isinstance(restored_item, test_item.__class__)
+    assert restored_item.object_type == test_item.object_type
+    assert restored_item.object_id == test_item.object_id
+    assert restored_item.created_at == '2015-05-07T14:31:16-07:00'
+    assert restored_item.modified_at == '2015-05-07T14:31:16-07:00'
+    assert restored_item.created_by['type'] == 'user'
+    assert restored_item.created_by['id'] == '11111'
+
+
+def test_permanently_delete(test_item_and_response, test_trash, mock_box_session):
+    # pylint:disable=redefined-outer-name, protected-access
+    test_item, _ = test_item_and_response
+    expected_url = '{0}/{1}/{2}/trash'.format(API.BASE_API_URL, test_item.object_type + 's', test_item.object_id)
+    mock_box_session.delete.return_value.ok = True
+    info = test_trash.permanently_delete_item(test_item)
+    mock_box_session.delete.assert_called_once_with(expected_url, expect_json_response=False)
+    assert info is True
+
+
+def test_get_trashed_items(test_item_and_response, test_trash, mock_box_session):
+    test_item, _ = test_item_and_response
+    item_name = 'Test Trashed Item'
+    expected_url = '{0}/folders/trash/items'.format(API.BASE_API_URL)
+    mock_trash = {
+        'type': test_item.object_type,
+        'id': test_item.object_id,
+        'name': 'Test Trashed Item'
+    }
+    mock_box_session.get.return_value.json.return_value = {
+        'total_count': 5,
+        'offset': 0,
+        'limit': 100,
+        'entries': [mock_trash]
+    }
+    trashed_items = test_trash.get_items(fields=['name'])
+    trashed_item = trashed_items.next()
+    mock_box_session.get.assert_called_once_with(expected_url, params={'fields': 'name', 'offset': None})
+    assert isinstance(trashed_item, test_item.__class__)
+    assert trashed_item.type == mock_trash['type']
+    assert trashed_item.id == mock_trash['id']
+    assert trashed_item.name == item_name
diff --git a/test/unit/util/test_translator.py b/test/unit/util/test_translator.py
index f5a000b..d4997b6 100644
--- a/test/unit/util/test_translator.py
+++ b/test/unit/util/test_translator.py
@@ -11,6 +11,7 @@
 from boxsdk.object.folder import Folder
 from boxsdk.object.group import Group
 from boxsdk.object.user import User
+from boxsdk.object.web_link import WebLink
 from boxsdk.util.translator import Translator
 
 
@@ -43,6 +44,7 @@ def translator_response(
         mock_folder_response,
         mock_group_response,
         mock_user_response,
+        mock_web_link_response,
 ):
     # pylint:disable=redefined-outer-name
     _response_to_class_mapping['bookmark'] = (bookmark_response, BaseObject)
@@ -51,6 +53,7 @@ def translator_response(
     _response_to_class_mapping['folder'] = (mock_folder_response, Folder)
     _response_to_class_mapping['group'] = (mock_group_response, Group)
     _response_to_class_mapping['user'] = (mock_user_response, User)
+    _response_to_class_mapping['web_link'] = (mock_web_link_response, WebLink)
 
 
 @pytest.mark.parametrize('response_type', ['bookmark', 'box_note', 'file', 'folder', 'group', 'user'])