Repository: box/box-python-sdk
Pull request #353 (2018-10-25 06:33:13)

Metadata template endpoints

boxcla: Verified that @mattwiller has signed the CLA. Thanks for the pull request!
coveralls: ## Pull Request Test Coverage Report for [Build 1210](https://coveralls.io/builds/19711373)

* **92** of **92**   **(100.0%)**  changed or added relevant lines in **3** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.2%**) to **95.437%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/19711373/badge)](https://coveralls.io/builds/19711373) |
| :-- | --: |
| Change from base [Build 1203](https://coveralls.io/builds/19707399): |  0.2% |
| Covered Lines: | 2238 |
| Relevant Lines: | 2345 |

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

mattwiller: @Jeff-Meadows I added the field exclusion mechanism so object classes can specify which fields should not be translated.  I wasn't certain how we might want to decentralize the logic for finding an ID, so I left it in the translator for now.

Patch:
diff --git a/boxsdk/client/client.py b/boxsdk/client/client.py
index 226e663..d45e2c7 100644
--- a/boxsdk/client/client.py
+++ b/boxsdk/client/client.py
@@ -1500,3 +1500,127 @@ def device_pinners(self, enterprise=None, direction=None, limit=None, marker=Non
             fields=fields,
             return_full_pages=False,
         )
+
+    def metadata_template(self, scope, template_key):
+        """
+        Initialize a :class:`MetadataTemplate` object with the given scope and template key.
+
+        :param scope:
+            The scope of the metadata template, e.g. 'enterprise' or 'global'
+        :type scope:
+            `unicode`
+        :param template_key:
+            The key of the metadata template
+        :type template_key:
+            `unicode`
+        :returns:
+            The metadata template object
+        :rtype:
+            :class:`MetadataTemplate`
+        """
+        return self.translator.get('metadata_template')(
+            session=self._session,
+            object_id=None,
+            response_object={
+                'type': 'metadata_template',
+                'scope': scope,
+                'templateKey': template_key,
+            },
+        )
+
+    def metadata_template_by_id(self, template_id):
+        """
+        Retrieves a metadata template by ID
+
+        :param template_id:
+            The ID of the template object
+        :type template_id:
+            `unicode`
+        :returns:
+            The metadata template with data populated from the API
+        :rtype:
+            :class:`MetadataTemplate`
+        """
+        return self.translator.get('metadata_template')(
+            session=self._session,
+            object_id=template_id,
+        )
+
+    @api_call
+    def get_metadata_templates(self, scope='enterprise', limit=None, marker=None, fields=None):
+        """
+        Get all metadata templates for a given scope.  By default, retrieves all metadata templates for the current
+        enterprise.
+
+        :param scope:
+            The scope to retrieve templates for
+        :type scope:
+            `unicode`
+        :type limit:
+            `int` or None
+        :param marker:
+            The paging marker to start paging from.
+        :type marker:
+            `unicode` or None
+        :param fields:
+            List of fields to request.
+        :type fields:
+            `Iterable` of `unicode`
+        :returns:
+            The collection of metadata templates for the given scope
+        :rtype:
+            :class:`BoxObjectCollection`
+        """
+        return MarkerBasedObjectCollection(
+            url=self._session.get_url('metadata_templates', scope),
+            session=self._session,
+            limit=limit,
+            marker=marker,
+            fields=fields,
+            return_full_pages=False,
+        )
+
+    @api_call
+    def create_metadata_template(self, display_name, fields, template_key=None, hidden=False, scope='enterprise'):
+        """
+        Create a new metadata template.  By default, only the display name and fields are required; the template key
+        will be automatically generated based on the display name and the template will be created in the enterprise
+        scope.
+
+        :param display_name:
+            The human-readable name of the template
+        :type display_name:
+            `unicode`
+        :param fields:
+            The metadata fields for the template.
+        :type fields:
+            `Iterable` of :class:`MetadataField`
+        :param template_key:
+            An optional key for the template.  If one is not provided, it will be derived from the display name.
+        :type template_key:
+            `unicode`
+        :param hidden:
+            Whether the template should be hidden in the UI
+        :type hidden:
+            `bool`
+        :param scope:
+            The scope the template should be created in
+        :type scope:
+            `unicode`
+        """
+        url = self._session.get_url('metadata_templates', 'schema')
+        body = {
+            'scope': scope,
+            'displayName': display_name,
+            'hidden': hidden,
+            'fields': [field.json() for field in fields]
+        }
+
+        if template_key is not None:
+            body['templateKey'] = template_key
+
+        response = self._session.post(url, data=json.dumps(body)).json()
+        return self.translator.translate(
+            session=self._session,
+            response_object=response,
+        )
diff --git a/boxsdk/object/__init__.py b/boxsdk/object/__init__.py
index f79867f..c44e598 100644
--- a/boxsdk/object/__init__.py
+++ b/boxsdk/object/__init__.py
@@ -25,6 +25,7 @@
     'legal_hold',
     'legal_hold_policy',
     'legal_hold_policy_assignment',
+    'metadata_template',
     'recent_item',
     'retention_policy',
     'retention_policy_assignment',
diff --git a/boxsdk/object/base_api_json_object.py b/boxsdk/object/base_api_json_object.py
index eecbdd4..e8a3820 100644
--- a/boxsdk/object/base_api_json_object.py
+++ b/boxsdk/object/base_api_json_object.py
@@ -72,6 +72,7 @@ class BaseAPIJSONObject(object):
     # also important to add the module name to __all__ in object/__init__.py,
     # so that it will be imported and registered with the default translator.
     _item_type = None
+    _untranslated_fields = ()
 
     def __init__(self, response_object=None, **kwargs):
         """
@@ -134,6 +135,16 @@ def object_type(self):
         """
         return self._item_type
 
+    @classmethod
+    def untranslated_fields(cls):
+        """
+        The fields that should not be translated on this object.
+
+        :rtype:
+            `tuple`
+        """
+        return cls._untranslated_fields
+
     @property
     def response_object(self):
         """
diff --git a/boxsdk/object/metadata_template.py b/boxsdk/object/metadata_template.py
new file mode 100644
index 0000000..c1b8200
--- /dev/null
+++ b/boxsdk/object/metadata_template.py
@@ -0,0 +1,339 @@
+# coding: utf-8
+
+from __future__ import unicode_literals, absolute_import
+
+import json
+
+from .base_object import BaseObject
+from ..util.api_call_decorator import api_call
+from ..util.text_enum import TextEnum
+
+
+class MetadataTemplateUpdate(object):
+    """Represents a set of update operations to a metadata template."""
+
+    def __init__(self):
+        super(MetadataTemplateUpdate, self).__init__()
+        self._ops = []
+
+    def json(self):
+        return self._ops
+
+    def add_enum_option(self, field_key, option_key):
+        """
+        Adds a new option to an enum field.
+
+        :param field_key:
+            The key of the template field to add the option to
+        :type field_key:
+            `unicode`
+        :param option_key:
+            The option to add
+        :type option_key:
+            `unicode`
+        """
+        self.add_operation({
+            'op': 'addEnumOption',
+            'fieldKey': field_key,
+            'data': {
+                'key': option_key,
+            },
+        })
+
+    def add_field(self, field):
+        """
+        Add a new field to the template.
+
+        :param field:
+            The new field to add
+        :type field:
+            :class:`MetadataField`
+        """
+        self.add_operation({
+            'op': 'addField',
+            'data': field.json(),
+        })
+
+    def edit_template(self, data):
+        """
+        Edit top-level template properties.
+
+        :param data:
+            The properties to modify
+        :type data:
+            `dict`
+        """
+        self.add_operation({
+            'op': 'editTemplate',
+            'data': data,
+        })
+
+    def reorder_enum_options(self, field_key, option_keys):
+        """
+        Reorders the options in an enum field, which affects their display in UI.
+
+        :param field_key:
+            The key of the enum field to reorder
+        :type field_key:
+            `unicode`
+        :param option_keys:
+            The option keys in the desired order
+        :type option_keys:
+            `list` of `unicode`
+        """
+        self.add_operation({
+            'op': 'reorderEnumOptions',
+            'fieldKey': field_key,
+            'enumOptionKeys': option_keys,
+        })
+
+    def reorder_fields(self, field_keys):
+        """
+        Reorders the fields in a metadata template, which affects their display in UI.
+
+        :param field_keys:
+            The field keys in the desired order
+        :type field_keys:
+            `list` of `unicode`
+        """
+        self.add_operation({
+            'op': 'reorderFields',
+            'fieldKeys': field_keys,
+        })
+
+    def edit_field(self, field_key, field):
+        """
+        Edits a field in the template.
+
+        :param field_key:
+            The key of the field to update
+        :type field_key:
+            `unicode`
+        :param field:
+            The updated field values
+        :type field:
+            :class:`MetadataField`
+        """
+        self.add_operation({
+            'op': 'editField',
+            'fieldKey': field_key,
+            'data': field.json(),
+        })
+
+    def edit_enum_option_key(self, field_key, old_option_key, new_option_key):
+        """
+        Change the key of an enum field option.
+
+        :param field_key:
+            The key of the template field in which the option appears
+        :type field_key:
+            `unicode`
+        :param old_option_key:
+            The old option key
+        :type old_option_key:
+            `unicode`
+        :param new_option_key:
+            The new option key
+        :type new_option_key:
+            `unicode`
+        """
+        self.add_operation({
+            'op': 'editEnumOption',
+            'fieldKey': field_key,
+            'enumOptionKey': old_option_key,
+            'data': {
+                'key': new_option_key,
+            },
+        })
+
+    def remove_enum_option(self, field_key, option_key):
+        """
+        Remove an option from an enum field.
+
+        :param field_key:
+            The key of the template field in which the option appears
+        :type field_key:
+            `unicode`
+        :param option_key:
+            The key of the enum option to remove
+        :type option_key:
+            `unicode`
+        """
+        self.add_operation({
+            'op': 'removeEnumOption',
+            'fieldKey': field_key,
+            'enumOptionKey': option_key,
+        })
+
+    def remove_field(self, field_key):
+        """
+        Remove a field from the metadata template.
+
+        :param field_key:
+            The key of the field to remove
+        :type field_key:
+            `unicode`
+        """
+        self.add_operation({
+            'op': 'removeField',
+            'fieldKey': field_key,
+        })
+
+    def add_operation(self, operation):
+        """
+        Adds an update operation.
+
+        :param operation:
+            The operation to add.
+        :type operation:
+            `dict`
+        """
+        self._ops.append(operation)
+
+
+class MetadataFieldType(TextEnum):
+    STRING = 'string'
+    DATE = 'date'
+    ENUM = 'enum'
+    MULTISELECT = 'multiSelect'
+    FLOAT = 'float'
+
+
+class MetadataField(object):
+    """Represents a metadata field when creating or updating a metadata template."""
+
+    def __init__(self, field_type, display_name, key=None, options=None):
+        """
+        :param field_type:
+            The type of the metadata field
+        :type field_type:
+            :class:`MetadataFieldType`
+        :param display_name:
+            The human-readable name of the metadata field
+        :type display_name:
+            `unicode`
+        :param key:
+            The machine-readable key for the metadata field
+        :type key:
+            `unicode` or None
+        :param options:
+            For 'enum' or 'multiSelect' fields, the selectable options
+        :type options:
+            `Iterable` of `unicode`
+        """
+        super(MetadataField, self).__init__()
+        self.type = field_type
+        self.name = display_name
+        self.key = key
+        self.options = options
+
+    def json(self):
+        """
+        Returns the correct representation of the template field for the API.
+
+        :rtype:
+            `dict`
+        """
+        values = {}
+
+        if self.type is not None:
+            values['type'] = self.type
+
+        if self.name is not None:
+            values['displayName'] = self.name
+
+        if self.key is not None:
+            values['key'] = self.key
+
+        if self.type in ['enum', 'multiSelect']:
+            values['options'] = [{'key': opt} for opt in self.options or ()]
+
+        return values
+
+
+class MetadataTemplate(BaseObject):
+    """Represents a metadata template, which contains the the type information for associated metadata fields."""
+
+    _item_type = 'metadata_template'
+    _untranslated_fields = ('fields',)
+    _scope = None
+    _template_key = None
+
+    def __init__(self, session, object_id, response_object=None):
+        """
+        :param session:
+            The Box session used to make requests.
+        :type session:
+            :class:`BoxSession`
+        :param object_id:
+            The primary GUID key for the metadata template
+        :type object_id:
+            `unicode` or None
+        :param response_object:
+            A JSON object representing the object returned from a Box API request.  This should
+            contain 'scope' and 'templateKey' properties if the instance is being constructed without
+            a primary GUID object_id.
+        :type response_object:
+            `dict` or None
+        """
+        super(MetadataTemplate, self).__init__(session, object_id, response_object)
+        if response_object:
+            self._scope = response_object.get('scope', None)
+            self._template_key = response_object.get('templateKey', None)
+        elif not object_id:
+            raise ValueError('Metadata template must be constructed with an ID or scope and templateKey')
+
+    @property
+    def scope(self):
+        return self._scope
+
+    @property
+    def template_key(self):
+        return self._template_key
+
+    def get_url(self, *args):
+        """
+        Base class override, since metadata templates have a weird compound ID and non-standard URL format
+
+        :rtype:
+            `unicode`
+        """
+        if self._scope and self._template_key:
+            return self._session.get_url('metadata_templates', self._scope, self._template_key, 'schema', *args)
+
+        return super(MetadataTemplate, self).get_url(*args)
+
+    @staticmethod
+    def start_update():
+        """
+        Start an update operation on the template.
+
+        :returns:
+            An update object to collect the desired update operations.
+        :rtype:
+            :class:`MetadataTemplateUpdate`
+        """
+        return MetadataTemplateUpdate()
+
+    @api_call
+    def update_info(self, updates):
+        # pylint: disable=arguments-differ
+        """
+        Update a metadata template with a set of update operations.
+
+        :param updates:
+            The update operations to apply to the template
+        :type updates:
+            :class:`MetadataTemplateUpdate`
+        :returns:
+            The updated metadata template object
+        :rtype:
+            :class:`MetadataTemplate`
+        """
+        url = self.get_url()
+        response = self._session.put(url, data=json.dumps(updates.json())).json()
+        return self.__class__(
+            session=self._session,
+            object_id=self.object_id,
+            response_object=response,
+        )
diff --git a/boxsdk/util/translator.py b/boxsdk/util/translator.py
index 9cdc775..82b1891 100644
--- a/boxsdk/util/translator.py
+++ b/boxsdk/util/translator.py
@@ -28,9 +28,6 @@ def _get_object_id(obj):
         `dict`
     :return:
     """
-    if obj.get('type', '') == 'event':
-        return obj.get('event_id', None)
-
     return obj.get('id', None)
 
 
@@ -162,7 +159,14 @@ def translate(self, session, response_object):
             return response_object
 
         translated_obj = {}
+        object_type = response_object.get('type', None)
+        object_class = self.get(object_type) if object_type is not None else None
+        # Parent classes have the ability to "blacklist" fields that they do not want translated
+        blacklisted_fields = object_class.untranslated_fields() if object_class is not None else ()
         for key in response_object:
+            if key in blacklisted_fields:
+                translated_obj[key] = response_object[key]
+                continue
             if isinstance(response_object[key], dict):
                 translated_obj[key] = self.translate(session, response_object[key])
             elif isinstance(response_object[key], list):
@@ -173,8 +177,9 @@ def translate(self, session, response_object):
         # Try to translate any API object with a `type` property, except for metadata instances
         # The $type value in metadata instances isn't directly usable, so we avoid it altogether
         # NOTE: Currently, we represent metadata as just a `dict`, so there's no need to translate it anyway
-        if 'type' in translated_obj and '$type' not in translated_obj:
-            object_class = self.get(translated_obj.get('type', ''))
+        # Metadata field objects are another issue; they contain a 'type' property that doesn't really
+        # map to a Box object.  We probably want to treat these as just `dict`s, so they're excluded here
+        if object_class is not None and '$type' not in translated_obj:
             param_values = {
                 'session': session,
                 'response_object': translated_obj,
diff --git a/docs/metadata.md b/docs/metadata.md
new file mode 100644
index 0000000..bf9179d
--- /dev/null
+++ b/docs/metadata.md
@@ -0,0 +1,145 @@
+Metadata
+========
+
+Metadata allows users and applications to define and store custom data associated
+with their files/folders. Metadata consists of key:value pairs that belong to
+files/folders. For example, an important contract may have key:value pairs of
+`"clientNumber":"820183"` and `"clientName":"bioMedicalCorp"`.
+
+Metadata that belongs to a file/folder is grouped by templates. Templates allow
+the metadata service to provide a multitude of services, such as pre-defining sets
+of key:value pairs or schema enforcement on specific fields.
+
+Each file/folder can have multiple distinct template instances associated with it,
+and templates are also grouped by scopes. Currently, the only scopes support are
+`enterprise` and `global`. Enterprise scopes are defined on a per enterprises basis,
+whereas global scopes are Box application-wide.
+
+In addition to `enterprise` scoped templates, every file on Box has access to the
+`global` `properties` template. The Properties template is a bucket of free form
+key:value string pairs, with no additional schema associated with it. Properties
+are ideal for scenarios where applications want to write metadata to file objects
+in a flexible way, without pre-defined template structure.
+
+<!-- START doctoc generated TOC please keep comment here to allow auto update -->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+
+
+- [Create Metadata Template](#create-metadata-template)
+- [Get Metadata Template](#get-metadata-template)
+  - [Get by scope and template key](#get-by-scope-and-template-key)
+  - [Get by template ID](#get-by-template-id)
+- [Update Metadata Template](#update-metadata-template)
+- [Get Enterprise Metadata Templates](#get-enterprise-metadata-templates)
+- [Delete Metadata Template](#delete-metadata-template)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+Create Metadata Template
+------------------------
+
+To create a new metadata template, call
+[`client.create_metadata_template(display_name, fields, template_key=None, hidden=False, scope='enterprise')`][create_template]
+with the human-readable name of the template and the [`MetadataField`s][metadata_field_class] the template should have.
+You can optionally specify a key for the template, otherwise one will be derived from the display name.  At the current
+time, only `enterprise` scope templates are supported.  This method returns a
+[`MetadataTemplate`][metadata_template_class] object representing the created template.
+
+```python
+from boxsdk.object.metadata_template import MetadataField, MetadataFieldType
+
+fields = [
+    MetadataField(MetadataFieldType.STRING, 'Name')
+    MetadataField(MetadataFieldType.DATE, 'Birthday', 'bday')
+    MetadataField(MetadataFieldType.ENUM, 'State', options=['CA', 'TX', 'NY'])
+]
+template = client.create_metadata_template('Employee Record', fields, hidden=True)
+print('Metadata template ID {0}/{1} created!'.format(template.scope, template.templateKey))
+```
+
+[create_template]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.client.html#boxsdk.client.client.Client.create_metadata_template
+[metadata_field_class]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.metadata_template.MetadataField
+[metadata_template_class]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.metadata_template.MetadataTemplate
+
+Get Metadata Template
+---------------------
+
+### Get by scope and template key
+
+To retrieve a specific template by scope and template key, first use
+[`client.metadata_template(scope, template_key)`][metadata_template] to construct the appropriate
+[`MetadataTemplate`][metadata_template_class] object, and then call [`template.get()`][get] to retrieve data about
+the template.  This method returns a new [`MetadataTemplate`][metadata_template_class] object with fields populated by
+data from the API, leaving the original object unmodified.
+
+```python
+template = client.metadata_template('enterprise', 'employeeRecord').get()
+print('The {0} template has {1} fields'.format(template.displayName, len(template.fields)))
+```
+
+[metadata_template]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.client.html#boxsdk.client.client.Client.metadata_template
+[get]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.base_object.BaseObject.get
+
+### Get by template ID
+
+To retrieve a template by ID, call [`client.get_metadata_template_by_id(template_id)`][get_by_id] with the ID of the
+metadata template.  This method returns a [`MetadataTemplate`][metadata_template_class] object with fields populated by
+data from the API.
+
+```python
+template = client.get_metadata_template_by_id(template_id='abcdef-fba434-ace44')
+print('The {0} template has {1} fields'.format(template.displayName, len(template.fields)))
+```
+
+[get_by_id]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.client.html#boxsdk.client.client.Client.get_metadata_template_by_id
+
+Update Metadata Template
+------------------------
+
+To make changes to a metadata template, first call [`template.start_update()`][start_update] to create a
+[`MetadataTemplateUpdate`][template_update_class] to track updates.  Call the methods on this object to add the
+necessary update operations, and then call [`template.update_info(updates)`][update_info] with the updates object to
+apply the changes to the metadata template.  This method returns an updated
+[`MetadataTemplate`][metadata_template_class] object with the changes applied, leaving the original object unmodified.
+
+```python
+template = client.metadata_template('enterprise', 'employeeRecord')
+updates = template.start_update()
+updates.add_enum_option('state', 'WI')
+updates.edit_template({'hidden': False})
+updated_template = template.update_info(updates)
+```
+
+[start_update]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.metadata_template.MetadataTemplate.start_update
+[template_update_class]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.metadata_template.MetadataTemplateUpdate
+[update_info]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.metadata_template.MetadataTemplate.update_info
+
+Get Enterprise Metadata Templates
+---------------------------------
+
+Get all metadata templates for the current enterprise by calling
+[`client.get_metadata_templates(scope='enterprise', limit=None, marker=None, fields=None)`][get_metadata_templates].
+By default, this retrieves all templates scoped to the current enterprise, but you can pass the `scope` parameter to
+retrieve templates for a different scope.  This method returns a [`BoxObjectCollection`][box_object_collection] that
+allows you to iterate over all the [`MetadataTemplate`][metadata_template_class] objects in the collection.
+
+```python
+templates = client.get_metadata_templates()
+for template in templates:
+    print('Metadata template {0} is in enterprise scope'.format(template.templateKey))
+```
+
+[get_metadata_templates]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.client.html#boxsdk.client.client.Client.get_metadata_templates
+[box_object_collection]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.pagination.html#boxsdk.pagination.box_object_collection.BoxObjectCollection
+
+Delete Metadata Template
+------------------------
+
+To delete a metadata template, call [`template.delete()`][delete].  This method returns `True` to indicate the deletion
+was successful.
+
+```python
+client.metadata_template('enterprise', 'employeeRecord').delete()
+```
+
+[delete]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.base_object.BaseObject.delete


Test patch:
diff --git a/test/unit/client/test_client.py b/test/unit/client/test_client.py
index 4749c91..b4b9fea 100644
--- a/test/unit/client/test_client.py
+++ b/test/unit/client/test_client.py
@@ -14,6 +14,7 @@
 from six.moves import zip
 # pylint:enable=redefined-builtin
 # pylint:enable=import-error
+# pylint: disable=too-many-lines
 
 
 from boxsdk.auth.oauth2 import OAuth2, TokenScope
@@ -40,6 +41,7 @@
 from boxsdk.object.upload_session import UploadSession
 from boxsdk.object.trash import Trash
 from boxsdk.object.group_membership import GroupMembership
+from boxsdk.object.metadata_template import MetadataTemplate, MetadataField, MetadataFieldType
 from boxsdk.object.retention_policy import RetentionPolicy
 from boxsdk.object.retention_policy_assignment import RetentionPolicyAssignment
 from boxsdk.object.file_version_retention import FileVersionRetention
@@ -1094,6 +1096,116 @@ def test_device_pins_for_enterprise(mock_client, mock_box_session, device_pins_r
     mock_box_session.get.assert_called_once_with(expected_url, params={'direction': 'ASC'})
 
 
+def test_metadata_template_initializer(mock_client, mock_box_session):
+    template = mock_client.metadata_template('enterprise', 'VendorContract')
+    assert isinstance(template, MetadataTemplate)
+    # pylint:disable=protected-access
+    assert template._session == mock_box_session
+    assert template.object_id is None
+    assert template.scope == 'enterprise'
+    assert template.template_key == 'VendorContract'
+
+
+def test_metadata_template_by_id(mock_client, mock_box_session):
+    template_id = 'sdkjfhgsdg-nb34745bndfg-qw4hbsajdg'
+
+    template = mock_client.metadata_template_by_id(template_id)
+
+    assert isinstance(template, MetadataTemplate)
+    # pylint:disable=protected-access
+    assert template._session == mock_box_session
+    assert template.object_id == template_id
+    assert template.scope is None
+    assert template.template_key is None
+
+
+def test_get_metadata_templates(mock_client, mock_box_session):
+    expected_url = '{0}/metadata_templates/enterprise'.format(API.BASE_API_URL)
+    mock_box_session.get.return_value.json.return_value = {
+        'total_count': 1,
+        'entries': [
+            {
+                'type': 'metadata_template',
+                'scope': 'enterprise_33333',
+                'displayName': 'Vendor Contract',
+                'templateKey': 'vendorContract',
+                'fields': [
+                    {
+                        'type': 'string',
+                        'displayName': 'Name',
+                        'key': 'name',
+                    },
+                ],
+            },
+        ],
+        'next_marker': None,
+        'previous_marker': None,
+    }
+
+    templates = mock_client.get_metadata_templates()
+    template = templates.next()
+
+    mock_box_session.get.assert_called_once_with(expected_url, params={})
+    assert isinstance(template, MetadataTemplate)
+    assert template.object_id is None
+    assert template.displayName == 'Vendor Contract'
+    fields = template.fields
+    assert len(fields) == 1
+    field = fields[0]
+    assert isinstance(field, dict)
+    assert field['type'] == 'string'
+    assert field['key'] == 'name'
+
+
+def test_create_metadata_template(mock_client, mock_box_session):
+    expected_url = '{0}/metadata_templates/schema'.format(API.BASE_API_URL)
+    name = 'Vendor Contract'
+    key = 'vContract'
+    field1 = MetadataField(MetadataFieldType.DATE, 'Birthday', 'bday')
+    field2 = MetadataField(MetadataFieldType.ENUM, 'State', options=['CA', 'TX', 'NY'])
+    expected_body = {
+        'scope': 'enterprise',
+        'displayName': 'Vendor Contract',
+        'hidden': True,
+        'fields': [
+            {
+                'type': 'date',
+                'displayName': 'Birthday',
+                'key': 'bday',
+            },
+            {
+                'type': 'enum',
+                'displayName': 'State',
+                'options': [
+                    {'key': 'CA'},
+                    {'key': 'TX'},
+                    {'key': 'NY'},
+                ],
+            },
+        ],
+        'templateKey': 'vContract',
+    }
+
+    response = {
+        'type': 'metadata_template',
+    }
+    response.update(expected_body)
+    mock_box_session.post.return_value.json.return_value = response
+
+    template = mock_client.create_metadata_template(name, [field1, field2], key, hidden=True)
+
+    mock_box_session.post.assert_called_once_with(expected_url, data=json.dumps(expected_body))
+    assert isinstance(template, MetadataTemplate)
+    assert template.object_id is None
+    assert template.displayName == 'Vendor Contract'
+    fields = template.fields
+    assert len(fields) == 2
+    field = fields[0]
+    assert isinstance(field, dict)
+    assert field['type'] == 'date'
+    assert field['key'] == 'bday'
+
+
 def test_get_current_enterprise(mock_client, mock_box_session):
     expected_url = '{0}/users/me'.format(API.BASE_API_URL)
     expected_params = {
diff --git a/test/unit/object/test_metadata_template.py b/test/unit/object/test_metadata_template.py
new file mode 100644
index 0000000..a2d18ea
--- /dev/null
+++ b/test/unit/object/test_metadata_template.py
@@ -0,0 +1,183 @@
+from __future__ import unicode_literals, absolute_import
+
+import json
+import pytest
+
+from boxsdk.config import API
+from boxsdk.object.metadata_template import MetadataTemplate, MetadataField, MetadataFieldType
+
+
+@pytest.fixture()
+def test_metadata_template(mock_box_session):
+    fake_response = {
+        'type': 'metadata_template',
+        'scope': 'enterprise',
+        'templateKey': 'vContract',
+    }
+    return MetadataTemplate(mock_box_session, None, fake_response)
+
+
+def test_get(test_metadata_template, mock_box_session):
+    expected_url = '{0}/metadata_templates/{1}/{2}/schema'.format(
+        API.BASE_API_URL,
+        test_metadata_template.scope,
+        test_metadata_template.template_key,
+    )
+    mock_box_session.get.return_value.json.return_value = {
+        'scope': 'enterprise',
+        'displayName': 'Vendor Contract',
+        'hidden': True,
+        'fields': [
+            {
+                'type': 'date',
+                'displayName': 'Birthday',
+                'key': 'bday',
+            },
+            {
+                'type': 'enum',
+                'displayName': 'State',
+                'options': [
+                    {'key': 'CA'},
+                    {'key': 'TX'},
+                    {'key': 'NY'},
+                ],
+            },
+        ],
+        'templateKey': 'vContract',
+    }
+
+    template = test_metadata_template.get()
+
+    mock_box_session.get.assert_called_once_with(expected_url, params=None, headers=None)
+    assert isinstance(template, MetadataTemplate)
+    assert template.object_id is None
+    assert template.displayName == 'Vendor Contract'
+    fields = template.fields
+    assert len(fields) == 2
+    field = fields[0]
+    assert isinstance(field, dict)
+    assert field['type'] == 'date'
+    assert field['key'] == 'bday'
+
+
+def test_delete(test_metadata_template, mock_box_session):
+    expected_url = '{0}/metadata_templates/{1}/{2}/schema'.format(
+        API.BASE_API_URL,
+        test_metadata_template.scope,
+        test_metadata_template.template_key,
+    )
+    mock_box_session.delete.return_value.ok = True
+
+    result = test_metadata_template.delete()
+
+    mock_box_session.delete.assert_called_once_with(expected_url, expect_json_response=False, headers=None, params={})
+    assert result is True
+
+
+def test_update_info(test_metadata_template, mock_box_session):
+    expected_url = '{0}/metadata_templates/{1}/{2}/schema'.format(
+        API.BASE_API_URL,
+        test_metadata_template.scope,
+        test_metadata_template.template_key,
+    )
+
+    updates = test_metadata_template.start_update()
+    updates.add_enum_option('state', 'WI')
+    updates.add_field(MetadataField(MetadataFieldType.STRING, 'Name'))
+    updates.reorder_enum_options('state', ['CA', 'NY', 'TX', 'WI'])
+    updates.reorder_fields(['bday', 'name', 'state'])
+    updates.edit_field('state', MetadataField(None, 'State of Residency'))
+    updates.edit_enum_option_key('state', 'WI', 'WY')
+    updates.remove_enum_option('state', 'NY')
+    updates.remove_field('bday')
+    updates.edit_template({'hidden': False})
+
+    expected_body = [
+        {
+            'op': 'addEnumOption',
+            'fieldKey': 'state',
+            'data': {'key': 'WI'},
+        },
+        {
+            'op': 'addField',
+            'data': {
+                'type': 'string',
+                'displayName': 'Name',
+            },
+        },
+        {
+            'op': 'reorderEnumOptions',
+            'fieldKey': 'state',
+            'enumOptionKeys': ['CA', 'NY', 'TX', 'WI'],
+        },
+        {
+            'op': 'reorderFields',
+            'fieldKeys': ['bday', 'name', 'state'],
+        },
+        {
+            'op': 'editField',
+            'fieldKey': 'state',
+            'data': {
+                'displayName': 'State of Residency',
+            },
+        },
+        {
+            'op': 'editEnumOption',
+            'fieldKey': 'state',
+            'enumOptionKey': 'WI',
+            'data': {
+                'key': 'WY',
+            },
+        },
+        {
+            'op': 'removeEnumOption',
+            'fieldKey': 'state',
+            'enumOptionKey': 'NY',
+        },
+        {
+            'op': 'removeField',
+            'fieldKey': 'bday',
+        },
+        {
+            'op': 'editTemplate',
+            'data': {'hidden': False},
+        },
+    ]
+
+    mock_box_session.put.return_value.json.return_value = {
+        'scope': 'enterprise',
+        'displayName': 'Vendor Contract',
+        'hidden': False,
+        'fields': [
+            {
+                'type': 'string',
+                'key': 'name',
+                'displayName': 'Name',
+            },
+            {
+                'type': 'enum',
+                'key': 'state',
+                'displayName': 'State of Residency',
+                'options': [
+                    {'key': 'CA'},
+                    {'key': 'TX'},
+                    {'key': 'WY'},
+                ],
+            },
+        ],
+        'templateKey': 'vContract',
+    }
+
+    updated_template = test_metadata_template.update_info(updates)
+
+    mock_box_session.put.assert_called_once_with(expected_url, data=json.dumps(expected_body))
+    assert isinstance(updated_template, MetadataTemplate)
+    assert updated_template.hidden is False
+    assert updated_template.object_id is None
+    fields = updated_template.fields
+    assert len(fields) == 2
+    field = fields[1]
+    assert field['type'] == 'enum'
+    assert field['displayName'] == 'State of Residency'
+    assert len(field['options']) == 3
+    assert field['options'][2]['key'] == 'WY'
diff --git a/test/unit/util/test_translator.py b/test/unit/util/test_translator.py
index 340bd6c..6d15ccc 100644
--- a/test/unit/util/test_translator.py
+++ b/test/unit/util/test_translator.py
@@ -56,6 +56,22 @@ def translator_response(
     _response_to_class_mapping['web_link'] = (mock_web_link_response, WebLink)
 
 
+@pytest.fixture(params=('scope', 'id', 'both'))
+def metadata_template_response(request):
+    response = {
+        'type': 'metadata_template',
+    }
+    if request.param == 'scope' or request.param == 'both':
+        response.update({
+            'scope': 'enterprise',
+            'templateKey': 'vContract',
+        })
+    if request.param == 'id' or request.param == 'both':
+        response['id'] = '2f2e84e9-afdb-4e9d-b293-d6d1d932fc85'
+
+    return response
+
+
 @pytest.mark.parametrize('response_type', ['bookmark', 'box_note', 'file', 'folder', 'group', 'user'])
 def test_translator_converts_response_to_correct_type(response_type):
     response, object_class = _response_to_class_mapping[response_type]
@@ -195,3 +211,18 @@ def test_translate(default_translator, mock_box_session):
     # It should not modify the original
     assert isinstance(response_object['entries'][0], dict)
     assert isinstance(response_object['entries'][1], dict)
+
+
+def test_translator_translates_metadata_template(default_translator, mock_box_session, metadata_template_response):
+    metadata_template = default_translator.translate(mock_box_session, metadata_template_response)
+
+    if metadata_template_response.get('id'):
+        assert metadata_template.id == metadata_template_response['id']
+        assert metadata_template.object_id == metadata_template_response['id']
+
+    if metadata_template_response.get('scope') and metadata_template_response.get('templateKey'):
+        assert metadata_template.scope == metadata_template_response['scope']
+        assert metadata_template.template_key == metadata_template_response['templateKey']
+
+    if metadata_template_response.get('id') is None:
+        assert metadata_template.object_id is None