diff --git a/connect_ext_ppr/errors.py b/connect_ext_ppr/errors.py index 466b1bd..38b3e2b 100644 --- a/connect_ext_ppr/errors.py +++ b/connect_ext_ppr/errors.py @@ -78,11 +78,24 @@ 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.", + 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.", } class ExtensionValidationError(ExtensionErrorBase): PREFIX = 'VAL' ERRORS = { - 0: "{validation_error}", # PPR Shema validation + 0: "{validation_error}", # PPR Schema validation } diff --git a/connect_ext_ppr/schemas.py b/connect_ext_ppr/schemas.py index 7942727..5e67fdd 100644 --- a/connect_ext_ppr/schemas.py +++ b/connect_ext_ppr/schemas.py @@ -118,3 +118,22 @@ class DeploymentRequestSchema(NonNullSchema): class Config: orm_mode = True + + +class StreamSchema(NonNullSchema): + id: str + name: str + status: str + + +class BatchSchema(NonNullSchema): + id: str + name: str + status: str + stream: StreamSchema + test: Optional[bool] + stream_updated: Optional[bool] + + +class BatchProcessResponseSchema(NonNullSchema): + task_info: str diff --git a/connect_ext_ppr/services/pricing.py b/connect_ext_ppr/services/pricing.py new file mode 100644 index 0000000..2797389 --- /dev/null +++ b/connect_ext_ppr/services/pricing.py @@ -0,0 +1,231 @@ +from datetime import datetime +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 + + +def determine_dataset(ws, batch_id): + dataset = { + 'cost': False, + 'price': False, + 'msrp': False, + 'effective_date': None, + } + + headers = [cell.value.lower() for cell in ws[1]] + for key in dataset.keys(): + if key in headers: + dataset[key] = True + + if 'effective date' not in headers: + raise ExtensionHttpError.EXT_013( + format_kwargs={ + 'batch_id': batch_id, + }, + ) + + effective_date_index = headers.index('effective date') + + first_row = [str(cell.value).lower() 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={ + 'batch_id': batch_id, + 'date': effective_date_str, + }, + ) + else: + raise ExtensionHttpError.EXT_014( + 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}, + ) + + return [marketplace['id'] for marketplace in marketplaces] + + +def identify_cbc_hubs( + client: ConnectClient, + marketplace: Dict, +): + if 'hubs' in marketplace.keys(): + hub_ids = [hub['hub']['id'] for hub in marketplace['hubs']] + + osa_hubs = client.hubs.filter( + R().id.in_(hub_ids), + R().instance.type.eq('OA'), + ) + + return osa_hubs + + +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'])) + + if hub_details and 'external_id' in hub_details.keys(): + 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}, + ) + + if len(files) > 1: + raise ExtensionHttpError.EXT_008( + 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" + + 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): + batches = client('pricing').batches.filter(id=batch_id).select('+stream.context') + if not batches: + raise ExtensionHttpError.EXT_007( + format_kwargs={'batch_id': batch_id}, + ) + + batch = batches[0] + + product_id = batch['stream']['context']['product']['id'] + + if product_id != deployment.product_id: + raise ExtensionHttpError.EXT_011( + format_kwargs={ + 'batch_id': batch_id, + 'b_product_id': product_id, + 'deployment_id': deployment.id, + 'd_product_id': deployment.product_id, + }, + ) + + return batch + + +def identify_reseller_id(client, batch, deployment): + marketplace_id = batch['stream']['context']['marketplace']['id'] + marketplace = client.marketplaces[marketplace_id].get() + + 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( + format_kwargs={ + 'hub_id': deployment.hub_id, + 'marketplace_id': marketplace_id, + 'batch_id': batch['id'], + }, + ) + + reseller_id = get_reseller_id(marketplace, deployment.hub_id) + + if not reseller_id: + raise ExtensionHttpError.EXT_010( + format_kwargs={ + 'hub_id': deployment.hub_id, + 'marketplace_id': marketplace_id, + }, + ) + + return reseller_id + + +def process_batch( + cbc_db, + 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, + }, + ) + + 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, + ) + + data_id = parsed_price['dataId'] + + cbc_service.prepare_price_proposal( + reseller_id, + parsed_price, + dataset['cost'], + dataset['price'], + dataset['msrp'], + dataset['effective_date'], + ) + + cbc_service.apply_prices( + reseller_id, + parsed_price, + dataset['cost'], + dataset['price'], + dataset['msrp'], + dataset['effective_date'], + file_name, + ) + + return data_id diff --git a/connect_ext_ppr/webapp.py b/connect_ext_ppr/webapp.py index 3785830..5b99b08 100644 --- a/connect_ext_ppr/webapp.py +++ b/connect_ext_ppr/webapp.py @@ -6,6 +6,7 @@ from typing import List from connect.client import ConnectClient +from connect.client.rql import R from connect.eaas.core.decorators import ( module_pages, proxied_connect_api, @@ -17,12 +18,19 @@ get_installation_client, ) from connect.eaas.core.extension import WebApplicationBase +from fastapi.responses import JSONResponse from fastapi import Depends, Request, Response, status from sqlalchemy import exists from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, Session -from connect_ext_ppr.db import create_db, get_db, VerboseBaseSession +from connect_ext_ppr.client.exception import ClientError +from connect_ext_ppr.db import ( + create_db, + get_cbc_extension_db, + get_db, + VerboseBaseSession, +) from connect_ext_ppr.errors import ExtensionHttpError from connect_ext_ppr.models.configuration import Configuration from connect_ext_ppr.models.deployment import Deployment, DeploymentRequest @@ -31,6 +39,8 @@ from connect_ext_ppr.models.replicas import Product from connect_ext_ppr.service import add_deployments from connect_ext_ppr.schemas import ( + BatchProcessResponseSchema, + BatchSchema, ConfigurationCreateSchema, ConfigurationSchema, DeploymentRequestSchema, @@ -38,6 +48,13 @@ HubSchema, ProductSchema, ) +from connect_ext_ppr.services.pricing import ( + fetch_and_validate_batch, + identify_marketplaces, + identify_reseller_id, + prepare_file, + process_batch, +) from connect_ext_ppr.utils import ( _get_extension_client, _get_installation, @@ -354,6 +371,73 @@ def list_hubs_by_product( reponse_list.append(HubSchema(id=hub['id'], name=hub['name'])) return reponse_list + @router.get( + '/deployments/{deployment_id}/pricing/batches', + summary='List Pricing Batch for Deployment', + response_model=List[BatchSchema], + ) + def get_deployment_batches( + self, + deployment_id: str, + db: VerboseBaseSession = Depends(get_db), + client: ConnectClient = Depends(get_installation_client), + installation: dict = Depends(get_installation), + ): + deployment = get_deployment_by_id(deployment_id, db, installation) + marketplace_ids = identify_marketplaces( + client, + deployment.hub_id, + ) + + batches = client('pricing').batches.filter( + R().stream.owner.id.eq(deployment.account_id), + R().stream.context.product.id.eq(deployment.product_id), + R().stream.context.marketplace.id.in_(marketplace_ids), + R().test.ne(True), + R().status.eq('published'), + ) + + 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), + ): + 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: + self.logger.exception(f'Error while uploading price file for {batch_id}') + JSONResponse(status_code=400, content=e.json) + @classmethod def on_startup(cls, logger, config): # When database schema is completely defined