diff --git a/connect/resources/__init__.py b/connect/resources/__init__.py index c5d38de..c618b05 100644 --- a/connect/resources/__init__.py +++ b/connect/resources/__init__.py @@ -3,6 +3,7 @@ # This file is part of the Ingram Micro Cloud Blue Connect SDK. # Copyright (c) 2019 Ingram Micro. All Rights Reserved. +from .directory import Directory from .fulfillment_automation import FulfillmentAutomation from .template import TemplateResource from .tier_config_automation import TierConfigAutomation @@ -11,6 +12,7 @@ __all__ = [ + 'Directory', 'FulfillmentAutomation', 'TemplateResource', 'TierConfigAutomation', diff --git a/connect/resources/base.py b/connect/resources/base.py index d989975..d1dc043 100644 --- a/connect/resources/base.py +++ b/connect/resources/base.py @@ -55,7 +55,7 @@ def get_url(self, path=''): def urljoin(*args): # type: (str) -> str return functools.reduce( - lambda a, b: compat.urljoin(a + ('' if a.endswith('/') else '/'), b), + lambda a, b: compat.urljoin(a + ('' if a.endswith('/') else '/'), b) if b else a, args) @function_log diff --git a/connect/resources/directory.py b/connect/resources/directory.py new file mode 100644 index 0000000..ecd500c --- /dev/null +++ b/connect/resources/directory.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect SDK. +# Copyright (c) 2019 Ingram Micro. All Rights Reserved. + +from connect.config import Config +from connect.models import Asset, Product, TierConfig +from connect.resources.base import ApiClient + + +class Directory(object): + """ Allows listing and obtaining several types of objects. + + :param Config config: Config object or ``None`` to use environment config (default). + """ + + _config = None # type: Config + + def __init__(self, config=None): + self._config = config or Config.get_instance() + + def list_assets(self, filters=None): + """ List the assets. + + :param (dict[str, Any] filters: Filters to pass to the request. + :return: A list with the assets that match the given filters. + :rtype: list[Asset] + """ + products = ','.join(self._config.products) if self._config.products else None + url = self._config.api_url + 'assets?in(product.id,(' + products + '))' \ + if products \ + else 'assets' + text, code = ApiClient(self._config, url).get(params=filters) + return Asset.deserialize(text) + + def get_asset(self, asset_id): + """ Returns the asset with the given id. + + :param str asset_id: The id of the asset. + :return: The asset with the given id, or ``None`` if such asset does not exist. + :rtype: Asset|None + """ + text, code = ApiClient(self._config, 'assets/' + asset_id).get() + return Asset.deserialize(text) + + def list_products(self): + """ List the products. Filtering is not possible at the moment. + + :return: A list with all products. + :rtype: list[Product] + """ + text, code = ApiClient(self._config, 'products').get() + return Product.deserialize(text) + + def get_product(self, product_id): + """ Returns the product with the given id. + + :param str product_id: The id of the product. + :return: The product with the given id, or ``None`` if such product does not exist. + :rtype: Product|None + """ + text, code = ApiClient(self._config, 'products/' + product_id).get() + return Product.deserialize(text) + + def list_tier_configs(self, filters=None): + """ List the tier configs. + + :param (dict[str, Any] filters: Filters to pass to the request. + :return: A list with the tier configs that match the given filters. + :rtype: list[TierConfig] + """ + filters = filters or {} + products_key = 'product.id' + if products_key not in filters and self._config.products: + filters[products_key] = ','.join(self._config.products) + text, code = ApiClient(self._config, 'tier/configs').get(params=filters) + return TierConfig.deserialize(text) + + def get_tier_config(self, tier_config_id): + """ Returns the tier config with the given id. + + :param str tier_config_id: The id of the tier config. + :return: The Tier Config with the given id, or ``None`` if such Tier Config does not exist. + :rtype: TierConfig|None + """ + text, code = ApiClient(self._config, 'tier/configs/' + tier_config_id).get() + return TierConfig.deserialize(text) diff --git a/connect/resources/fulfillment_automation.py b/connect/resources/fulfillment_automation.py index 1a40826..9bfd50b 100644 --- a/connect/resources/fulfillment_automation.py +++ b/connect/resources/fulfillment_automation.py @@ -38,9 +38,8 @@ class FulfillmentAutomation(AutomationEngine): model_class = Fulfillment def filters(self, status='pending', **kwargs): - """ - Returns the default set of filters for Fulfillment request, plus any others that you might - specify. The allowed filters are: + """ Returns the default set of filters for Fulfillment request, plus any others that you + might specify. The allowed filters are: - status - created diff --git a/connect/resources/usage_automation.py b/connect/resources/usage_automation.py index 7866e7a..7fd45e3 100644 --- a/connect/resources/usage_automation.py +++ b/connect/resources/usage_automation.py @@ -27,9 +27,9 @@ class UsageAutomation(AutomationEngine): resource = 'listings' model_class = UsageFile - def filters(self, status='draft', **kwargs): + def filters(self, status='listed', **kwargs): """ - :param str status: Status of the requests. Default: ``'draft'``. + :param str status: Status of the requests. Default: ``'listed'``. :param dict[str,Any] kwargs: Additional filters to add to the default ones. :return: The set of filters for this resource. :rtype: dict[str,Any] diff --git a/tests/data/response_asset.json b/tests/data/response_asset.json new file mode 100644 index 0000000..e607f11 --- /dev/null +++ b/tests/data/response_asset.json @@ -0,0 +1,78 @@ +{ + "id": "AS-9861-7949-8492", + "status": "active", + "events": { + "created": { + "at": "2018-06-04T13:19:10+00:00" + }, + "updated": { + "at": "2018-06-04T13:19:10+00:00" + } + }, + "external_id": "12345", + "external_uid": "12435,518e10e8-3b1b-49b5-a480-b67675af4ae5", + "external_name": "Fallball 498c84b1-d53318a6", + "product": { + "id": "CN-9861-7949-8492", + "icon": "https://provider.connect.cloud.im/media/dapper-lynxes-35/mj301/media/mj301-logo.png", + "name": "Fallball Awesome" + }, + "connection": { + "id": "CT-9861-7949-8492", + "type": "production", + "hub": { + "id": "HB-12345-12345", + "name": "Provider Production Hub", + "account_id": "PA-9861-7949", + "instance_id": "c106b775-7155-4b94-810d-a0c597fe801f" + }, + "provider": { + "name": "Ingram Micro Prod DA", + "id": "PA-9861-7949" + }, + "vendor": { + "name": "Large Largo and Co", + "id": "VA-9861-7949" + } + }, + "items": [ + { + "id": "SKU-9861-7949-8492-0001", + "mpn": "TEAM-ST3L2TAC1M", + "quantity": "3" + }, + { + "id": "SKU-9861-7949-8492-0002", + "mpn": "USR-FFFAC1M", + "quantity": "1" + } + ], + "params": [ + { + "id": "PM-9861-7949-8492-0001 #AUTOGEN #PRODUCT", + "name": "Secondary email", + "description": "This is a backup email for emergency", + "type": "text", + "value": "daniel.lark@gmail.com" + } + ], + "tiers": { + "customer": { + "id": "TA-0-9861-7949-8492" + }, + "tier1": { + "id": "TA-1-9861-7949-8492" + }, + "tier2": { + "id": "TA-2-9861-7949-8492" + } + }, + "marketplace": { + "id": "MP-12345", + "name": "France and territories" + }, + "contract": { + "id": "CRP-00000-00000-00000", + "name": "Contract of Program Agreement" + } +} diff --git a/tests/data/response_product.json b/tests/data/response_product.json new file mode 100644 index 0000000..620c238 --- /dev/null +++ b/tests/data/response_product.json @@ -0,0 +1,50 @@ +{ + "id": "CN-783-317-575", + "name": "Test Product", + "icon": "https://provider.connect.cloud.im/media/dapper-lynxes-35/mj301/media/mj301-logo.png", + "short_description": "", + "detailed_description": "", + "version": 2, + "published_at": "2018-09-03T10:28:18.472670+00:00", + "configurations": { + "suspend_resume_supported": true, + "requires_reseller_information": true + }, + "customer_ui_settings": { + "description": "description", + "getting_started": "short description", + "download_links": [ + { + "title": "Windows", + "url": "https://fallball.io/download/windows" + }, + { + "title": "macOS", + "url": "https://fallball.io/download/macos" + } + ], + "documents": [ + { + "title": "Admin Manual", + "url": "https://fallball.io/manual/admin", + "visible_for": "admin" + }, + { + "title": "User Manual", + "url": "https://fallball.io/manual/user", + "visible_for": "user" + } + ], + "languages": [ + { + "id": "en_EN", + "name": "English" + }, + { + "id": "de_DE", + "name": "German" + } + ] + } +} + diff --git a/tests/data/response_tier_config.json b/tests/data/response_tier_config.json new file mode 100644 index 0000000..147822c --- /dev/null +++ b/tests/data/response_tier_config.json @@ -0,0 +1,63 @@ +{ + "id": "TC-000-000-000", + "name": "Configuration of Reseller", + "account": { + "id": "TA-1-000-000-000" + }, + "product": { + "id": "PRD-000-000-000", + "icon": "https://provider.connect.cloud.im/media/dapper-lynxes-35/mj301/media/mj301-logo.png", + "name": "Product" + }, + "tier_level": 1, + "connection": { + "id": "CT-9861-7949-8492", + "type": "production", + "hub": { + "id": "HB-12345-12345", + "name": "Provider Production Hub" + }, + "provider": { + "name": "Ingram Micro Prod DA", + "id": "PA-9861-7949" + }, + "vendor": { + "name": "Large Largo and Co", + "id": "VA-9861-7949" + } + }, + "events": { + "created": { + "at": "2018-11-21T11:10:29+00:00" + }, + "updated": { + "at": "2018-11-21T11:10:29+00:00", + "by": { + "id": "PA-000-000", + "name": "Username" + } + } + }, + "params": [ + { + "id": "param_a", + "value": "param_a_value" + } + ], + "open_request": { + "id": "TCR-000-000-000" + }, + "template": { + "id": "TP-000-000-000", + "representation": "Render text is here......" + }, + "contract": { + "id": "CRD-00000-00000-00000", + "name": "ACME Distribution Contract" + }, + "marketplace": { + "icon": "/media/PA-239-689/marketplaces/MP-54865/icon.png", + "id": "MP-54865", + "name": "Germany" + } +} diff --git a/tests/test_conversation.py b/tests/test_conversation.py index 2152a31..1452d27 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -57,7 +57,7 @@ def test_add_message(post_mock): post_mock.assert_called_with( headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, json={'text': text}, - url='http://localhost:8080/api/public/v1/conversations/CO-750-033-356/messages/') + url='http://localhost:8080/api/public/v1/conversations/CO-750-033-356/messages') assert isinstance(message, ConversationMessage) assert message.id == 'ME-000-000-000' @@ -85,7 +85,7 @@ def test_get_conversation_ok(get_mock): call( headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, params={'instance_id': request.id}, - url='http://localhost:8080/api/public/v1/conversations/'), + url='http://localhost:8080/api/public/v1/conversations'), call( headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, url='http://localhost:8080/api/public/v1/conversations/' + conversation.id) @@ -107,7 +107,7 @@ def test_get_conversation_empty(get_mock): call( headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, params={'instance_id': request.id}, - url='http://localhost:8080/api/public/v1/conversations/') + url='http://localhost:8080/api/public/v1/conversations') ]) assert conversation is None @@ -129,7 +129,7 @@ def test_get_conversation_bad_deserialize(get_mock): call( headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, params={'instance_id': request.id}, - url='http://localhost:8080/api/public/v1/conversations/'), + url='http://localhost:8080/api/public/v1/conversations'), call( headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, url='http://localhost:8080/api/public/v1/conversations/CO-750-033-356') diff --git a/tests/test_directory.py b/tests/test_directory.py new file mode 100644 index 0000000..bdf3901 --- /dev/null +++ b/tests/test_directory.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect SDK. +# Copyright (c) 2019 Ingram Micro. All Rights Reserved. + +# TODO: Assert received request data + +from mock import patch, MagicMock +import os + +import pytest + +from connect.exceptions import ServerError +from connect.models import Asset, Product, TierConfig +from connect.resources import Directory +from .common import Response, load_str + + +def _get_asset_response(): + return _get_response_from_file('response_asset.json') + + +def _get_product_response(): + return _get_response_from_file('response_product.json') + + +def _get_tier_config_response(): + return _get_response_from_file('response_tier_config.json') + + +def _get_response_from_file(filename): + return Response( + ok=True, + text=load_str(os.path.join(os.path.dirname(__file__), 'data', filename)), + status_code=200 + ) + + +def _get_array_response(object_response): + return Response( + ok=True, + text='[{}]'.format(object_response.text), + status_code=200 + ) + + +def _get_bad_response(): + return Response( + ok=False, + text='{}', + status_code=404 + ) + + +@patch('requests.get') +def test_list_assets(get_mock): + get_mock.return_value = _get_array_response(_get_asset_response()) + assets = Directory().list_assets() + assert isinstance(assets, list) + assert len(assets) == 1 + assert isinstance(assets[0], Asset) + assert assets[0].id == 'AS-9861-7949-8492' + + get_mock.assert_called_with( + url='http://localhost:8080/api/public/v1/assets?in(product.id,(CN-631-322-000))', + headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, + params=None) + + +@patch('requests.get') +def test_get_asset(get_mock): + get_mock.return_value = _get_asset_response() + asset = Directory().get_asset('AS-9861-7949-8492') + assert isinstance(asset, Asset) + assert asset.id == 'AS-9861-7949-8492' + + get_mock.assert_called_with( + url='http://localhost:8080/api/public/v1/assets/AS-9861-7949-8492', + headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}) + + +@patch('requests.get', MagicMock(return_value=_get_bad_response())) +def test_get_asset_bad(): + with pytest.raises(ServerError): + Directory().get_asset('AS-9861-7949-8492') + + +@patch('requests.get') +def test_list_products(get_mock): + get_mock.return_value = _get_array_response(_get_product_response()) + products = Directory().list_products() + assert isinstance(products, list) + assert len(products) == 1 + assert isinstance(products[0], Product) + assert products[0].id == 'CN-783-317-575' + + get_mock.assert_called_with( + url='http://localhost:8080/api/public/v1/products', + headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}) + + +@patch('requests.get') +def test_get_product(get_mock): + get_mock.return_value = _get_product_response() + product = Directory().get_product('CN-783-317-575') + assert isinstance(product, Product) + assert product.id == 'CN-783-317-575' + + get_mock.assert_called_with( + url='http://localhost:8080/api/public/v1/products/CN-783-317-575', + headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}) + + +@patch('requests.get', MagicMock(return_value=_get_bad_response())) +def test_get_product_bad(): + with pytest.raises(ServerError): + Directory().get_product('CN-783-317-575') + + +@patch('requests.get') +def test_list_tier_configs(get_mock): + get_mock.return_value = _get_array_response(_get_tier_config_response()) + tier_configs = Directory().list_tier_configs() + assert isinstance(tier_configs, list) + assert len(tier_configs) == 1 + assert isinstance(tier_configs[0], TierConfig) + assert tier_configs[0].id == 'TC-000-000-000' + + get_mock.assert_called_with( + url='http://localhost:8080/api/public/v1/tier/configs', + headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, + params={'product.id': 'CN-631-322-000'}) + + +@patch('requests.get') +def test_get_tier_config(get_mock): + get_mock.return_value = _get_tier_config_response() + tier_config = Directory().get_tier_config('TC-000-000-000') + assert isinstance(tier_config, TierConfig) + assert tier_config.id == 'TC-000-000-000' + + get_mock.assert_called_with( + url='http://localhost:8080/api/public/v1/tier/configs/TC-000-000-000', + headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}) + + +@patch('requests.get', MagicMock(return_value=_get_bad_response())) +def test_get_tier_config_bad(): + with pytest.raises(ServerError): + Directory().get_tier_config('TC-000-000-000') diff --git a/tests/test_models.py b/tests/test_models.py index cbdaf70..21e844b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -49,7 +49,7 @@ def _get_response_migration(): def test_resource_url(): resource = FulfillmentAutomation() - assert resource._api.get_url() == resource.config.api_url + resource.resource + '/' + assert resource._api.get_url() == resource.config.api_url + resource.resource def test_resource_urljoin(): @@ -161,7 +161,7 @@ def test_get_tier_config(get_mock): config = TierConfig.get('account_id', 'product_id') assert isinstance(config, TierConfig) get_mock.assert_called_with( - url='http://localhost:8080/api/public/v1/tier/config-requests/', + url='http://localhost:8080/api/public/v1/tier/config-requests', headers={ 'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'},