From d90053400bd8ec1e76e74c76446165385df58c95 Mon Sep 17 00:00:00 2001 From: Leonid Maksimov Date: Wed, 20 Sep 2023 10:34:35 +0200 Subject: [PATCH] LITE-28568 task for price list uploading --- connect_ext_ppr/client/client.py | 6 +- connect_ext_ppr/client/exception.py | 2 +- connect_ext_ppr/client/utils.py | 11 + connect_ext_ppr/db.py | 2 +- connect_ext_ppr/errors.py | 38 +- connect_ext_ppr/models/enums.py | 2 + connect_ext_ppr/models/task.py | 4 +- connect_ext_ppr/schemas.py | 17 - connect_ext_ppr/service.py | 30 ++ connect_ext_ppr/services/cbc_hub.py | 6 +- connect_ext_ppr/services/pricing.py | 361 +++++++++++------ ...eployment-details.b0d7de95e55401ae12ca.js} | 0 ...13af1.js => index.bbc6cee29733e32ecf28.js} | 2 +- connect_ext_ppr/static/index.html | 2 +- ...> request-details.2e8575914807cddd3bc9.js} | 0 connect_ext_ppr/tasks_manager.py | 154 +++++-- connect_ext_ppr/utils.py | 22 + connect_ext_ppr/validator.py | 14 +- connect_ext_ppr/webapp.py | 56 +-- tests/api/test_deployment_requests.py | 21 +- tests/api/test_pricing_batches.py | 57 --- tests/client/test_client.py | 4 +- tests/client/test_service.py | 4 +- tests/client/test_utils.py | 33 ++ tests/services/test_cbc_hub.py | 8 +- tests/services/test_pricing.py | 379 ++++++++++++++++-- tests/test_tasks_manager.py | 239 ++++++++++- 27 files changed, 1108 insertions(+), 366 deletions(-) create mode 100644 connect_ext_ppr/client/utils.py rename connect_ext_ppr/static/{deployment-details.43c6bbfab7e443946135.js => deployment-details.b0d7de95e55401ae12ca.js} (100%) rename connect_ext_ppr/static/{index.e6db85b4789728913af1.js => index.bbc6cee29733e32ecf28.js} (99%) rename connect_ext_ppr/static/{request-details.3464656fe301d1df40ca.js => request-details.2e8575914807cddd3bc9.js} (100%) delete mode 100644 tests/api/test_pricing_batches.py create mode 100644 tests/client/test_utils.py diff --git a/connect_ext_ppr/client/client.py b/connect_ext_ppr/client/client.py index 13010e6..9bcf69a 100644 --- a/connect_ext_ppr/client/client.py +++ b/connect_ext_ppr/client/client.py @@ -10,7 +10,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.exception import CBCClientError from connect_ext_ppr.client.ns import ( Collection, Resource, @@ -88,13 +88,13 @@ def execute_request( except Exception as e: if response is not None: - raise ClientError( + raise CBCClientError( message=f'{type(e).__name__} : {str(e)}', response=response, cause=e, ) else: - raise ClientError( + raise CBCClientError( message=f'{type(e).__name__} : {str(e)}', cause=e, ) diff --git a/connect_ext_ppr/client/exception.py b/connect_ext_ppr/client/exception.py index a7aa2ed..95566b3 100644 --- a/connect_ext_ppr/client/exception.py +++ b/connect_ext_ppr/client/exception.py @@ -3,7 +3,7 @@ from requests import Response -class ClientError(RuntimeError): +class CBCClientError(RuntimeError): def __init__( self, message: str, diff --git a/connect_ext_ppr/client/utils.py b/connect_ext_ppr/client/utils.py new file mode 100644 index 0000000..8bfb536 --- /dev/null +++ b/connect_ext_ppr/client/utils.py @@ -0,0 +1,11 @@ +from connect_ext_ppr.services.cbc_extension import get_hub_credentials +from connect_ext_ppr.services.cbc_hub import CBCService +from connect_ext_ppr.errors import ExtensionHttpError + + +def get_cbc_service(hub_id, cbc_db, verify_certificate=False): + hub_credentials = get_hub_credentials(hub_id, cbc_db) + if not hub_credentials: + raise ExtensionHttpError.EXT_012(format_kwargs={'hub_id': hub_id}) + + return CBCService(hub_credentials, verify_certificate) diff --git a/connect_ext_ppr/db.py b/connect_ext_ppr/db.py index 47ffab9..a9b1fc8 100644 --- a/connect_ext_ppr/db.py +++ b/connect_ext_ppr/db.py @@ -220,7 +220,7 @@ def get_cbc_extension_db(engine: Engine = Depends(get_cbc_extension_db_engine)): ) db: Session = SessionMaker(bind=engine) try: - yield db + return db finally: db.close() diff --git a/connect_ext_ppr/errors.py b/connect_ext_ppr/errors.py index a5e8486..fb56e58 100644 --- a/connect_ext_ppr/errors.py +++ b/connect_ext_ppr/errors.py @@ -78,19 +78,9 @@ class ExtensionHttpError(ExtensionErrorBase): 5: "Configuration `{obj_id}` cannot be deleted, because related deployment is not synced.", 6: "Can not autogenerate a new PPR for deployment {deployment_id}:" " There must be one `active` configuration file.", - 7: "Pricing Batch with ID '{batch_id}' s¡is not found.", - 8: "More than file found of Pricing Batch '{batch_id}'.", - 9: "Deployment Hub {hub_id} does not serve Marketplace " - "{marketplace_id} associated with the batch {batch_id}", - 10: "Not able to find out Reseller ID for Marketplace {marketplace_id} and Hub {hub_id}.", - 11: "Deployment {deployment_id} Product ID {d_product_id} and Batch " - "{batch_id} Product ID {b_product_id} does not match.", + # Placeholder 7 - 11 12: "Hub Credentials not found for Hub ID {hub_id}.", - 13: "Effective Date field not found in Batch {batch_id}.", - 14: "Effective date {date} is either not found or invalid" - " for first row in Batch {batch_id}.", - 15: 'No Marketplace is linked with Deployment Hub {hub_id}', - 16: "Pricing Batch '{batch_id}' does not have any file.", + # Placeholder 13 - 16 17: "Cannot create a new request, an open one already exists.", 18: "Deployment request `{dep_request_id}` can not be retried, newer requests were" " created for related deployment `{deployment_id}`: {new_requests}.", @@ -109,3 +99,27 @@ class ExtensionValidationError(ExtensionErrorBase): " '{target}', allowed {field_name} sources for '{target}' are '{allowed}'.", 6: "Pricing batches invalid: {ids}.", } + + +class PriceUpdateError(ExtensionErrorBase): + PREFIX = 'PLT' + ERRORS = { + 1: "Pricing Batch with ID '{batch_id}' s¡is not found.", + 2: "More than file found of Pricing Batch '{batch_id}'.", + 3: "Deployment Hub {hub_id} does not serve Marketplace " + "{marketplace_id} associated with the batch {batch_id}.", + 4: "Not able to find out Reseller ID for Marketplace {marketplace_id} " + "and Hub {hub_id}.", + 5: "Pricing Batch output '{batch_id}' does not contain " + "either Cost or Price column.", + 6: "Effective date '{date}' is either not found or invalid " + "for first row in Batch {batch_id}.", + 7: "Pricing Batch '{batch_id}' does not have any file.", + 8: 'No Marketplace is linked with Deployment Hub {hub_id}.', + 9: "Deployment {deployment_id} Product ID {d_product_id} and Batch " + "{batch_id} Product ID {b_product_id} does not match.", + 10: "Pricing Batch output '{batch_id}' does not contain " + "mandatory column: {col_name}.", + 11: "Pricing Batch output '{batch_id}' contains invalid value at " + "column '{column}' of row '{row}'.", + } diff --git a/connect_ext_ppr/models/enums.py b/connect_ext_ppr/models/enums.py index 86e3cf5..4bd5d83 100644 --- a/connect_ext_ppr/models/enums.py +++ b/connect_ext_ppr/models/enums.py @@ -28,6 +28,8 @@ class TaskTypesChoices(str, enum.Enum): product_setup = 'product_setup' apply_and_delegate = 'apply_ppr_and_delegate_to_marketplace' delegate_to_l2 = 'delegate_to_l2' + validate_pricelists = 'validate_pricelists' + apply_pricelist = 'apply_pricelist' class MimeTypeChoices(str, enum.Enum): diff --git a/connect_ext_ppr/models/task.py b/connect_ext_ppr/models/task.py index 4b9935f..e1d40cd 100644 --- a/connect_ext_ppr/models/task.py +++ b/connect_ext_ppr/models/task.py @@ -5,7 +5,7 @@ from connect_ext_ppr.db import Model from connect_ext_ppr.models.enums import TasksStatusChoices, TaskTypesChoices -from connect_ext_ppr.models.deployment import DeploymentRequest +from connect_ext_ppr.models.deployment import DeploymentRequest, MarketplaceConfiguration from connect_ext_ppr.models.models_utils import transition @@ -23,6 +23,7 @@ class Task(Model): default=STATUSES.pending, ) deployment_request_id = db.Column(db.ForeignKey(DeploymentRequest.id)) + marketplace_id = db.Column(db.ForeignKey(MarketplaceConfiguration.id), nullable=True) title = db.Column(db.String(100)) error_message = db.Column(db.String(4000)) type = db.Column(db.Enum(TaskTypesChoices, validate_strings=True)) @@ -35,6 +36,7 @@ class Task(Model): aborted_by = db.Column(db.String(20), nullable=True) deployment_request = relationship(DeploymentRequest, foreign_keys='Task.deployment_request_id') + marketplace = relationship(MarketplaceConfiguration, foreign_keys='Task.marketplace_id') @transition('status', target=STATUSES.aborted, sources=[STATUSES.pending]) def abort(self, by): diff --git a/connect_ext_ppr/schemas.py b/connect_ext_ppr/schemas.py index aa72ed5..efe542e 100644 --- a/connect_ext_ppr/schemas.py +++ b/connect_ext_ppr/schemas.py @@ -6,11 +6,9 @@ from datetime import datetime from pydantic import BaseModel, Field, root_validator -from fastapi import status from typing import Dict, List, Optional, Union -from connect_ext_ppr.errors import ExtensionValidationError from connect_ext_ppr.models.enums import ( ConfigurationStateChoices, DeploymentRequestStatusChoices, @@ -69,17 +67,6 @@ class ReferenceSchema(NonNullSchema): icon: Optional[str] -class ChoicesSchema(BaseModel): - choices: Optional[List[PrimaryKeyReference]] = [] - all: bool - - @root_validator - def check_choices_exists_if_all_is_false(cls, values): - if not values.get('all') and not values.get('choices'): - raise ExtensionValidationError.VAL_003(status_code=status.HTTP_400_BAD_REQUEST) - return values - - class ProductSchema(NonNullSchema): id: str name: str @@ -200,10 +187,6 @@ class BatchSchema(NonNullSchema): stream_updated: Optional[bool] -class BatchProcessResponseSchema(NonNullSchema): - task_info: str - - class MarketplaceSchema(NonNullSchema): id: str name: str diff --git a/connect_ext_ppr/service.py b/connect_ext_ppr/service.py index 3dfaf31..ba9f316 100644 --- a/connect_ext_ppr/service.py +++ b/connect_ext_ppr/service.py @@ -336,6 +336,7 @@ def add_new_deployment_request(db, dr_data, deployment, account_id, logger): db.add(mc) tasks = [] + tasks.append(Task( deployment_request_id=deployment_request.id, title='Product check up and update', @@ -348,6 +349,35 @@ def add_new_deployment_request(db, dr_data, deployment, account_id, logger): type=Task.TYPES.apply_and_delegate, created_by=account_id, )) + + dep_marketplaces = {m.marketplace: m for m in deployment.marketplaces} + req_marketplaces = {m.marketplace: m for m in deployment_request.marketplaces} + + update_prices = False + for mp_name, marketplace in req_marketplaces.items(): + if ( + (not marketplace.pricelist_id) + or (marketplace.pricelist_id == dep_marketplaces[mp_name].pricelist_id) + ): + continue + + tasks.append(Task( + deployment_request=deployment_request, + title=f'Apply price list to marketplace {marketplace.marketplace}', + marketplace=marketplace, + type=Task.TYPES.apply_pricelist, + created_by=account_id, + )) + update_prices = True + + if update_prices: + tasks.insert(0, Task( + deployment_request_id=deployment_request.id, + title='Validate price lists', + type=Task.TYPES.validate_pricelists, + created_by=account_id, + )) + if deployment_request.delegate_l2: tasks.append(Task( deployment_request_id=deployment_request.id, diff --git a/connect_ext_ppr/services/cbc_hub.py b/connect_ext_ppr/services/cbc_hub.py index eb3395d..a316732 100644 --- a/connect_ext_ppr/services/cbc_hub.py +++ b/connect_ext_ppr/services/cbc_hub.py @@ -8,7 +8,7 @@ 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.client.exception import CBCClientError from connect_ext_ppr.models.cbc_extenstion import HubCredential @@ -55,7 +55,7 @@ def __validate_client(self): method='GET', path=f'{self.hub_credential.controller_url}/aps', ) - except ClientError: + except CBCClientError: raise ValueError('hub_credential are not valid!') @cached_property @@ -192,6 +192,8 @@ def parse_price_file( 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', } + file.seek(0) + fcs = self.get_flat_catalog_service(account_id) return fcs.flat_catalog.price_import_wizard.action( name=f'upload?vendorId={vendor_id}', diff --git a/connect_ext_ppr/services/pricing.py b/connect_ext_ppr/services/pricing.py index ad536b7..1970585 100644 --- a/connect_ext_ppr/services/pricing.py +++ b/connect_ext_ppr/services/pricing.py @@ -1,16 +1,101 @@ from datetime import datetime +from tempfile import NamedTemporaryFile from typing import Dict from connect.client import ConnectClient from connect.client.rql import R from openpyxl import load_workbook -from connect_ext_ppr.errors import ExtensionHttpError -from connect_ext_ppr.services.cbc_extension import get_hub_credentials -from connect_ext_ppr.services.cbc_hub import CBCService +from connect_ext_ppr.errors import PriceUpdateError +from connect_ext_ppr.client.exception import CBCClientError +from connect_ext_ppr.utils import execute_with_retry + + +PRICELIST_COLUMNS = { + 'MPN': str, + 'Billing Period': str, + 'Cost': (int, float), + 'Cost Currency': str, + 'Price': (int, float), + 'Price Currency': str, + 'MSRP': (int, float), + 'Effective Date': str, +} + + +def validate_pricelist_batch(connect_client, batch_id): + """ + @param Client connect_client: + @param str batch_id: + + @returns None + @raises ClientError + """ + excel_file, file_name = _fetch_batch_output_file(connect_client, batch_id) + try: + wb = load_workbook(excel_file) + ws = wb['Data'] + _determine_dataset(ws, batch_id) + wb.close() + finally: + excel_file.close() + + +def apply_pricelist_to_marketplace( + deployment_request, + cbc_service, + connect_client, + marketplace, +): + """ + @param DeploymentRequest deployment_request: + @param CBCService cbc_service: + @param Client connect_client: + @param MarketplaceConfiguration marketplace: + + @returns None + @raises ClientError, CBCClientError + """ + reseller_id = _identify_reseller_id( + client=connect_client, + batch_id=marketplace.pricelist_id, + marketplace_id=marketplace.marketplace, + hub_id=deployment_request.deployment.hub_id, + ) + excel_file, file_name, dataset = _prepare_file( + client=connect_client, + batch_id=marketplace.pricelist_id, + ) + try: + _process_batch( + cbc_service=cbc_service, + excel_file=excel_file, + file_name=file_name, + reseller_id=reseller_id, + deployment=deployment_request.deployment, + dataset=dataset, + ) + finally: + excel_file.close() -def determine_dataset(ws, batch_id): +def identify_marketplaces( + client: ConnectClient, + hub_id: str, +): + marketplaces = client.marketplaces.filter( + R().hubs.id.eq(hub_id), + ) + + if not marketplaces: + raise PriceUpdateError.PLT_008( + format_kwargs={'hub_id': hub_id}, + ) + + return [marketplace['id'] for marketplace in marketplaces] + + +def _determine_dataset(ws, batch_id): dataset = { 'cost': False, 'price': False, @@ -18,64 +103,89 @@ def determine_dataset(ws, batch_id): 'effective_date': None, } - headers = [cell.value.lower() for cell in ws[1]] - for key in dataset.keys(): - if key in headers: - dataset[key] = True + headers = { + cell.value: i + for i, cell in enumerate(ws[1]) + if cell.value in PRICELIST_COLUMNS + } - if 'effective date' not in headers: - raise ExtensionHttpError.EXT_013( - format_kwargs={ + dataset['msrp'] = 'MSRP' in headers + + _validate_required_columns(headers.keys(), batch_id) + + _validate_value_columns(headers.keys(), batch_id) + + dataset.update(_validate_value_columns(headers.keys(), batch_id)) + + _validate_pricelist_content(ws, headers, batch_id) + + dataset['effective_date'] = _parse_effective_date(ws, headers, batch_id) + + return dataset + + +def _validate_required_columns(columns, batch_id): + for required_col in ['MPN', 'Billing Period', 'Effective Date']: + if required_col not in columns: + raise PriceUpdateError.PLT_010(format_kwargs={ 'batch_id': batch_id, - }, - ) + 'col_name': required_col, + }) + - effective_date_index = headers.index('effective date') - - first_row = [str(cell.value).lower() if cell.value else cell.value for cell in ws[2]] - effective_date_str = first_row[effective_date_index] - if effective_date_str: - try: - effective_date = effective_date_str.split('t')[0] - dataset['effective_date'] = datetime.strptime( - effective_date, - '%Y-%m-%d', - ).strftime('%m/%d/%Y') - except (ValueError, KeyError): - raise ExtensionHttpError.EXT_014( - format_kwargs={ +def _validate_value_columns(columns, batch_id): + dataset = { + 'cost': False, + 'price': False, + } + + for val_col in ['Cost', 'Price']: + if val_col in columns: + dataset[val_col.lower()] = True + if f'{val_col} Currency' not in columns: + raise PriceUpdateError.PLT_010(format_kwargs={ 'batch_id': batch_id, - 'date': effective_date_str, - }, - ) - else: - raise ExtensionHttpError.EXT_014( - format_kwargs={ - 'batch_id': batch_id, - 'date': effective_date_str, - }, - ) + 'col_name': f'{val_col} Currency', + }) - return dataset + if (not dataset['cost']) and (not dataset['price']): + raise PriceUpdateError.PLT_005(format_kwargs={ + 'batch_id': batch_id, + }) + return dataset -def identify_marketplaces( - client: ConnectClient, - hub_id: str, -): - marketplaces = client.marketplaces.filter( - R().hubs.id.eq(hub_id), - ) - if not marketplaces: - raise ExtensionHttpError.EXT_015( - format_kwargs={'hub_id': hub_id}, +def _validate_pricelist_content(ws, columns, batch_id): + for row_number, row in enumerate(ws.iter_rows(min_row=2), start=2): + for col in columns: + if not isinstance(row[columns[col]].value, PRICELIST_COLUMNS[col]): + raise PriceUpdateError.PLT_011(format_kwargs={ + 'batch_id': batch_id, + 'column': col, + 'row': row_number, + }) + + +def _parse_effective_date(ws, columns, batch_id): + effective_date_str = ws[2][columns['Effective Date']].value + + try: + effective_date = effective_date_str.split('T')[0] + return datetime.strptime( + effective_date, + '%Y-%m-%d', + ).strftime('%m/%d/%Y') + except (ValueError, KeyError): + raise PriceUpdateError.PLT_006( + format_kwargs={ + 'batch_id': batch_id, + 'date': effective_date_str, + }, ) - return [marketplace['id'] for marketplace in marketplaces] - -def identify_cbc_hubs( +def _identify_cbc_hubs( client: ConnectClient, marketplace: Dict, ): @@ -92,7 +202,7 @@ def identify_cbc_hubs( return [] -def get_reseller_id(marketplace, hub_id): +def _get_reseller_id(marketplace, hub_id): hub_details = None if 'hubs' in marketplace.keys(): hub_details = next(filter(lambda h: h['hub']['id'] == hub_id, marketplace['hubs'])) @@ -101,41 +211,27 @@ def get_reseller_id(marketplace, hub_id): return hub_details['external_id'] -def prepare_file(client, batch_id): - files = list(client('pricing').batches[batch_id].files.filter(type='output')) - if not files: - raise ExtensionHttpError.EXT_016( - format_kwargs={'batch_id': batch_id}, - ) +def _prepare_file(client, batch_id): + excel_file, file_name = _fetch_batch_output_file(client, batch_id) + wb = load_workbook(excel_file) + try: + ws = wb['Data'] + dataset = _determine_dataset(ws, batch_id) + ws.title = 'Price-list' + excel_file.seek(0) + wb.save(excel_file) + excel_file.seek(0) + finally: + wb.close() - if len(files) > 1: - raise ExtensionHttpError.EXT_008( - format_kwargs={'batch_id': batch_id}, - ) + return excel_file, file_name, dataset - file = files[0] - file_location = file['name'] - file_content = client.get( - file_location[11:] if file_location.startswith('/public/v1/') else file_location, - ) - file_name = f"{file['id']}.xlsx" - - with open(file_name, 'wb') as file: - file.write(file_content) - - wb = load_workbook(filename=file_name) - ws = wb['Data'] - dataset = determine_dataset(ws, batch_id) - ws.title = 'Price-list' - wb.save(file_name) - - return file_name, dataset - -def fetch_and_validate_batch(client, batch_id, deployment): +# TODO: no used +def _fetch_and_validate_batch(client, batch_id, deployment): batches = client('pricing').batches.filter(id=batch_id).select('+stream.context') if not batches: - raise ExtensionHttpError.EXT_007( + raise PriceUpdateError.PLT_001( format_kwargs={'batch_id': batch_id}, ) @@ -144,7 +240,7 @@ def fetch_and_validate_batch(client, batch_id, deployment): product_id = batch['stream']['context']['product']['id'] if product_id != deployment.product_id: - raise ExtensionHttpError.EXT_011( + raise PriceUpdateError.PLT_009( format_kwargs={ 'batch_id': batch_id, 'b_product_id': product_id, @@ -156,29 +252,54 @@ def fetch_and_validate_batch(client, batch_id, deployment): return batch -def identify_reseller_id(client, batch, deployment): - marketplace_id = batch['stream']['context']['marketplace']['id'] +def _fetch_batch_output_file(client, batch_id): + files = list(client('pricing').batches[batch_id].files.filter(type='output')) + if not files: + raise PriceUpdateError.PLT_007( + format_kwargs={'batch_id': batch_id}, + ) + + if len(files) > 1: + raise PriceUpdateError.PLT_002( + format_kwargs={'batch_id': batch_id}, + ) + + file = files[0] + file_location = file['name'] + file_content = client.get( + file_location[11:] if file_location.startswith('/public/v1/') else file_location, + ) + file_name = f"{file['id']}.xlsx" + + excel_file = NamedTemporaryFile(suffix='.xlsx') + excel_file.write(file_content) + excel_file.seek(0) + + return excel_file, file_name + + +def _identify_reseller_id(client, batch_id, marketplace_id, hub_id): marketplace = client.marketplaces[marketplace_id].get() - marketplace_hubs = identify_cbc_hubs(client, marketplace) + marketplace_hubs = _identify_cbc_hubs(client, marketplace) marketplace_hub_ids = [hub['id'] for hub in marketplace_hubs] - if deployment.hub_id not in marketplace_hub_ids: - raise ExtensionHttpError.EXT_009( + if hub_id not in marketplace_hub_ids: + raise PriceUpdateError.PLT_003( format_kwargs={ - 'hub_id': deployment.hub_id, + 'hub_id': hub_id, 'marketplace_id': marketplace_id, - 'batch_id': batch['id'], + 'batch_id': batch_id, }, ) - reseller_id = get_reseller_id(marketplace, deployment.hub_id) + reseller_id = _get_reseller_id(marketplace, hub_id) if not reseller_id: - raise ExtensionHttpError.EXT_010( + raise PriceUpdateError.PLT_004( format_kwargs={ - 'hub_id': deployment.hub_id, + 'hub_id': hub_id, 'marketplace_id': marketplace_id, }, ) @@ -186,42 +307,37 @@ def identify_reseller_id(client, batch, deployment): return reseller_id -def process_batch( - cbc_db, +def _process_batch( + cbc_service, + excel_file, file_name, reseller_id, deployment, dataset, ): - hub_credentials = get_hub_credentials(deployment.hub_id, cbc_db) - if not hub_credentials: - raise ExtensionHttpError.EXT_012( - format_kwargs={ - 'hub_id': deployment.hub_id, - }, - ) + excel_file.seek(0) - cbc_service = CBCService(hub_credentials, False) - - with open(file_name, 'rb') as price_file: - parsed_price = cbc_service.parse_price_file( - reseller_id, - deployment.vendor_id, - price_file, - ) + parsed_price = execute_with_retry( + function=cbc_service.parse_price_file, + exception_class=CBCClientError, + args=(reseller_id, deployment.vendor_id, excel_file), + ) - data_id = parsed_price['dataId'] + data_id = parsed_price['dataId'] - cbc_service.prepare_price_proposal( - reseller_id, - parsed_price, - dataset['cost'], - dataset['price'], - dataset['msrp'], - dataset['effective_date'], - ) + execute_with_retry( + function=cbc_service.prepare_price_proposal, + exception_class=CBCClientError, + args=( + reseller_id, parsed_price, dataset['cost'], + dataset['price'], dataset['msrp'], dataset['effective_date'], + ), + ) - cbc_service.apply_prices( + execute_with_retry( + function=cbc_service.apply_prices, + exception_class=CBCClientError, + args=( reseller_id, parsed_price, dataset['cost'], @@ -229,6 +345,7 @@ def process_batch( dataset['msrp'], dataset['effective_date'], file_name, - ) + ), + ) - return data_id + return data_id diff --git a/connect_ext_ppr/static/deployment-details.43c6bbfab7e443946135.js b/connect_ext_ppr/static/deployment-details.b0d7de95e55401ae12ca.js similarity index 100% rename from connect_ext_ppr/static/deployment-details.43c6bbfab7e443946135.js rename to connect_ext_ppr/static/deployment-details.b0d7de95e55401ae12ca.js diff --git a/connect_ext_ppr/static/index.e6db85b4789728913af1.js b/connect_ext_ppr/static/index.bbc6cee29733e32ecf28.js similarity index 99% rename from connect_ext_ppr/static/index.e6db85b4789728913af1.js rename to connect_ext_ppr/static/index.bbc6cee29733e32ecf28.js index a5827d0..e918da1 100644 --- a/connect_ext_ppr/static/index.e6db85b4789728913af1.js +++ b/connect_ext_ppr/static/index.bbc6cee29733e32ecf28.js @@ -3096,7 +3096,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac /******/ // This function allow to reference async chunks /******/ __webpack_require__.u = (chunkId) => { /******/ // return url for filenames based on template -/******/ return "" + chunkId + "." + {"deployment-details":"43c6bbfab7e443946135","request-details":"3464656fe301d1df40ca"}[chunkId] + ".js"; +/******/ return "" + chunkId + "." + {"deployment-details":"b0d7de95e55401ae12ca","request-details":"2e8575914807cddd3bc9"}[chunkId] + ".js"; /******/ }; /******/ })(); /******/ diff --git a/connect_ext_ppr/static/index.html b/connect_ext_ppr/static/index.html index ff0e079..85379a1 100644 --- a/connect_ext_ppr/static/index.html +++ b/connect_ext_ppr/static/index.html @@ -12,7 +12,7 @@ Index - +
diff --git a/connect_ext_ppr/static/request-details.3464656fe301d1df40ca.js b/connect_ext_ppr/static/request-details.2e8575914807cddd3bc9.js similarity index 100% rename from connect_ext_ppr/static/request-details.3464656fe301d1df40ca.js rename to connect_ext_ppr/static/request-details.2e8575914807cddd3bc9.js diff --git a/connect_ext_ppr/tasks_manager.py b/connect_ext_ppr/tasks_manager.py index 75245b6..803a22a 100644 --- a/connect_ext_ppr/tasks_manager.py +++ b/connect_ext_ppr/tasks_manager.py @@ -2,11 +2,12 @@ from datetime import datetime from io import BufferedReader +from connect.client import ClientError from sqlalchemy.orm import joinedload, selectinload -from connect_ext_ppr.client.exception import ClientError +from connect_ext_ppr.client.exception import CBCClientError from connect_ext_ppr.constants import PPR_FILE_NAME_DELEGATION_L2 -from connect_ext_ppr.db import get_cbc_extension_db, get_db_ctx_manager +from connect_ext_ppr.db import get_cbc_extension_db, get_cbc_extension_db_engine, get_db_ctx_manager from connect_ext_ppr.models.enums import CBCTaskLogStatus from connect_ext_ppr.models.enums import ( DeploymentRequestStatusChoices, @@ -14,13 +15,17 @@ TasksStatusChoices, TaskTypesChoices, ) -from connect_ext_ppr.models.deployment import DeploymentRequest +from connect_ext_ppr.models.deployment import DeploymentRequest, MarketplaceConfiguration from connect_ext_ppr.models.ppr import PPRVersion from connect_ext_ppr.models.task import Task -from connect_ext_ppr.services.cbc_extension import get_hub_credentials -from connect_ext_ppr.services.cbc_hub import CBCService +from connect_ext_ppr.client.utils import get_cbc_service +from connect_ext_ppr.services.pricing import ( + apply_pricelist_to_marketplace, + validate_pricelist_batch, +) from connect_ext_ppr.utils import ( create_dr_file_to_media, + execute_with_retry, get_base_workbook, get_file_size, get_ppr_from_media, @@ -33,48 +38,52 @@ class TaskException(Exception): def _get_cbc_service(config, deployment): - cbc_db = get_cbc_extension_db(config) - hub_credentials = get_hub_credentials(deployment.hub_id, cbc_db) - if not hub_credentials: - raise TaskException(f'Hub Credentials not found for Hub ID {deployment.hub_id}') - - return CBCService(hub_credentials, False) + cbc_db = get_cbc_extension_db(engine=get_cbc_extension_db_engine(config)) + try: + return get_cbc_service(deployment.hub_id, cbc_db) + except ClientError as e: + raise TaskException(e.message) -def _execute_with_retries(function, func_args, num_retries=5): +def _execute_with_retries(function, func_kwargs, num_retries=5): """ @param function: reference to function to execute - @param func_args: dict with the mapping of function's arguments + @param func_kwargs: dict with the mapping of function's arguments @param num_retries: Max amount of retries @return function return value """ - while num_retries > 0: - try: - num_retries -= 1 - return function(**func_args) - except ClientError as ex: - if num_retries == 0: - raise TaskException(str(ex)) + try: + return execute_with_retry( + function=function, + exception_class=CBCClientError, + kwargs=func_kwargs, + num_retries=num_retries, + ) + except CBCClientError as ex: + raise TaskException(str(ex)) def _send_ppr(cbc_service, file: BufferedReader): - parsed_ppr = _execute_with_retries(cbc_service.parse_ppr, func_args={'file': file}) + parsed_ppr = _execute_with_retries(cbc_service.parse_ppr, func_kwargs={'file': file}) if 'error' in parsed_ppr.keys(): raise TaskException(parsed_ppr.get('message')) - tracking_id = _execute_with_retries(cbc_service.apply_ppr, func_args={'parsed_ppr': parsed_ppr}) + tracking_id = _execute_with_retries( + cbc_service.apply_ppr, + func_kwargs={'parsed_ppr': parsed_ppr}, + ) if not tracking_id: - raise TaskException('Some error ocurred trying to upload ppr.') + raise TaskException('Some error occurred trying to upload ppr.') return tracking_id def _check_cbc_task_status(cbc_service, tracking_id): task_log = _execute_with_retries( - cbc_service.search_task_logs_by_name, func_args={'partial_name': tracking_id}, + cbc_service.search_task_logs_by_name, func_kwargs={'partial_name': tracking_id}, ) # Setting this first default value in case takes time to create it in extenal system. task_log = task_log[0] if task_log else {'status': CBCTaskLogStatus.not_started} @@ -141,7 +150,7 @@ def check_and_update_product(deployment_request, cbc_service, **kwargs): product_id = deployment_request.deployment.product_id response = _execute_with_retries( - cbc_service.get_product_details, func_args={'product_id': product_id}, + cbc_service.get_product_details, func_kwargs={'product_id': product_id}, ) if 'error' in response.keys(): @@ -149,7 +158,7 @@ def check_and_update_product(deployment_request, cbc_service, **kwargs): if response.get('isUpdateAvailable'): response = _execute_with_retries( - cbc_service.update_product, func_args={'product_id': product_id}, + cbc_service.update_product, func_kwargs={'product_id': product_id}, ) if 'error' in response.keys(): @@ -162,6 +171,82 @@ def apply_ppr_and_delegate_to_marketplaces(deployment_request, **kwargs): return True +def apply_pricelist_task( + deployment_request, + cbc_service, + connect_client, + marketplace, + db, + **kwargs, +): + """ Applies a price list for a sinle marketplace + + @param DeploymentRequest deployment_request: + @param CBCService cbc_service: + @param Client connect_client: + @param MarketplaceConfiguration marketplace: + @param Session db: + + @returns bool + @raises TaskException + """ + if not deployment_request.manually: + try: + apply_pricelist_to_marketplace( + deployment_request, + cbc_service, + connect_client, + marketplace, + ) + except (ClientError, CBCClientError) as e: + raise TaskException(f'Error while processing pricelist: {e}') + + deployment_marketplace = db.query(MarketplaceConfiguration).filter_by( + deployment_id=deployment_request.deployment_id, + ).with_for_update().one() + deployment_marketplace.pricelist_id = marketplace.pricelist_id + db.add(deployment_marketplace) + db.commit() + + return True + + +def validate_pricelists_task( + deployment_request, + connect_client, + **kwargs, +): + """ Validates all price lists of deployment request + + @param DeploymentRequest deployment_request: + @param Client connect_client: + + @returns bool + @raises TaskException + """ + dep_marketplaces = {mp.marketplace: mp for mp in deployment_request.deployment.marketplaces} + + for marketplace in deployment_request.marketplaces: + if ( + (not marketplace.pricelist_id) + or marketplace.pricelist_id == dep_marketplaces[marketplace.marketplace].pricelist_id + ): + continue + + try: + validate_pricelist_batch(connect_client, marketplace.pricelist_id) + except ClientError as e: + raise TaskException( + 'Price list {pl_id} of marketplace {mp} validation failed: {msg}'.format( + pl_id=marketplace.pricelist_id, + mp=marketplace.marketplace, + msg=e.message, + ), + ) + + return True + + def delegate_to_l2(deployment_request, cbc_service, connect_client, **kwargs): if deployment_request.manually: return True @@ -193,12 +278,14 @@ def delegate_to_l2(deployment_request, cbc_service, connect_client, **kwargs): TaskTypesChoices.product_setup: check_and_update_product, TaskTypesChoices.apply_and_delegate: apply_ppr_and_delegate_to_marketplaces, TaskTypesChoices.delegate_to_l2: delegate_to_l2, + TaskTypesChoices.validate_pricelists: validate_pricelists_task, + TaskTypesChoices.apply_pricelist: apply_pricelist_task, } -def execute_tasks(db, config, tasks, connect_client): +def execute_tasks(db, config, tasks, connect_client): # noqa: CCR001 was_succesfull = False - cbc_service = _get_cbc_service(config, tasks[0].deployment_request.deployment) + cbc_service = None for task in tasks: db.refresh(task, with_for_update=True) @@ -209,10 +296,17 @@ def execute_tasks(db, config, tasks, connect_client): db.commit() try: + if not cbc_service: + cbc_service = _get_cbc_service( + config=config, + deployment=task.deployment_request.deployment, + ) was_succesfull = TASK_PER_TYPE.get(task.type)( deployment_request=task.deployment_request, cbc_service=cbc_service, connect_client=connect_client, + marketplace=task.marketplace, + db=db, ) task.status = TasksStatusChoices.done if not was_succesfull: @@ -221,9 +315,9 @@ def execute_tasks(db, config, tasks, connect_client): was_succesfull = False task.error_message = str(ex) task.status = TasksStatusChoices.error - except Exception: + except Exception as err: was_succesfull = False - task.error_message = 'Unexpected error' + task.error_message = str(err) task.status = TasksStatusChoices.error task.finished_at = datetime.utcnow() diff --git a/connect_ext_ppr/utils.py b/connect_ext_ppr/utils.py index de6bd04..05a6fd7 100644 --- a/connect_ext_ppr/utils.py +++ b/connect_ext_ppr/utils.py @@ -660,3 +660,25 @@ def process_ppr_file_for_delelegate_l2(sheet_name, ws): ws['Published'] = 'TRUE' elif sheet_name == 'ServicePlans': ws['Published'] = 'FALSE' + + +def execute_with_retry(function, exception_class, args=None, kwargs=None, num_retries=5): + """ + @param function: reference to function to execute + @param exception_class: retry the function call if this exception occurred + @param args: list with function's arguments + @param kwargs: dict function's named arguments + @param num_retries: Max amount of retries + + @return function return value + """ + while num_retries > 0: + try: + num_retries -= 1 + return function( + *(args or []), + **(kwargs or {}), + ) + except exception_class: + if num_retries == 0: + raise diff --git a/connect_ext_ppr/validator.py b/connect_ext_ppr/validator.py index bd004e9..fa1d79a 100644 --- a/connect_ext_ppr/validator.py +++ b/connect_ext_ppr/validator.py @@ -38,10 +38,10 @@ def validate_dr_marketplaces(client, product_id, dr_marketplaces, dep_marketplac format_kwargs={'field': 'marketplace'}, ) - mp_configs = {mp.id: mp for mp in dr_marketplaces} - dep_ids = {mp.marketplace for mp in dep_marketplaces} + req_mp_configs = {mp.id: mp for mp in dr_marketplaces} + dep_mp_configs = {mp.marketplace: mp for mp in dep_marketplaces} - diff = list(mp_configs.keys() - dep_ids) + diff = list(req_mp_configs.keys() - dep_mp_configs.keys()) if diff: diff.sort() raise ExtensionValidationError.VAL_002( @@ -52,14 +52,14 @@ def validate_dr_marketplaces(client, product_id, dr_marketplaces, dep_marketplac status_code=status.HTTP_400_BAD_REQUEST, ) - validate_pricelist_ids(client, product_id, mp_configs) + validate_pricelist_ids(client, product_id, req_mp_configs, dep_mp_configs) -def validate_pricelist_ids(client, product_id, mp_configs): +def validate_pricelist_ids(client, product_id, req_mp_configs, dep_mp_configs): rql_filters = [] pricelist_ids = set() - for mp_id, config in mp_configs.items(): - if config.pricelist: + for mp_id, config in req_mp_configs.items(): + if config.pricelist and config.pricelist.id != dep_mp_configs[mp_id].pricelist_id: pricelist_ids.add(config.pricelist.id) rql_filters.append( R().stream.context.marketplace.id.eq(mp_id) diff --git a/connect_ext_ppr/webapp.py b/connect_ext_ppr/webapp.py index 51a13af..7a4cfb3 100644 --- a/connect_ext_ppr/webapp.py +++ b/connect_ext_ppr/webapp.py @@ -4,7 +4,7 @@ # All rights reserved. # from concurrent.futures import ThreadPoolExecutor -from logging import Logger, LoggerAdapter +from logging import Logger from typing import List from connect.client import ConnectClient @@ -22,16 +22,13 @@ ) from connect.eaas.core.extension import WebApplicationBase from fastapi import Depends, Request, Response, status -from fastapi.responses import JSONResponse from fastapi_filter import FilterDepends from sqlalchemy import desc, exists from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import joinedload, selectinload, Session +from sqlalchemy.orm import joinedload, selectinload -from connect_ext_ppr.client.exception import ClientError from connect_ext_ppr.db import ( create_db, - get_cbc_extension_db, get_config, get_db, VerboseBaseSession, @@ -62,7 +59,6 @@ validate_configuration, ) from connect_ext_ppr.schemas import ( - BatchProcessResponseSchema, BatchSchema, ConfigurationCreateSchema, ConfigurationSchema, @@ -76,13 +72,7 @@ ProductSchema, TaskSchema, ) -from connect_ext_ppr.services.pricing import ( - fetch_and_validate_batch, - identify_marketplaces, - identify_reseller_id, - prepare_file, - process_batch, -) +from connect_ext_ppr.services.pricing import identify_marketplaces from connect_ext_ppr.tasks_manager import main_process from connect_ext_ppr.utils import ( _get_extension_client, @@ -798,46 +788,6 @@ def get_deployment_batches( return [BatchSchema(**b) for b in batches] - @router.post( - '/deployments/{deployment_id}/pricing/batches/{batch_id}/process', - summary='Process Pricing Batch for Deployment', - response_model=BatchProcessResponseSchema, - ) - def process_pricing_batch( - self, - deployment_id: str, - batch_id: str, - client: ConnectClient = Depends(get_installation_client), - db: VerboseBaseSession = Depends(get_db), - cbc_db: Session = Depends(get_cbc_extension_db), - installation: dict = Depends(get_installation), - logger: LoggerAdapter = Depends(get_logger), - ): - try: - deployment = get_deployment_by_id(deployment_id, db, installation) - batch = fetch_and_validate_batch(client, batch_id, deployment) - reseller_id = identify_reseller_id(client, batch, deployment) - file_name, dataset = prepare_file(client, batch_id) - - data_id = process_batch( - cbc_db, - file_name, - reseller_id, - deployment, - dataset, - ) - - response = BatchProcessResponseSchema( - task_info=f'/flat-catalog/price-import-wizard/{data_id}/set-prices', - ) - return JSONResponse( - status_code=202, - content=response.dict(), - ) - except ClientError as e: - logger.exception(f'Error while uploading price file for {batch_id}') - return JSONResponse(status_code=400, content=e.json if e.json else {}) - @classmethod def on_startup(cls, logger, config): # When database schema is completely defined diff --git a/tests/api/test_deployment_requests.py b/tests/api/test_deployment_requests.py index 24a5070..b47f14e 100644 --- a/tests/api/test_deployment_requests.py +++ b/tests/api/test_deployment_requests.py @@ -447,6 +447,9 @@ def test_create_deployment_request( marketplace_config_factory(deployment=dep, marketplace_id='MP-124') marketplace_config_factory(deployment=dep, marketplace_id='MP-123', ppr_id=ppr.id) marketplace_config_factory(deployment=dep, marketplace_id='MP-234', ppr_id=ppr.id) + marketplace_config_factory( + deployment=dep, marketplace_id='MP-345', ppr_id=ppr.id, pricelist_id='BAT-222', + ) mocker.patch('connect_ext_ppr.webapp.get_client_object', side_effect=[hub_data]) client_mocker = client_mocker_factory(base_url=connect_client.endpoint) @@ -471,8 +474,9 @@ def test_create_deployment_request( 'manually': True, 'delegate_l2': True, 'marketplaces': [ - {'id': 'MP-123'}, - {'id': 'MP-234', 'pricelist': {'id': 'BAT-111'}}, + {'id': 'MP-123'}, # pricelist not defined. skip + {'id': 'MP-234', 'pricelist': {'id': 'BAT-111'}}, # appy pricelist + {'id': 'MP-345', 'pricelist': {'id': 'BAT-222'}}, # already applied pricelist. skip ], } @@ -532,13 +536,18 @@ def test_create_deployment_request( ).count() == 1 tasks = dbsession.query(Task).order_by(Task.id) - assert tasks.count() == 3 + assert tasks.count() == 5 assert tasks[0].id == f'TSK-{deployment_request.id[5:]}-000' - assert tasks[0].type == Task.TYPES.product_setup + assert tasks[0].type == Task.TYPES.validate_pricelists assert tasks[1].id == f'TSK-{deployment_request.id[5:]}-001' - assert tasks[1].type == Task.TYPES.apply_and_delegate + assert tasks[1].type == Task.TYPES.product_setup assert tasks[2].id == f'TSK-{deployment_request.id[5:]}-002' - assert tasks[2].type == Task.TYPES.delegate_to_l2 + assert tasks[2].type == Task.TYPES.apply_and_delegate + assert tasks[3].id == f'TSK-{deployment_request.id[5:]}-003' + assert tasks[3].type == Task.TYPES.apply_pricelist + assert tasks[3].title == 'Apply price list to marketplace MP-234' + assert tasks[4].id == f'TSK-{deployment_request.id[5:]}-004' + assert tasks[4].type == Task.TYPES.delegate_to_l2 def test_create_deployment_request_without_delegation_to_l2( diff --git a/tests/api/test_pricing_batches.py b/tests/api/test_pricing_batches.py deleted file mode 100644 index 107791c..0000000 --- a/tests/api/test_pricing_batches.py +++ /dev/null @@ -1,57 +0,0 @@ -from unittest import TestCase -from unittest.mock import patch - -from connect_ext_ppr.client.exception import ClientError - - -@patch('connect_ext_ppr.webapp.get_deployment_by_id') -@patch('connect_ext_ppr.webapp.fetch_and_validate_batch') -@patch('connect_ext_ppr.webapp.identify_reseller_id', return_value='1000001') -@patch('connect_ext_ppr.webapp.prepare_file', return_value=('File.xlsx', {})) -@patch('connect_ext_ppr.webapp.process_batch', return_value=1) -def test_process_pricing_batch_positive( - mock_process_batch, - mock_prepare_file, - mock_identify_reseller_id, - mock_fetch_and_validate_batch, - mock_get_deployment_by_id, - api_client, - deployment, - installation, - batch, -): - mock_get_deployment_by_id.return_value = deployment - mock_fetch_and_validate_batch.return_value = batch - - response = api_client.post( - f'/api/deployments/{deployment.id}/pricing/batches/BT-000-000-000/process', - installation=installation, - ) - - assert response.status_code == 202 - TestCase().assertDictEqual( - response.json(), - {'task_info': '/flat-catalog/price-import-wizard/1/set-prices'}, - ) - - -@patch('connect_ext_ppr.webapp.get_deployment_by_id') -@patch('connect_ext_ppr.webapp.fetch_and_validate_batch') -def test_process_pricing_batch_negative( - mock_fetch_and_validate_batch, - mock_get_deployment_by_id, - api_client, - deployment, - installation, - batch, -): - mock_get_deployment_by_id.return_value = deployment - mock_fetch_and_validate_batch.side_effect = ClientError( - 'Unexpected error during batch validation.') - - response = api_client.post( - f'/api/deployments/{deployment.id}/pricing/batches/BT-000-000-000/process', - installation=installation, - ) - - assert response.status_code == 400 diff --git a/tests/client/test_client.py b/tests/client/test_client.py index d6b7c2f..d6a38b4 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -7,7 +7,7 @@ from requests_oauthlib import OAuth1 from connect_ext_ppr.client import CBCClient -from connect_ext_ppr.client.exception import ClientError +from connect_ext_ppr.client.exception import CBCClientError @responses.activate @@ -128,5 +128,5 @@ def test_collection_service_discovery_invalid_type(cbc_client): @patch.object(Session, 'send', side_effect=Exception('Mock error!')) def test_client_get_connection_error(mock_send, cbc_client): - with pytest.raises(ClientError): + with pytest.raises(CBCClientError): cbc_client.get('identifier') diff --git a/tests/client/test_service.py b/tests/client/test_service.py index 6a892ab..743e94f 100644 --- a/tests/client/test_service.py +++ b/tests/client/test_service.py @@ -3,7 +3,7 @@ import pytest import responses -from connect_ext_ppr.client.exception import ClientError +from connect_ext_ppr.client.exception import CBCClientError @responses.activate @@ -35,7 +35,7 @@ def test_service_discovery_client_error( status=401, ) - with pytest.raises(ClientError): + with pytest.raises(CBCClientError): cbc_client(flat_catalog_type).get() diff --git a/tests/client/test_utils.py b/tests/client/test_utils.py new file mode 100644 index 0000000..7fee8b3 --- /dev/null +++ b/tests/client/test_utils.py @@ -0,0 +1,33 @@ +from unittest.mock import patch + +import pytest + +from connect_ext_ppr.errors import ClientError +from connect_ext_ppr.client.utils import CBCService +from connect_ext_ppr.client.utils import get_cbc_service + + +@patch.object(CBCService, '__init__', return_value=None) +@patch('connect_ext_ppr.client.utils.get_hub_credentials') +def test_get_cbc_service_ok( + mock_hub_cred, + mock_service, + cbc_db_session, +): + service = get_cbc_service('HUB-0', cbc_db_session, True) + + assert isinstance(service, CBCService) + assert mock_hub_cred.called_once_with('HUB-0', cbc_db_session) + + +@patch.object(CBCService, '__init__', return_value=None) +@patch('connect_ext_ppr.client.utils.get_hub_credentials', return_value=None) +def test_get_cbc_service_failed( + mock_hub_cred, + mock_service, + cbc_db_session, +): + with pytest.raises(ClientError) as e: + get_cbc_service('HUB-0', cbc_db_session, True) + + assert e.value.message == 'Hub Credentials not found for Hub ID HUB-0.' diff --git a/tests/services/test_cbc_hub.py b/tests/services/test_cbc_hub.py index d8cbf35..2b63027 100644 --- a/tests/services/test_cbc_hub.py +++ b/tests/services/test_cbc_hub.py @@ -5,7 +5,7 @@ import pytest import responses -from connect_ext_ppr.client.exception import ClientError +from connect_ext_ppr.client.exception import CBCClientError from connect_ext_ppr.client.ns import Service from connect_ext_ppr.services.cbc_hub import CBCService @@ -88,7 +88,7 @@ def test_get_product_details_not_found( ) cbc_service = CBCService(hub_credentials) - with pytest.raises(ClientError): + with pytest.raises(CBCClientError): cbc_service.get_product_details(product_id) @@ -159,7 +159,7 @@ def test_install_product_not_found( ) cbc_service = CBCService(hub_credentials) - with pytest.raises(ClientError): + with pytest.raises(CBCClientError): cbc_service.install_product(product_id) @@ -232,7 +232,7 @@ def test_update_product_negative_product_not_installed( ) cbc_service = CBCService(hub_credentials) - with pytest.raises(ClientError): + with pytest.raises(CBCClientError): cbc_service.update_product(product_id) diff --git a/tests/services/test_pricing.py b/tests/services/test_pricing.py index cabdfa0..ccaac9f 100644 --- a/tests/services/test_pricing.py +++ b/tests/services/test_pricing.py @@ -1,7 +1,9 @@ from copy import deepcopy +from tempfile import NamedTemporaryFile from unittest import TestCase from unittest.mock import patch +import openpyxl import pytest import responses from connect.client import ClientError @@ -10,16 +12,250 @@ from connect_ext_ppr.services.cbc_hub import CBCService from connect_ext_ppr.services.pricing import ( - determine_dataset, - fetch_and_validate_batch, - get_reseller_id, - identify_marketplaces, - identify_reseller_id, - prepare_file, - process_batch, + _determine_dataset, + _fetch_and_validate_batch, + _get_reseller_id, + _identify_reseller_id, + _prepare_file, + _process_batch, + apply_pricelist_to_marketplace, + identify_marketplaces, validate_pricelist_batch, ) +@pytest.fixture +@patch.object(CBCService, 'parse_price_file') +@patch.object(CBCService, 'prepare_price_proposal') +@patch.object(CBCService, 'apply_prices') +@patch.object(CBCService, '__init__') +def cbc_service( + mock___init__, + mock_apply_prices, + mock_prepare_price_proposal, + mock_parse_price_file, + parse_price_file_response, + price_proposal_response, +): + mock_parse_price_file.return_value = parse_price_file_response + mock_prepare_price_proposal.return_value = price_proposal_response + mock_apply_prices.return_value = 'Request Accepted.' + mock___init__.return_value = None + + return CBCService() + + +@patch('connect_ext_ppr.services.pricing._fetch_batch_output_file') +def test_validate_pricelist_positive(mock_fetch_file): + excel_file = NamedTemporaryFile(suffix='.xlsx') + wb = openpyxl.load_workbook('./tests/fixtures/MFL-0000-0000-0000.xlsx') + wb.save(excel_file) + excel_file.seek(0) + + mock_fetch_file.return_value = (excel_file, 'filename.xlsx') + + validate_pricelist_batch(excel_file, 'BAT-1') + + assert excel_file.closed + + +@pytest.mark.parametrize('col_id,col_name', ( + (1, 'Billing Period'), + (4, 'MPN'), + (16, 'Effective Date'), +)) +@patch('connect_ext_ppr.services.pricing._fetch_batch_output_file') +def test_validate_pricelist_negative_missed_req_cols( + mock_fetch_file, + col_id, + col_name, +): + excel_file = NamedTemporaryFile(suffix='.xlsx') + wb = openpyxl.load_workbook('./tests/fixtures/MFL-0000-0000-0000.xlsx') + wb['Data'].delete_cols(col_id) + wb.save(excel_file) + excel_file.seek(0) + + mock_fetch_file.return_value = (excel_file, 'filename.xlsx') + + with pytest.raises(ClientError) as e: + validate_pricelist_batch(excel_file, 'BAT-1') + + assert excel_file.closed + + assert e.value.message == ( + "Pricing Batch output 'BAT-1' does not " + f"contain mandatory column: {col_name}." + ) + + +@patch('connect_ext_ppr.services.pricing._fetch_batch_output_file') +def test_validate_pricelist_negative_no_cost_price(mock_fetch_file): + excel_file = NamedTemporaryFile(suffix='.xlsx') + wb = openpyxl.load_workbook('./tests/fixtures/MFL-0000-0000-0000.xlsx') + wb['Data'].delete_cols(7) # Cost + wb['Data'].delete_cols(16) # Price + wb.save(excel_file) + excel_file.seek(0) + + mock_fetch_file.return_value = (excel_file, 'filename.xlsx') + + with pytest.raises(ClientError) as e: + validate_pricelist_batch(excel_file, 'BAT-1') + + assert e.value.message == ( + "Pricing Batch output 'BAT-1' does not " + "contain either Cost or Price column." + ) + + +@pytest.mark.parametrize('col_id,col_name', ( + (12, 'Cost Currency'), + (13, 'Price Currency'), +)) +@patch('connect_ext_ppr.services.pricing._fetch_batch_output_file') +def test_validate_pricelist_negative_no_cost_price_currency( + mock_fetch_file, + col_id, + col_name, +): + excel_file = NamedTemporaryFile(suffix='.xlsx') + wb = openpyxl.load_workbook('./tests/fixtures/MFL-0000-0000-0000.xlsx') + wb['Data'].delete_cols(col_id) # Cost + wb.save(excel_file) + excel_file.seek(0) + + mock_fetch_file.return_value = (excel_file, 'filename.xlsx') + + with pytest.raises(ClientError) as e: + validate_pricelist_batch(excel_file, 'BAT-1') + + assert e.value.message == ( + "Pricing Batch output 'BAT-1' does not " + f"contain mandatory column: {col_name}." + ) + + +@pytest.mark.parametrize('col_id,col_name,value', ( + (7, 'Cost', None), + (17, 'Price', 'not number'), + (4, 'MPN', None), +)) +@patch('connect_ext_ppr.services.pricing._fetch_batch_output_file') +def test_validate_pricelist_negative_invalid_value( + mock_fetch_file, + col_id, + col_name, + value, +): + excel_file = NamedTemporaryFile(suffix='.xlsx') + wb = openpyxl.load_workbook('./tests/fixtures/MFL-0000-0000-0000.xlsx') + wb['Data'].cell(row=2, column=col_id).value = value + wb.save(excel_file) + excel_file.seek(0) + + mock_fetch_file.return_value = (excel_file, 'filename.xlsx') + + with pytest.raises(ClientError) as e: + validate_pricelist_batch(excel_file, 'BAT-1') + + assert e.value.message == ( + "Pricing Batch output 'BAT-1' contains invalid " + f"value at column '{col_name}' of row '2'." + ) + + +@patch('connect_ext_ppr.services.pricing._fetch_batch_output_file') +def test_validate_pricelist_negative_invalid_effective_date(mock_fetch_file): + excel_file = NamedTemporaryFile(suffix='.xlsx') + wb = openpyxl.load_workbook('./tests/fixtures/MFL-0000-0000-0000.xlsx') + wb['Data'].cell(row=2, column=16).value = 'Not a date' + wb.save(excel_file) + excel_file.seek(0) + + mock_fetch_file.return_value = (excel_file, 'filename.xlsx') + + with pytest.raises(ClientError) as e: + validate_pricelist_batch(excel_file, 'BAT-1') + + assert e.value.message == ( + "Effective date 'Not a date' is either not " + "found or invalid for first row in Batch BAT-1." + ) + + +def test_apply_pricelist_to_marketplace_positive( + mocker, + marketplace, + cbc_service, + batch_output_file, + connect_client, + client_mocker_factory, + deployment_request_factory, + marketplace_config_factory, +): + dep_req = deployment_request_factory() + mp_conf = marketplace_config_factory('MP-123', deployment_request=dep_req) + + price_excel_file = NamedTemporaryFile(suffix='.xlsx') + price_excel_file.read() + price_excel_file.seek(0) + + reseller_id_mock = mocker.patch( + 'connect_ext_ppr.services.pricing._identify_reseller_id', + return_value=marketplace['hubs'][0]['external_id'], + ) + prepare_file_mock = mocker.patch( + 'connect_ext_ppr.services.pricing._prepare_file', + return_value=( + price_excel_file, + 'MFL-001.xlsx', + { + 'cost': True, + 'price': True, + 'msrp': True, + 'effective_date': '07/26/2023', + }, + ), + ) + process_batch_mock = mocker.patch( + 'connect_ext_ppr.services.pricing._process_batch', + return_value=None, + ) + + apply_pricelist_to_marketplace( + dep_req, + cbc_service, + connect_client, + mp_conf, + ) + + reseller_id_mock.assert_called_once_with( + client=connect_client, + batch_id=mp_conf.pricelist_id, + marketplace_id=mp_conf.marketplace, + hub_id=dep_req.deployment.hub_id, + ) + prepare_file_mock.assert_called_once_with( + client=connect_client, + batch_id=mp_conf.pricelist_id, + ) + process_batch_mock.assert_called_once_with( + cbc_service=cbc_service, + excel_file=price_excel_file, + file_name='MFL-001.xlsx', + reseller_id=marketplace['hubs'][0]['external_id'], + deployment=dep_req.deployment, + dataset={ + 'cost': True, + 'price': True, + 'msrp': True, + 'effective_date': '07/26/2023', + }, + ) + + assert price_excel_file + + def test_identify_marketplaces_positive( connect_client, client_mocker_factory, @@ -78,8 +314,9 @@ def test_prepare_file_positive( content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ) - file_name, dataset = prepare_file(connect_client, batch_id) + excel_file, file_name, dataset = _prepare_file(connect_client, batch_id) + assert len(excel_file.read()) assert file_name == f"{batch_file['id']}.xlsx" TestCase().assertDictEqual( dataset, @@ -106,7 +343,7 @@ def test_prepare_file_negative_no_batch_file( ) with pytest.raises(ClientError): - prepare_file(connect_client, batch_id) + _prepare_file(connect_client, batch_id) def test_prepare_file_negative_more_than_one_batch_file( @@ -124,7 +361,7 @@ def test_prepare_file_negative_more_than_one_batch_file( ) with pytest.raises(ClientError): - prepare_file(connect_client, batch_id) + _prepare_file(connect_client, batch_id) def test_fetch_and_validate_batch_positive( @@ -144,7 +381,7 @@ def test_fetch_and_validate_batch_positive( return_value=[batch], ) - response = fetch_and_validate_batch( + response = _fetch_and_validate_batch( connect_client, batch_id, no_db_deployment, @@ -169,7 +406,7 @@ def test_fetch_and_validate_batch_negative_no_batch( ) with pytest.raises(ClientError): - fetch_and_validate_batch( + _fetch_and_validate_batch( connect_client, batch_id, None, @@ -196,7 +433,7 @@ def test_fetch_and_validate_batch_negative_batch_does_not_belong_to_deployment( ) with pytest.raises(ClientError): - fetch_and_validate_batch( + _fetch_and_validate_batch( connect_client, batch_id, deployment, @@ -224,7 +461,12 @@ def test_identify_reseller_id_positive( return_value=[hub], ) - reseller_id = identify_reseller_id(connect_client, batch, no_db_deployment) + reseller_id = _identify_reseller_id( + connect_client, + batch['id'], + marketplace_id, + no_db_deployment.hub_id, + ) assert reseller_id == marketplace['hubs'][0]['external_id'] @@ -250,7 +492,12 @@ def test_identify_reseller_id_negative_deployment_hub_not_present_in_batch_marke ) with pytest.raises(ClientError): - identify_reseller_id(connect_client, batch, no_db_deployment) + _identify_reseller_id( + connect_client, + batch['id'], + marketplace_id, + no_db_deployment.hub_id, + ) def test_identify_reseller_id_negative_hubs_not_configured_in_batch_marketplace( @@ -270,7 +517,12 @@ def test_identify_reseller_id_negative_hubs_not_configured_in_batch_marketplace( ) with pytest.raises(ClientError): - identify_reseller_id(connect_client, batch, no_db_deployment) + _identify_reseller_id( + connect_client, + batch['id'], + marketplace_id, + no_db_deployment.hub_id, + ) def test_get_reseller_id_negative_hubs_not_configured( @@ -279,7 +531,7 @@ def test_get_reseller_id_negative_hubs_not_configured( modified_marketplace = deepcopy(marketplace) modified_marketplace.pop('hubs') - reseller_id = get_reseller_id(modified_marketplace, 'HUB-0000-0000') + reseller_id = _get_reseller_id(modified_marketplace, 'HUB-0000-0000') assert reseller_id is None @@ -308,7 +560,12 @@ def test_identify_reseller_id_negative_reseller_id_not_mapped( ) with pytest.raises(ClientError): - identify_reseller_id(connect_client, batch, no_db_deployment) + _identify_reseller_id( + connect_client, + batch['id'], + marketplace_id, + no_db_deployment.hub_id, + ) @patch.object(CBCService, 'parse_price_file') @@ -322,7 +579,7 @@ def test_process_batch_positive( mock_parse_price_file, parse_price_file_response, price_proposal_response, - cbc_db_session, + hub_credentials, no_db_deployment, batch_dataset, ): @@ -333,9 +590,14 @@ def test_process_batch_positive( mock_apply_prices.return_value = 'Request Accepted.' mock___init__.return_value = None - data_id = process_batch( - cbc_db_session, - './tests/fixtures/MFL-0000-0000-0000.xlsx', + excel_file = NamedTemporaryFile(suffix='.xlsx') + excel_file.write(open('./tests/fixtures/MFL-0000-0000-0000.xlsx', 'rb').read()) + excel_file.seek(0) + + data_id = _process_batch( + CBCService(hub_credentials), + excel_file, + 'MFL-0000-0000-0000.xlsx', reseller_id, no_db_deployment, batch_dataset, @@ -343,22 +605,57 @@ def test_process_batch_positive( assert data_id == parse_price_file_response['dataId'] - -def test_process_batch_negative_hub_credential_not_found( - cbc_db_session, - no_db_deployment, -): - deployment = deepcopy(no_db_deployment) - deployment.hub_id = 'HB-0000-0001' - - with pytest.raises(ClientError): - process_batch( - cbc_db_session, - None, - None, - deployment, - None, - ) + assert mock_parse_price_file.call_args[0] == ( + '10000001', + 'VA-000-000', + excel_file, + ) + assert mock_prepare_price_proposal.call_args[0] == ( + '10000001', + { + '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, + }, + True, + True, + True, + '07/26/2023', + ) + assert mock_apply_prices.call_args[0] == ( + '10000001', + { + '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, + }, + True, + True, + True, + '07/26/2023', + 'MFL-0000-0000-0000.xlsx', + ) def test_determine_dataset_negative_effective_date_column_not_present(): @@ -367,7 +664,7 @@ def test_determine_dataset_negative_effective_date_column_not_present(): ) ws = wb['Data'] with pytest.raises(ClientError): - determine_dataset(ws, 'BAT-000-000-000') + _determine_dataset(ws, 'BAT-000-000-000') def test_determine_dataset_negative_empty_effective_date(): @@ -376,7 +673,7 @@ def test_determine_dataset_negative_empty_effective_date(): ) ws = wb['Data'] with pytest.raises(ClientError): - determine_dataset(ws, 'BAT-000-000-000') + _determine_dataset(ws, 'BAT-000-000-000') def test_determine_dataset_negative_effective_date_wrong_format(): @@ -385,4 +682,4 @@ def test_determine_dataset_negative_effective_date_wrong_format(): ) ws = wb['Data'] with pytest.raises(ClientError): - determine_dataset(ws, 'BAT-000-000-000') + _determine_dataset(ws, 'BAT-000-000-000') diff --git a/tests/test_tasks_manager.py b/tests/test_tasks_manager.py index 4f7e4c9..6c8b707 100644 --- a/tests/test_tasks_manager.py +++ b/tests/test_tasks_manager.py @@ -5,6 +5,7 @@ from unittest.mock import patch +from connect.client import ClientError from sqlalchemy import null from connect_ext_ppr.models.deployment import Deployment, DeploymentRequest @@ -15,16 +16,18 @@ TasksStatusChoices, TaskTypesChoices, ) -from connect_ext_ppr.client.exception import ClientError +from connect_ext_ppr.client.exception import CBCClientError from connect_ext_ppr.models.task import Task from connect_ext_ppr.tasks_manager import ( _check_cbc_task_status, _send_ppr, apply_ppr_and_delegate_to_marketplaces, + apply_pricelist_task, check_and_update_product, delegate_to_l2, main_process, TaskException, + validate_pricelists_task, ) from connect_ext_ppr.services.cbc_hub import CBCService @@ -44,7 +47,7 @@ def test__send_ppr(parse_ppr_success_response, sample_ppr_file, mocker): def test__send_ppr_max_retries(sample_ppr_file, mocker): cbc_service = mocker.Mock() - cbc_service.parse_ppr.side_effect = ClientError('Some random error') + cbc_service.parse_ppr.side_effect = CBCClientError('Some random error') with pytest.raises(TaskException) as ex: _send_ppr(cbc_service, sample_ppr_file) assert str(ex) == 'Some random error' @@ -66,7 +69,7 @@ def test__check_cbc_task_status_with_max_retries(task_logs_response, mocker): not_started_log = copy.deepcopy(task_logs_response) not_started_log[0]['status'] = CBCTaskLogStatus.not_started cbc_service = mocker.Mock() - cbc_service.search_task_logs_by_name.side_effect = ClientError('Some random error') + cbc_service.search_task_logs_by_name.side_effect = CBCClientError('Some random error') with mocker.patch('connect_ext_ppr.tasks_manager.time.sleep', return_value=None): with pytest.raises(TaskException) as ex: _check_cbc_task_status(cbc_service, 100) @@ -505,6 +508,27 @@ def mock_get(key): ).count() == pending_tasks +def test_main_process_wo_hub_credentials( + deployment_factory, + deployment_request_factory, + ppr_version_factory, + task_factory, + connect_client, + mocker, +): + dep = deployment_factory() + ppr = ppr_version_factory(id='PPR-123', product_version=1, deployment=dep, version=1) + dr = deployment_request_factory(deployment=dep, delegate_l2=True, ppr=ppr) + task = task_factory( + deployment_request=dr, task_index='0001', type=TaskTypesChoices.product_setup, + ) + mocker.patch('connect_ext_ppr.client.utils.get_hub_credentials', return_value=None) + assert main_process(dr.id, {}, connect_client) == DeploymentRequestStatusChoices.error + + assert task.status == TasksStatusChoices.error + assert task.error_message == 'Hub Credentials not found for Hub ID HB-0000-0000.' + + @patch.object(CBCService, '__init__', return_value=None) @pytest.mark.parametrize( ('task_statuses', 'done_tasks', 'aborted_tasks'), @@ -720,3 +744,212 @@ def mock_get(key): Task.started_at.is_(null()), Task.finished_at.is_(null()), ).count() == pending_tasks + + +def test_validate_pricelists_task_ok( + deployment_factory, + deployment_request_factory, + marketplace_config_factory, + mocker, +): + validate_mock = mocker.patch( + 'connect_ext_ppr.tasks_manager.validate_pricelist_batch', + ) + + dep = deployment_factory() + dep_req = deployment_request_factory(deployment=dep) + + marketplace_config_factory( + deployment=dep, marketplace_id='MP-1', pricelist_id='BAT-1', + ) + marketplace_config_factory( + deployment=dep, marketplace_id='MP-2', pricelist_id=None, + ) + marketplace_config_factory( + deployment=dep, marketplace_id='MP-3', pricelist_id='BAT-OLD', + ) + marketplace_config_factory( + deployment=dep, marketplace_id='MP-4', pricelist_id=None, + ) + + # already applied. skip + marketplace_config_factory( + deployment_request=dep_req, marketplace_id='MP-1', pricelist_id='BAT-1', + ) + # price list is not set. skip + marketplace_config_factory( + deployment_request=dep_req, marketplace_id='MP-2', pricelist_id=None, + ) + marketplace_config_factory( + deployment_request=dep_req, marketplace_id='MP-3', pricelist_id='BAT-3', + ) + marketplace_config_factory( + deployment_request=dep_req, marketplace_id='MP-4', pricelist_id='BAT-4', + ) + + validate_pricelists_task(dep_req, mocker.MagicMock()) + + assert validate_mock.call_count == 2 + assert { + validate_mock.call_args_list[0][0][1], + validate_mock.call_args_list[1][0][1], + } == {'BAT-3', 'BAT-4'} + + +def test_validate_pricelists_task_validation_failed( + deployment_factory, + deployment_request_factory, + marketplace_config_factory, + mocker, +): + validate_mock = mocker.patch( + 'connect_ext_ppr.tasks_manager.validate_pricelist_batch', + side_effect=ClientError(message='Not valid.', status_code='VAL-001'), + ) + + dep = deployment_factory() + dep_req = deployment_request_factory(deployment=dep) + + marketplace_config_factory( + deployment=dep, marketplace_id='MP-3', pricelist_id='BAT-OLD', + ) + marketplace_config_factory( + deployment=dep, marketplace_id='MP-4', pricelist_id=None, + ) + + marketplace_config_factory( + deployment_request=dep_req, marketplace_id='MP-3', pricelist_id='BAT-3', + ) + marketplace_config_factory( + deployment_request=dep_req, marketplace_id='MP-4', pricelist_id='BAT-4', + ) + + with pytest.raises(TaskException) as e: + validate_pricelists_task(dep_req, mocker.MagicMock()) + + assert str(e.value) == ( + 'Price list BAT-3 of marketplace MP-3 validation failed: Not valid.' + ) + + assert validate_mock.call_count == 1 + + +def test_apply_pricelist_task_ok( + deployment_factory, + deployment_request_factory, + marketplace_config_factory, + mocker, + dbsession, +): + apply_mock = mocker.patch( + 'connect_ext_ppr.tasks_manager.apply_pricelist_to_marketplace', + ) + + dep = deployment_factory() + dep_req = deployment_request_factory(deployment=dep) + + dep_mp = marketplace_config_factory( + deployment=dep, marketplace_id='MP-1', pricelist_id='BAT-0', + ) + req_mp = marketplace_config_factory( + deployment_request=dep_req, marketplace_id='MP-1', pricelist_id='BAT-1', + ) + + cbc_service = mocker.MagicMock() + connect_client = mocker.MagicMock() + + apply_pricelist_task( + dep_req, + cbc_service, + connect_client, + req_mp, + dbsession, + ) + + assert apply_mock.called_once_with( + dep_req, + cbc_service, + connect_client, + req_mp, + ) + + dbsession.refresh(dep_mp) + + assert dep_mp.pricelist_id == req_mp.pricelist_id + + +def test_apply_pricelist_task_ok_manual( + deployment_factory, + deployment_request_factory, + marketplace_config_factory, + mocker, + dbsession, +): + apply_mock = mocker.patch( + 'connect_ext_ppr.tasks_manager.apply_pricelist_to_marketplace', + ) + + dep = deployment_factory() + dep_req = deployment_request_factory(deployment=dep, manually=True) + + dep_mp = marketplace_config_factory( + deployment=dep, marketplace_id='MP-1', pricelist_id='BAT-0', + ) + req_mp = marketplace_config_factory( + deployment_request=dep_req, marketplace_id='MP-1', pricelist_id='BAT-1', + ) + + cbc_service = mocker.MagicMock() + connect_client = mocker.MagicMock() + + apply_pricelist_task( + dep_req, + cbc_service, + connect_client, + req_mp, + dbsession, + ) + + assert apply_mock.call_count == 0 + assert dep_mp.pricelist_id == req_mp.pricelist_id + + +def test_apply_pricelist_task_error( + deployment_factory, + deployment_request_factory, + marketplace_config_factory, + mocker, + dbsession, +): + mocker.patch( + 'connect_ext_ppr.tasks_manager.apply_pricelist_to_marketplace', + side_effect=ClientError( + message='olala', + status_code='VAL-1', + ), + ) + + dep = deployment_factory() + dep_req = deployment_request_factory(deployment=dep) + + dep_mp = marketplace_config_factory( + deployment=dep, marketplace_id='MP-1', pricelist_id='BAT-0', + ) + req_mp = marketplace_config_factory( + deployment_request=dep_req, marketplace_id='MP-1', pricelist_id='BAT-1', + ) + + cbc_service = mocker.MagicMock() + connect_client = mocker.MagicMock() + + with pytest.raises(TaskException) as e: + apply_pricelist_task( + dep_req, + cbc_service, + connect_client, + req_mp, + dbsession, + ) + + assert str(e.value) == 'Error while processing pricelist: olala' + assert dep_mp.pricelist_id == 'BAT-0'