From a1e19dae48a4a4e8638d1f8ec4b6a9b98ff2180b Mon Sep 17 00:00:00 2001 From: Hrishabh Tiwari Date: Fri, 19 Jul 2024 13:09:52 +0530 Subject: [PATCH 1/4] add support for code prepending in CATEGORY --- apps/mappings/imports/modules/categories.py | 78 +++++++++- apps/mappings/imports/queues.py | 4 +- apps/sage300/helpers.py | 3 +- apps/sage300/utils.py | 11 +- .../test_imports/test_modules/conftest.py | 25 +++ .../test_imports/test_modules/fixtures.py | 17 ++ .../test_modules/test_categories.py | 145 +++++++++++++++++- 7 files changed, 274 insertions(+), 9 deletions(-) diff --git a/apps/mappings/imports/modules/categories.py b/apps/mappings/imports/modules/categories.py index 58630039..7922a645 100644 --- a/apps/mappings/imports/modules/categories.py +++ b/apps/mappings/imports/modules/categories.py @@ -1,7 +1,14 @@ +import logging from datetime import datetime -from typing import List +from typing import List, Dict +from apps.workspaces.models import ImportSetting, FyleCredential from apps.mappings.imports.modules.base import Base -from fyle_accounting_mappings.models import DestinationAttribute, CategoryMapping +from apps.mappings.helpers import format_attribute_name +from fyle_integrations_platform_connector import PlatformConnector +from fyle_accounting_mappings.models import DestinationAttribute, ExpenseAttribute, CategoryMapping + +logger = logging.getLogger(__name__) +logger.level = logging.INFO class Category(Base): @@ -82,3 +89,70 @@ def create_mappings(self): self.destination_field, self.workspace_id, ) + + +def disable_categories(workspace_id: int, categories_to_disable: Dict): + """ + categories_to_disable object format: + { + 'destination_id': { + 'value': 'old_category_name', + 'updated_value': 'new_category_name', + 'code': 'old_code', + 'update_code': 'new_code' ---- if the code is updated else same as code + } + } + """ + fyle_credentials = FyleCredential.objects.get(workspace_id=workspace_id) + platform = PlatformConnector(fyle_credentials=fyle_credentials) + + use_code_in_naming = ImportSetting.objects.filter(workspace_id=workspace_id, import_code_fields__contains=['ACCOUNT']).first() + category_account_mapping = CategoryMapping.objects.filter( + workspace_id=workspace_id, + destination_account__destination_id__in=categories_to_disable.keys() + ) + + logger.info(f"Deleting Category-Account Mappings | WORKSPACE_ID: {workspace_id} | COUNT: {category_account_mapping.count()}") + category_account_mapping.delete() + + category_values = [] + for category_map in categories_to_disable.values(): + category_name = format_attribute_name(use_code_in_naming=use_code_in_naming, attribute_name=category_map['value'], attribute_code=category_map['code']) + category_values.append(category_name) + + filters = { + 'workspace_id': workspace_id, + 'attribute_type': 'CATEGORY', + 'value__in': category_values, + 'active': True + } + + # Expense attribute value map is as follows: {old_category_name: destination_id} + expense_attribute_value_map = {} + for k, v in categories_to_disable.items(): + category_name = format_attribute_name(use_code_in_naming=use_code_in_naming, attribute_name=v['value'], attribute_code=v['code']) + expense_attribute_value_map[category_name] = k + + expense_attributes = ExpenseAttribute.objects.filter(**filters) + + bulk_payload = [] + for expense_attribute in expense_attributes: + code = expense_attribute_value_map.get(expense_attribute.value, None) + if code: + payload = { + 'name': expense_attribute.value, + 'code': code, + 'is_enabled': False, + 'id': expense_attribute.source_id + } + bulk_payload.append(payload) + else: + logger.error(f"Category not found in categories_to_disable: {expense_attribute.value}") + + if bulk_payload: + logger.info(f"Disabling Category in Fyle | WORKSPACE_ID: {workspace_id} | COUNT: {len(bulk_payload)}") + platform.categories.post_bulk(bulk_payload) + else: + logger.info(f"No Categories to Disable in Fyle | WORKSPACE_ID: {workspace_id}") + + return bulk_payload diff --git a/apps/mappings/imports/queues.py b/apps/mappings/imports/queues.py index 64e30159..bc739488 100644 --- a/apps/mappings/imports/queues.py +++ b/apps/mappings/imports/queues.py @@ -45,7 +45,9 @@ def chain_import_fields_to_fyle(workspace_id): 'apps.mappings.imports.tasks.trigger_import_via_schedule', workspace_id, 'ACCOUNT', - 'CATEGORY' + 'CATEGORY', + False, + True if 'ACCOUNT' in import_code_fields else False ) if import_settings.import_vendors_as_merchants: diff --git a/apps/sage300/helpers.py b/apps/sage300/helpers.py index 79fa70f3..ae49d751 100644 --- a/apps/sage300/helpers.py +++ b/apps/sage300/helpers.py @@ -135,11 +135,10 @@ def disable_projects(workspace_id: int, projects_to_disable: Dict): 'is_enabled': False, 'id': expense_attribute.source_id } + bulk_payload.append(payload) else: logger.error(f"Project with value {expense_attribute.value} not found | WORKSPACE_ID: {workspace_id}") - bulk_payload.append(payload) - if bulk_payload: logger.info(f"Disabling Projects in Fyle | WORKSPACE_ID: {workspace_id} | COUNT: {len(bulk_payload)}") platform.projects.post_bulk(bulk_payload) diff --git a/apps/sage300/utils.py b/apps/sage300/utils.py index def534cf..79dee70e 100644 --- a/apps/sage300/utils.py +++ b/apps/sage300/utils.py @@ -11,6 +11,12 @@ logger.level = logging.INFO +ATTRIBUTE_CALLBACK_MAP = { + 'JOB': 'apps.sage300.helpers.disable_projects', + 'ACCOUNT': 'apps.mappings.imports.modules.categories.disable_categories', +} + + class SageDesktopConnector: """ Sage300 utility functions for syncing data from Sage Desktop SDK to your application @@ -144,14 +150,13 @@ def _sync_data(self, data_gen, attribute_type, display_name, workspace_id, field if destination_attr: destination_attributes.append(destination_attr) - if attribute_type == 'JOB': - project_disable_callback_path = 'apps.sage300.helpers.disable_projects' + if attribute_type in ATTRIBUTE_CALLBACK_MAP.keys(): DestinationAttribute.bulk_create_or_update_destination_attributes( destination_attributes, attribute_type, workspace_id, True, - attribute_disable_callback_path=project_disable_callback_path + attribute_disable_callback_path=ATTRIBUTE_CALLBACK_MAP[attribute_type] ) else: DestinationAttribute.bulk_create_or_update_destination_attributes( diff --git a/tests/test_mappings/test_imports/test_modules/conftest.py b/tests/test_mappings/test_imports/test_modules/conftest.py index 8b5ae2d9..50e63b69 100644 --- a/tests/test_mappings/test_imports/test_modules/conftest.py +++ b/tests/test_mappings/test_imports/test_modules/conftest.py @@ -260,3 +260,28 @@ def add_expense_destination_attributes_2(): }, active=True ) + + +@pytest.fixture() +@pytest.mark.django_db(databases=['default']) +def add_expense_destination_attributes_3(): + ExpenseAttribute.objects.create( + workspace_id=1, + attribute_type='CATEGORY', + display_name='Category', + value="123 Sage300", + source_id='10095', + detail='Merchant - Platform APIs, Id - 10085', + active=True + ) + + DestinationAttribute.objects.create( + workspace_id=1, + attribute_type='ACCOUNT', + display_name='Account', + value="Sage300", + destination_id='10085', + detail='Merchant - Platform APIs, Id - 10085', + active=True, + code='123' + ) diff --git a/tests/test_mappings/test_imports/test_modules/fixtures.py b/tests/test_mappings/test_imports/test_modules/fixtures.py index 82f8496d..d9af80ce 100644 --- a/tests/test_mappings/test_imports/test_modules/fixtures.py +++ b/tests/test_mappings/test_imports/test_modules/fixtures.py @@ -8201,5 +8201,22 @@ 'description': 'Sage 300 Project - 123 Integrations CRE, Id - 10082', 'is_enabled': True } + ], + "create_fyle_category_payload_with_code_create_new_case":[ + { + 'name': 'Internet', + 'code': 'Internet', + 'is_enabled': True + }, + { + 'name': 'Meals', + 'code': 'Meals', + 'is_enabled': True + }, + { + 'name': '123 Sage300', + 'code': '10085', + 'is_enabled': True + } ] } diff --git a/tests/test_mappings/test_imports/test_modules/test_categories.py b/tests/test_mappings/test_imports/test_modules/test_categories.py index 670055d7..7fdd9b0a 100644 --- a/tests/test_mappings/test_imports/test_modules/test_categories.py +++ b/tests/test_mappings/test_imports/test_modules/test_categories.py @@ -1,6 +1,8 @@ -from apps.mappings.imports.modules.categories import Category +from apps.workspaces.models import ImportSetting +from apps.mappings.imports.modules.categories import Category, disable_categories from fyle_accounting_mappings.models import CategoryMapping, DestinationAttribute, ExpenseAttribute from tests.test_mappings.test_imports.test_modules.fixtures import data as destination_attributes_data +from .fixtures import data def test_construct_fyle_payload( @@ -82,3 +84,144 @@ def test_create_mappings( assert category_mappings.count() == 2 assert category_mappings[0].destination_account.value == "Internet" assert category_mappings[1].destination_account.value == "Meals" + + +def test_get_existing_fyle_attributes( + db, + create_temp_workspace, + add_expense_destination_attributes_1, + add_expense_destination_attributes_3, + add_import_settings +): + category = Category(1, 'ACCOUNT', None) + + paginated_destination_attributes = DestinationAttribute.objects.filter(workspace_id=1, attribute_type='ACCOUNT') + paginated_destination_attributes_without_duplicates = category.remove_duplicate_attributes(paginated_destination_attributes) + paginated_destination_attribute_values = [attribute.value for attribute in paginated_destination_attributes_without_duplicates] + existing_fyle_attributes_map = category.get_existing_fyle_attributes(paginated_destination_attribute_values) + + assert existing_fyle_attributes_map == {'internet': '10091', 'meals': '10092'} + + # with code prepending + category.use_code_in_naming = True + paginated_destination_attributes = DestinationAttribute.objects.filter(workspace_id=1, attribute_type='ACCOUNT', code__isnull=False) + paginated_destination_attributes_without_duplicates = category.remove_duplicate_attributes(paginated_destination_attributes) + paginated_destination_attribute_values = [attribute.value for attribute in paginated_destination_attributes_without_duplicates] + existing_fyle_attributes_map = category.get_existing_fyle_attributes(paginated_destination_attribute_values) + + assert existing_fyle_attributes_map == {'123 sage300': '10095'} + + +def test_construct_fyle_payload_with_code( + db, + create_temp_workspace, + add_expense_destination_attributes_1, + add_expense_destination_attributes_3, + add_import_settings +): + category = Category(1, 'ACCOUNT', None, True) + + paginated_destination_attributes = DestinationAttribute.objects.filter(workspace_id=1, attribute_type='ACCOUNT') + paginated_destination_attributes_without_duplicates = category.remove_duplicate_attributes(paginated_destination_attributes) + paginated_destination_attribute_values = [attribute.value for attribute in paginated_destination_attributes_without_duplicates] + existing_fyle_attributes_map = category.get_existing_fyle_attributes(paginated_destination_attribute_values) + + # already exists + fyle_payload = category.construct_fyle_payload( + paginated_destination_attributes, + existing_fyle_attributes_map, + True + ) + + assert fyle_payload == [] + + # create new case + existing_fyle_attributes_map = {} + fyle_payload = category.construct_fyle_payload( + paginated_destination_attributes, + existing_fyle_attributes_map, + True + ) + + assert fyle_payload == data["create_fyle_category_payload_with_code_create_new_case"] + + +def test_disable_categories( + db, + mocker, + create_temp_workspace, + add_fyle_credentials, + add_expense_destination_attributes_1, + add_import_settings +): + workspace_id = 1 + + projects_to_disable = { + 'destination_id': { + 'value': 'old_category', + 'updated_value': 'new_category', + 'code': 'old_category_code', + 'updated_code': 'old_category_code' + } + } + + ExpenseAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='CATEGORY', + display_name='Category', + value='old_category', + source_id='source_id', + active=True + ) + + mock_platform = mocker.patch('apps.mappings.imports.modules.categories.PlatformConnector') + bulk_post_call = mocker.patch.object(mock_platform.return_value.categories, 'post_bulk') + + disable_categories(workspace_id, projects_to_disable) + + assert bulk_post_call.call_count == 1 + + projects_to_disable = { + 'destination_id': { + 'value': 'old_category_2', + 'updated_value': 'new_category', + 'code': 'old_category_code', + 'updated_code': 'new_category_code' + } + } + + disable_categories(workspace_id, projects_to_disable) + assert bulk_post_call.call_count == 1 + + # Test disable projects with code in naming + import_settings = ImportSetting.objects.get(workspace_id=workspace_id) + import_settings.import_code_fields = ['ACCOUNT'] + import_settings.save() + + ExpenseAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='CATEGORY', + display_name='Category', + value='old_category_code old_category', + source_id='source_id_123', + active=True + ) + + projects_to_disable = { + 'destination_id': { + 'value': 'old_category', + 'updated_value': 'new_category', + 'code': 'old_category_code', + 'updated_code': 'old_category_code' + } + } + + payload = [{ + 'name': 'old_category_code old_category', + 'code': 'destination_id', + 'is_enabled': False, + 'id': 'source_id_123' + }] + + bulk_payload = disable_categories(workspace_id, projects_to_disable) + assert bulk_payload == payload From 41adfd85003f96e4c8accfcd5d7336a3e0ec2d91 Mon Sep 17 00:00:00 2001 From: Hrishabh Tiwari Date: Sat, 20 Jul 2024 19:36:52 +0530 Subject: [PATCH 2/4] rename the helper method --- apps/mappings/imports/modules/categories.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/mappings/imports/modules/categories.py b/apps/mappings/imports/modules/categories.py index 7922a645..0de3210d 100644 --- a/apps/mappings/imports/modules/categories.py +++ b/apps/mappings/imports/modules/categories.py @@ -3,7 +3,7 @@ from typing import List, Dict from apps.workspaces.models import ImportSetting, FyleCredential from apps.mappings.imports.modules.base import Base -from apps.mappings.helpers import format_attribute_name +from apps.mappings.helpers import prepend_code_to_name from fyle_integrations_platform_connector import PlatformConnector from fyle_accounting_mappings.models import DestinationAttribute, ExpenseAttribute, CategoryMapping @@ -117,7 +117,7 @@ def disable_categories(workspace_id: int, categories_to_disable: Dict): category_values = [] for category_map in categories_to_disable.values(): - category_name = format_attribute_name(use_code_in_naming=use_code_in_naming, attribute_name=category_map['value'], attribute_code=category_map['code']) + category_name = prepend_code_to_name(prepend_code_in_name=use_code_in_naming, value=category_map['value'], code=category_map['code']) category_values.append(category_name) filters = { @@ -130,7 +130,7 @@ def disable_categories(workspace_id: int, categories_to_disable: Dict): # Expense attribute value map is as follows: {old_category_name: destination_id} expense_attribute_value_map = {} for k, v in categories_to_disable.items(): - category_name = format_attribute_name(use_code_in_naming=use_code_in_naming, attribute_name=v['value'], attribute_code=v['code']) + category_name = prepend_code_to_name(prepend_code_in_name=use_code_in_naming, value=v['value'], code=v['code']) expense_attribute_value_map[category_name] = k expense_attributes = ExpenseAttribute.objects.filter(**filters) From f7c052afd60f4045f2ad89612ba4a1523c9c0464 Mon Sep 17 00:00:00 2001 From: Hrishabh Tiwari Date: Sat, 20 Jul 2024 19:37:24 +0530 Subject: [PATCH 3/4] rename the helper method --- apps/mappings/imports/modules/categories.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mappings/imports/modules/categories.py b/apps/mappings/imports/modules/categories.py index 0de3210d..1167ff53 100644 --- a/apps/mappings/imports/modules/categories.py +++ b/apps/mappings/imports/modules/categories.py @@ -91,7 +91,7 @@ def create_mappings(self): ) -def disable_categories(workspace_id: int, categories_to_disable: Dict): +def disable_categories(workspace_id: int, categories_to_disable: Dict, *args, **kwargs): """ categories_to_disable object format: { From 08bfa7a3bfef3a82390539be02f22a155944a064 Mon Sep 17 00:00:00 2001 From: Hrishabh Tiwari <74908943+Hrishabh17@users.noreply.github.com> Date: Tue, 23 Jul 2024 12:26:56 +0530 Subject: [PATCH 4/4] add support for code prepending in MERCHANT (#209) * add support for code prepending in MERCHANT * change the callback methods are sent * bug fix * rename the helper method * fix callback method mapping of vendor * Code naming support cost center (#210) * add support for code prepending in COST_CENTER * rename the helper method * add support for code prepending in CUSTOM attribute (#211) * add support for code prepending in CUSTOM attribute * improve test case --- apps/mappings/imports/modules/cost_centers.py | 85 +++++++++- .../imports/modules/expense_custom_fields.py | 32 +++- apps/mappings/imports/modules/merchants.py | 50 +++++- apps/mappings/imports/queues.py | 4 +- apps/sage300/utils.py | 22 ++- .../test_imports/test_modules/conftest.py | 76 +++++++++ .../test_imports/test_modules/fixtures.py | 14 ++ .../test_modules/test_cost_centers.py | 154 +++++++++++++++++- .../test_modules/test_expense_fields.py | 51 +++++- .../test_modules/test_merchants.py | 139 +++++++++++++++- 10 files changed, 609 insertions(+), 18 deletions(-) diff --git a/apps/mappings/imports/modules/cost_centers.py b/apps/mappings/imports/modules/cost_centers.py index 0623fcb0..99ea8911 100644 --- a/apps/mappings/imports/modules/cost_centers.py +++ b/apps/mappings/imports/modules/cost_centers.py @@ -1,7 +1,14 @@ +import logging from datetime import datetime -from typing import List +from typing import List, Dict from apps.mappings.imports.modules.base import Base -from fyle_accounting_mappings.models import DestinationAttribute +from fyle_accounting_mappings.models import DestinationAttribute, ExpenseAttribute, MappingSetting, Mapping +from apps.workspaces.models import FyleCredential, ImportSetting +from fyle_integrations_platform_connector import PlatformConnector +from apps.mappings.helpers import prepend_code_to_name + +logger = logging.getLogger(__name__) +logger.level = logging.INFO class CostCenter(Base): @@ -56,3 +63,77 @@ def construct_fyle_payload( payload.append(cost_center) return payload + + +def disable_cost_centers(workspace_id: int, cost_centers_to_disable: Dict, *args, **kwargs): + """ + cost_centers_to_disable object format: + { + 'destination_id': { + 'value': 'old_cost_center_name', + 'updated_value': 'new_cost_center_name', + 'code': 'old_code', + 'update_code': 'new_code' ---- if the code is updated else same as code + } + } + """ + destination_type = MappingSetting.objects.get(workspace_id=workspace_id, source_field='COST_CENTER').destination_field + use_code_in_naming = ImportSetting.objects.filter(workspace_id=workspace_id, import_code_fields__contains=[destination_type]).first() + + cost_center_mappings = Mapping.objects.filter( + workspace_id=workspace_id, + source_type='COST_CENTER', + destination_type=destination_type, + destination_id__destination_id__in=cost_centers_to_disable.keys() + ) + + logger.info(f"Deleting Cost Center Mappings | WORKSPACE_ID: {workspace_id} | COUNT: {cost_center_mappings.count()}") + cost_center_mappings.delete() + + fyle_credentials = FyleCredential.objects.get(workspace_id=workspace_id) + platform = PlatformConnector(fyle_credentials=fyle_credentials) + + cost_center_values = [] + for cost_center_map in cost_centers_to_disable.values(): + cost_center_name = prepend_code_to_name(prepend_code_in_name=use_code_in_naming, value=cost_center_map['value'], code=cost_center_map['code']) + cost_center_values.append(cost_center_name) + + filters = { + 'workspace_id': workspace_id, + 'attribute_type': 'COST_CENTER', + 'value__in': cost_center_values, + 'active': True + } + + expense_attribute_value_map = {} + for k, v in cost_centers_to_disable.items(): + cost_center_name = prepend_code_to_name(prepend_code_in_name=use_code_in_naming, value=v['value'], code=v['code']) + expense_attribute_value_map[cost_center_name] = k + + expense_attributes = ExpenseAttribute.objects.filter(**filters) + + bulk_payload = [] + for expense_attribute in expense_attributes: + code = expense_attribute_value_map.get(expense_attribute.value, None) + if code: + payload = { + 'name': expense_attribute.value, + 'code': code, + 'is_enabled': False, + 'id': expense_attribute.source_id, + 'description': 'Cost Center - {0}, Id - {1}'.format( + expense_attribute.value, + code + ) + } + bulk_payload.append(payload) + else: + logger.error(f"Cost Center with value {expense_attribute.value} not found | WORKSPACE_ID: {workspace_id}") + + if bulk_payload: + logger.info(f"Disabling Cost Center in Fyle | WORKSPACE_ID: {workspace_id} | COUNT: {len(bulk_payload)}") + platform.cost_centers.post_bulk(bulk_payload) + else: + logger.info(f"No Cost Center to Disable in Fyle | WORKSPACE_ID: {workspace_id}") + + return bulk_payload diff --git a/apps/mappings/imports/modules/expense_custom_fields.py b/apps/mappings/imports/modules/expense_custom_fields.py index 73a5f332..97891b39 100644 --- a/apps/mappings/imports/modules/expense_custom_fields.py +++ b/apps/mappings/imports/modules/expense_custom_fields.py @@ -1,9 +1,11 @@ +import logging from datetime import datetime from typing import List, Dict from apps.mappings.imports.modules.base import Base from fyle_accounting_mappings.models import ( DestinationAttribute, - ExpenseAttribute + ExpenseAttribute, + Mapping ) from apps.mappings.exceptions import handle_import_exceptions from apps.mappings.models import ImportLog @@ -11,6 +13,9 @@ from apps.workspaces.models import FyleCredential from apps.mappings.constants import FYLE_EXPENSE_SYSTEM_FIELDS +logger = logging.getLogger(__name__) +logger.level = logging.INFO + class ExpenseCustomField(Base): """ @@ -176,3 +181,28 @@ def import_destination_attribute_to_fyle(self, import_log: ImportLog): self.sync_expense_attributes(platform) self.create_mappings() + + +def disable_custom_attributes(workspace_id: int, custom_fields_to_disable: Dict, *args, **kwargs): + """ + custom_fields_to_disable object format: + { + 'destination_id': { + 'value': 'old_custom_field_name', + 'updated_value': 'new_custom_field_name', + 'code': 'old_code', + 'update_code': 'new_code' ---- if the code is updated else same as code + } + } + + Currently JOB is only field that can be imported as Custom Field, may need to + update this function if more fields are added in future + """ + custom_field_mappings = Mapping.objects.filter( + workspace_id=workspace_id, + destination_type='JOB', + destination_id__destination_id__in=custom_fields_to_disable.keys() + ) + + logger.info(f"Deleting Custom Field Mappings | WORKSPACE_ID: {workspace_id} | COUNT: {custom_field_mappings.count()}") + custom_field_mappings.delete() diff --git a/apps/mappings/imports/modules/merchants.py b/apps/mappings/imports/modules/merchants.py index e8ac6e32..325d1fc8 100644 --- a/apps/mappings/imports/modules/merchants.py +++ b/apps/mappings/imports/modules/merchants.py @@ -1,11 +1,16 @@ +import logging from datetime import datetime -from typing import List +from typing import List, Dict from apps.mappings.imports.modules.base import Base -from fyle_accounting_mappings.models import DestinationAttribute +from fyle_accounting_mappings.models import DestinationAttribute, ExpenseAttribute from apps.mappings.models import ImportLog from apps.mappings.exceptions import handle_import_exceptions -from apps.workspaces.models import FyleCredential +from apps.workspaces.models import FyleCredential, ImportSetting from fyle_integrations_platform_connector import PlatformConnector +from apps.mappings.helpers import prepend_code_to_name + +logger = logging.getLogger(__name__) +logger.level = logging.INFO class Merchant(Base): @@ -68,3 +73,42 @@ def import_destination_attribute_to_fyle(self, import_log: ImportLog): self.construct_payload_and_import_to_fyle(platform, import_log) self.sync_expense_attributes(platform) + + +def disable_merchants(workspace_id: int, merchants_to_disable: Dict, *args, **kwargs): + """ + merchants_to_disable object format: + { + 'destination_id': { + 'value': 'old_merchant_name', + 'updated_value': 'new_merchant_name', + 'code': 'old_code', + 'update_code': 'new_code' ---- if the code is updated else same as code + } + } + """ + fyle_credentials = FyleCredential.objects.get(workspace_id=workspace_id) + platform = PlatformConnector(fyle_credentials=fyle_credentials) + use_code_in_naming = ImportSetting.objects.filter(workspace_id = workspace_id, import_code_fields__contains=['VENDOR']).first() + + merchant_values = [] + for merchant_map in merchants_to_disable.values(): + merchant_name = prepend_code_to_name(prepend_code_in_name=use_code_in_naming, value=merchant_map['value'], code=merchant_map['code']) + merchant_values.append(merchant_name) + + filters = { + 'workspace_id': workspace_id, + 'attribute_type': 'MERCHANT', + 'value__in': merchant_values, + 'active': True + } + + bulk_payload = ExpenseAttribute.objects.filter(**filters).values_list('value', flat=True) + + if bulk_payload: + logger.info(f"Disabling Merchants in Fyle | WORKSPACE_ID: {workspace_id} | COUNT: {len(bulk_payload)}") + platform.merchants.post(bulk_payload, delete_merchants=True) + else: + logger.info(f"No Merchants to Disable in Fyle | WORKSPACE_ID: {workspace_id}") + + return bulk_payload diff --git a/apps/mappings/imports/queues.py b/apps/mappings/imports/queues.py index bc739488..5ba216e2 100644 --- a/apps/mappings/imports/queues.py +++ b/apps/mappings/imports/queues.py @@ -55,7 +55,9 @@ def chain_import_fields_to_fyle(workspace_id): 'apps.mappings.imports.tasks.trigger_import_via_schedule', workspace_id, 'VENDOR', - 'MERCHANT' + 'MERCHANT', + False, + True if 'VENDOR' in import_code_fields else False ) for mapping_setting in mapping_settings: diff --git a/apps/sage300/utils.py b/apps/sage300/utils.py index 79dee70e..873575b3 100644 --- a/apps/sage300/utils.py +++ b/apps/sage300/utils.py @@ -1,6 +1,6 @@ import logging from django.utils.module_loading import import_string -from fyle_accounting_mappings.models import DestinationAttribute +from fyle_accounting_mappings.models import DestinationAttribute, MappingSetting from apps.workspaces.models import Sage300Credential from sage_desktop_sdk.sage_desktop_sdk import SageDesktopSDK from apps.sage300.models import CostCategory @@ -12,8 +12,11 @@ ATTRIBUTE_CALLBACK_MAP = { - 'JOB': 'apps.sage300.helpers.disable_projects', - 'ACCOUNT': 'apps.mappings.imports.modules.categories.disable_categories', + 'PROJECT': 'apps.sage300.helpers.disable_projects', + 'CATEGORY': 'apps.mappings.imports.modules.categories.disable_categories', + 'MERCHANT': 'apps.mappings.imports.modules.merchants.disable_merchants', + 'COST_CENTER': 'apps.mappings.imports.modules.cost_centers.disable_cost_centers', + 'CUSTOM': 'apps.mappings.imports.modules.expense_custom_fields.disable_custom_attributes' } @@ -138,6 +141,15 @@ def _sync_data(self, data_gen, attribute_type, display_name, workspace_id, field :param workspace_id: ID of the workspace :param field_names: Names of fields to include in detail """ + source_type = None + mapping_setting = MappingSetting.objects.filter(workspace_id=workspace_id, destination_field=attribute_type).first() + if mapping_setting: + if attribute_type == 'VENDOR': + source_type = 'MERCHANT' + elif mapping_setting.is_custom: + source_type = 'CUSTOM' + else: + source_type = mapping_setting.source_field if is_generator: for data in data_gen: @@ -150,13 +162,13 @@ def _sync_data(self, data_gen, attribute_type, display_name, workspace_id, field if destination_attr: destination_attributes.append(destination_attr) - if attribute_type in ATTRIBUTE_CALLBACK_MAP.keys(): + if source_type in ATTRIBUTE_CALLBACK_MAP.keys(): DestinationAttribute.bulk_create_or_update_destination_attributes( destination_attributes, attribute_type, workspace_id, True, - attribute_disable_callback_path=ATTRIBUTE_CALLBACK_MAP[attribute_type] + attribute_disable_callback_path=ATTRIBUTE_CALLBACK_MAP[source_type] ) else: DestinationAttribute.bulk_create_or_update_destination_attributes( diff --git a/tests/test_mappings/test_imports/test_modules/conftest.py b/tests/test_mappings/test_imports/test_modules/conftest.py index 50e63b69..20e3f93f 100644 --- a/tests/test_mappings/test_imports/test_modules/conftest.py +++ b/tests/test_mappings/test_imports/test_modules/conftest.py @@ -120,6 +120,44 @@ def add_cost_center_mappings(): detail='Cost Center - Platform APIs, Id - 10081', active=True ) + DestinationAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='JOB', + display_name='CRE Platform', + value='CRE Platform', + destination_id='10065', + detail='Sage 300 Project - CRE Platform, Id - 10065', + active=True, + code='123' + ) + DestinationAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='JOB', + display_name='Integrations CRE', + value='Integrations CRE', + destination_id='10082', + detail='Sage 300 Project - Integrations CRE, Id - 10082', + active=True, + code='123' + ) + ExpenseAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='COST_CENTER', + display_name='CRE Platform', + value='123 CRE Platform', + source_id='10065', + detail='Sage 300 Cost_Center - 123 CRE Platform, Id - 10065', + active=True + ) + ExpenseAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='COST_CENTER', + display_name='Integrations CRE', + value='123 Integrations CRE', + source_id='10082', + detail='Sage 300 Cost_Center - 123 Integrations CRE, Id - 10082', + active=True + ) @pytest.fixture() @@ -150,6 +188,44 @@ def add_merchant_mappings(): detail='Merchant - Platform APIs, Id - 10081', active=True ) + DestinationAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='VENDOR', + display_name='CRE Platform', + value='CRE Platform', + destination_id='10065', + detail='Sage 300 Merchant - CRE Platform, Id - 10065', + active=True, + code='123' + ) + DestinationAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='VENDOR', + display_name='Integrations CRE', + value='Integrations CRE', + destination_id='10082', + detail='Sage 300 Merchant - Integrations CRE, Id - 10082', + active=True, + code='123' + ) + ExpenseAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='MERCHANT', + display_name='CRE Platform', + value='123 CRE Platform', + source_id='10065', + detail='Sage 300 Merchant - 123 CRE Platform, Id - 10065', + active=True + ) + ExpenseAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='MERCHANT', + display_name='Integrations CRE', + value='123 Integrations CRE', + source_id='10082', + detail='Sage 300 Merchant - 123 Integrations CRE, Id - 10082', + active=True + ) @pytest.fixture() diff --git a/tests/test_mappings/test_imports/test_modules/fixtures.py b/tests/test_mappings/test_imports/test_modules/fixtures.py index d9af80ce..26635a2c 100644 --- a/tests/test_mappings/test_imports/test_modules/fixtures.py +++ b/tests/test_mappings/test_imports/test_modules/fixtures.py @@ -8218,5 +8218,19 @@ 'code': '10085', 'is_enabled': True } + ], + "create_fyle_cost_center_payload_with_code_create_new_case":[ + { + "name": "123 CRE Platform", + "code": "10065", + "is_enabled": True, + "description": "Cost Center - 123 CRE Platform, Id - 10065" + }, + { + "name": "123 Integrations CRE", + "code": "10082", + "is_enabled": True, + "description": "Cost Center - 123 Integrations CRE, Id - 10082" + } ] } diff --git a/tests/test_mappings/test_imports/test_modules/test_cost_centers.py b/tests/test_mappings/test_imports/test_modules/test_cost_centers.py index 1a1dbed3..ff2ad084 100644 --- a/tests/test_mappings/test_imports/test_modules/test_cost_centers.py +++ b/tests/test_mappings/test_imports/test_modules/test_cost_centers.py @@ -1,5 +1,5 @@ -from apps.mappings.imports.modules.cost_centers import CostCenter -from fyle_accounting_mappings.models import DestinationAttribute +from apps.mappings.imports.modules.cost_centers import CostCenter, disable_cost_centers, ImportSetting +from fyle_accounting_mappings.models import DestinationAttribute, ExpenseAttribute, MappingSetting from .fixtures import data @@ -19,3 +19,153 @@ def test_construct_fyle_payload(api_client, test_connection, mocker, create_temp ) assert fyle_payload == data['create_fyle_cost_center_payload_create_new_case'] + + +def test_get_existing_fyle_attributes( + db, + create_temp_workspace, + add_cost_center_mappings, + add_import_settings +): + cost_center = CostCenter(1, 'JOB', None) + + paginated_destination_attributes = DestinationAttribute.objects.filter(workspace_id=1, attribute_type='JOB') + paginated_destination_attributes_without_duplicates = cost_center.remove_duplicate_attributes(paginated_destination_attributes) + paginated_destination_attribute_values = [attribute.value for attribute in paginated_destination_attributes_without_duplicates] + existing_fyle_attributes_map = cost_center.get_existing_fyle_attributes(paginated_destination_attribute_values) + + assert existing_fyle_attributes_map == {} + + # with code prepending + cost_center.use_code_in_naming = True + paginated_destination_attributes = DestinationAttribute.objects.filter(workspace_id=1, attribute_type='JOB', code__isnull=False) + paginated_destination_attributes_without_duplicates = cost_center.remove_duplicate_attributes(paginated_destination_attributes) + paginated_destination_attribute_values = [attribute.value for attribute in paginated_destination_attributes_without_duplicates] + existing_fyle_attributes_map = cost_center.get_existing_fyle_attributes(paginated_destination_attribute_values) + + assert existing_fyle_attributes_map == {'123 cre platform': '10065', '123 integrations cre': '10082'} + + +def test_construct_fyle_payload_with_code( + db, + create_temp_workspace, + add_cost_center_mappings, + add_import_settings +): + cost_center = CostCenter(1, 'JOB', None, True) + + paginated_destination_attributes = DestinationAttribute.objects.filter(workspace_id=1, attribute_type='JOB') + paginated_destination_attributes_without_duplicates = cost_center.remove_duplicate_attributes(paginated_destination_attributes) + paginated_destination_attribute_values = [attribute.value for attribute in paginated_destination_attributes_without_duplicates] + existing_fyle_attributes_map = cost_center.get_existing_fyle_attributes(paginated_destination_attribute_values) + + # already exists + fyle_payload = cost_center.construct_fyle_payload( + paginated_destination_attributes, + existing_fyle_attributes_map, + True + ) + + assert fyle_payload == [] + + # create new case + existing_fyle_attributes_map = {} + fyle_payload = cost_center.construct_fyle_payload( + paginated_destination_attributes, + existing_fyle_attributes_map, + True + ) + + assert fyle_payload == data["create_fyle_cost_center_payload_with_code_create_new_case"] + + +def test_disable_cost_centers( + db, + mocker, + create_temp_workspace, + add_fyle_credentials, + add_cost_center_mappings, + add_import_settings +): + workspace_id = 1 + + MappingSetting.objects.create( + workspace_id=workspace_id, + source_field='COST_CENTER', + destination_field='JOB', + import_to_fyle=True, + is_custom=False + ) + + cost_centers_to_disable = { + 'destination_id': { + 'value': 'old_cost_center', + 'updated_value': 'new_cost_center', + 'code': 'old_cost_center_code', + 'updated_code': 'old_cost_center_code' + } + } + + ExpenseAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='COST_CENTER', + display_name='CostCenter', + value='old_cost_center', + source_id='source_id', + active=True + ) + + mock_platform = mocker.patch('apps.mappings.imports.modules.cost_centers.PlatformConnector') + bulk_post_call = mocker.patch.object(mock_platform.return_value.cost_centers, 'post_bulk') + + disable_cost_centers(workspace_id, cost_centers_to_disable) + + assert bulk_post_call.call_count == 1 + + cost_centers_to_disable = { + 'destination_id': { + 'value': 'old_cost_center_2', + 'updated_value': 'new_cost_center', + 'code': 'old_cost_center_code', + 'updated_code': 'new_cost_center_code' + } + } + + disable_cost_centers(workspace_id, cost_centers_to_disable) + assert bulk_post_call.call_count == 1 + + # Test disable projects with code in naming + import_settings = ImportSetting.objects.get(workspace_id=workspace_id) + import_settings.import_code_fields = ['JOB'] + import_settings.save() + + ExpenseAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='COST_CENTER', + display_name='CostCenter', + value='old_cost_center_code old_cost_center', + source_id='source_id_123', + active=True + ) + + cost_centers_to_disable = { + 'destination_id': { + 'value': 'old_cost_center', + 'updated_value': 'new_cost_center', + 'code': 'old_cost_center_code', + 'updated_code': 'old_cost_center_code' + } + } + + payload = [ + { + 'name': 'old_cost_center_code old_cost_center', + 'code': 'destination_id', + 'is_enabled': False, + 'id': 'source_id_123', + 'description': 'Cost Center - old_cost_center_code old_cost_center, Id - destination_id' + } + ] + + bulk_payload = disable_cost_centers(workspace_id, cost_centers_to_disable) + assert bulk_payload == payload diff --git a/tests/test_mappings/test_imports/test_modules/test_expense_fields.py b/tests/test_mappings/test_imports/test_modules/test_expense_fields.py index 6027e013..9d5f0ef6 100644 --- a/tests/test_mappings/test_imports/test_modules/test_expense_fields.py +++ b/tests/test_mappings/test_imports/test_modules/test_expense_fields.py @@ -1,5 +1,5 @@ -from apps.mappings.imports.modules.expense_custom_fields import ExpenseCustomField -from fyle_accounting_mappings.models import DestinationAttribute +from apps.mappings.imports.modules.expense_custom_fields import ExpenseCustomField, disable_custom_attributes +from fyle_accounting_mappings.models import DestinationAttribute, ExpenseAttribute, Mapping from apps.mappings.models import ImportLog @@ -202,3 +202,50 @@ def test_post_to_fyle_and_sync( 'is_enabled': True }] ) + + +def test_disable_custom_attributes(db, create_temp_workspace): + destination_attribute = DestinationAttribute.objects.create( + workspace_id=1, + attribute_type='JOB', + value='old_custom_field_name', + code='old_code', + destination_id='123', + active=True + ) + + expense_attribute = ExpenseAttribute.objects.create( + workspace_id=1, + attribute_type='CUSTOM', + value='old_code old_custom_field_name', + source_id='456', + active=True + ) + + mapping = Mapping.objects.create( + workspace_id=1, + source_id=expense_attribute.id, + source_type='CUSTOM', + destination_id=destination_attribute.id, + destination_type='JOB' + ) + + custom_fields_to_disable = { + '123': { + 'value': 'old_custom_field_name', + 'updated_value': 'new_custom_field_name', + 'code': 'old_code', + 'updated_code': 'new_code' + } + } + + disable_custom_attributes(1, custom_fields_to_disable) + + count = 0 + try: + mapping.refresh_from_db() + except Exception as e: + count += 1 + assert str(e) == 'Mapping matching query does not exist.' + + assert count == 1 diff --git a/tests/test_mappings/test_imports/test_modules/test_merchants.py b/tests/test_mappings/test_imports/test_modules/test_merchants.py index 4281c133..f50507db 100644 --- a/tests/test_mappings/test_imports/test_modules/test_merchants.py +++ b/tests/test_mappings/test_imports/test_modules/test_merchants.py @@ -1,6 +1,7 @@ -from apps.mappings.imports.modules.merchants import Merchant -from fyle_accounting_mappings.models import DestinationAttribute from apps.mappings.models import ImportLog +from apps.workspaces.models import ImportSetting +from apps.mappings.imports.modules.merchants import Merchant, disable_merchants +from fyle_accounting_mappings.models import DestinationAttribute, ExpenseAttribute def test_construct_fyle_payload(api_client, test_connection, mocker, create_temp_workspace, add_sage300_creds, add_fyle_credentials, add_merchant_mappings): @@ -63,3 +64,137 @@ def test_import_destination_attribute_to_fyle( merchant.import_destination_attribute_to_fyle(import_log) assert True + + +def test_get_existing_fyle_attributes( + db, + create_temp_workspace, + add_merchant_mappings, + add_import_settings +): + merchant = Merchant(1, 'VENDOR', None) + + paginated_destination_attributes = DestinationAttribute.objects.filter(workspace_id=1, attribute_type='VENDOR') + paginated_destination_attributes_without_duplicates = merchant.remove_duplicate_attributes(paginated_destination_attributes) + paginated_destination_attribute_values = [attribute.value for attribute in paginated_destination_attributes_without_duplicates] + existing_fyle_attributes_map = merchant.get_existing_fyle_attributes(paginated_destination_attribute_values) + + assert existing_fyle_attributes_map == {} + + # with code prepending + merchant.use_code_in_naming = True + paginated_destination_attributes = DestinationAttribute.objects.filter(workspace_id=1, attribute_type='VENDOR', code__isnull=False) + paginated_destination_attributes_without_duplicates = merchant.remove_duplicate_attributes(paginated_destination_attributes) + paginated_destination_attribute_values = [attribute.value for attribute in paginated_destination_attributes_without_duplicates] + existing_fyle_attributes_map = merchant.get_existing_fyle_attributes(paginated_destination_attribute_values) + + assert existing_fyle_attributes_map == {'123 cre platform': '10065', '123 integrations cre': '10082'} + + +def test_construct_fyle_payload_with_code( + db, + create_temp_workspace, + add_merchant_mappings, + add_import_settings +): + merchant = Merchant(1, 'VENDOR', None, True) + + paginated_destination_attributes = DestinationAttribute.objects.filter(workspace_id=1, attribute_type='VENDOR') + paginated_destination_attributes_without_duplicates = merchant.remove_duplicate_attributes(paginated_destination_attributes) + paginated_destination_attribute_values = [attribute.value for attribute in paginated_destination_attributes_without_duplicates] + existing_fyle_attributes_map = merchant.get_existing_fyle_attributes(paginated_destination_attribute_values) + + # already exists + fyle_payload = merchant.construct_fyle_payload( + paginated_destination_attributes, + existing_fyle_attributes_map, + True + ) + + assert fyle_payload == [] + + # create new case + existing_fyle_attributes_map = {} + fyle_payload = merchant.construct_fyle_payload( + paginated_destination_attributes, + existing_fyle_attributes_map, + True + ) + + assert fyle_payload == ['123 CRE Platform', '123 Integrations CRE'] + + +def test_disable_merchants( + db, + mocker, + create_temp_workspace, + add_fyle_credentials, + add_merchant_mappings, + add_import_settings +): + workspace_id = 1 + + projects_to_disable = { + 'destination_id': { + 'value': 'old_merchant', + 'updated_value': 'new_merchant', + 'code': 'old_merchant_code', + 'updated_code': 'old_merchant_code' + } + } + + ExpenseAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='MERCHANT', + display_name='Merchant', + value='old_merchant', + source_id='source_id', + active=True + ) + + mock_platform = mocker.patch('apps.mappings.imports.modules.merchants.PlatformConnector') + bulk_post_call = mocker.patch.object(mock_platform.return_value.merchants, 'post') + + disable_merchants(workspace_id, projects_to_disable) + + assert bulk_post_call.call_count == 1 + + projects_to_disable = { + 'destination_id': { + 'value': 'old_merchant_2', + 'updated_value': 'new_merchant', + 'code': 'old_merchant_code', + 'updated_code': 'new_merchant_code' + } + } + + disable_merchants(workspace_id, projects_to_disable) + assert bulk_post_call.call_count == 1 + + # Test disable projects with code in naming + import_settings = ImportSetting.objects.get(workspace_id=workspace_id) + import_settings.import_code_fields = ['VENDOR'] + import_settings.save() + + ExpenseAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='MERCHANT', + display_name='Merchant', + value='old_merchant_code old_merchant', + source_id='source_id_123', + active=True + ) + + projects_to_disable = { + 'destination_id': { + 'value': 'old_merchant', + 'updated_value': 'new_merchant', + 'code': 'old_merchant_code', + 'updated_code': 'old_merchant_code' + } + } + + payload = ['old_merchant_code old_merchant'] + + bulk_payload = disable_merchants(workspace_id, projects_to_disable) + assert bulk_payload[0] == payload[0]