diff --git a/connect/exceptions.py b/connect/exceptions.py index 1d52660..05496e3 100644 --- a/connect/exceptions.py +++ b/connect/exceptions.py @@ -84,6 +84,7 @@ class ServerError(Exception): """ def __init__(self, error): + self.error = error super(ServerError, self).__init__(str(error), error.error_code) diff --git a/connect/models/__init__.py b/connect/models/__init__.py index 93b52b6..5a49335 100644 --- a/connect/models/__init__.py +++ b/connect/models/__init__.py @@ -13,6 +13,7 @@ from .asset import Asset from .base import BaseModel from .billing import Billing +from .commitment import Commitment from .company import Company from .configuration import Configuration from .connection import Connection @@ -55,6 +56,8 @@ from .tier_account_request import TierAccountRequest from .tier_config import TierConfig from .tier_config_request import TierConfigRequest +from .ui import UI +from .unit import Unit from .usage_file import UsageFile from .usage_listing import UsageListing from .usage_record import UsageRecord @@ -83,6 +86,7 @@ 'BillingRequest', 'Company', 'Configuration', + 'Commitment', 'Connection', 'Constraints', 'Contact', @@ -125,6 +129,8 @@ 'TierAccounts', 'TierConfig', 'TierConfigRequest', + 'UI', + 'Unit', 'UsageFile', 'UsageListing', 'UsageRecord', diff --git a/connect/models/commitment.py b/connect/models/commitment.py new file mode 100644 index 0000000..aab4c89 --- /dev/null +++ b/connect/models/commitment.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect SDK. +# Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. + +from .base import BaseModel +from .schemas import CommitmentSchema + + +class Commitment(BaseModel): + """ Billing commitment object. """ + + _schema = CommitmentSchema() + + multiplier = None # type: str + """ (str) Commitment multiplier. """ + + count = None # type: int + """ (int) Number of commitments. """ diff --git a/connect/models/item.py b/connect/models/item.py index 6f54166..8d078cf 100644 --- a/connect/models/item.py +++ b/connect/models/item.py @@ -6,9 +6,12 @@ from typing import List, Optional, Union from .base import BaseModel +from .commitment import Commitment from .param import Param from .renewal import Renewal from .schemas import ItemSchema +from .ui import UI +from .unit import Unit class Item(BaseModel): @@ -55,6 +58,15 @@ class Item(BaseModel): name = None # type: str """ (str) Name. """ + unit = None # type: Unit + """ (Unit) Measure unit. """ + + commitment = None # type: Optional[Commitment] + """ (Commitment) item billing commitment. """ + + ui = None # type: UI + """ (UI) UI visibility. """ + def get_param_by_id(self, param_id): """ Get a parameter of the item. diff --git a/connect/models/schemas.py b/connect/models/schemas.py index 6686f72..e9ad096 100644 --- a/connect/models/schemas.py +++ b/connect/models/schemas.py @@ -9,6 +9,11 @@ class BaseSchema(Schema): + + def __init__(self, *args, **kwargs): + # kwargs['strict'] = True + super(BaseSchema, self).__init__(*args, **kwargs) + id = fields.Str() # Set allow_none to True in all fields @@ -334,18 +339,50 @@ def make_object(self, data): return Param(**data) +class UISchema(BaseSchema): + visibility = fields.Bool() + @post_load + def make_object(self, data): + from connect.models import UI + return UI(**data) + + +class UnitSchema(BaseSchema): + title = fields.Str() + unit = fields.Str() + + @post_load + def make_object(self, data): + from connect.models import Unit + return Unit(**data) + + +class CommitmentSchema(BaseSchema): + multiplier = fields.Str() + count = fields.Int() + + @post_load + def make_object(self, data): + from connect.models import Commitment + return Commitment(**data) + + class ItemSchema(BaseSchema): mpn = fields.Str() quantity = QuantityField() old_quantity = QuantityField() renewal = fields.Nested(RenewalSchema) + unit = fields.Nested(UnitSchema) + commitment = fields.Nested(CommitmentSchema) params = fields.Nested(ParamSchema, many=True) display_name = fields.Str() global_id = fields.Str() item_type = fields.Str() + description = fields.Str() period = fields.Str() type = fields.Str() name = fields.Str() + ui = fields.Nested(UISchema) @post_load def make_object(self, data): @@ -828,8 +865,8 @@ def make_object(self, data): class AttributesSchema(BaseSchema): - provider = fields.Nested(CompanySchema, only=('external_id')) - vendor = fields.Nested(CompanySchema, only=('external_id')) + provider = fields.Nested(CompanySchema, only=('external_id',)) + vendor = fields.Nested(CompanySchema, only=('external_id',)) @post_load def make_object(self, data): diff --git a/connect/models/ui.py b/connect/models/ui.py new file mode 100644 index 0000000..43371dc --- /dev/null +++ b/connect/models/ui.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect SDK. +# Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. + +from .base import BaseModel +from .schemas import UISchema + + +class UI(BaseModel): + """ UI object. """ + + _schema = UISchema() + + visibility = None # type: bool + """ (str) Item UI visibility. """ diff --git a/connect/models/unit.py b/connect/models/unit.py new file mode 100644 index 0000000..d724bad --- /dev/null +++ b/connect/models/unit.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect SDK. +# Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. + +from .base import BaseModel +from .schemas import UnitSchema + + +class Unit(BaseModel): + """ Unit object. """ + + _schema = UnitSchema() + + title = None # type: str + """ (str) Name of measure unit. """ + + unit = None # type: str + """ (str) unit code of measure unit. """ diff --git a/connect/resources/base.py b/connect/resources/base.py index 97c0f69..449093d 100644 --- a/connect/resources/base.py +++ b/connect/resources/base.py @@ -140,11 +140,11 @@ class BaseResource(object): def __init__(self, config=None): # Set client - if not self.__class__.resource: + if not self.resource: raise AttributeError('Resource name not specified in class {}. ' 'Add an attribute `resource` with the name of the resource' .format(self.__class__.__name__)) - self._api = ApiClient(config, self.__class__.resource) + self._api = ApiClient(config, self.resource) @property def config(self): @@ -161,12 +161,15 @@ def get(self, pk): def filters(self, **kwargs): # type: (Dict[str, Any]) -> Dict[str, Any] - filters = {} + query = Query() if self.limit: - filters['limit'] = self.limit + query = query.limit(self.limit) for key, val in kwargs.items(): - filters[key] = val - return filters + if isinstance(val, (list, tuple)): + query = query.in_(key, val) + else: + query = query.equal(key, val) + return query @function_log def search(self, filters=None): @@ -208,3 +211,14 @@ def update(self, id_obj, body): def list(self, filters=None): return self.search(filters) + + +class NestedResource(BaseResource): + """Base class for all nested resources""" + + def __init__(self, config=None, parent_path=''): + self.resource = '{}/{}'.format( + parent_path, + self.__class__.resource, + ) + super(NestedResource, self).__init__(config) diff --git a/connect/resources/fulfillment_automation.py b/connect/resources/fulfillment_automation.py index 41a481c..3f1f456 100644 --- a/connect/resources/fulfillment_automation.py +++ b/connect/resources/fulfillment_automation.py @@ -68,10 +68,10 @@ def filters(self, status='pending', **kwargs): :return: The set of filters for this resource. :rtype: dict[str,Any] """ - filters = super(FulfillmentAutomation, self).filters(status=status, **kwargs) + query = super(FulfillmentAutomation, self).filters(status=status, **kwargs) if self.config.products: - filters['asset.product.id__in'] = ','.join(self.config.products) - return filters + query.in_('asset.product.id', self.config.products) + return query @function_log def dispatch(self, request): diff --git a/connect/resources/product.py b/connect/resources/product.py index 0f446ff..3bd79b4 100644 --- a/connect/resources/product.py +++ b/connect/resources/product.py @@ -1,5 +1,12 @@ import json -from .base import BaseResource + +from .base import BaseResource, NestedResource +from ..models import Product, Item + + +class ProductItemResource(NestedResource): + resource = 'items' + model_class = Item class ProductsResource(BaseResource): @@ -7,6 +14,7 @@ class ProductsResource(BaseResource): :param Config config: Config object or ``None`` to use environment config (default). """ resource = 'products' + model_class = Product def list_parameters(self, product_id): """ List parameters for a product. @@ -75,3 +83,7 @@ def delete_parameter(self, product_id, parameter_id): path=path ) return response + + def items(self, product_id): + """Returns the ProductItemResource resource""" + return ProductItemResource(self.config, 'products/{}'.format(product_id)) diff --git a/connect/resources/tier_config_automation.py b/connect/resources/tier_config_automation.py index b980589..4f57066 100644 --- a/connect/resources/tier_config_automation.py +++ b/connect/resources/tier_config_automation.py @@ -57,10 +57,10 @@ def filters(self, status='pending', **kwargs): :return: The set of filters for this resource. :rtype: dict[str,Any] """ - filters = super(TierConfigAutomation, self).filters(status=status, **kwargs) + query = super(TierConfigAutomation, self).filters(status=status, **kwargs) if self.config.products: - filters['configuration.product.id'] = ','.join(self.config.products) - return filters + query.in_('configuration.product.id', self.config.products) + return query @function_log def dispatch(self, request): diff --git a/connect/resources/usage_automation.py b/connect/resources/usage_automation.py index ed8e162..b7a187e 100644 --- a/connect/resources/usage_automation.py +++ b/connect/resources/usage_automation.py @@ -35,10 +35,10 @@ def filters(self, status='listed', **kwargs): :return: The set of filters for this resource. :rtype: dict[str,Any] """ - filters = super(UsageAutomation, self).filters(status, **kwargs) + query = super(UsageAutomation, self).filters(status, **kwargs) if self.config.products: - filters['product__id'] = ','.join(self.config.products) - return filters + query.in_('product.id', self.config.products) + return query def dispatch(self, request): # type: (UsageListing) -> str diff --git a/connect/resources/usage_file_automation.py b/connect/resources/usage_file_automation.py index 7060898..0f04188 100644 --- a/connect/resources/usage_file_automation.py +++ b/connect/resources/usage_file_automation.py @@ -30,10 +30,10 @@ def filters(self, status='ready', **kwargs): :return: The set of filters for this resource. :rtype: dict[str,Any] """ - filters = super(UsageFileAutomation, self).filters(status, **kwargs) + query = super(UsageFileAutomation, self).filters(status, **kwargs) if self.config.products: - filters['product_id'] = ','.join(self.config.products) - return filters + query.in_('product_id', self.config.products) + return query def dispatch(self, request): # type: (UsageFile) -> str diff --git a/requirements/sdk.txt b/requirements/sdk.txt index c110d90..1a4539e 100644 --- a/requirements/sdk.txt +++ b/requirements/sdk.txt @@ -1,6 +1,6 @@ deprecation==2.0.6 marshmallow==2.18.0 -openpyxl==2.5.14 +openpyxl>=2.5.14 requests==2.21.0 six==1.12.0 pathlib==1.0.1 diff --git a/tests/test_billing_request.py b/tests/test_billing_request.py index 7cedbc6..fa70637 100644 --- a/tests/test_billing_request.py +++ b/tests/test_billing_request.py @@ -85,9 +85,8 @@ def test_list_billing_request_ok(self, get_mock): get_mock.assert_has_calls([ call( headers={'Authorization': 'ApiKey XXXX:YYYYY', 'Content-Type': 'application/json'}, - params={'limit': 100}, timeout=300, - url='http://localhost:8080/api/public/v1/subscriptions/requests') + url='http://localhost:8080/api/public/v1/subscriptions/requests?limit=100') ]) self.assertEqual(len(billing_request), 2, msg=None) diff --git a/tests/test_directory.py b/tests/test_directory.py index 6e367b1..c143488 100644 --- a/tests/test_directory.py +++ b/tests/test_directory.py @@ -100,9 +100,8 @@ def test_list_marketplaces(get_mock): assert marketplaces[0].id == 'MP-12345' get_mock.assert_called_with( - url='http://localhost:8080/api/public/v1/marketplaces', + url='http://localhost:8080/api/public/v1/marketplaces?limit=100', headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, - params={'limit': 100}, timeout=300) diff --git a/tests/test_models.py b/tests/test_models.py index 21bb7a5..08e6ab8 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,6 +9,7 @@ import six from mock import MagicMock, patch +from connect.config import Config from connect.models import Asset, Param, Fulfillment, Item, TierConfig, Configuration, User from connect.resources import FulfillmentAutomation from .common import Response, load_str @@ -259,3 +260,27 @@ def test_needs_migration(): request = requests[0] assert isinstance(request, Fulfillment) assert request.needs_migration() + + +@patch('requests.get') +def test_filter_by_products(get_mock): + get_mock.return_value = Response( + ok=True, + text='[]', + status_code=200, + ) + config = Config( + 'http://localhost:8080/api/public/v1', + 'ApiKey XXXX:YYYY', + products=['PRD-000', 'PRD-001'], + ) + FulfillmentAutomation(config=config).list() + expected_url = 'http://localhost:8080/api/public/v1/requests?' \ + 'in(asset.product.id,(PRD-000,PRD-001))&eq(status,pending)&limit=1000' + get_mock.assert_called_with( + url=expected_url, + headers={ + 'Content-Type': 'application/json', + 'Authorization': 'ApiKey XXXX:YYYY'}, + timeout=300, + ) diff --git a/tests/test_product.py b/tests/test_product.py index 91185bf..ab4407d 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -10,6 +10,7 @@ from mock import Mock, call, patch from connect.config import Config +from connect.models import Item from connect.resources.product import ProductsResource from .common import Response, load_json, load_str @@ -126,5 +127,20 @@ def test_delete_parameter_bad(self): assert str(e.value) == 'Invalid ID' +class TestItems(unittest.TestCase): + + def setUp(self): + self.config = Config(file='tests/config.json') + + def test_items(self): + product_resource = ProductsResource(config=self.config) + item_resource = product_resource.items('PRD-000') + assert item_resource.resource == 'products/PRD-000/items' + assert item_resource.model_class == Item + + item_resource = product_resource.items('PRD-001') + assert item_resource.resource == 'products/PRD-001/items' + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_recurring_asset.py b/tests/test_recurring_asset.py index a4d0d31..ad3e51b 100644 --- a/tests/test_recurring_asset.py +++ b/tests/test_recurring_asset.py @@ -74,9 +74,8 @@ def test_list_recurring_asset_ok(self, get_mock): get_mock.assert_has_calls([ call( headers={'Authorization': 'ApiKey XXXX:YYYYY', 'Content-Type': 'application/json'}, - params={'limit': 100}, timeout=300, - url='http://localhost:8080/api/public/v1/subscriptions/assets') + url='http://localhost:8080/api/public/v1/subscriptions/assets?limit=100') ]) self.assertEqual(len(recurring_asset), 2, msg=None) diff --git a/tests/test_tier_account_request.py b/tests/test_tier_account_request.py index 71b9168..dd39b6b 100644 --- a/tests/test_tier_account_request.py +++ b/tests/test_tier_account_request.py @@ -77,9 +77,8 @@ def test_list_tier_account_request_ok(self, get_mock): get_mock.assert_has_calls([ call( headers={'Authorization': 'ApiKey XXXX:YYYYY', 'Content-Type': 'application/json'}, - params={'limit': 100}, timeout=300, - url='http://localhost:8080/api/public/v1/tier/account-requests') + url='http://localhost:8080/api/public/v1/tier/account-requests?limit=100') ]) self.assertEqual(len(tier_account_request), 3, msg=None) diff --git a/tests/test_tier_config_request.py b/tests/test_tier_config_request.py index 05de778..703e352 100644 --- a/tests/test_tier_config_request.py +++ b/tests/test_tier_config_request.py @@ -60,9 +60,8 @@ def test_list_tier_config_request_ok(self, get_mock): get_mock.assert_has_calls([ call( headers={'Authorization': 'ApiKey XXXX:YYYYY', 'Content-Type': 'application/json'}, - params={'limit': 100}, timeout=300, - url='http://localhost:8080/api/public/v1/tier/config-requests') + url='http://localhost:8080/api/public/v1/tier/config-requests?limit=100') ]) self.assertEqual(len(tier_config_request), 2, msg=None)