From 1b8d23a63a0bf09c280ffd7d331c8d90f86d4640 Mon Sep 17 00:00:00 2001 From: Matt Willer Date: Wed, 24 Oct 2018 15:17:08 -0700 Subject: [PATCH 01/13] WIP: Add metadata template endpoints --- boxsdk/client/client.py | 114 +++++++++++++ boxsdk/object/__init__.py | 1 + boxsdk/object/metadata_template.py | 247 +++++++++++++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 boxsdk/object/metadata_template.py diff --git a/boxsdk/client/client.py b/boxsdk/client/client.py index 4ff65a0ba..c170035de 100644 --- a/boxsdk/client/client.py +++ b/boxsdk/client/client.py @@ -1302,3 +1302,117 @@ def device_pinners(self, enterprise_id, direction=None, limit=None, marker=None, 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='{0}/{1}'.format(scope, template_key), + ) + + @api_call + def get_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` + """ + url = self._session.get_url('metadata_templates', template_id) + response = self._session.get(url).json() + return self.translator.get('metadata_template')( + session=self._session, + object_id='{0}/{1}'.format(response['scope'], response['templateKey']), + response_object=response, + ) + + @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` + :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.get('metadata_template')( + session=self._session, + object_id='{0}/{1}'.format(response['scope'], response['templateKey']), + response_object=response, + ) diff --git a/boxsdk/object/__init__.py b/boxsdk/object/__init__.py index 53d5d4e17..f81ea447d 100644 --- a/boxsdk/object/__init__.py +++ b/boxsdk/object/__init__.py @@ -24,6 +24,7 @@ 'legal_hold', 'legal_hold_policy', 'legal_hold_policy_assignment', + 'metadata_template', 'recent_item', 'retention_policy', 'retention_policy_assignment', diff --git a/boxsdk/object/metadata_template.py b/boxsdk/object/metadata_template.py new file mode 100644 index 000000000..61d794d4e --- /dev/null +++ b/boxsdk/object/metadata_template.py @@ -0,0 +1,247 @@ +# coding: utf-8 + +from __future__ import unicode_literals, absolute_import + +import json + +from .base_object import BaseObject + + +class MetadataTemplateUpdate(object): + """Represents a set of update operations to a metadata template.""" + + def __init__(self): + 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.ops.append({ + '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.ops.append({ + 'op': 'addField', + 'data': field.json(), + }) + + 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.ops.append({ + '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.ops.append({ + '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.ops.append({ + '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.ops.append({ + '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.ops.append({ + '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.ops.append({ + 'op': 'removeField', + 'fieldKey': field_key, + }) + + +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): + self.type = field_type + self.name = display_name + self.key = key + self.options = options + + def json(self): + """ + Returns the correct representation of the temnplate 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] if self.options is not None else [] + + return values + + +class MetadataTemplate(BaseObject): + """Represents a metadata template, which contains the the type information for associated metadata fields.""" + + _item_type = 'metadata_template' + _scope = None + _template_key = None + + def __init__(self, session, object_id, response_object=None): + super(MetadataTemplate, self).__init__(session, object_id, response_object) + self._scope, self._template_key = object_id.split('/') + + def get_url(self, *args): + """ + Base class override, since metadata templates have a weird compound ID and non-standard URL format + + :rtype: + `unicode` + """ + return self._session.get_url('metadata_templates', self._scope, self._template_key, 'schema') + + def start_update(self): + """ + Start an update operation on the template. + + :returns: + An update object to collect the desired update operations. + :rtype: + :class:`MetadataTemplateUpdate` + """ + return MetadataTemplateUpdate() + + def update(self, updates): + """ + 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.ops)).json() + return self.__class__( + session=self._session, + object_id=self.object_id, + response_object=response, + ) + \ No newline at end of file From 77925918c7a0adaf8e421c8ebd048d056c6b4dd2 Mon Sep 17 00:00:00 2001 From: Matt Willer Date: Wed, 24 Oct 2018 16:53:24 -0700 Subject: [PATCH 02/13] Finish metadata template endpoints implementation --- boxsdk/util/translator.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/boxsdk/util/translator.py b/boxsdk/util/translator.py index 9cdc77547..866e21be6 100644 --- a/boxsdk/util/translator.py +++ b/boxsdk/util/translator.py @@ -28,12 +28,33 @@ def _get_object_id(obj): `dict` :return: """ - if obj.get('type', '') == 'event': + obj_type = obj.get('type') + if obj_type == 'event': return obj.get('event_id', None) + if obj_type == 'metadata_template': + return '{0}/{1}'.format(obj.get('scope', ''), obj.get('templateKey')) + return obj.get('id', None) +def _is_metadata_field(obj): + """ + Check if an object is a metadata field, which we don't really want to translate. + Since 'displayName' is a non-standard field name in the V2 API, that should be sufficient + to identify it. + + :param obj: + The object to check + :type obj: + `dict` + :rtype: + `bool` + """ + + return 'displayName' in obj and obj['type'] != 'metadata_template' + + class Translator(ChainMap): """ Translate item responses from the Box API to Box objects. @@ -173,7 +194,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: + # 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 'type' in translated_obj and '$type' not in translated_obj and not _is_metadata_field(translated_obj): object_class = self.get(translated_obj.get('type', '')) param_values = { 'session': session, From d2d96ec0386bc644a2df58e1cb4204e8f51c76b9 Mon Sep 17 00:00:00 2001 From: Matt Willer Date: Wed, 24 Oct 2018 18:01:08 -0700 Subject: [PATCH 03/13] Add tests for metadata template endpoints --- boxsdk/object/metadata_template.py | 28 +++- test/unit/client/test_client.py | 117 +++++++++++++++ test/unit/object/test_metadata_template.py | 166 +++++++++++++++++++++ 3 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 test/unit/object/test_metadata_template.py diff --git a/boxsdk/object/metadata_template.py b/boxsdk/object/metadata_template.py index 61d794d4e..dbc442f74 100644 --- a/boxsdk/object/metadata_template.py +++ b/boxsdk/object/metadata_template.py @@ -5,6 +5,8 @@ import json from .base_object import BaseObject +from ..util.api_call_decorator import api_call +from ..util.text_enum import TextEnum class MetadataTemplateUpdate(object): @@ -48,6 +50,20 @@ def add_field(self, field): 'data': field.json(), }) + def edit_template(self, data): + """ + Edit top-level template properties. + + :param data: + The properties to modify + :type data: + `dict` + """ + self.ops.append({ + '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. @@ -160,6 +176,14 @@ def remove_field(self, field_key): }) +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.""" @@ -224,7 +248,9 @@ def start_update(self): """ return MetadataTemplateUpdate() - def update(self, updates): + @api_call + def update_info(self, updates): + # pylint: disable=arguments-differ """ Update a metadata template with a set of update operations. diff --git a/test/unit/client/test_client.py b/test/unit/client/test_client.py index 838240ad3..24aed7dc8 100644 --- a/test/unit/client/test_client.py +++ b/test/unit/client/test_client.py @@ -27,6 +27,7 @@ from boxsdk.object.user import User 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.file_version_retention import FileVersionRetention from boxsdk.object.legal_hold_policy import LegalHoldPolicy @@ -931,3 +932,119 @@ def test_device_pins_for_enterprise(mock_client, mock_box_session, device_pins_r assert pin.object_id == expected_id # pylint:disable=protected-access assert pin._session == mock_box_session + + +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 == 'enterprise/VendorContract' + assert template._scope == 'enterprise' + assert template._template_key == 'VendorContract' + + +def test_get_metadata_template_by_id(mock_client, mock_box_session): + template_id = 'sdkjfhgsdg-nb34745bndfg-qw4hbsajdg' + expected_url = '{0}/metadata_templates/{1}'.format(API.BASE_API_URL, template_id) + mock_box_session.get.return_value.json.return_value = { + 'type': 'metadata_template', + 'id': template_id, + 'scope': 'enterprise_33333', + 'displayName': 'Vendor Contract', + 'templateKey': 'vendorContract', + } + + template = mock_client.get_metadata_template_by_id(template_id) + + mock_box_session.get.assert_called_once_with(expected_url) + assert isinstance(template, MetadataTemplate) + # pylint:disable=protected-access + assert template._session == mock_box_session + assert template.object_id == 'enterprise_33333/vendorContract' + assert template._scope == 'enterprise_33333' + assert template._template_key == 'vendorContract' + assert template.id == template_id + assert template.displayName == 'Vendor Contract' + + +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 == 'enterprise_33333/vendorContract' + 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', + } + mock_box_session.post.return_value.json.return_value = expected_body + + 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 == 'enterprise/vContract' + 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' diff --git a/test/unit/object/test_metadata_template.py b/test/unit/object/test_metadata_template.py new file mode 100644 index 000000000..939fd20f9 --- /dev/null +++ b/test/unit/object/test_metadata_template.py @@ -0,0 +1,166 @@ +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): + return MetadataTemplate(mock_box_session, 'enterprise/vContract') + + +def test_get(test_metadata_template, mock_box_session): + expected_url = '{0}/metadata_templates/{1}/schema'.format(API.BASE_API_URL, test_metadata_template.object_id) + 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 == 'enterprise/vContract' + 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}/schema'.format(API.BASE_API_URL, test_metadata_template.object_id) + 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}/schema'.format(API.BASE_API_URL, test_metadata_template.object_id) + + 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 == False + assert updated_template.object_id == 'enterprise/vContract' + 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' From 54a8e66052952d7438429a433b6c6fc4eb64f7cc Mon Sep 17 00:00:00 2001 From: Matt Willer Date: Wed, 24 Oct 2018 18:24:26 -0700 Subject: [PATCH 04/13] Fix lint errors --- boxsdk/object/metadata_template.py | 6 +++--- test/unit/client/test_client.py | 1 + test/unit/object/test_metadata_template.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/boxsdk/object/metadata_template.py b/boxsdk/object/metadata_template.py index dbc442f74..2e200ec19 100644 --- a/boxsdk/object/metadata_template.py +++ b/boxsdk/object/metadata_template.py @@ -235,9 +235,10 @@ def get_url(self, *args): :rtype: `unicode` """ - return self._session.get_url('metadata_templates', self._scope, self._template_key, 'schema') + return self._session.get_url('metadata_templates', self._scope, self._template_key, 'schema', *args) - def start_update(self): + @staticmethod + def start_update(): """ Start an update operation on the template. @@ -270,4 +271,3 @@ def update_info(self, updates): object_id=self.object_id, response_object=response, ) - \ No newline at end of file diff --git a/test/unit/client/test_client.py b/test/unit/client/test_client.py index 24aed7dc8..d75d1969c 100644 --- a/test/unit/client/test_client.py +++ b/test/unit/client/test_client.py @@ -12,6 +12,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 from boxsdk.client import Client, DeveloperTokenClient, DevelopmentClient, LoggingClient diff --git a/test/unit/object/test_metadata_template.py b/test/unit/object/test_metadata_template.py index 939fd20f9..a76179e39 100644 --- a/test/unit/object/test_metadata_template.py +++ b/test/unit/object/test_metadata_template.py @@ -155,7 +155,7 @@ def test_update_info(test_metadata_template, mock_box_session): mock_box_session.put.assert_called_once_with(expected_url, data=json.dumps(expected_body)) assert isinstance(updated_template, MetadataTemplate) - assert updated_template.hidden == False + assert updated_template.hidden is False assert updated_template.object_id == 'enterprise/vContract' fields = updated_template.fields assert len(fields) == 2 From 7f83a3f29649f6c230693639a9e27afc4316e8f5 Mon Sep 17 00:00:00 2001 From: Matt Willer Date: Wed, 24 Oct 2018 22:41:02 -0700 Subject: [PATCH 05/13] Add documentation --- docs/metadata.md | 131 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 docs/metadata.md diff --git a/docs/metadata.md b/docs/metadata.md new file mode 100644 index 000000000..08b8f7b70 --- /dev/null +++ b/docs/metadata.md @@ -0,0 +1,131 @@ +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. + +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 From 4633ccf3f3eba60b6e6fd5377d51f385bd525a63 Mon Sep 17 00:00:00 2001 From: Matt Willer Date: Wed, 24 Oct 2018 23:29:04 -0700 Subject: [PATCH 06/13] Document paging params --- boxsdk/client/client.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/boxsdk/client/client.py b/boxsdk/client/client.py index 6d99d17d5..091113234 100644 --- a/boxsdk/client/client.py +++ b/boxsdk/client/client.py @@ -1355,6 +1355,16 @@ def get_metadata_templates(self, scope='enterprise', limit=None, marker=None, fi 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: From 40e4fbe6c36e22c23f18cad7e9da29e467f39a78 Mon Sep 17 00:00:00 2001 From: Matt Willer Date: Wed, 24 Oct 2018 23:30:29 -0700 Subject: [PATCH 07/13] Add scope and template_key properties --- boxsdk/object/metadata_template.py | 8 ++++++++ test/unit/client/test_client.py | 8 ++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/boxsdk/object/metadata_template.py b/boxsdk/object/metadata_template.py index 2e200ec19..2bbe20389 100644 --- a/boxsdk/object/metadata_template.py +++ b/boxsdk/object/metadata_template.py @@ -228,6 +228,14 @@ def __init__(self, session, object_id, response_object=None): super(MetadataTemplate, self).__init__(session, object_id, response_object) self._scope, self._template_key = object_id.split('/') + @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 diff --git a/test/unit/client/test_client.py b/test/unit/client/test_client.py index d75d1969c..4d384c4d5 100644 --- a/test/unit/client/test_client.py +++ b/test/unit/client/test_client.py @@ -941,8 +941,8 @@ def test_metadata_template_initializer(mock_client, mock_box_session): # pylint:disable=protected-access assert template._session == mock_box_session assert template.object_id == 'enterprise/VendorContract' - assert template._scope == 'enterprise' - assert template._template_key == 'VendorContract' + assert template.scope == 'enterprise' + assert template.template_key == 'VendorContract' def test_get_metadata_template_by_id(mock_client, mock_box_session): @@ -963,8 +963,8 @@ def test_get_metadata_template_by_id(mock_client, mock_box_session): # pylint:disable=protected-access assert template._session == mock_box_session assert template.object_id == 'enterprise_33333/vendorContract' - assert template._scope == 'enterprise_33333' - assert template._template_key == 'vendorContract' + assert template.scope == 'enterprise_33333' + assert template.template_key == 'vendorContract' assert template.id == template_id assert template.displayName == 'Vendor Contract' From f6c4e8301a9a47c942913350f41e7a36406d3e35 Mon Sep 17 00:00:00 2001 From: Matt Willer Date: Thu, 25 Oct 2018 11:18:34 -0700 Subject: [PATCH 08/13] Add table of contents to docs --- docs/metadata.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/metadata.md b/docs/metadata.md index 08b8f7b70..bf9179d6b 100644 --- a/docs/metadata.md +++ b/docs/metadata.md @@ -21,6 +21,20 @@ 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. + + + + +- [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) + + + Create Metadata Template ------------------------ From 5eb4453f4d61df089eab832dad50afae5b15089c Mon Sep 17 00:00:00 2001 From: Matt Willer Date: Thu, 25 Oct 2018 20:25:45 -0700 Subject: [PATCH 09/13] Allow object classes to exclude fields from translation --- boxsdk/object/base_api_json_object.py | 1 + boxsdk/object/metadata_template.py | 1 + boxsdk/util/translator.py | 29 +++++++++------------------ 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/boxsdk/object/base_api_json_object.py b/boxsdk/object/base_api_json_object.py index eecbdd4a2..bf691b6f8 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 = None def __init__(self, response_object=None, **kwargs): """ diff --git a/boxsdk/object/metadata_template.py b/boxsdk/object/metadata_template.py index 2bbe20389..fd9399e86 100644 --- a/boxsdk/object/metadata_template.py +++ b/boxsdk/object/metadata_template.py @@ -221,6 +221,7 @@ 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 diff --git a/boxsdk/util/translator.py b/boxsdk/util/translator.py index 866e21be6..c2f519373 100644 --- a/boxsdk/util/translator.py +++ b/boxsdk/util/translator.py @@ -38,23 +38,6 @@ def _get_object_id(obj): return obj.get('id', None) -def _is_metadata_field(obj): - """ - Check if an object is a metadata field, which we don't really want to translate. - Since 'displayName' is a non-standard field name in the V2 API, that should be sufficient - to identify it. - - :param obj: - The object to check - :type obj: - `dict` - :rtype: - `bool` - """ - - return 'displayName' in obj and obj['type'] != 'metadata_template' - - class Translator(ChainMap): """ Translate item responses from the Box API to Box objects. @@ -183,7 +166,14 @@ def translate(self, session, response_object): return response_object translated_obj = {} + object_type = response_object.get('type', '') + if object_type is not None: + # Parent classes have the ability to "blacklist" fields that they do not want translated + blacklisted_fields = self.get(object_type)._untranslated_fields or [] # pylint: disable=protected-access 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): @@ -196,8 +186,9 @@ def translate(self, session, response_object): # NOTE: Currently, we represent metadata as just a `dict`, so there's no need to translate it anyway # 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 'type' in translated_obj and '$type' not in translated_obj and not _is_metadata_field(translated_obj): - object_class = self.get(translated_obj.get('type', '')) + if 'type' in translated_obj and '$type' not in translated_obj: + object_type = translated_obj.get('type', '') + object_class = self.get(object_type) param_values = { 'session': session, 'response_object': translated_obj, From 519f0607008cad2518dce7cea4c875fcec830fde Mon Sep 17 00:00:00 2001 From: Matt Willer Date: Fri, 26 Oct 2018 14:25:19 -0700 Subject: [PATCH 10/13] Fixes from code review feedback --- boxsdk/object/base_api_json_object.py | 12 ++++++++- boxsdk/object/metadata_template.py | 38 ++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/boxsdk/object/base_api_json_object.py b/boxsdk/object/base_api_json_object.py index bf691b6f8..e9b8a43d8 100644 --- a/boxsdk/object/base_api_json_object.py +++ b/boxsdk/object/base_api_json_object.py @@ -72,7 +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 = None + _untranslated_fields = () def __init__(self, response_object=None, **kwargs): """ @@ -135,6 +135,16 @@ def object_type(self): """ return self._item_type + @property + def object_untranslated_fields(self): + """ + The fields that should not be translated on this object. + + :rtype: + `tuple` + """ + return self._untranslated_fields + @property def response_object(self): """ diff --git a/boxsdk/object/metadata_template.py b/boxsdk/object/metadata_template.py index fd9399e86..277b93531 100644 --- a/boxsdk/object/metadata_template.py +++ b/boxsdk/object/metadata_template.py @@ -188,6 +188,24 @@ 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: + `uniocode` + :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` + """ self.type = field_type self.name = display_name self.key = key @@ -195,7 +213,7 @@ def __init__(self, field_type, display_name, key=None, options=None): def json(self): """ - Returns the correct representation of the temnplate field for the API. + Returns the correct representation of the template field for the API. :rtype: `dict` @@ -212,7 +230,7 @@ def json(self): values['key'] = self.key if self.type in ['enum', 'multiSelect']: - values['options'] = [{'key': opt} for opt in self.options] if self.options is not None else [] + values['options'] = [{'key': opt} for opt in self.options or ()] return values @@ -221,11 +239,25 @@ class MetadataTemplate(BaseObject): """Represents a metadata template, which contains the the type information for associated metadata fields.""" _item_type = 'metadata_template' - _untranslated_fields = ['fields'] + _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 compound ID for the metadata template, formatted as "/templateKey>" + :type object_id: + `unicode` + :param response_object: + A JSON object representing the object returned from a Box API request. + :type response_object: + `dict` or None + """ super(MetadataTemplate, self).__init__(session, object_id, response_object) self._scope, self._template_key = object_id.split('/') From 3d88adbb4e8f1ae78747254e0f3e641c31aff1d8 Mon Sep 17 00:00:00 2001 From: Matt Willer Date: Wed, 31 Oct 2018 12:24:50 -0700 Subject: [PATCH 11/13] Fixes from code review --- boxsdk/object/base_api_json_object.py | 6 +++--- boxsdk/object/metadata_template.py | 2 ++ boxsdk/util/translator.py | 5 ++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/boxsdk/object/base_api_json_object.py b/boxsdk/object/base_api_json_object.py index e9b8a43d8..e8a382008 100644 --- a/boxsdk/object/base_api_json_object.py +++ b/boxsdk/object/base_api_json_object.py @@ -135,15 +135,15 @@ def object_type(self): """ return self._item_type - @property - def object_untranslated_fields(self): + @classmethod + def untranslated_fields(cls): """ The fields that should not be translated on this object. :rtype: `tuple` """ - return self._untranslated_fields + return cls._untranslated_fields @property def response_object(self): diff --git a/boxsdk/object/metadata_template.py b/boxsdk/object/metadata_template.py index 277b93531..fba299cef 100644 --- a/boxsdk/object/metadata_template.py +++ b/boxsdk/object/metadata_template.py @@ -13,6 +13,7 @@ class MetadataTemplateUpdate(object): """Represents a set of update operations to a metadata template.""" def __init__(self): + super(MetadataTemplateUpdate, self).__init__() self.ops = [] def add_enum_option(self, field_key, option_key): @@ -206,6 +207,7 @@ def __init__(self, field_type, display_name, key=None, options=None): :type options: `Iterable` of `unicode` """ + super(MetadataField, self).__init__() self.type = field_type self.name = display_name self.key = key diff --git a/boxsdk/util/translator.py b/boxsdk/util/translator.py index c2f519373..e84929ec2 100644 --- a/boxsdk/util/translator.py +++ b/boxsdk/util/translator.py @@ -167,9 +167,10 @@ def translate(self, session, response_object): translated_obj = {} object_type = response_object.get('type', '') + object_class = self.get(object_type) if object_type is not None: # Parent classes have the ability to "blacklist" fields that they do not want translated - blacklisted_fields = self.get(object_type)._untranslated_fields or [] # pylint: disable=protected-access + blacklisted_fields = object_class.untranslated_fields() or [] for key in response_object: if key in blacklisted_fields: translated_obj[key] = response_object[key] @@ -187,8 +188,6 @@ def translate(self, session, response_object): # 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 'type' in translated_obj and '$type' not in translated_obj: - object_type = translated_obj.get('type', '') - object_class = self.get(object_type) param_values = { 'session': session, 'response_object': translated_obj, From 4e208a98bad5a8786d4bb05f19a67b64e58a0ffc Mon Sep 17 00:00:00 2001 From: Matt Willer Date: Wed, 31 Oct 2018 16:17:46 -0700 Subject: [PATCH 12/13] Fix metadata template object ID kludge --- boxsdk/client/client.py | 13 +++-- boxsdk/object/metadata_template.py | 57 +++++++++++++++------- boxsdk/util/translator.py | 18 ++----- test/unit/client/test_client.py | 15 ++++-- test/unit/object/test_metadata_template.py | 29 ++++++++--- test/unit/util/test_translator.py | 28 +++++++++++ 6 files changed, 114 insertions(+), 46 deletions(-) diff --git a/boxsdk/client/client.py b/boxsdk/client/client.py index 091113234..d9e11c321 100644 --- a/boxsdk/client/client.py +++ b/boxsdk/client/client.py @@ -1320,7 +1320,12 @@ def metadata_template(self, scope, template_key): """ return self.translator.get('metadata_template')( session=self._session, - object_id='{0}/{1}'.format(scope, template_key), + object_id=None, + response_object={ + 'type': 'metadata_template', + 'scope': scope, + 'templateKey': template_key, + }, ) @api_call @@ -1339,9 +1344,8 @@ def get_metadata_template_by_id(self, template_id): """ url = self._session.get_url('metadata_templates', template_id) response = self._session.get(url).json() - return self.translator.get('metadata_template')( + return self.translator.translate( session=self._session, - object_id='{0}/{1}'.format(response['scope'], response['templateKey']), response_object=response, ) @@ -1419,8 +1423,7 @@ def create_metadata_template(self, display_name, fields, template_key=None, hidd body['templateKey'] = template_key response = self._session.post(url, data=json.dumps(body)).json() - return self.translator.get('metadata_template')( + return self.translator.translate( session=self._session, - object_id='{0}/{1}'.format(response['scope'], response['templateKey']), response_object=response, ) diff --git a/boxsdk/object/metadata_template.py b/boxsdk/object/metadata_template.py index fba299cef..c1b820086 100644 --- a/boxsdk/object/metadata_template.py +++ b/boxsdk/object/metadata_template.py @@ -14,7 +14,10 @@ class MetadataTemplateUpdate(object): def __init__(self): super(MetadataTemplateUpdate, self).__init__() - self.ops = [] + self._ops = [] + + def json(self): + return self._ops def add_enum_option(self, field_key, option_key): """ @@ -29,7 +32,7 @@ def add_enum_option(self, field_key, option_key): :type option_key: `unicode` """ - self.ops.append({ + self.add_operation({ 'op': 'addEnumOption', 'fieldKey': field_key, 'data': { @@ -46,7 +49,7 @@ def add_field(self, field): :type field: :class:`MetadataField` """ - self.ops.append({ + self.add_operation({ 'op': 'addField', 'data': field.json(), }) @@ -60,7 +63,7 @@ def edit_template(self, data): :type data: `dict` """ - self.ops.append({ + self.add_operation({ 'op': 'editTemplate', 'data': data, }) @@ -78,7 +81,7 @@ def reorder_enum_options(self, field_key, option_keys): :type option_keys: `list` of `unicode` """ - self.ops.append({ + self.add_operation({ 'op': 'reorderEnumOptions', 'fieldKey': field_key, 'enumOptionKeys': option_keys, @@ -93,7 +96,7 @@ def reorder_fields(self, field_keys): :type field_keys: `list` of `unicode` """ - self.ops.append({ + self.add_operation({ 'op': 'reorderFields', 'fieldKeys': field_keys, }) @@ -111,7 +114,7 @@ def edit_field(self, field_key, field): :type field: :class:`MetadataField` """ - self.ops.append({ + self.add_operation({ 'op': 'editField', 'fieldKey': field_key, 'data': field.json(), @@ -134,7 +137,7 @@ def edit_enum_option_key(self, field_key, old_option_key, new_option_key): :type new_option_key: `unicode` """ - self.ops.append({ + self.add_operation({ 'op': 'editEnumOption', 'fieldKey': field_key, 'enumOptionKey': old_option_key, @@ -156,7 +159,7 @@ def remove_enum_option(self, field_key, option_key): :type option_key: `unicode` """ - self.ops.append({ + self.add_operation({ 'op': 'removeEnumOption', 'fieldKey': field_key, 'enumOptionKey': option_key, @@ -171,11 +174,22 @@ def remove_field(self, field_key): :type field_key: `unicode` """ - self.ops.append({ + 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' @@ -197,7 +211,7 @@ def __init__(self, field_type, display_name, key=None, options=None): :param display_name: The human-readable name of the metadata field :type display_name: - `uniocode` + `unicode` :param key: The machine-readable key for the metadata field :type key: @@ -252,16 +266,22 @@ def __init__(self, session, object_id, response_object=None): :type session: :class:`BoxSession` :param object_id: - The compound ID for the metadata template, formatted as "/templateKey>" + The primary GUID key for the metadata template :type object_id: - `unicode` + `unicode` or None :param response_object: - A JSON object representing the object returned from a Box API request. + 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) - self._scope, self._template_key = object_id.split('/') + 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): @@ -278,7 +298,10 @@ def get_url(self, *args): :rtype: `unicode` """ - return self._session.get_url('metadata_templates', self._scope, self._template_key, 'schema', *args) + 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(): @@ -308,7 +331,7 @@ def update_info(self, updates): :class:`MetadataTemplate` """ url = self.get_url() - response = self._session.put(url, data=json.dumps(updates.ops)).json() + response = self._session.put(url, data=json.dumps(updates.json())).json() return self.__class__( session=self._session, object_id=self.object_id, diff --git a/boxsdk/util/translator.py b/boxsdk/util/translator.py index e84929ec2..82b1891ce 100644 --- a/boxsdk/util/translator.py +++ b/boxsdk/util/translator.py @@ -28,13 +28,6 @@ def _get_object_id(obj): `dict` :return: """ - obj_type = obj.get('type') - if obj_type == 'event': - return obj.get('event_id', None) - - if obj_type == 'metadata_template': - return '{0}/{1}'.format(obj.get('scope', ''), obj.get('templateKey')) - return obj.get('id', None) @@ -166,11 +159,10 @@ def translate(self, session, response_object): return response_object translated_obj = {} - object_type = response_object.get('type', '') - object_class = self.get(object_type) - if object_type is not None: - # Parent classes have the ability to "blacklist" fields that they do not want translated - blacklisted_fields = object_class.untranslated_fields() or [] + 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] @@ -187,7 +179,7 @@ def translate(self, session, response_object): # NOTE: Currently, we represent metadata as just a `dict`, so there's no need to translate it anyway # 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 'type' in translated_obj and '$type' not in translated_obj: + if object_class is not None and '$type' not in translated_obj: param_values = { 'session': session, 'response_object': translated_obj, diff --git a/test/unit/client/test_client.py b/test/unit/client/test_client.py index 4d384c4d5..70a30de54 100644 --- a/test/unit/client/test_client.py +++ b/test/unit/client/test_client.py @@ -940,7 +940,7 @@ def test_metadata_template_initializer(mock_client, mock_box_session): assert isinstance(template, MetadataTemplate) # pylint:disable=protected-access assert template._session == mock_box_session - assert template.object_id == 'enterprise/VendorContract' + assert template.object_id is None assert template.scope == 'enterprise' assert template.template_key == 'VendorContract' @@ -962,7 +962,7 @@ def test_get_metadata_template_by_id(mock_client, mock_box_session): assert isinstance(template, MetadataTemplate) # pylint:disable=protected-access assert template._session == mock_box_session - assert template.object_id == 'enterprise_33333/vendorContract' + assert template.object_id == template_id assert template.scope == 'enterprise_33333' assert template.template_key == 'vendorContract' assert template.id == template_id @@ -997,7 +997,7 @@ def test_get_metadata_templates(mock_client, mock_box_session): mock_box_session.get.assert_called_once_with(expected_url, params={}) assert isinstance(template, MetadataTemplate) - assert template.object_id == 'enterprise_33333/vendorContract' + assert template.object_id is None assert template.displayName == 'Vendor Contract' fields = template.fields assert len(fields) == 1 @@ -1035,13 +1035,18 @@ def test_create_metadata_template(mock_client, mock_box_session): ], 'templateKey': 'vContract', } - mock_box_session.post.return_value.json.return_value = expected_body + + 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 == 'enterprise/vContract' + assert template.object_id is None assert template.displayName == 'Vendor Contract' fields = template.fields assert len(fields) == 2 diff --git a/test/unit/object/test_metadata_template.py b/test/unit/object/test_metadata_template.py index a76179e39..a2d18ea69 100644 --- a/test/unit/object/test_metadata_template.py +++ b/test/unit/object/test_metadata_template.py @@ -9,11 +9,20 @@ @pytest.fixture() def test_metadata_template(mock_box_session): - return MetadataTemplate(mock_box_session, 'enterprise/vContract') + 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}/schema'.format(API.BASE_API_URL, test_metadata_template.object_id) + 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', @@ -41,7 +50,7 @@ def test_get(test_metadata_template, mock_box_session): mock_box_session.get.assert_called_once_with(expected_url, params=None, headers=None) assert isinstance(template, MetadataTemplate) - assert template.object_id == 'enterprise/vContract' + assert template.object_id is None assert template.displayName == 'Vendor Contract' fields = template.fields assert len(fields) == 2 @@ -52,7 +61,11 @@ def test_get(test_metadata_template, mock_box_session): def test_delete(test_metadata_template, mock_box_session): - expected_url = '{0}/metadata_templates/{1}/schema'.format(API.BASE_API_URL, test_metadata_template.object_id) + 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() @@ -62,7 +75,11 @@ def test_delete(test_metadata_template, mock_box_session): def test_update_info(test_metadata_template, mock_box_session): - expected_url = '{0}/metadata_templates/{1}/schema'.format(API.BASE_API_URL, test_metadata_template.object_id) + 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') @@ -156,7 +173,7 @@ def test_update_info(test_metadata_template, mock_box_session): 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 == 'enterprise/vContract' + assert updated_template.object_id is None fields = updated_template.fields assert len(fields) == 2 field = fields[1] diff --git a/test/unit/util/test_translator.py b/test/unit/util/test_translator.py index 340bd6cfe..eade80b19 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,15 @@ 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'] From dec4a0b9d341bd3dd12de4c06f62464b11cd2b1a Mon Sep 17 00:00:00 2001 From: Matt Willer Date: Wed, 31 Oct 2018 16:53:57 -0700 Subject: [PATCH 13/13] Fix code review comments --- boxsdk/client/client.py | 9 +++------ test/unit/client/test_client.py | 21 +++++---------------- test/unit/util/test_translator.py | 3 +++ 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/boxsdk/client/client.py b/boxsdk/client/client.py index 0a28d65d1..cca4597b8 100644 --- a/boxsdk/client/client.py +++ b/boxsdk/client/client.py @@ -1509,8 +1509,7 @@ def metadata_template(self, scope, template_key): }, ) - @api_call - def get_metadata_template_by_id(self, template_id): + def metadata_template_by_id(self, template_id): """ Retrieves a metadata template by ID @@ -1523,11 +1522,9 @@ def get_metadata_template_by_id(self, template_id): :rtype: :class:`MetadataTemplate` """ - url = self._session.get_url('metadata_templates', template_id) - response = self._session.get(url).json() - return self.translator.translate( + return self.translator.get('metadata_template')( session=self._session, - response_object=response, + object_id=template_id, ) @api_call diff --git a/test/unit/client/test_client.py b/test/unit/client/test_client.py index c99d06076..7e1ac8459 100644 --- a/test/unit/client/test_client.py +++ b/test/unit/client/test_client.py @@ -1102,28 +1102,17 @@ def test_metadata_template_initializer(mock_client, mock_box_session): assert template.template_key == 'VendorContract' -def test_get_metadata_template_by_id(mock_client, mock_box_session): +def test_metadata_template_by_id(mock_client, mock_box_session): template_id = 'sdkjfhgsdg-nb34745bndfg-qw4hbsajdg' - expected_url = '{0}/metadata_templates/{1}'.format(API.BASE_API_URL, template_id) - mock_box_session.get.return_value.json.return_value = { - 'type': 'metadata_template', - 'id': template_id, - 'scope': 'enterprise_33333', - 'displayName': 'Vendor Contract', - 'templateKey': 'vendorContract', - } - template = mock_client.get_metadata_template_by_id(template_id) + template = mock_client.metadata_template_by_id(template_id) - mock_box_session.get.assert_called_once_with(expected_url) assert isinstance(template, MetadataTemplate) # pylint:disable=protected-access assert template._session == mock_box_session assert template.object_id == template_id - assert template.scope == 'enterprise_33333' - assert template.template_key == 'vendorContract' - assert template.id == template_id - assert template.displayName == 'Vendor Contract' + assert template.scope is None + assert template.template_key is None def test_get_metadata_templates(mock_client, mock_box_session): @@ -1210,7 +1199,7 @@ def test_create_metadata_template(mock_client, mock_box_session): field = fields[0] assert isinstance(field, dict) assert field['type'] == 'date' - assert field['key'] == 'bday' + assert field['key'] == 'bday' def test_get_current_enterprise(mock_client, mock_box_session): diff --git a/test/unit/util/test_translator.py b/test/unit/util/test_translator.py index eade80b19..6d15ccc94 100644 --- a/test/unit/util/test_translator.py +++ b/test/unit/util/test_translator.py @@ -223,3 +223,6 @@ def test_translator_translates_metadata_template(default_translator, mock_box_se 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