From 3205258efd286c7229231a4b9c7c2313281f28ab Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Mon, 28 Sep 2020 16:37:31 +0200 Subject: [PATCH 1/2] fix sync product cmd in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 96a3c7c6..c45cd338 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ This command will generate a excel file named PRD-000-000-000.xlsx in the curren To synchronize a product from Excel run: ``` - $ ccli product sync --in my_products.xlsx + $ ccli product sync my_products.xlsx ``` From 81528ea001b7155e20fc01f5ab1c7d41786cc687 Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Fri, 2 Oct 2020 17:13:15 +0200 Subject: [PATCH 2/2] Fix sync based on new excel format Create uom if it doesn't exist Silent mode --- cnctcli/actions/products/__init__.py | 2 +- cnctcli/actions/products/constants.py | 16 +- cnctcli/actions/products/export.py | 54 ++++-- cnctcli/actions/products/sync.py | 231 ++++++++++++++++---------- cnctcli/api/products.py | 34 ++++ cnctcli/ccli.py | 11 +- cnctcli/commands/account.py | 20 ++- cnctcli/commands/product.py | 91 +++++----- cnctcli/config.py | 9 + 9 files changed, 308 insertions(+), 160 deletions(-) diff --git a/cnctcli/actions/products/__init__.py b/cnctcli/actions/products/__init__.py index a35e35f7..54118261 100644 --- a/cnctcli/actions/products/__init__.py +++ b/cnctcli/actions/products/__init__.py @@ -4,4 +4,4 @@ # Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. from cnctcli.actions.products.export import dump_product # noqa: F401 -from cnctcli.actions.products.sync import sync_product, validate_input_file # noqa: F401 +from cnctcli.actions.products.sync import ProductSynchronizer # noqa: F401 diff --git a/cnctcli/actions/products/constants.py b/cnctcli/actions/products/constants.py index 3ce50920..b6564577 100644 --- a/cnctcli/actions/products/constants.py +++ b/cnctcli/actions/products/constants.py @@ -4,12 +4,16 @@ # Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. ITEMS_COLS_HEADERS = { - 'A': 'Name', + 'A': 'ID', 'B': 'MPN', - 'C': 'Billing Period', - 'D': 'Reservation', - 'E': 'Description', - 'F': 'Yearly Commitment', + 'C': 'Name', + 'D': 'Description', + 'E': 'Type', + 'F': 'Precision', 'G': 'Unit', - 'H': 'Connect Item ID', + 'H': 'Billing Period', + 'I': 'Commitment', + 'J': 'Status', + 'K': 'Created', + 'L': 'Modified', } diff --git a/cnctcli/actions/products/export.py b/cnctcli/actions/products/export.py index 6e126611..321b60cb 100644 --- a/cnctcli/actions/products/export.py +++ b/cnctcli/actions/products/export.py @@ -46,7 +46,7 @@ def _setup_cover_sheet(ws, product): def _setup_items_header(ws): color = Color('d3d3d3') fill = PatternFill('solid', color) - cels = ws['A1': 'H1'] + cels = ws['A1': 'L1'] for cel in cels[0]: ws.column_dimensions[cel.column_letter].width = 25 ws.column_dimensions[cel.column_letter].auto_size = True @@ -54,19 +54,49 @@ def _setup_items_header(ws): cel.value = ITEMS_COLS_HEADERS[cel.column_letter] +def _calculate_commitment(item): + period = item.get('period') + if not period: + return '-' + commitment = item.get('commitment') + if not commitment: + return '-' + count = commitment['count'] + if count == 1: + return '-' + + multiplier = commitment['multiplier'] + + if multiplier == 'billing_period': + if period == 'monthly': + years = count // 12 + return '{} year{}'.format( + years, + 's' if years > 1 else '', + ) + else: + return '{} years'.format(count) + + # One-time + return '-' + + def _fill_item_row(ws, row_idx, item): - ws.cell(row_idx, 1, value=item['display_name']) + ws.cell(row_idx, 1, value=item['id']) ws.cell(row_idx, 2, value=item['mpn']) - ws.cell(row_idx, 3, value=item['period']) - ws.cell(row_idx, 4, value=item['type'] == 'reservation') - ws.cell(row_idx, 5, value=item['description']) - commitment = item['commitment']['count'] == 12 if item.get('commitment') else False - ws.cell(row_idx, 6, value=commitment) + ws.cell(row_idx, 3, value=item['display_name']) + ws.cell(row_idx, 4, value=item['description']) + ws.cell(row_idx, 5, value=item['type']) + ws.cell(row_idx, 6, value=item['precision']) ws.cell(row_idx, 7, value=item['unit']['unit']) - ws.cell(row_idx, 8, value=item['id']) + ws.cell(row_idx, 8, value=item.get('period', 'monthly')) + ws.cell(row_idx, 9, value=_calculate_commitment(item)) + ws.cell(row_idx, 10, value=item['status']) + ws.cell(row_idx, 11, value=item['events']['created']['at']) + ws.cell(row_idx, 12, value=item['events'].get('updated', {}).get('at')) -def _dump_items(ws, api_url, api_key, product_id): +def _dump_items(ws, api_url, api_key, product_id, silent): _setup_items_header(ws) processed_items = 0 @@ -81,7 +111,7 @@ def _dump_items(ws, api_url, api_key, product_id): items = iter(items) - progress = trange(0, count, position=0) + progress = trange(0, count, position=0, disable=silent) while True: try: @@ -100,7 +130,7 @@ def _dump_items(ws, api_url, api_key, product_id): break -def dump_product(api_url, api_key, product_id, output_file): +def dump_product(api_url, api_key, product_id, output_file, silent): if not output_file: output_file = os.path.abspath( os.path.join('.', f'{product_id}.xlsx'), @@ -110,7 +140,7 @@ def dump_product(api_url, api_key, product_id, output_file): wb = Workbook() _setup_cover_sheet(wb.active, product) - _dump_items(wb.create_sheet('product_items'), api_url, api_key, product_id) + _dump_items(wb.create_sheet('product_items'), api_url, api_key, product_id, silent) wb.save(output_file) return output_file diff --git a/cnctcli/actions/products/sync.py b/cnctcli/actions/products/sync.py index 2da06f60..9d658b19 100644 --- a/cnctcli/actions/products/sync.py +++ b/cnctcli/actions/products/sync.py @@ -15,108 +15,159 @@ from cnctcli.actions.products.constants import ITEMS_COLS_HEADERS from cnctcli.api.products import ( create_item, + create_unit, get_item, get_item_by_mpn, get_product, + get_units, update_item, ) -def _open_workbook(input_file): - try: - return load_workbook(input_file) - except InvalidFileException as ife: - raise ClickException(str(ife)) - except BadZipFile: - raise ClickException(f'{input_file} is not a valid xlsx file.') - - -def _validate_item_sheet(ws): - cels = ws['A1': 'H1'] - for cel in cels[0]: - if cel.value != ITEMS_COLS_HEADERS[cel.column_letter]: - raise ClickException( - f'Invalid input file: column {cel.column_letter} ' - f'must be {ITEMS_COLS_HEADERS[cel.column_letter]}' - ) - +class ProductSynchronizer: + def __init__(self, endpoint, api_key, silent): + self._endpoint = endpoint + self._api_key = api_key + self._silent = silent + self._units = get_units(self._endpoint, self._api_key) + self._product_id = None + self._wb = None + + def _open_workbook(self, input_file): + try: + self._wb = load_workbook(input_file) + except InvalidFileException as ife: + raise ClickException(str(ife)) + except BadZipFile: + raise ClickException(f'{input_file} is not a valid xlsx file.') + + def _validate_item_sheet(self, ws): + cels = ws['A1': 'H1'] + for cel in cels[0]: + if cel.value != ITEMS_COLS_HEADERS[cel.column_letter]: + raise ClickException( + f'Invalid input file: column {cel.column_letter} ' + f'must be {ITEMS_COLS_HEADERS[cel.column_letter]}' + ) + + def _get_commitment_count(self, data): + period = data[7] + if period == 'onetime': + return 1 + if data[8] == '-': + return 1 + try: + years, _ = data[8].split() + years = int(years) + if period == 'monthly': + return years * 12 + return years + except: # noqa + return 1 + + def _get_or_create_unit(self, data): + for unit in self._units: + if unit['id'] == data[6]: + return unit['id'] + if unit['type'] == data[4] and unit['description'] == data[6]: + return unit['id'] + + created = create_unit( + self._endpoint, + self._api_key, + { + 'description': data[6], + 'type': data[4], + 'unit': 'unit' if data[4] == 'reservation' else 'unit-h' + }, + ) + return created['id'] -def _get_item_payload(data): - if data[3] is True: - # reservation - period = 'monthly' - if data[2].lower() == 'yearly': - period = 'yearly' - if data[2].lower() == 'onetime': - period = 'onetime' - return { - 'name': data[0], - 'mpn': data[1], - 'description': data[4], - 'ui': {'visibility': True}, + def _get_item_payload(self, data): + commitment = { 'commitment': { - 'count': 12 if data[5] is True else 1, + 'count': self._get_commitment_count(data), }, - 'unit': {'id': data[6]}, - 'type': 'reservation', - 'period': period, } - else: - # PPU - return { - 'name': data[0], + payload = { 'mpn': data[1], - 'description': data[4], + 'name': data[2], + 'description': data[3], + 'type': data[4], + 'precision': data[5], + 'unit': {'id': self._get_or_create_unit(data)}, + 'period': data[7], 'ui': {'visibility': True}, - 'unit': {'id': data[6]}, - 'type': 'ppu', - 'precision': 'decimal(2)', } - - -def validate_input_file(api_url, api_key, input_file): - wb = _open_workbook(input_file) - if len(wb.sheetnames) != 2: - raise ClickException('Invalid input file: not enough sheets.') - product_id = wb.active['B5'].value - get_product(api_url, api_key, product_id) - - ws = wb[wb.sheetnames[1]] - _validate_item_sheet(ws) - - return product_id, wb - - -def sync_product(api_url, api_key, product_id, wb): - ws = wb[wb.sheetnames[1]] - row_indexes = trange(2, ws.max_row + 1, position=0) - for row_idx in row_indexes: - data = [ws.cell(row_idx, col_idx).value for col_idx in range(1, 9)] - row_indexes.set_description(f'Processing item {data[7] or data[1]}') - if data[7]: - item = get_item(api_url, api_key, product_id, data[7]) - elif data[1]: - item = get_item_by_mpn(api_url, api_key, product_id, data[1]) - else: - raise ClickException( - f'Invalid item at row {row_idx}: ' - 'one between MPN or Connect Item ID must be specified.' + if data[4] == 'reservation': + payload.update(commitment) + + return payload + + def _update_sheet_row(self, ws, row_idx, item): + ws.cell(row_idx, 1, value=item['id']) + ws.cell(row_idx, 10, value=item['status']) + ws.cell(row_idx, 11, value=item['events']['created']['at']) + ws.cell(row_idx, 12, value=item['events'].get('updated', {}).get('at')) + + def validate_input_file(self, input_file): + self._open_workbook(input_file) + if len(self._wb.sheetnames) != 2: + raise ClickException('Invalid input file: not enough sheets.') + ws = self._wb[self._wb.sheetnames[0]] + product_id = ws['B5'].value + get_product(self._endpoint, self._api_key, product_id) + ws = self._wb[self._wb.sheetnames[1]] + self._validate_item_sheet(ws) + + self._product_id = product_id + return self._product_id + + def sync_product(self): + ws = self._wb[self._wb.sheetnames[1]] + row_indexes = trange(2, ws.max_row + 1, position=0, disable=self._silent) + for row_idx in row_indexes: + data = [ws.cell(row_idx, col_idx).value for col_idx in range(1, 13)] + row_indexes.set_description(f'Processing item {data[0] or data[1]}') + if data[0]: + item = get_item(self._endpoint, self._api_key, self._product_id, data[0]) + elif data[1]: + item = get_item_by_mpn(self._endpoint, self._api_key, self._product_id, data[1]) + else: + raise ClickException( + f'Invalid item at row {row_idx}: ' + 'one between MPN or Connect Item ID must be specified.' + ) + if item: + row_indexes.set_description(f"Updating item {item['id']}") + if item['status'] == 'published': + payload = { + 'name': data[2], + 'mpn': data[1], + 'description': data[3], + 'ui': {'visibility': True}, + } + else: + payload = self._get_item_payload(data) + if item['type'] == 'ppu': + del payload['period'] + update_item( + self._endpoint, + self._api_key, + self._product_id, + item['id'], + payload, + ) + self._update_sheet_row(ws, row_idx, item) + continue + row_indexes.set_description(f"Creating item {data[1]}") + item = create_item( + self._endpoint, + self._api_key, + self._product_id, + self._get_item_payload(data), ) - if item: - row_indexes.set_description(f"Updating item {item['id']}") - update_item( - api_url, - api_key, - product_id, - item['id'], - { - 'name': data[0], - 'mpn': data[1], - 'description': data[4], - 'ui': {'visibility': True}, - }, - ) - continue - row_indexes.set_description(f"Creating item {data[1]}") - item = create_item(api_url, api_key, product_id, _get_item_payload(data)) - ws.cell(row_idx, 8, value=item['id']) + self._update_sheet_row(ws, row_idx, item) + + def save(self, output_file): + self._wb.save(output_file) diff --git a/cnctcli/api/products.py b/cnctcli/api/products.py index f772a728..3ff7562e 100644 --- a/cnctcli/api/products.py +++ b/cnctcli/api/products.py @@ -9,6 +9,40 @@ ) +def get_units(endpoint, api_key): + results = [] + + has_more = True + limit = 100 + offset = 0 + + while has_more: + res = requests.get( + f'{endpoint}/settings/units', + params={'limit': limit, 'offset': offset}, + headers=get_headers(api_key), + ) + if res.status_code == 200: + page = res.json() + results.extend(page) + has_more = len(page) == 100 + continue + handle_http_error(res) + return results + + +def create_unit(endpoint, api_key, data): + res = requests.post( + f'{endpoint}/settings/units', + headers=get_headers(api_key), + json=data, + ) + if res.status_code == 201: + return res.json() + + handle_http_error(res) + + def get_products(endpoint, api_key, query, limit, offset): url = f'{endpoint}/products' if query: diff --git a/cnctcli/ccli.py b/cnctcli/ccli.py index 022cbddd..9653e342 100644 --- a/cnctcli/ccli.py +++ b/cnctcli/ccli.py @@ -27,13 +27,20 @@ @click.option('-c', '--config-dir', default=os.path.join(os.path.expanduser('~'), '.ccli'), type=click.Path(file_okay=False), - help='set the config directory') + help='set the config directory.') +@click.option( + '-s', + '--silent', + is_flag=True, + help='Prevent the output of informational messages.', +) @pass_config -def cli(config, config_dir): +def cli(config, config_dir, silent): """CloudBlue Connect Command Line Interface""" if not os.path.exists(config_dir): os.makedirs(config_dir) config.load(config_dir) + config.silent = silent cli.add_command(grp_account) diff --git a/cnctcli/commands/account.py b/cnctcli/commands/account.py index b9a38b27..1a0c54a4 100644 --- a/cnctcli/commands/account.py +++ b/cnctcli/commands/account.py @@ -34,9 +34,10 @@ def grp_account(): @pass_config def cmd_add_account(config, api_key, endpoint): account_id, name = add_account(config, api_key, endpoint) - click.echo( - click.style(f'New account added: {account_id} - {name}', fg='green') - ) + if not config.silent: + click.echo( + click.style(f'New account added: {account_id} - {name}', fg='green') + ) @grp_account.command( @@ -65,12 +66,13 @@ def cmd_list_account(config): @pass_config def cmd_activate_account(config, id): acc = activate_account(config, id) - click.echo( - click.style( - f'Current active account is: {acc.id} - {acc.name}', - fg='green', - ), - ) + if not config.silent: + click.echo( + click.style( + f'Current active account is: {acc.id} - {acc.name}', + fg='green', + ), + ) @grp_account.command( diff --git a/cnctcli/commands/product.py b/cnctcli/commands/product.py index fa7a49e9..b8d83fab 100644 --- a/cnctcli/commands/product.py +++ b/cnctcli/commands/product.py @@ -5,7 +5,7 @@ import click -from cnctcli.actions.products import dump_product, sync_product, validate_input_file +from cnctcli.actions.products import ProductSynchronizer, dump_product from cnctcli.api.products import get_products from cnctcli.commands.utils import continue_or_quit from cnctcli.config import pass_config @@ -24,26 +24,34 @@ def grp_product(): '--query', '-q', 'query', - help='RQL query expression', + help='RQL query expression.', ) @click.option( '--page-size', '-p', 'page_size', type=int, - help='Number of products per page', + help='Number of products per page.', default=25, ) +@click.option( + '--always-continue', + '-c', + 'always_continue', + is_flag=True, + help='Do not prompt to continue.', +) @pass_config -def cmd_list_products(config, query, page_size): +def cmd_list_products(config, query, page_size, always_continue): acc_id = config.active.id acc_name = config.active.name - click.echo( - click.style( - f'Current active account: {acc_id} - {acc_name}\n', - fg='blue', + if not config.silent: + click.echo( + click.style( + f'Current active account: {acc_id} - {acc_name}\n', + fg='blue', + ) ) - ) offset = 0 has_more = True while has_more: @@ -61,8 +69,9 @@ def cmd_list_products(config, query, page_size): click.echo( f"{prod['id']} - {prod['name']}" ) - if not continue_or_quit(): - return + if not always_continue: + if not continue_or_quit(): + return has_more = len(products) == page_size offset += page_size @@ -86,24 +95,27 @@ def cmd_dump_products(config, product_id, output_file): config.validate() acc_id = config.active.id acc_name = config.active.name - click.echo( - click.style( - f'Current active account: {acc_id} - {acc_name}\n', - fg='blue', + if not config.silent: + click.echo( + click.style( + f'Current active account: {acc_id} - {acc_name}\n', + fg='blue', + ) ) - ) outfile = dump_product( config.active.endpoint, config.active.api_key, product_id, output_file, + config.silent, ) - click.echo( - click.style( - f'\nThe product {product_id} has been successfully exported to {outfile}.', - fg='green', + if not config.silent: + click.echo( + click.style( + f'\nThe product {product_id} has been successfully exported to {outfile}.', + fg='green', + ) ) - ) @grp_product.command( @@ -124,17 +136,19 @@ def cmd_sync_products(config, input_file, yes): config.validate() acc_id = config.active.id acc_name = config.active.name - click.echo( - click.style( - f'Current active account: {acc_id} - {acc_name}\n', - fg='blue', + if not config.silent: + click.echo( + click.style( + f'Current active account: {acc_id} - {acc_name}\n', + fg='blue', + ) ) - ) - product_id, wb = validate_input_file( + synchronizer = ProductSynchronizer( config.active.endpoint, config.active.api_key, - input_file, + config.silent, ) + product_id = synchronizer.validate_input_file(input_file) if not yes: click.confirm( @@ -143,18 +157,15 @@ def cmd_sync_products(config, input_file, yes): abort=True, ) click.echo('') - sync_product( - config.active.endpoint, - config.active.api_key, - product_id, - wb, - ) - wb.save(input_file) + synchronizer.sync_product() + + synchronizer.save(input_file) - click.echo( - click.style( - f'\nThe product {product_id} has been successfully synchronized.', - fg='green', + if not config.silent: + click.echo( + click.style( + f'\nThe product {product_id} has been successfully synchronized.', + fg='green', + ) ) - ) diff --git a/cnctcli/config.py b/cnctcli/config.py index eed107e5..0500d058 100644 --- a/cnctcli/config.py +++ b/cnctcli/config.py @@ -26,6 +26,7 @@ class Config(object): def __init__(self): self._config_path = None self._active = None + self._silent = True self._accounts = {} def add_account(self, id, name, api_key, endpoint=DEFAULT_ENDPOINT): @@ -41,6 +42,14 @@ def active(self): def accounts(self): return self._accounts + @property + def silent(self): + return self._silent + + @silent.setter + def silent(self, val): + self._silent = val + def activate(self, id): account = self._accounts.get(id) if account: