Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LITE-28133 Add capability to upload price file for a Marketplace in a Hub #40

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
37 changes: 15 additions & 22 deletions connect_ext_ppr/client/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from io import FileIO
from json import JSONDecodeError
from typing import Dict, Optional
from typing import Dict, Optional, Union

from requests import (
Request,
Expand All @@ -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: Union[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