From d454eb7bedadb23b0f322c8ef13d1c6aa9876076 Mon Sep 17 00:00:00 2001 From: Tarek Ahmed Date: Mon, 22 Sep 2025 14:59:53 +0300 Subject: [PATCH 1/3] Add v2 item groups endpoints, add tests --- constructor_io/modules/catalog.py | 139 +++++++----- tests/helpers/utils.py | 2 +- tests/modules/test_catalog_item_groups.py | 257 +++++++++++----------- 3 files changed, 218 insertions(+), 180 deletions(-) diff --git a/constructor_io/modules/catalog.py b/constructor_io/modules/catalog.py index f4efba8..0540064 100644 --- a/constructor_io/modules/catalog.py +++ b/constructor_io/modules/catalog.py @@ -77,6 +77,42 @@ def _create_query_params_for_items(parameters): return query_params +def _create_query_params_for_item_groups(parameters): + '''Create query params for item groups API''' + + query_params = {} + + if parameters: + item_group_id = parameters.get('item_group_id') + notification_email = parameters.get('notification_email') + force = parameters.get('force') + num_results_per_page = parameters.get('num_results_per_page') + page = parameters.get('page') + ids = parameters.get('ids') + offset = parameters.get('offset') + + if item_group_id: + query_params['item_group_id'] = item_group_id + + if ids: + query_params['id'] = ids + + if notification_email: + query_params['notification_email'] = notification_email + + if force: + query_params['force'] = force + + if num_results_per_page: + query_params['num_results_per_page'] = num_results_per_page + + if page: + query_params['page'] = page + elif offset: + query_params['offset'] = offset + + return query_params + def _create_catalog_url(path, options, additional_query_params): '''Create catalog API url''' @@ -124,7 +160,21 @@ def _create_item_groups_url(path, options, additional_query_params): query_params = clean_params(query_params) query_string = urlencode(query_params, doseq=True) - return f'{options.get("service_url")}/v1/{quote(path)}?{query_string}' + return f'{options.get("service_url")}/v2/{quote(path)}?{query_string}' + +def _create_item_group_url(path, options): + '''Create item group API url''' + + api_key = options.get('api_key') + + if not path or not isinstance(path, str): + raise ConstructorException('path is a required parameter of type string') + + query_params = { 'key': api_key } + query_params = clean_params(query_params) + query_string = urlencode(query_params, doseq=True) + + return f'{options.get("service_url")}/v2/{quote(path)}?{query_string}' class Catalog: '''Catalog Class''' @@ -464,15 +514,23 @@ def retrieve_variations(self, parameters=None): return json - def retrieve_item_groups(self, parameters=None): + def retrieve_item_group(self, parameters=None): ''' - Retrieve all item groups. + Retrieve an item group. - :param str parameters.section: The section to retrieve from + :param str parameters.item_group_id: item group ID to retrieve. ''' - query_params = _create_query_params_for_items(parameters) - request_url = _create_item_groups_url('item_groups', self.__options, query_params) + if not parameters: + parameters = {} + + item_group_id = parameters.get('item_group_id') + + if not item_group_id or not isinstance(item_group_id, str): + raise ConstructorException('item_group_id is a required parameter of type string') + + request_url = _create_item_group_url(f'item_groups/{item_group_id}', self.__options) + # API does not permit c param for retrieve_item_group endpoint requests = self.__options.get('requests') or r response = requests.get( @@ -488,23 +546,24 @@ def retrieve_item_groups(self, parameters=None): return json - def create_item_groups(self, parameters=None): + def retrieve_item_groups(self, parameters=None): ''' - Create new item groups. If the item groups already exist, they will be skipped. + Retrieve all item groups. - :param list parameters.item_groups: A list of item groups to create - :param str parameters.section: The section to update + :param list parameters.ids: A list of item group ID(s) to filter by. + :param int parameters.num_results_per_page: The number of results per page to return. Defaults to 20. Maximum value is 100 + :param int parameters.page: The page of results to return. Defaults to 1 + :param int parameters.offset: The number of results to skip from the beginning. Cannot be used together with page. ''' - query_params = _create_query_params_for_items(parameters) + query_params = _create_query_params_for_item_groups(parameters) request_url = _create_item_groups_url('item_groups', self.__options, query_params) requests = self.__options.get('requests') or r - response = requests.post( + response = requests.get( request_url, auth=create_auth_header(self.__options), - headers=create_request_headers(self.__options), - json={ 'item_groups': parameters.get('item_groups') } + headers=create_request_headers(self.__options) ) if not response.ok: @@ -516,15 +575,15 @@ def create_item_groups(self, parameters=None): def create_or_replace_item_groups(self, parameters=None): ''' - Create or replace item groups. If the item groups already exist, - they will be updated. If not, they will be created. - Existing item groups not sent in the request will be deleted. + Create item groups or replace the data of existing item groups. + Returns an identifier for a background task. - :param list parameters.item_groups: A list of item groups to create or replace - :param str parameters.section: The section to update + :param list parameters.item_groups: A list of item groups to create. Maximum length is 10000 + :param list parameters.force: A flag to process the catalog even if it will invalidate a large part of existing data. Defaults to False. + :param list parameters.notification_email: The email address to send a notification to if the task fails. ''' - query_params = _create_query_params_for_items(parameters) + query_params = _create_query_params_for_item_groups(parameters) request_url = _create_item_groups_url('item_groups', self.__options, query_params) requests = self.__options.get('requests') or r @@ -542,16 +601,18 @@ def create_or_replace_item_groups(self, parameters=None): return json - def create_or_update_item_groups(self, parameters=None): + def update_item_groups(self, parameters=None): ''' - Update item groups. If the item groups already exist, - they will be updated. If not, they will be created. + Update existing item groups data. + Data included in the request will be merged with data of the existing items groups. + Returns an identifier for a background task. - :param list parameters.item_groups: A list of item groups to create or update - :param str parameters.section: The section to update - ''' + :param list parameters.item_groups: A list of item groups to create. Maximum length is 10000 + :param list parameters.force: A flag to process the catalog even if it will invalidate a large part of existing data. Defaults to False. + :param list parameters.notification_email: The email address to send a notification to if the task fails. + ''' - query_params = _create_query_params_for_items(parameters) + query_params = _create_query_params_for_item_groups(parameters) request_url = _create_item_groups_url('item_groups', self.__options, query_params) requests = self.__options.get('requests') or r @@ -568,27 +629,3 @@ def create_or_update_item_groups(self, parameters=None): json = response.json() return json - - def delete_item_groups(self, parameters=None): - ''' - Delete all item groups. - - :param str parameters.section: The section to delete from - ''' - - query_params = _create_query_params_for_items(parameters) - request_url = _create_item_groups_url('item_groups', self.__options, query_params) - requests = self.__options.get('requests') or r - - response = requests.delete( - request_url, - auth=create_auth_header(self.__options), - headers=create_request_headers(self.__options) - ) - - if not response.ok: - throw_http_exception_from_response(response) - - json = response.json() - - return json diff --git a/tests/helpers/utils.py b/tests/helpers/utils.py index 68b32b1..70e1125 100644 --- a/tests/helpers/utils.py +++ b/tests/helpers/utils.py @@ -66,7 +66,7 @@ def create_mock_item_group(): 'id': item_group_id, 'name': name, 'data': data, - 'children': [] + 'parent_ids': [] } return item_group diff --git a/tests/modules/test_catalog_item_groups.py b/tests/modules/test_catalog_item_groups.py index 32ae4b8..66bf7a5 100644 --- a/tests/modules/test_catalog_item_groups.py +++ b/tests/modules/test_catalog_item_groups.py @@ -22,10 +22,12 @@ def slow_down_tests(): yield sleep(1) -def test_create_item_groups(): +def test_create_or_replace_item_groups(): '''Should create new item groups''' - catalog = ConstructorIO(VALID_OPTIONS).catalog + constructorIO = ConstructorIO(VALID_OPTIONS) + catalog = constructorIO.catalog + tasks = constructorIO.tasks item_groups = [ create_mock_item_group(), @@ -35,88 +37,43 @@ def test_create_item_groups(): 'item_groups': item_groups } - response = catalog.create_item_groups(parameters) + response = catalog.create_or_replace_item_groups(parameters) assert response is not None - stats = response['item_groups'] - expected_stats = { - 'processed': 2, - 'inserted': 2, - 'deleted': 0, - 'updated': 0 - } - - assert stats == expected_stats - - catalog.delete_item_groups() + task_id = response.get('task_id') + task = tasks.get_task(task_id) -def test_create_item_groups_validation(): - '''Should validate item_groups parameter requirements''' + assert task.get('status') in ['QUEUED', 'IN_PROGRESS', 'DONE'] - catalog = ConstructorIO(VALID_OPTIONS).catalog +def test_create_or_replace_item_groups_with_all_parameters(): + '''Should create new item groups with all parameters''' - # Test with missing item_groups parameter - with raises( - HttpException, - match=r'item_groups: none is not an allowed value' - ): - catalog.create_item_groups({}) - - # Test with empty item_groups array - with raises( - HttpException, - match=r'item_groups: ensure this value has at least 1 items' - ): - catalog.create_item_groups({'item_groups': []}) - - # Test with non-array item_groups - with raises( - HttpException, - match=r'item_groups: value is not a valid list' - ): - catalog.create_item_groups({'item_groups': 'not an array'}) - -def test_create_or_replace_item_groups(): - '''Should create or replace item groups''' - - catalog = ConstructorIO(VALID_OPTIONS).catalog + constructorIO = ConstructorIO(VALID_OPTIONS) + catalog = constructorIO.catalog + tasks = constructorIO.tasks - initial_item_groups = [ - create_mock_item_group(), - create_mock_item_group(), - ] - parameters = { - 'item_groups': initial_item_groups - } - - response = catalog.create_item_groups(parameters) - - updated_item_groups = [ - # Keep one existing item group but modify it - {**initial_item_groups[0], 'name': 'Updated Name'}, + item_groups = [ create_mock_item_group(), + create_mock_item_group() ] parameters = { - 'item_groups': updated_item_groups + 'item_groups': item_groups, + 'notification_email': 'test@constructor.io', + 'force': True, } response = catalog.create_or_replace_item_groups(parameters) assert response is not None - stats = response['item_groups'] - expected_stats = { - 'processed': 2, - 'inserted': 1, - 'updated': 1, - 'deleted': 1 - } + task_id = response.get('task_id') + task = tasks.get_task(task_id) - assert stats == expected_stats + assert task.get('status') in ['QUEUED', 'IN_PROGRESS', 'DONE'] def test_create_or_replace_item_groups_validation(): - '''Should validate item_groups parameter requirements for create_or_replace''' + '''Should validate item_groups parameter requirements''' catalog = ConstructorIO(VALID_OPTIONS).catalog @@ -141,99 +98,90 @@ def test_create_or_replace_item_groups_validation(): ): catalog.create_or_replace_item_groups({'item_groups': 'not an array'}) -def test_retrieve_item_groups(): - '''Should retrieve all item groups''' +def test_update_item_groups(): + '''Should update item groups''' - catalog = ConstructorIO(VALID_OPTIONS).catalog + constructorIO = ConstructorIO(VALID_OPTIONS) + catalog = constructorIO.catalog + tasks = constructorIO.tasks + + item_1 = create_mock_item_group() + item_2 = create_mock_item_group() item_groups = [ - create_mock_item_group(), - create_mock_item_group() + item_1, + item_2 ] - parameters = { + create_parameters = { 'item_groups': item_groups } - response = catalog.create_item_groups(parameters) - - assert response is not None - - retrieve_response = catalog.retrieve_item_groups() - - assert retrieve_response is not None - assert 'item_groups' in retrieve_response + create_response = catalog.create_or_replace_item_groups(create_parameters) - catalog.delete_item_groups() + sleep(2) + assert create_response is not None -def test_delete_item_groups(): - '''Should delete all item groups''' + item_1['name'] = 'Updated Item 1' + item_2['name'] = 'Updated Item 2' - catalog = ConstructorIO(VALID_OPTIONS).catalog - - item_groups = [ - create_mock_item_group(), - create_mock_item_group() - ] - parameters = { + update_parameters = { 'item_groups': item_groups } - response = catalog.create_item_groups(parameters) + update_response = catalog.update_item_groups(update_parameters) - assert response is not None + assert update_response is not None - delete_response = catalog.delete_item_groups() + task_id = update_response.get('task_id') + task = tasks.get_task(task_id) - assert delete_response is not None - assert 'message' in delete_response - expected_message = 'We\'ve started deleting all of your groups. This may take some time to complete.' - assert delete_response['message'] == expected_message + assert task.get('status') in ['QUEUED', 'IN_PROGRESS', 'DONE'] +def test_update_item_groups_with_all_parameters(): + '''Should update item groups with all parameters''' -def test_create_or_update_item_groups(): - '''Should create or update item groups''' + constructorIO = ConstructorIO(VALID_OPTIONS) + catalog = constructorIO.catalog + tasks = constructorIO.tasks - catalog = ConstructorIO(VALID_OPTIONS).catalog + item_1 = create_mock_item_group() + item_2 = create_mock_item_group() - initial_item_groups = [ - create_mock_item_group(), - create_mock_item_group(), + item_groups = [ + item_1, + item_2 ] - parameters = { - 'item_groups': initial_item_groups + create_parameters = { + 'item_groups': item_groups } - response = catalog.create_item_groups(parameters) + create_response = catalog.create_or_replace_item_groups(create_parameters) - updated_item_groups = [ - # Keep one existing item group but modify it - {**initial_item_groups[0], 'name': 'Updated Name'}, - create_mock_item_group(), - ] - parameters = { - 'item_groups': updated_item_groups - } + sleep(2) - response = catalog.create_or_update_item_groups(parameters) + assert create_response is not None - assert response is not None + item_1['name'] = 'Updated Item 1' + item_2['name'] = 'Updated Item 2' - stats = response['item_groups'] - expected_stats = { - 'processed': 2, - 'inserted': 1, - 'updated': 1, - 'deleted': 0 + update_parameters = { + 'item_groups': item_groups, + 'notification_email': 'test@constructor.io', + 'force': True, } - assert stats == expected_stats + update_response = catalog.update_item_groups(update_parameters) - catalog.delete_item_groups() + assert update_response is not None + task_id = update_response.get('task_id') + task = tasks.get_task(task_id) -def test_create_or_update_item_groups_validation(): - '''Should validate item_groups parameter requirements for create_or_update''' + assert task.get('status') in ['QUEUED', 'IN_PROGRESS', 'DONE'] + +def test_update_item_groups_validation(): + '''Should validate item_groups parameter requirements''' catalog = ConstructorIO(VALID_OPTIONS).catalog @@ -242,18 +190,71 @@ def test_create_or_update_item_groups_validation(): HttpException, match=r'item_groups: none is not an allowed value' ): - catalog.create_or_update_item_groups({}) + catalog.update_item_groups({}) # Test with empty item_groups array with raises( HttpException, match=r'item_groups: ensure this value has at least 1 items' ): - catalog.create_or_update_item_groups({'item_groups': []}) + catalog.update_item_groups({'item_groups': []}) # Test with non-array item_groups with raises( HttpException, match=r'item_groups: value is not a valid list' ): - catalog.create_or_update_item_groups({'item_groups': 'not an array'}) + catalog.update_item_groups({'item_groups': 'not an array'}) + +def test_retrieve_item_group(): + '''Should retrieve item group''' + + constructorIO = ConstructorIO(VALID_OPTIONS) + catalog = constructorIO.catalog + + item_group = create_mock_item_group() + + item_groups = [item_group] + parameters = { + 'item_groups': item_groups + } + + response = catalog.create_or_replace_item_groups(parameters) + + assert response is not None + + sleep(2) + + retrieve_response = catalog.retrieve_item_group({ + 'item_group_id': item_group['id'] + }) + + assert retrieve_response is not None + assert retrieve_response['id'] == item_group['id'] + +def test_retrieve_item_groups(): + '''Should retrieve all item groups''' + + constructorIO = ConstructorIO(VALID_OPTIONS) + catalog = constructorIO.catalog + + item_groups = [ + create_mock_item_group(), + create_mock_item_group() + ] + parameters = { + 'item_groups': item_groups + } + + response = catalog.create_or_replace_item_groups(parameters) + + assert response is not None + + sleep(2) + + retrieve_response = catalog.retrieve_item_groups() + + assert retrieve_response is not None + assert 'item_groups' in retrieve_response + assert len(retrieve_response['item_groups']) > 0 + From a9e5e0d9f3bf0c41082d1fdc45e21bf66405bc41 Mon Sep 17 00:00:00 2001 From: Tarek Ahmed Date: Mon, 22 Sep 2025 16:13:22 +0300 Subject: [PATCH 2/3] Increase sleep, check on item group ids, lint --- tests/modules/test_catalog_item_groups.py | 42 ++++++++++++----------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/tests/modules/test_catalog_item_groups.py b/tests/modules/test_catalog_item_groups.py index 66bf7a5..7677b95 100644 --- a/tests/modules/test_catalog_item_groups.py +++ b/tests/modules/test_catalog_item_groups.py @@ -25,9 +25,9 @@ def slow_down_tests(): def test_create_or_replace_item_groups(): '''Should create new item groups''' - constructorIO = ConstructorIO(VALID_OPTIONS) - catalog = constructorIO.catalog - tasks = constructorIO.tasks + constructor_io = ConstructorIO(VALID_OPTIONS) + catalog = constructor_io.catalog + tasks = constructor_io.tasks item_groups = [ create_mock_item_group(), @@ -49,9 +49,9 @@ def test_create_or_replace_item_groups(): def test_create_or_replace_item_groups_with_all_parameters(): '''Should create new item groups with all parameters''' - constructorIO = ConstructorIO(VALID_OPTIONS) - catalog = constructorIO.catalog - tasks = constructorIO.tasks + constructor_io = ConstructorIO(VALID_OPTIONS) + catalog = constructor_io.catalog + tasks = constructor_io.tasks item_groups = [ create_mock_item_group(), @@ -101,9 +101,9 @@ def test_create_or_replace_item_groups_validation(): def test_update_item_groups(): '''Should update item groups''' - constructorIO = ConstructorIO(VALID_OPTIONS) - catalog = constructorIO.catalog - tasks = constructorIO.tasks + constructor_io = ConstructorIO(VALID_OPTIONS) + catalog = constructor_io.catalog + tasks = constructor_io.tasks item_1 = create_mock_item_group() item_2 = create_mock_item_group() @@ -141,9 +141,9 @@ def test_update_item_groups(): def test_update_item_groups_with_all_parameters(): '''Should update item groups with all parameters''' - constructorIO = ConstructorIO(VALID_OPTIONS) - catalog = constructorIO.catalog - tasks = constructorIO.tasks + constructor_io = ConstructorIO(VALID_OPTIONS) + catalog = constructor_io.catalog + tasks = constructor_io.tasks item_1 = create_mock_item_group() item_2 = create_mock_item_group() @@ -209,8 +209,8 @@ def test_update_item_groups_validation(): def test_retrieve_item_group(): '''Should retrieve item group''' - constructorIO = ConstructorIO(VALID_OPTIONS) - catalog = constructorIO.catalog + constructor_io = ConstructorIO(VALID_OPTIONS) + catalog = constructor_io.catalog item_group = create_mock_item_group() @@ -223,7 +223,7 @@ def test_retrieve_item_group(): assert response is not None - sleep(2) + sleep(3) retrieve_response = catalog.retrieve_item_group({ 'item_group_id': item_group['id'] @@ -235,8 +235,8 @@ def test_retrieve_item_group(): def test_retrieve_item_groups(): '''Should retrieve all item groups''' - constructorIO = ConstructorIO(VALID_OPTIONS) - catalog = constructorIO.catalog + constructor_io = ConstructorIO(VALID_OPTIONS) + catalog = constructor_io.catalog item_groups = [ create_mock_item_group(), @@ -250,11 +250,13 @@ def test_retrieve_item_groups(): assert response is not None - sleep(2) + sleep(3) retrieve_response = catalog.retrieve_item_groups() + ids1 = {item_group['id'] for item_group in item_groups} + ids2 = {item_group['id'] for item_group in retrieve_response['item_groups']} + assert retrieve_response is not None assert 'item_groups' in retrieve_response - assert len(retrieve_response['item_groups']) > 0 - + assert ids1.issubset(ids2) From 6b945e33227baaf35d65d969b26a8a7a18454d2f Mon Sep 17 00:00:00 2001 From: Tarek Ahmed Date: Mon, 22 Sep 2025 17:15:18 +0300 Subject: [PATCH 3/3] Version bump --- constructor_io/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constructor_io/__init__.py b/constructor_io/__init__.py index b91cbed..4b81aeb 100644 --- a/constructor_io/__init__.py +++ b/constructor_io/__init__.py @@ -1,2 +1,2 @@ '''Version File''' -__version__ = "1.9.0" +__version__ = "2.0.0"