Skip to content

Commit

Permalink
LITE-28133 Add capability to upload price file for a Marketplace in a…
Browse files Browse the repository at this point in the history
… Hub
  • Loading branch information
rahulmondal committed Jul 21, 2023
1 parent 3725d30 commit 8fe74e9
Show file tree
Hide file tree
Showing 13 changed files with 629 additions and 32 deletions.
4 changes: 2 additions & 2 deletions CBCClientHowTo.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Create CBCClient instance

```python
from requests_oauthlib import OAuth1
from connect_ext_ppr.client import CBCClient


Expand All @@ -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,
)

Expand Down
22 changes: 22 additions & 0 deletions connect_ext_ppr/client/auth.py
Original file line number Diff line number Diff line change
@@ -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
35 changes: 14 additions & 21 deletions connect_ext_ppr/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.')

Expand Down
2 changes: 1 addition & 1 deletion connect_ext_ppr/client/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
165 changes: 161 additions & 4 deletions connect_ext_ppr/services/cbc_hub.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand Down Expand Up @@ -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',
Expand All @@ -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(
Expand Down Expand Up @@ -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
25 changes: 23 additions & 2 deletions tests/client/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand All @@ -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('')
Expand Down
15 changes: 15 additions & 0 deletions tests/client/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
Loading

0 comments on commit 8fe74e9

Please sign in to comment.