From 8fe74e9386db1ccd88c409fba422d51144274375 Mon Sep 17 00:00:00 2001 From: rmondal00 Date: Fri, 21 Jul 2023 15:32:12 +0200 Subject: [PATCH] LITE-28133 Add capability to upload price file for a Marketplace in a Hub --- CBCClientHowTo.md | 4 +- connect_ext_ppr/client/auth.py | 22 ++ connect_ext_ppr/client/client.py | 35 +-- connect_ext_ppr/client/exception.py | 2 +- connect_ext_ppr/services/cbc_hub.py | 165 +++++++++++- tests/client/test_client.py | 25 +- tests/client/test_service.py | 15 ++ tests/conftest.py | 59 ++++- tests/fixtures/Sweet_Pies_Price_List_USD.xlsx | Bin 0 -> 9565 bytes tests/fixtures/Sweet_Pies_v2.xlsx | Bin 20056 -> 20085 bytes tests/fixtures/reseller_accounts.json | 54 ++++ tests/fixtures/reseller_admin_users.json | 36 +++ tests/services/test_cbc_hub.py | 244 ++++++++++++++++++ 13 files changed, 629 insertions(+), 32 deletions(-) create mode 100644 connect_ext_ppr/client/auth.py create mode 100644 tests/fixtures/Sweet_Pies_Price_List_USD.xlsx create mode 100644 tests/fixtures/reseller_accounts.json create mode 100644 tests/fixtures/reseller_admin_users.json diff --git a/CBCClientHowTo.md b/CBCClientHowTo.md index 6e9a11a..9c4e570 100644 --- a/CBCClientHowTo.md +++ b/CBCClientHowTo.md @@ -3,6 +3,7 @@ ## Create CBCClient instance ```python +from requests_oauthlib import OAuth1 from connect_ext_ppr.client import CBCClient @@ -17,8 +18,7 @@ app_id = '*****' client = CBCClient( endpoint=endpoint, - oauth_key=client_id, - oauth_secret=client_secret, + auth=OAuth1(client_id, client_secret), app_id=app_id, ) diff --git a/connect_ext_ppr/client/auth.py b/connect_ext_ppr/client/auth.py new file mode 100644 index 0000000..232dad2 --- /dev/null +++ b/connect_ext_ppr/client/auth.py @@ -0,0 +1,22 @@ +from requests.auth import AuthBase + + +class APSTokenAuth(AuthBase): + ''' + Need to consider that APS Token has a very limited time validity and hence + the client using this type auth should not be reused. + ''' + + def __init__( + self, + aps_token: str, + aps_identity: str, + ): + self.aps_token = aps_token + self.aps_identity = aps_identity + + def __call__(self, r): + r.headers['aps-token'] = self.aps_token + r.headers['aps-identity-id'] = self.aps_identity + + return r diff --git a/connect_ext_ppr/client/client.py b/connect_ext_ppr/client/client.py index b0825dc..f508b1a 100644 --- a/connect_ext_ppr/client/client.py +++ b/connect_ext_ppr/client/client.py @@ -9,6 +9,7 @@ ) from requests_oauthlib import OAuth1 +from connect_ext_ppr.client.auth import APSTokenAuth from connect_ext_ppr.client.exception import ClientError from connect_ext_ppr.client.ns import ( Collection, @@ -20,35 +21,31 @@ class CBCClient: def __init__( - self, - endpoint: str, - oauth_key: str, - oauth_secret: str, - app_id: str, - verify_certificate: bool = True, - default_headers: Dict[str, str] = None, + self, + endpoint: str, + auth: OAuth1 | APSTokenAuth, + app_id: str = None, + verify_certificate: bool = True, + default_headers: Dict[str, str] = None, ): super().__init__() self.endpoint = endpoint - self.auth_key = oauth_key - self.auth_secret = oauth_secret self.app_id = app_id self.verify = verify_certificate self.default_headers = default_headers self.path = self.endpoint + self.auth = auth if not default_headers: self.default_headers = { - 'User-Agent': 'Connect-CBC-Client', + 'user-agent': 'Connect-CBC-Client', } - self.default_headers['aps-resource-id'] = self.app_id - - self.auth = OAuth1( - client_key=oauth_key, - client_secret=oauth_secret, - ) + if type(auth) == OAuth1: + if not app_id: + raise ValueError('Parameter app_id can not be empty in case of OAuth1.') + self.default_headers['aps-resource-id'] = self.app_id def execute_request( self, @@ -148,11 +145,7 @@ def resource(self, resource_id: str): f'{self.path}/aps/2/resources/{resource_id}', ) - def get( - self, - resource_id: str, - **kwargs, - ): + def get(self, resource_id: str, **kwargs): if not isinstance(resource_id, str): raise TypeError('`identifier` must be a string.') diff --git a/connect_ext_ppr/client/exception.py b/connect_ext_ppr/client/exception.py index 1ad5f44..a7aa2ed 100644 --- a/connect_ext_ppr/client/exception.py +++ b/connect_ext_ppr/client/exception.py @@ -15,6 +15,6 @@ def __init__( self.status_code = response.status_code if response else None self.cause = cause try: - self.json = response.json() if response else None + self.json = response.json() if response is not None else None except JSONDecodeError: self.json = None diff --git a/connect_ext_ppr/services/cbc_hub.py b/connect_ext_ppr/services/cbc_hub.py index 61a2c0a..eb3395d 100644 --- a/connect_ext_ppr/services/cbc_hub.py +++ b/connect_ext_ppr/services/cbc_hub.py @@ -1,28 +1,37 @@ import base64 from functools import cached_property -from io import FileIO +from io import BufferedReader import re from typing import Dict +from requests_oauthlib import OAuth1 + from connect_ext_ppr.client import CBCClient +from connect_ext_ppr.client.auth import APSTokenAuth from connect_ext_ppr.client.exception import ClientError from connect_ext_ppr.models.cbc_extenstion import HubCredential class CBCService: + FLAT_CATALOG_TYPE = 'http://ingrammicro.com/pa/flat-catalog' PLM_TYPE = 'http://com.odin.platform/inhouse-products/application' SUBSCRIPTION_TYPE = 'http://parallels.com/aps/types/pa/subscription' ADAPTER_TYPE = 'http://connect.cloudblue.com/aps-openapi-adapter/app' + ADMIN_USER_TYPE = 'http://aps-standard.org/types/core/admin-user' + ACCOUNT_TYPE = 'http://aps-standard.org/types/core/account' def __init__(self, hub_credential: HubCredential, verify_certificate: bool = False): self.hub_credential = hub_credential + self.verify_certificate = verify_certificate self.__validate_hub_credentials_object() self.client = CBCClient( endpoint=hub_credential.controller_url, - oauth_key=hub_credential.oauth_key, - oauth_secret=hub_credential.oauth_secret, + auth=OAuth1( + hub_credential.oauth_key, + hub_credential.oauth_secret, + ), app_id=hub_credential.app_id, verify_certificate=verify_certificate, ) @@ -68,6 +77,47 @@ def subscription_service(self): def adapter_service(self): return self.client(self.ADAPTER_TYPE) + @cached_property + def account_service(self): + return self.client(self.ACCOUNT_TYPE) + + def get_aps_token_auth(self, account_id: int) -> APSTokenAuth: + accounts = self.account_service.get(id=account_id) + + if not accounts: + raise ValueError(f'Reseller Account not found for id {account_id}') + account_uuid = accounts[0]['aps']['id'] + + # Identify Reseller Admin User + reseller_admin_users = self.client.collection( + f'aps/2/collections/admin-users?organization.id={account_id}', + ).get() + + if not reseller_admin_users: + raise ValueError(f'Admin user not found for reseller {account_id}') + user_id = reseller_admin_users[0]['userId'] + + # Get Reseller Admin User APS Token + token_object = self.adapter_service.getToken.get( + user_id=user_id, + ) + + return APSTokenAuth( + token_object['aps_token'], + account_uuid, + ) + + def get_aps_token_client(self, account_id: int) -> CBCClient: + return CBCClient( + endpoint=self.hub_credential.controller_url, + auth=self.get_aps_token_auth(account_id), + verify_certificate=self.verify_certificate, + ) + + def get_flat_catalog_service(self, account_id: int): + aps_token_client = self.get_aps_token_client(account_id) + return aps_token_client(self.FLAT_CATALOG_TYPE) + def get_product_details(self, product_id: str): return self.plm_service.appDetails[product_id].get( fulfillmentSystem='connect', @@ -91,7 +141,7 @@ def update_product(self, product_id: str): }, ) - def parse_ppr(self, file: FileIO): + def parse_ppr(self, file: BufferedReader): base64_content = base64.b64encode(file.read()).decode('ascii') return self.plm_service.action( @@ -122,3 +172,110 @@ def search_task_logs_by_name(self, partial_name: str): return self.adapter_service.getTaskLog.get( task_name=f'%{partial_name}%', ) + + def parse_price_file( + self, + account_id: int, + vendor_id: str, + file: BufferedReader, + ) -> Dict: + """ + + This method is used to parse price file. + + @param account_id: Reseller account ID associated with Marketplace + @param vendor_id: Vendor ID owner of the product for which price is getting updated. + @param file: XLSX file + @return: Dict containing the information about parsed data for the price file. + """ + headers = { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + } + + fcs = self.get_flat_catalog_service(account_id) + return fcs.flat_catalog.price_import_wizard.action( + name=f'upload?vendorId={vendor_id}', + file=file, + headers=headers, + ) + + def prepare_price_proposal( + self, + account_id: int, + parsed_prices: Dict, + export_costs: bool, + export_prices: bool, + export_msrp: bool, + effective_date: str, + ) -> Dict: + """ + + This method creates a proposal for the price change. This is mandatory + to create a proposal before the new prices can be applied. + + @param account_id: Reseller account ID associated with Marketplace + @param parsed_prices: Dict from parse_price_file method + @param export_costs: Do you want to change cost data? + @param export_prices: Do you want to change price data? + @param export_msrp: Do you want to change MSRP data? + @param effective_date: Date in format 'MM/DD/YYYY'. + From when this prices should be reflected? + @return: Dict with details of proposal. + """ + payload = { + 'exportCosts': export_costs, + 'exportPrices': export_prices, + 'exportMSRP': export_msrp, + 'effectiveDate': effective_date, + 'priceModel': parsed_prices['pricingModel'], + 'feeType': parsed_prices['feeType'], + 'vendorId': parsed_prices['vendorId'], + } + + fcs = self.get_flat_catalog_service(account_id) + return fcs.flat_catalog.price_import_wizard[parsed_prices['dataId']].action( + name='prepare-proposals', + payload=payload, + ) + + def apply_prices( + self, + account_id: int, + parsed_prices: Dict, + export_costs: bool, + export_prices: bool, + export_msrp: bool, + effective_date: str, + file_name: str, + ): + """ + + This method can be used to apply price changes created through proposal. + + @param account_id: Reseller account ID associated with Marketplace. + @param parsed_prices: Dict from parse_price_file method. + @param export_costs: Do you want to apply cost data? + @param export_prices: Do you want to apply price data? + @param export_msrp: Do you want to apply MSRP data? + @param effective_date: Date in format 'MM/DD/YYYY'. + From when this prices should be reflected? + @param file_name: Name of the file used to create the proposal. + """ + payload = { + 'exportCosts': export_costs, + 'exportPrices': export_prices, + 'exportMSRP': export_msrp, + 'effectiveDate': effective_date, + 'priceModel': parsed_prices['pricingModel'], + 'feeType': parsed_prices['feeType'], + 'fileName': file_name, + } + + fcs = self.get_flat_catalog_service(account_id) + headers = fcs.flat_catalog.price_import_wizard[parsed_prices['dataId']].action( + name='set-prices', + payload=payload, + output='headers', + ) + + return headers['APS-Info'] if 'APS-Info' in headers.keys() else None diff --git a/tests/client/test_client.py b/tests/client/test_client.py index efc4e1e..d6b7c2f 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -4,6 +4,7 @@ import pytest import responses from requests import Session +from requests_oauthlib import OAuth1 from connect_ext_ppr.client import CBCClient from connect_ext_ppr.client.exception import ClientError @@ -84,8 +85,10 @@ def test_client_with_default_headers( cbc_client = CBCClient( endpoint=cbc_endpoint, - oauth_key=cbc_oauth_key, - oauth_secret=cbc_oauth_secret, + auth=OAuth1( + cbc_oauth_key, + cbc_oauth_secret, + ), default_headers=headers, app_id=cbc_app_id, ) @@ -95,6 +98,24 @@ def test_client_with_default_headers( assert cbc_client.default_headers == headers +def test_client_with_oauth_without_app_id( + cbc_endpoint, + cbc_oauth_key, + cbc_oauth_secret, + cbc_app_id, +): + headers = {'Custom': 'Value'} + with pytest.raises(ValueError): + CBCClient( + endpoint=cbc_endpoint, + auth=OAuth1( + cbc_oauth_key, + cbc_oauth_secret, + ), + default_headers=headers, + ) + + def test_collection_service_discovery_empty_type(cbc_client): with pytest.raises(ValueError): cbc_client('') diff --git a/tests/client/test_service.py b/tests/client/test_service.py index c309ce0..6a892ab 100644 --- a/tests/client/test_service.py +++ b/tests/client/test_service.py @@ -149,3 +149,18 @@ def test_service_discovery_collection_blank_collection_value( with pytest.raises(ValueError): cbc_client(flat_catalog_type).collection('') + + +@responses.activate +def test_service_action_with_payload_and_file( + cbc_client, + flat_catalog_type, +): + with pytest.raises(ValueError): + cbc_client(flat_catalog_type).action( + name='upload', + payload={ + 'key': 'value', + }, + file='new file', + ) diff --git a/tests/conftest.py b/tests/conftest.py index 768ff9a..56eda43 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ import pandas as pd import pytest from connect.client import AsyncConnectClient, ConnectClient +from requests_oauthlib import OAuth1 from sqlalchemy.orm import sessionmaker from connect_ext_ppr.client import CBCClient @@ -410,8 +411,7 @@ def cbc_client( ): return CBCClient( endpoint=cbc_endpoint, - oauth_key=cbc_oauth_key, - oauth_secret=cbc_oauth_secret, + auth=OAuth1(cbc_oauth_key, cbc_oauth_secret), app_id=cbc_app_id, ) @@ -724,3 +724,58 @@ def task_logs_response(): 'task_id': 106, }, ] + + +@pytest.fixture +def parse_price_file_response(): + return { + 'status': 'PARSED', + 'priceListStructure': [ + 'MPN', 'Vendor ID', 'Vendor Name', + 'Service Template / Product Line', 'Product', + 'Billing Period', 'Subscription Period', + 'Billing Model', 'UOM', 'Lower Limit', + 'Effective Date', 'Cost Billing Period', + 'Cost Currency', 'Cost', 'Price Currency', + 'Price', 'MSRP', 'Margin', 'Reseller Margin', + 'Fee Type', 'Subscriptions', 'Seats', + 'Active'], + 'pricingModel': 'FLAT', + 'feeType': 'RECURRING', + 'vendorId': 'VA-000-000', + 'dataId': 1, + } + + +@pytest.fixture +def reseller_accounts(): + return json.load(open('./tests/fixtures/reseller_accounts.json')) + + +@pytest.fixture +def reseller_admin_users(): + return json.load(open('./tests/fixtures/reseller_admin_users.json')) + + +@pytest.fixture +def aps_token_response(): + return { + 'aps_token': 'Fake Token', + 'controller_uri': 'https://iamcontroller.com/', + } + + +@pytest.fixture +def price_proposal_response(): + return { + 'status': 'PREPARED', + 'dataId': 1, + 'overridings': { + 'effectiveDate': [ + 'CFQ7TTC0LF8S:0002', 'CFQ7TTC0LF8R:0001', + 'CFQ7TTC0LF8S:0002', 'CFQ7TTC0LF8R:0001', + 'CFQ7TTC0LF8R:0001', 'CFQ7TTC0LF8S:0002', + ], + 'currency': [], + }, + } diff --git a/tests/fixtures/Sweet_Pies_Price_List_USD.xlsx b/tests/fixtures/Sweet_Pies_Price_List_USD.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..0021c64d0d29ee03a3d7ae0632019e6f8d618c9d GIT binary patch literal 9565 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/Sweet_Pies_v2.xlsx b/tests/fixtures/Sweet_Pies_v2.xlsx index ec7c6ae648032f742b328d7016e0a6c3c19f5fbb..23b8d45f21ef2ba382b62f941fab290fed77985b 100644 GIT binary patch delta 7824 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 delta 7759 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 v{yT5{k-C3&_HQi~f}_qz`S;-I{mCo#e`+}d|K