diff --git a/.gitignore b/.gitignore index 5d4a56a4..1c429ba8 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,4 @@ docker-compose.yml # Cache db cache.db +fyle_integrations_platform_connector/ diff --git a/apps/fyle/__init__.py b/apps/fyle/__init__.py index e69de29b..c28981f6 100644 --- a/apps/fyle/__init__.py +++ b/apps/fyle/__init__.py @@ -0,0 +1 @@ +default_app_config = 'apps.fyle.apps.FyleConfig' diff --git a/apps/fyle/apps.py b/apps/fyle/apps.py index 662db2da..fe56e473 100644 --- a/apps/fyle/apps.py +++ b/apps/fyle/apps.py @@ -2,4 +2,8 @@ class FyleConfig(AppConfig): - name = 'fyle' + name = 'apps.fyle' + + def ready(self): + super(FyleConfig, self).ready() + import apps.fyle.signals diff --git a/apps/fyle/helpers.py b/apps/fyle/helpers.py index 5c1ae267..9ff9ec06 100644 --- a/apps/fyle/helpers.py +++ b/apps/fyle/helpers.py @@ -221,4 +221,9 @@ def construct_expense_filter_query(expense_filters: List[ExpenseFilter]): # Set the join type for the additonal filter join_by = expense_filter.join_by - return final_filter \ No newline at end of file + return final_filter + +def connect_to_platform(workspace_id: int) -> PlatformConnector: + fyle_credentials: FyleCredential = FyleCredential.objects.get(workspace_id=workspace_id) + + return PlatformConnector(fyle_credentials=fyle_credentials) diff --git a/apps/fyle/migrations/0020_dependentfield.py b/apps/fyle/migrations/0020_dependentfield.py new file mode 100644 index 00000000..a12762f8 --- /dev/null +++ b/apps/fyle/migrations/0020_dependentfield.py @@ -0,0 +1,34 @@ +# Generated by Django 3.1.14 on 2023-06-13 19:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('workspaces', '0025_auto_20230417_1124'), + ('fyle', '0019_expense_report_title'), + ] + + operations = [ + migrations.CreateModel( + name='DependentField', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('is_import_enabled', models.BooleanField(help_text='Is Import Enabled')), + ('project_field_id', models.IntegerField(help_text='Fyle Source Field ID')), + ('cost_code_field_name', models.CharField(help_text='Fyle Cost Code Field Name', max_length=255)), + ('cost_code_field_id', models.IntegerField(help_text='Fyle Cost Code Field ID')), + ('cost_type_field_name', models.CharField(help_text='Fyle Cost Type Field Name', max_length=255)), + ('cost_type_field_id', models.IntegerField(help_text='Fyle Cost Type Field ID')), + ('last_successful_import_at', models.DateTimeField(help_text='Last Successful Import At', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Updated at')), + ('workspace', models.OneToOneField(help_text='Reference to Workspace', on_delete=django.db.models.deletion.PROTECT, to='workspaces.workspace')), + ], + options={ + 'db_table': 'dependent_fields', + }, + ), + ] diff --git a/apps/fyle/migrations/0021_auto_20230615_0808.py b/apps/fyle/migrations/0021_auto_20230615_0808.py new file mode 100644 index 00000000..3e14f101 --- /dev/null +++ b/apps/fyle/migrations/0021_auto_20230615_0808.py @@ -0,0 +1,22 @@ +# Generated by Django 3.1.14 on 2023-06-15 08:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('workspaces', '0025_auto_20230417_1124'), + ('fyle', '0020_dependentfield'), + ] + + operations = [ + migrations.RenameModel( + old_name='DependentField', + new_name='DependentFieldSetting', + ), + migrations.AlterModelTable( + name='dependentfieldsetting', + table='dependent_field_settings', + ), + ] diff --git a/apps/fyle/models.py b/apps/fyle/models.py index 05feea35..16ef2e45 100644 --- a/apps/fyle/models.py +++ b/apps/fyle/models.py @@ -510,4 +510,25 @@ class ExpenseFilter(models.Model): updated_at = models.DateTimeField(auto_now=True, help_text='Updated at') class Meta: - db_table = 'expense_filters' \ No newline at end of file + db_table = 'expense_filters' + + +class DependentFieldSetting(models.Model): + """ + Fyle Dependent Fields + DB Table: dependent_field_settings: + """ + id = models.AutoField(primary_key=True) + is_import_enabled = models.BooleanField(help_text='Is Import Enabled') + project_field_id = models.IntegerField(help_text='Fyle Source Field ID') + cost_code_field_name = models.CharField(max_length=255, help_text='Fyle Cost Code Field Name') + cost_code_field_id = models.IntegerField(help_text='Fyle Cost Code Field ID') + cost_type_field_name = models.CharField(max_length=255, help_text='Fyle Cost Type Field Name') + cost_type_field_id = models.IntegerField(help_text='Fyle Cost Type Field ID') + workspace = models.OneToOneField(Workspace, on_delete=models.PROTECT, help_text='Reference to Workspace') + last_successful_import_at = models.DateTimeField(null=True, help_text='Last Successful Import At') + created_at = models.DateTimeField(auto_now_add=True, help_text='Created at') + updated_at = models.DateTimeField(auto_now=True, help_text='Updated at') + + class Meta: + db_table = 'dependent_field_settings' diff --git a/apps/fyle/serializers.py b/apps/fyle/serializers.py index ad53aca8..dd47bf67 100644 --- a/apps/fyle/serializers.py +++ b/apps/fyle/serializers.py @@ -2,7 +2,7 @@ from fyle_accounting_mappings.models import ExpenseAttribute -from .models import Expense, ExpenseFilter, ExpenseGroup, ExpenseGroupSettings, Reimbursement +from .models import Expense, ExpenseFilter, ExpenseGroup, ExpenseGroupSettings, DependentFieldSetting class ExpenseGroupSerializer(serializers.ModelSerializer): @@ -72,4 +72,17 @@ def create(self, validated_data): defaults=validated_data ) - return expense_filter \ No newline at end of file + return expense_filter + + +class DependentFieldSettingSerializer(serializers.ModelSerializer): + """ + Dependent Field serializer + """ + project_field_id = serializers.IntegerField(required=False) + cost_code_field_id = serializers.IntegerField(required=False) + cost_type_field_id = serializers.IntegerField(required=False) + + class Meta: + model = DependentFieldSetting + fields = '__all__' diff --git a/apps/fyle/signals.py b/apps/fyle/signals.py new file mode 100644 index 00000000..4a599758 --- /dev/null +++ b/apps/fyle/signals.py @@ -0,0 +1,59 @@ +""" +Fyle Signal +""" +import logging + +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver + +from apps.sage_intacct.dependent_fields import create_dependent_custom_field_in_fyle +from apps.sage_intacct.dependent_fields import schedule_dependent_field_imports + +from .helpers import connect_to_platform +from .models import DependentFieldSetting + + +logger = logging.getLogger(__name__) +logger.level = logging.INFO + + +@receiver(pre_save, sender=DependentFieldSetting) +def run_pre_save_dependent_field_settings_triggers(sender, instance: DependentFieldSetting, **kwargs): + """ + :param sender: Sender Class + :param instance: Row instance of Sender Class + :return: None + """ + # Patch alert - Skip creating dependent fields if they're already created + if instance.cost_code_field_id: + return + + platform = connect_to_platform(instance.workspace_id) + + instance.project_field_id = platform.expense_fields.get_project_field_id() + + cost_code = create_dependent_custom_field_in_fyle( + workspace_id=instance.workspace_id, + fyle_attribute_type=instance.cost_code_field_name, + platform=platform, + parent_field_id=instance.project_field_id + ) + instance.cost_code_field_id = cost_code['data']['id'] + + cost_type = create_dependent_custom_field_in_fyle( + workspace_id=instance.workspace_id, + fyle_attribute_type=instance.cost_type_field_name, + platform=platform, + parent_field_id=instance.cost_code_field_id + ) + instance.cost_type_field_id = cost_type['data']['id'] + + +@receiver(post_save, sender=DependentFieldSetting) +def run_post_save_dependent_field_settings_triggers(sender, instance: DependentFieldSetting, **kwargs): + """ + :param sender: Sender Class + :param instance: Row instance of Sender Class + :return: None + """ + schedule_dependent_field_imports(instance.workspace_id, instance.is_import_enabled) diff --git a/apps/fyle/urls.py b/apps/fyle/urls.py index bb775fe2..8872b43b 100644 --- a/apps/fyle/urls.py +++ b/apps/fyle/urls.py @@ -16,10 +16,14 @@ from django.urls import path import itertools -from .views import CustomFieldView, ExpenseFilterView, ExpenseGroupExpenseView, ExpenseGroupView, ExpenseGroupScheduleView, ExpenseGroupByIdView, \ - ExpenseView, EmployeeView, CategoryView, ProjectView, CostCenterView, FyleFieldsView, \ - ExpenseAttributesView, ExpenseGroupSettingsView, RefreshFyleDimensionView, SyncFyleDimensionView, \ - ExpenseGroupCountView +from .views import ( + CustomFieldView, ExpenseFilterView, ExpenseGroupExpenseView, + ExpenseGroupView, ExpenseGroupScheduleView, ExpenseGroupByIdView, + ExpenseView, EmployeeView, CategoryView, ProjectView, CostCenterView, + FyleFieldsView, ExpenseAttributesView, ExpenseGroupSettingsView, + RefreshFyleDimensionView, SyncFyleDimensionView, ExpenseGroupCountView, + DependentFieldSettingView +) expense_groups_paths = [ path('expense_groups/', ExpenseGroupView.as_view()), @@ -44,7 +48,8 @@ path('fyle_fields/', FyleFieldsView.as_view()), path('expense_filters/', ExpenseFilterView.as_view(), name='expense-filters'), path('expenses/', ExpenseView.as_view(), name='expenses'), - path('custom_fields/', CustomFieldView.as_view(), name='custom-field') + path('custom_fields/', CustomFieldView.as_view(), name='custom-field'), + path('dependent_field_settings/', DependentFieldSettingView.as_view(), name='dependent-field') ] urlpatterns = list(itertools.chain(expense_groups_paths, fyle_dimension_paths, other_paths)) diff --git a/apps/fyle/views.py b/apps/fyle/views.py index 62622f8d..b4a9f7db 100644 --- a/apps/fyle/views.py +++ b/apps/fyle/views.py @@ -19,9 +19,12 @@ from .tasks import create_expense_groups, schedule_expense_group_creation from .helpers import check_interval_and_sync_dimension, sync_dimensions -from .models import Expense, ExpenseFilter, ExpenseGroup, ExpenseGroupSettings -from .serializers import ExpenseFilterSerializer, ExpenseGroupExpenseSerializer, ExpenseGroupSerializer, ExpenseSerializer, ExpenseFieldSerializer, \ - ExpenseGroupSettingsSerializer +from .models import Expense, ExpenseFilter, ExpenseGroup, ExpenseGroupSettings, DependentFieldSetting +from .serializers import ( + ExpenseFilterSerializer, ExpenseGroupExpenseSerializer, ExpenseGroupSerializer, + ExpenseSerializer, ExpenseFieldSerializer, ExpenseGroupSettingsSerializer, + DependentFieldSettingSerializer +) class ExpenseGroupView(generics.ListCreateAPIView): @@ -432,3 +435,11 @@ def get(self, request, *args, **kwargs): status=status.HTTP_200_OK ) + +class DependentFieldSettingView(generics.CreateAPIView, generics.RetrieveUpdateAPIView): + """ + Dependent Field view + """ + serializer_class = DependentFieldSettingSerializer + lookup_field = 'workspace_id' + queryset = DependentFieldSetting.objects.all() diff --git a/apps/mappings/signals.py b/apps/mappings/signals.py index 83f9d048..f4bd2bd5 100644 --- a/apps/mappings/signals.py +++ b/apps/mappings/signals.py @@ -14,7 +14,7 @@ from fyle.platform.exceptions import WrongParamsError from apps.mappings.tasks import schedule_cost_centers_creation, schedule_fyle_attributes_creation,\ - upload_attributes_to_fyle, upload_dependent_field_to_fyle + upload_attributes_to_fyle from apps.workspaces.models import Configuration from apps.mappings.helpers import schedule_or_delete_fyle_import_tasks from apps.workspaces.tasks import delete_cards_mapping_settings @@ -39,7 +39,7 @@ def run_post_mapping_settings_triggers(sender, instance: MappingSetting, **kwarg if instance.is_custom: schedule_fyle_attributes_creation(int(instance.workspace_id)) - + if configuration: delete_cards_mapping_settings(configuration) @@ -54,19 +54,16 @@ def run_pre_mapping_settings_triggers(sender, instance: MappingSetting, **kwargs default_attributes = ['EMPLOYEE', 'CATEGORY', 'PROJECT', 'COST_CENTER', 'TAX_GROUP', 'CORPORATE_CARD'] instance.source_field = instance.source_field.upper().replace(' ', '_') - parent_field_id = instance.expense_field.source_field_id if instance.expense_field else None if instance.source_field not in default_attributes: - #TODO: sync intacct fields before we upload custom field + # TODO: sync intacct fields before we upload custom field try: - if not instance.expense_field: - upload_attributes_to_fyle( - workspace_id=int(instance.workspace_id), - sageintacct_attribute_type=instance.destination_field, - fyle_attribute_type=instance.source_field, - parent_field_id=parent_field_id, - source_placeholder=instance.source_placeholder - ) + upload_attributes_to_fyle( + workspace_id=int(instance.workspace_id), + sageintacct_attribute_type=instance.destination_field, + fyle_attribute_type=instance.source_field, + source_placeholder=instance.source_placeholder + ) except WrongParamsError as error: logger.error( @@ -87,6 +84,5 @@ def run_pre_mapping_settings_triggers(sender, instance: MappingSetting, **kwargs 'apps.mappings.tasks.auto_create_expense_fields_mappings', int(instance.workspace_id), instance.destination_field, - instance.source_field, - parent_field_id + instance.source_field ) diff --git a/apps/mappings/tasks.py b/apps/mappings/tasks.py index a479bad4..c325a54f 100644 --- a/apps/mappings/tasks.py +++ b/apps/mappings/tasks.py @@ -17,8 +17,11 @@ from sageintacctsdk.exceptions import InvalidTokenError, NoPrivilegeError +from apps.fyle.helpers import connect_to_platform +from apps.fyle.models import DependentFieldSetting from apps.mappings.models import GeneralMapping from apps.sage_intacct.utils import SageIntacctConnector +from apps.sage_intacct.models import CostType from apps.workspaces.models import SageIntacctCredential, FyleCredential, Configuration from .constants import FYLE_EXPENSE_SYSTEM_FIELDS @@ -395,9 +398,6 @@ def sync_sage_intacct_attributes(sageintacct_attribute_type: str, workspace_id: elif sageintacct_attribute_type == 'ITEM': sage_intacct_connection.sync_items() - - elif sageintacct_attribute_type == 'TASK': - sage_intacct_connection.sync_tasks() elif sageintacct_attribute_type == 'COST_TYPE': sage_intacct_connection.sync_cost_types() @@ -525,8 +525,36 @@ def schedule_cost_centers_creation(import_to_fyle, workspace_id): schedule.delete() +def construct_custom_field_placeholder(source_placeholder: str, fyle_attribute: str, existing_attribute: Dict): + new_placeholder = None + placeholder = None + + if existing_attribute: + placeholder = existing_attribute['placeholder'] if 'placeholder' in existing_attribute else None + + # Here is the explanation of what's happening in the if-else ladder below + # source_field is the field that's save in mapping settings, this field user may or may not fill in the custom field form + # placeholder is the field that's saved in the detail column of destination attributes + # fyle_attribute is what we're constructing when both of these fields would not be available + + if not (source_placeholder or placeholder): + # If source_placeholder and placeholder are both None, then we're creating adding a self constructed placeholder + new_placeholder = 'Select {0}'.format(fyle_attribute) + elif not source_placeholder and placeholder: + # If source_placeholder is None but placeholder is not, then we're choosing same place holder as 1 in detail section + new_placeholder = placeholder + elif source_placeholder and not placeholder: + # If source_placeholder is not None but placeholder is None, then we're choosing the placeholder as filled by user in form + new_placeholder = source_placeholder + else: + # Else, we're choosing the placeholder as filled by user in form or None + new_placeholder = source_placeholder + + return new_placeholder + + def create_fyle_expense_custom_field_payload(sageintacct_attributes: List[DestinationAttribute], workspace_id: int, - fyle_attribute: str, platform: PlatformConnector, parent_field_id: int = None, source_placeholder: str = None): + fyle_attribute: str, platform: PlatformConnector, source_placeholder: str = None): """ Create Fyle Expense Custom Field Payload from SageIntacct Objects :param workspace_id: Workspace ID @@ -544,58 +572,22 @@ def create_fyle_expense_custom_field_payload(sageintacct_attributes: List[Destin attribute_type=fyle_attribute, workspace_id=workspace_id).values_list('detail', flat=True).first() custom_field_id = None - placeholder = None if existing_attribute is not None: - placeholder = existing_attribute['placeholder'] if 'placeholder' in existing_attribute else None custom_field_id = existing_attribute['custom_field_id'] fyle_attribute = fyle_attribute.replace('_', ' ').title() - new_placeholder = None - - # Here is the explanation of what's happening in the if-else ladder below - # source_field is the field that's save in mapping settings, this field user may or may not fill in the custom field form - # placeholder is the field that's saved in the detail column of destination attributes - # fyle_attribute is what we're constructing when both of these fields would not be available - - if not (source_placeholder or placeholder): - # If source_placeholder and placeholder are both None, then we're creating adding a self constructed placeholder - new_placeholder = 'Select {0}'.format(fyle_attribute) - elif not source_placeholder and placeholder: - # If source_placeholder is None but placeholder is not, then we're choosing same place holder as 1 in detail section - new_placeholder = placeholder - elif source_placeholder and not placeholder: - # If source_placeholder is not None but placeholder is None, then we're choosing the placeholder as filled by user in form - new_placeholder = source_placeholder - else: - # Else, we're choosing the placeholder as filled by user in form or None - new_placeholder = source_placeholder - - - # if parent field is there that means it is a dependent field - if parent_field_id: - expense_custom_field_payload = { - 'field_name': fyle_attribute, - 'column_name': fyle_attribute, - 'type': 'DEPENDENT_SELECT', - 'is_custom': True, - 'is_enabled': True, - 'is_mandatory': False, - 'placeholder': new_placeholder, - 'options': fyle_expense_custom_field_options, - 'parent_field_id': parent_field_id, - 'code': None, - } - else: - expense_custom_field_payload = { - 'field_name': fyle_attribute, - 'type': 'SELECT', - 'is_enabled': True, - 'is_mandatory': False, - 'placeholder': new_placeholder, - 'options': fyle_expense_custom_field_options, - 'code': None - } + placeholder = construct_custom_field_placeholder(source_placeholder, fyle_attribute, existing_attribute) + + expense_custom_field_payload = { + 'field_name': fyle_attribute, + 'type': 'SELECT', + 'is_enabled': True, + 'is_mandatory': False, + 'placeholder': placeholder, + 'options': fyle_expense_custom_field_options, + 'code': None + } if custom_field_id: @@ -606,81 +598,7 @@ def create_fyle_expense_custom_field_payload(sageintacct_attributes: List[Destin return expense_custom_field_payload -def upload_dependent_field_to_fyle( - workspace_id: int, sageintacct_attribute_type: str, fyle_attribute_type: str, parent_field_id: str, source_placeholder: str = None -): - """ - Upload Dependent Fields To Fyle - """ - fyle_credentials: FyleCredential = FyleCredential.objects.get(workspace_id=workspace_id) - platform = PlatformConnector(fyle_credentials=fyle_credentials) - dependent_fields = upload_attributes_to_fyle(workspace_id, sageintacct_attribute_type, fyle_attribute_type, parent_field_id, source_placeholder) - - expense_field_id = ExpenseAttribute.objects.filter( - workspace_id=workspace_id, attribute_type=fyle_attribute_type - ).first().detail['custom_field_id'] - - si_attributes_count = DestinationAttribute.objects.filter( - workspace_id=workspace_id, attribute_type=sageintacct_attribute_type - ).count() - page_size = 300 - - for offset in range(0, si_attributes_count, page_size): - limit = offset + page_size - paginated_si_attributes = DestinationAttribute.objects.filter( - workspace_id=workspace_id, attribute_type=sageintacct_attribute_type - ).order_by('value', 'id')[offset:limit] - paginated_si_attributes = remove_duplicates(paginated_si_attributes, True) - expense_attribite_type = ExpenseField.objects.get(workspace_id=workspace_id, source_field_id=parent_field_id).attribute_type - - dependent_field_values = [] - for attribute in paginated_si_attributes: - # If anyone can think of a better way to handle this please mention i will be happy to fix - parent_expense_field_value = None - if attribute.attribute_type == 'COST_TYPE': - expense_attributes = ExpenseAttribute.objects.filter(workspace_id=workspace_id, attribute_type=expense_attribite_type).values_list('value', flat=True) - parent_expense_field = DestinationAttribute.objects.filter( - workspace_id=workspace_id, - attribute_type='TASK', - detail__project_name=attribute.detail['project_name'], - detail__external_id=attribute.detail['task_id'] - ).first() - - # parent value is combination of these two so filterig it out - if parent_expense_field and parent_expense_field.value in expense_attributes: - parent_expense_field_value = parent_expense_field.value - - else: - expense_attribute = ExpenseAttribute.objects.filter( - workspace_id=workspace_id, - attribute_type='PROJECT', - value=attribute.detail['project_name'] - ).first() - - if expense_attribute: - parent_expense_field_value = expense_attribute.value - - - if parent_expense_field_value: - payload = { - "parent_expense_field_id": parent_field_id, - "parent_expense_field_value": parent_expense_field_value, - "expense_field_id": expense_field_id, - "expense_field_value": attribute.value, - "is_enabled": True - } - - dependent_field_values.append(payload) - - if dependent_field_values: - platform.expense_fields.bulk_post_dependent_expense_field_values(dependent_field_values) - platform.expense_fields.sync() - - return dependent_fields - - -def upload_attributes_to_fyle(workspace_id: int, sageintacct_attribute_type: str, fyle_attribute_type: str, - parent_field_id: int = None, source_placeholder: str = None): +def upload_attributes_to_fyle(workspace_id: int, sageintacct_attribute_type: str, fyle_attribute_type: str, source_placeholder: str = None): """ Upload attributes to Fyle """ @@ -700,7 +618,6 @@ def upload_attributes_to_fyle(workspace_id: int, sageintacct_attribute_type: str sageintacct_attributes=sageintacct_attributes, workspace_id=workspace_id, platform=platform, - parent_field_id=parent_field_id, source_placeholder=source_placeholder ) @@ -712,20 +629,20 @@ def upload_attributes_to_fyle(workspace_id: int, sageintacct_attribute_type: str def auto_create_expense_fields_mappings( - workspace_id: int, sageintacct_attribute_type: str, fyle_attribute_type: str, parent_field_id: int = None, source_placeholder: str = None + workspace_id: int, sageintacct_attribute_type: str, fyle_attribute_type: str, source_placeholder: str = None ): """ Create Fyle Attributes Mappings :return: mappings """ try: - if parent_field_id: - fyle_attributes = upload_dependent_field_to_fyle(workspace_id=workspace_id,sageintacct_attribute_type=sageintacct_attribute_type, - fyle_attribute_type=fyle_attribute_type, parent_field_id=parent_field_id, source_placeholder=source_placeholder - ) - else: - fyle_attributes = upload_attributes_to_fyle(workspace_id=workspace_id, - sageintacct_attribute_type=sageintacct_attribute_type, fyle_attribute_type=fyle_attribute_type, source_placeholder=source_placeholder) + fyle_attributes = None + fyle_attributes = upload_attributes_to_fyle( + workspace_id=workspace_id, + sageintacct_attribute_type=sageintacct_attribute_type, + fyle_attribute_type=fyle_attribute_type, + source_placeholder=source_placeholder + ) if fyle_attributes: Mapping.bulk_create_mappings(fyle_attributes, fyle_attribute_type, sageintacct_attribute_type, workspace_id) @@ -762,11 +679,10 @@ def async_auto_create_custom_field_mappings(workspace_id: str): for mapping_setting in mapping_settings: try: if mapping_setting.import_to_fyle: - parent_field = mapping_setting.expense_field.source_field_id if mapping_setting.expense_field else None sync_sage_intacct_attributes(mapping_setting.destination_field, workspace_id) auto_create_expense_fields_mappings( workspace_id, mapping_setting.destination_field, mapping_setting.source_field, - parent_field, mapping_setting.source_placeholder + mapping_setting.source_placeholder ) except (SageIntacctCredential.DoesNotExist, InvalidTokenError): logger.info('Invalid Token or Sage Intacct credentials does not exist - %s', workspace_id) diff --git a/apps/sage_intacct/dependent_fields.py b/apps/sage_intacct/dependent_fields.py new file mode 100644 index 00000000..962bd0e9 --- /dev/null +++ b/apps/sage_intacct/dependent_fields.py @@ -0,0 +1,134 @@ +import logging +from datetime import datetime +from typing import Dict +from time import sleep + +from django_q.models import Schedule + +from django.contrib.postgres.aggregates import ArrayAgg +from fyle_integrations_platform_connector import PlatformConnector + +from fyle_accounting_mappings.models import ExpenseAttribute + +from sageintacctsdk.exceptions import InvalidTokenError, NoPrivilegeError + +from apps.fyle.helpers import connect_to_platform +from apps.fyle.models import DependentFieldSetting +from apps.mappings.tasks import construct_custom_field_placeholder, sync_sage_intacct_attributes +from apps.sage_intacct.models import CostType +from apps.workspaces.models import SageIntacctCredential + + +logger = logging.getLogger(__name__) +logger.level = logging.INFO + + +def post_dependent_cost_code(dependent_field_setting: DependentFieldSetting, platform: PlatformConnector, filters: Dict): + # TODO: use JSONBAgg get both status and name which will help in auto-sync satus + projects = CostType.objects.filter(**filters).values('project_name').annotate(tasks=ArrayAgg('task_name', distinct=True)) + for project in projects: + payload = [ + { + 'parent_expense_field_id': dependent_field_setting.project_field_id, + 'parent_expense_field_value': project['project_name'], + 'expense_field_id': dependent_field_setting.cost_code_field_id, + 'expense_field_value': task, + 'is_enabled': True + } for task in project['tasks'] + ] + sleep(0.2) + platform.expense_fields.bulk_post_dependent_expense_field_values(payload) + + +def post_dependent_cost_type(dependent_field_setting: DependentFieldSetting, platform: PlatformConnector, filters: Dict): + tasks = CostType.objects.filter(**filters).values('task_name').annotate(cost_types=ArrayAgg('name', distinct=True)) + for task in tasks: + payload = [ + { + 'parent_expense_field_id': dependent_field_setting.cost_code_field_id, + 'parent_expense_field_value': task['task_name'], + 'expense_field_id': dependent_field_setting.cost_type_field_id, + 'expense_field_value': cost_type, + 'is_enabled': True + } for cost_type in task['cost_types'] + ] + sleep(0.2) + platform.expense_fields.bulk_post_dependent_expense_field_values(payload) + + +def post_dependent_expense_field_values(workspace_id: int, dependent_field_setting: DependentFieldSetting, platform: PlatformConnector = None): + if not platform: + platform = connect_to_platform(workspace_id) + + filters = { + 'workspace_id': workspace_id + } + + if dependent_field_setting.last_successful_import_at: + filters['updated_at__gte'] = dependent_field_setting.last_successful_import_at + + post_dependent_cost_code(dependent_field_setting, platform, filters) + post_dependent_cost_type(dependent_field_setting, platform, filters) + + DependentFieldSetting.objects.filter(workspace_id=workspace_id).update(last_successful_import_at=datetime.now()) + + +def import_dependent_fields_to_fyle(workspace_id: str): + dependent_field = DependentFieldSetting.objects.get(workspace_id=workspace_id) + + try: + platform = connect_to_platform(workspace_id) + sync_sage_intacct_attributes('COST_TYPE', workspace_id) + post_dependent_expense_field_values(workspace_id, dependent_field, platform) + + except (SageIntacctCredential.DoesNotExist, InvalidTokenError): + logger.info('Invalid Token or Sage Intacct credentials does not exist - %s', workspace_id) + except NoPrivilegeError: + logger.info('Insufficient permission to access the requested module') + except Exception as exception: + logger.error('Exception while importing dependent fields to fyle - %s', exception) + + +def create_dependent_custom_field_in_fyle(workspace_id: int, fyle_attribute_type: str, platform: PlatformConnector, parent_field_id: str, source_placeholder: str = None): + existing_attribute = ExpenseAttribute.objects.filter( + attribute_type=fyle_attribute_type, + workspace_id=workspace_id + ).values_list('detail', flat=True).first() + + placeholder = construct_custom_field_placeholder(source_placeholder, fyle_attribute_type, existing_attribute) + + expense_custom_field_payload = { + 'field_name': fyle_attribute_type, + 'column_name': fyle_attribute_type, + 'type': 'DEPENDENT_SELECT', + 'is_custom': True, + 'is_enabled': True, + 'is_mandatory': False, + 'placeholder': placeholder, + 'options': [], + 'parent_field_id': parent_field_id, + 'code': None + } + + return platform.expense_custom_fields.post(expense_custom_field_payload) + + +def schedule_dependent_field_imports(workspace_id: int, is_import_enabled: bool): + if is_import_enabled: + Schedule.objects.update_or_create( + func='apps.sage_intacct.dependent_fields.import_dependent_fields_to_fyle', + args='{}'.format(workspace_id), + defaults={ + 'schedule_type': Schedule.MINUTES, + 'minutes': 24 * 60, + 'next_run': datetime.now() + } + ) + else: + schedule: Schedule = Schedule.objects.filter( + func='apps.sage_intacct.dependent_fields.import_dependent_fields_to_fyle', + args='{}'.format(workspace_id) + ).first() + + if schedule: + schedule.delete() diff --git a/apps/sage_intacct/helpers.py b/apps/sage_intacct/helpers.py index a33a30f3..8501a768 100644 --- a/apps/sage_intacct/helpers.py +++ b/apps/sage_intacct/helpers.py @@ -3,6 +3,9 @@ from django.utils.module_loading import import_string +from django_q.tasks import async_task + +from apps.fyle.models import DependentFieldSetting from apps.workspaces.models import Configuration, Workspace, SageIntacctCredential from apps.sage_intacct.tasks import schedule_ap_payment_creation, schedule_sage_intacct_objects_status_sync, \ @@ -49,6 +52,11 @@ def check_interval_and_sync_dimension(workspace: Workspace, si_credentials: Sage return False + +def is_dependent_field_import_enabled(workspace_id: int) -> bool: + return DependentFieldSetting.objects.filter(workspace_id=workspace_id).exists() + + def sync_dimensions(si_credentials: SageIntacctCredential, workspace_id: int, dimensions: list = []) -> None: sage_intacct_connection = import_string( 'apps.sage_intacct.utils.SageIntacctConnector' @@ -57,8 +65,7 @@ def sync_dimensions(si_credentials: SageIntacctCredential, workspace_id: int, di dimensions = [ 'locations', 'customers', 'departments', 'tax_details', 'projects', 'expense_payment_types', 'classes', 'charge_card_accounts','payment_accounts', - 'vendors', 'employees', 'accounts', 'expense_types', 'items', 'user_defined_dimensions', - 'tasks', 'cost_types' + 'vendors', 'employees', 'accounts', 'expense_types', 'items', 'user_defined_dimensions' ] for dimension in dimensions: diff --git a/apps/sage_intacct/migrations/0020_costtypes.py b/apps/sage_intacct/migrations/0020_costtypes.py new file mode 100644 index 00000000..418fb05d --- /dev/null +++ b/apps/sage_intacct/migrations/0020_costtypes.py @@ -0,0 +1,40 @@ +# Generated by Django 3.1.14 on 2023-06-07 09:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('workspaces', '0025_auto_20230417_1124'), + ('sage_intacct', '0019_auto_20230307_1746'), + ] + + operations = [ + migrations.CreateModel( + name='CostTypes', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('record_number', models.CharField(help_text='Sage Intacct Record No', max_length=255)), + ('project_key', models.CharField(help_text='Sage Intacct Project Key', max_length=255)), + ('project_id', models.CharField(help_text='Sage Intacct Project ID', max_length=255)), + ('project_name', models.CharField(help_text='Sage Intacct Project Name', max_length=255)), + ('task_key', models.CharField(help_text='Sage Intacct Task Key', max_length=255)), + ('task_id', models.CharField(help_text='Sage Intacct Task ID', max_length=255)), + ('status', models.CharField(help_text='Sage Intacct Status', max_length=255, null=True)), + ('task_name', models.CharField(help_text='Sage Intacct Task Name', max_length=255)), + ('cost_type_id', models.CharField(help_text='Sage Intacct Cost Type ID', max_length=255)), + ('name', models.CharField(help_text='Sage Intacct Cost Type Name', max_length=255)), + ('when_created', models.CharField(help_text='Sage Intacct When Created', max_length=255, null=True)), + ('when_modified', models.CharField(help_text='Sage Intacct When Modified', max_length=255, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Updated at')), + ('workspace', models.ForeignKey(help_text='Reference to Workspace', on_delete=django.db.models.deletion.PROTECT, to='workspaces.workspace')), + ], + options={ + 'db_table': 'cost_types', + 'unique_together': {('record_number', 'workspace_id')}, + }, + ), + ] diff --git a/apps/sage_intacct/migrations/0021_auto_20230608_1310.py b/apps/sage_intacct/migrations/0021_auto_20230608_1310.py new file mode 100644 index 00000000..3487c705 --- /dev/null +++ b/apps/sage_intacct/migrations/0021_auto_20230608_1310.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.14 on 2023-06-08 13:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('workspaces', '0025_auto_20230417_1124'), + ('sage_intacct', '0020_costtypes'), + ] + + operations = [ + migrations.RenameModel( + old_name='CostTypes', + new_name='CostType', + ), + ] diff --git a/apps/sage_intacct/migrations/0022_auto_20230615_1509.py b/apps/sage_intacct/migrations/0022_auto_20230615_1509.py new file mode 100644 index 00000000..8fe02480 --- /dev/null +++ b/apps/sage_intacct/migrations/0022_auto_20230615_1509.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.14 on 2023-06-15 15:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sage_intacct', '0021_auto_20230608_1310'), + ] + + operations = [ + migrations.AlterField( + model_name='costtype', + name='record_number', + field=models.IntegerField(help_text='Sage Intacct Record No'), + ), + ] diff --git a/apps/sage_intacct/models.py b/apps/sage_intacct/models.py index 837f9032..8d038501 100644 --- a/apps/sage_intacct/models.py +++ b/apps/sage_intacct/models.py @@ -9,11 +9,11 @@ from fyle_accounting_mappings.models import Mapping, MappingSetting, DestinationAttribute, CategoryMapping, \ EmployeeMapping -from apps.fyle.models import ExpenseGroup, Expense, ExpenseAttribute, Reimbursement, ExpenseGroupSettings +from apps.fyle.models import ExpenseGroup, Expense, ExpenseAttribute, Reimbursement, ExpenseGroupSettings, DependentFieldSetting from apps.mappings.models import GeneralMapping from apps.workspaces.models import Configuration, Workspace, FyleCredential -from typing import Union +from typing import Dict, List, Union def get_project_id_or_none(expense_group: ExpenseGroup, lineitem: Expense, general_mappings: GeneralMapping): @@ -179,50 +179,35 @@ def get_item_id_or_none(expense_group: ExpenseGroup, lineitem: Expense, general_ return item_id -def get_cost_type_id_or_none(expense_group: ExpenseGroup, lineitem: Expense, task_id: str): +def get_cost_type_id_or_none(expense_group: ExpenseGroup, lineitem: Expense, dependent_field_setting: DependentFieldSetting, project_id: str, task_id: str): cost_type_id = None - if task_id: - cost_type_setting: MappingSetting = MappingSetting.objects.filter( - workspace_id=expense_group.workspace_id, - destination_field='COST_TYPE' - ).first() - - if cost_type_setting: - attribute = ExpenseAttribute.objects.filter(attribute_type=cost_type_setting.source_field).first() - source_value = lineitem.custom_properties.get(attribute.display_name, None) - mapping: Mapping = Mapping.objects.filter( - source_type=cost_type_setting.source_field, - destination_type='COST_TYPE', - source__value=source_value, - workspace_id=expense_group.workspace_id - ).first() + selected_cost_type = lineitem.custom_properties.get(dependent_field_setting.cost_type_field_name, None) + cost_type = CostType.objects.filter( + workspace_id=expense_group.workspace_id, + task_id=task_id, + project_id=project_id, + name=selected_cost_type + ).first() - if mapping: - cost_type_id = mapping.destination.detail['external_id'] + if cost_type: + cost_type_id = cost_type.cost_type_id return cost_type_id -def get_task_id_or_none(expense_group: ExpenseGroup, lineitem: Expense, customer_id: str): +def get_task_id_or_none(expense_group: ExpenseGroup, lineitem: Expense, dependent_field_setting: DependentFieldSetting, project_id: str): task_id = None - task_setting: MappingSetting = MappingSetting.objects.filter( + + selected_cost_code = lineitem.custom_properties.get(dependent_field_setting.cost_code_field_name, None) + cost_type = CostType.objects.filter( workspace_id=expense_group.workspace_id, - destination_field='TASK' + task_name=selected_cost_code, + project_id=project_id ).first() - if customer_id and task_setting: - attribute = ExpenseAttribute.objects.filter(attribute_type=task_setting.source_field).first() - source_value = lineitem.custom_properties.get(attribute.display_name, None) - mapping: Mapping = Mapping.objects.filter( - source_type=task_setting.source_field, - destination_type='TASK', - source__value=source_value, - workspace_id=expense_group.workspace_id - ).first() - - if mapping: - task_id = mapping.destination.detail['external_id'] + if cost_type: + task_id = cost_type.task_id return task_id @@ -556,6 +541,9 @@ def create_bill_lineitems(expense_group: ExpenseGroup, configuration: Configura """ expenses = expense_group.expenses.all() bill = Bill.objects.get(expense_group=expense_group) + dependent_field_setting = DependentFieldSetting.objects.filter(workspace_id=expense_group.workspace_id).first() + task_id = None + cost_type_id = None default_employee_location_id = None default_employee_department_id = None @@ -590,8 +578,11 @@ def create_bill_lineitems(expense_group: ExpenseGroup, configuration: Configura class_id = get_class_id_or_none(expense_group, lineitem, general_mappings) customer_id = get_customer_id_or_none(expense_group, lineitem, general_mappings, project_id) item_id = get_item_id_or_none(expense_group, lineitem, general_mappings) - task_id = get_task_id_or_none(expense_group, lineitem, project_id) - cost_type_id = get_cost_type_id_or_none(expense_group, lineitem, task_id) + + if dependent_field_setting: + task_id = get_task_id_or_none(expense_group, lineitem, dependent_field_setting, project_id) + cost_type_id = get_cost_type_id_or_none(expense_group, lineitem, dependent_field_setting, project_id, task_id) + user_defined_dimensions = get_user_defined_dimension_object(expense_group, lineitem) bill_lineitem_object, _ = BillLineitem.objects.update_or_create( @@ -714,6 +705,9 @@ def create_expense_report_lineitems(expense_group: ExpenseGroup, configuration: """ expenses = expense_group.expenses.all() expense_report = ExpenseReport.objects.get(expense_group=expense_group) + task_id = None + cost_type_id = None + dependent_field_setting = DependentFieldSetting.objects.filter(workspace_id=expense_group.workspace_id).first() default_employee_location_id = None default_employee_department_id = None @@ -748,8 +742,11 @@ def create_expense_report_lineitems(expense_group: ExpenseGroup, configuration: class_id = get_class_id_or_none(expense_group, lineitem, general_mappings) customer_id = get_customer_id_or_none(expense_group, lineitem, general_mappings, project_id) item_id = get_item_id_or_none(expense_group, lineitem, general_mappings) - task_id = get_task_id_or_none(expense_group, lineitem, project_id) - cost_type_id = get_cost_type_id_or_none(expense_group, lineitem, task_id) + + if dependent_field_setting: + task_id = get_task_id_or_none(expense_group, lineitem, dependent_field_setting, project_id) + cost_type_id = get_cost_type_id_or_none(expense_group, lineitem, dependent_field_setting, project_id, task_id) + user_defined_dimensions = get_user_defined_dimension_object(expense_group, lineitem) if expense_group.fund_source == 'PERSONAL': @@ -872,6 +869,9 @@ def create_journal_entry_lineitems(expense_group: ExpenseGroup, configuration: C """ expenses = expense_group.expenses.all() journal_entry = JournalEntry.objects.get(expense_group=expense_group) + task_id = None + cost_type_id = None + dependent_field_setting = DependentFieldSetting.objects.filter(workspace_id=expense_group.workspace_id).first() default_employee_location_id = None default_employee_department_id = None @@ -915,8 +915,11 @@ def create_journal_entry_lineitems(expense_group: ExpenseGroup, configuration: C employee_id = entity.destination_employee.destination_id if employee_mapping_setting == 'EMPLOYEE' else None vendor_id = entity.destination_vendor.destination_id if employee_mapping_setting == 'VENDOR' else None class_id = get_class_id_or_none(expense_group, lineitem, general_mappings) - task_id = get_task_id_or_none(expense_group, lineitem, project_id) - cost_type_id = get_cost_type_id_or_none(expense_group, lineitem, task_id) + + if dependent_field_setting: + task_id = get_task_id_or_none(expense_group, lineitem, dependent_field_setting, project_id) + cost_type_id = get_cost_type_id_or_none(expense_group, lineitem, dependent_field_setting, project_id, task_id) + customer_id = get_customer_id_or_none(expense_group, lineitem, general_mappings, project_id) item_id = get_item_id_or_none(expense_group, lineitem, general_mappings) user_defined_dimensions = get_user_defined_dimension_object(expense_group, lineitem) @@ -1059,6 +1062,10 @@ def create_charge_card_transaction_lineitems(expense_group: ExpenseGroup, confi expenses = expense_group.expenses.all() charge_card_transaction = ChargeCardTransaction.objects.get(expense_group=expense_group) + task_id = None + cost_type_id = None + dependent_field_setting = DependentFieldSetting.objects.filter(workspace_id=expense_group.workspace_id).first() + default_employee_location_id = None default_employee_department_id = None @@ -1092,8 +1099,10 @@ def create_charge_card_transaction_lineitems(expense_group: ExpenseGroup, confi class_id = get_class_id_or_none(expense_group, lineitem, general_mappings) customer_id = get_customer_id_or_none(expense_group, lineitem, general_mappings, project_id) item_id = get_item_id_or_none(expense_group, lineitem, general_mappings) - task_id = get_task_id_or_none(expense_group, lineitem, project_id) - cost_type_id = get_cost_type_id_or_none(expense_group, lineitem, task_id) + + if dependent_field_setting: + task_id = get_task_id_or_none(expense_group, lineitem, dependent_field_setting, project_id) + cost_type_id = get_cost_type_id_or_none(expense_group, lineitem, dependent_field_setting, project_id, task_id) charge_card_transaction_lineitem_object, _ = ChargeCardTransactionLineitem.objects.update_or_create( charge_card_transaction=charge_card_transaction, @@ -1305,3 +1314,105 @@ def create_sage_intacct_reimbursement_lineitems(expense_group: ExpenseGroup, rec sage_intacct_reimbursement_lineitem_objects.append(sage_intacct_reimbursement_lineitem_object) return sage_intacct_reimbursement_lineitem_objects + + +class CostType(models.Model): + """ + Sage Intacct Cost Types + DB Table: cost_types: + """ + record_number = models.IntegerField(help_text='Sage Intacct Record No') + project_key = models.CharField(max_length=255, help_text='Sage Intacct Project Key') + project_id = models.CharField(max_length=255, help_text='Sage Intacct Project ID') + project_name = models.CharField(max_length=255, help_text='Sage Intacct Project Name') + task_key = models.CharField(max_length=255, help_text='Sage Intacct Task Key') + task_id = models.CharField(max_length=255, help_text='Sage Intacct Task ID') + status = models.CharField(max_length=255, help_text='Sage Intacct Status', null=True) + task_name = models.CharField(max_length=255, help_text='Sage Intacct Task Name') + cost_type_id = models.CharField(max_length=255, help_text='Sage Intacct Cost Type ID') + name = models.CharField(max_length=255, help_text='Sage Intacct Cost Type Name') + when_created = models.CharField(max_length=255, help_text='Sage Intacct When Created', null=True) + when_modified = models.CharField(max_length=255, help_text='Sage Intacct When Modified', null=True) + workspace = models.ForeignKey(Workspace, on_delete=models.PROTECT, help_text='Reference to Workspace') + created_at = models.DateTimeField(auto_now_add=True, help_text='Created at') + updated_at = models.DateTimeField(auto_now=True, help_text='Updated at') + + class Meta: + unique_together = ('record_number', 'workspace_id') + db_table = 'cost_types' + + @staticmethod + def bulk_create_or_update(cost_types: List[Dict], workspace_id: int): + """ + Bulk create or update cost types + """ + record_number_list = [cost_type['RECORDNO'] for cost_type in cost_types] + + filters = { + 'record_number__in': record_number_list, + 'workspace_id': workspace_id + } + + existing_cost_types = CostType.objects.filter(**filters).values( + 'id', + 'record_number', + 'name', + 'status' + ) + + existing_cost_type_record_numbers = [] + + primary_key_map = {} + + for existing_cost_type in existing_cost_types: + existing_cost_type_record_numbers.append(existing_cost_type['record_number']) + + primary_key_map[existing_cost_type['record_number']] = { + 'id': existing_cost_type['id'], + 'name': existing_cost_type['name'], + 'status': existing_cost_type['status'], + } + + cost_types_to_be_created = [] + cost_types_to_be_updated = [] + print('existing_cost_type_record_numbers',existing_cost_type_record_numbers) + + for cost_type in cost_types: + cost_type_object = CostType( + record_number=cost_type['RECORDNO'], + project_key=cost_type['PROJECTKEY'], + project_id=cost_type['PROJECTID'], + project_name=cost_type['PROJECTNAME'], + task_key=cost_type['TASKKEY'], + task_id=cost_type['TASKID'], + task_name=cost_type['TASKNAME'], + cost_type_id=cost_type['COSTTYPEID'], + name=cost_type['NAME'], + status=cost_type['STATUS'], + when_created=cost_type['WHENCREATED'] if 'WHENCREATED' in cost_type else None, + when_modified=cost_type['WHENMODIFIED'] if 'WHENMODIFIED' in cost_type else None, + workspace_id=workspace_id + ) + + if cost_type['RECORDNO'] not in existing_cost_type_record_numbers: + print('cost_type_objectcost_type_object',cost_type_object.record_number) + cost_types_to_be_created.append(cost_type_object) + + elif cost_type['RECORDNO'] in primary_key_map.keys() and ( + cost_type['NAME'] != primary_key_map[cost_type['RECORDNO']]['name'] or cost_type['STATUS'] != primary_key_map[cost_type['RECORDNO']]['status'] + ): + cost_type_object.id = primary_key_map[cost_type['RECORDNO']]['id'] + print('cost_type_objectcost_type_object2222',cost_type_object.record_number) + cost_types_to_be_updated.append(cost_type_object) + + if cost_types_to_be_created: + CostType.objects.bulk_create(cost_types_to_be_created, batch_size=2000) + + if cost_types_to_be_updated: + CostType.objects.bulk_update( + cost_types_to_be_updated, fields=[ + 'project_key', 'project_id', 'project_name', 'task_key', 'task_id', 'task_name', + 'cost_type_id', 'name', 'status', 'when_modified' + ], + batch_size=2000 + ) diff --git a/apps/sage_intacct/utils.py b/apps/sage_intacct/utils.py index 9cd1e7be..b74a5535 100644 --- a/apps/sage_intacct/utils.py +++ b/apps/sage_intacct/utils.py @@ -1,8 +1,9 @@ import logging import base64 from typing import List, Dict -from datetime import datetime +from datetime import datetime, timedelta import unidecode +import time from django.conf import settings from cryptography.fernet import Fernet @@ -14,12 +15,15 @@ from sageintacctsdk.exceptions import WrongParamsError from fyle_accounting_mappings.models import DestinationAttribute, ExpenseAttribute +from apps.fyle.models import DependentFieldSetting from apps.mappings.models import GeneralMapping, LocationEntityMapping from apps.workspaces.models import SageIntacctCredential, FyleCredential, Workspace, Configuration -from .models import ExpenseReport, ExpenseReportLineitem, Bill, BillLineitem, ChargeCardTransaction, \ - ChargeCardTransactionLineitem, APPayment, APPaymentLineitem, JournalEntry, JournalEntryLineitem, SageIntacctReimbursement, \ - SageIntacctReimbursementLineitem +from .models import ( + ExpenseReport, ExpenseReportLineitem, Bill, BillLineitem, ChargeCardTransaction, + ChargeCardTransactionLineitem, APPayment, APPaymentLineitem, JournalEntry, JournalEntryLineitem, SageIntacctReimbursement, + SageIntacctReimbursementLineitem, CostType +) logger = logging.getLogger(__name__) @@ -261,62 +265,27 @@ def sync_payment_accounts(self): return [] - def sync_tasks(self): - """ - Get of Tasks - """ - - intacct_tasks = self.connection.tasks.get_all() - task_attributes = [] - - # saving values as combination of taskid, name and recordno to avoid duplicates - for task in intacct_tasks: - task_attributes.append({ - 'attribute_type': 'TASK', - 'display_name': 'task', - 'value': '{}--{}--{}'.format(task['TASKID'], task['NAME'], task['RECORDNO']), - 'destination_id': task['RECORDNO'], # storing record number instead of TASKID to avoid duplicates - 'detail': { - 'project_id': task['PROJECTID'], - 'project_name': task['PROJECTNAME'], - 'external_id': task['TASKID'] - }, - 'active': True - }) - - DestinationAttribute.bulk_create_or_update_destination_attributes( - task_attributes, 'TASK', self.workspace_id, True) - - return [] - def sync_cost_types(self): """ Sync of Sage Intacct Cost Types """ + args = { + 'field': 'STATUS', + 'value': 'active' + } - cost_types = self.connection.cost_types.get_all() - cost_types_attributes = [] + dependent_field_setting = DependentFieldSetting.objects.filter(workspace_id=self.workspace_id, last_successful_import_at__isnull=False).first() - for cost_type in cost_types: - cost_types_attributes.append({ - 'attribute_type': 'COST_TYPE', - 'display_name': 'cost type', - 'value': '{}--{}--{}'.format(cost_type['COSTTYPEID'], cost_type['NAME'], cost_type['RECORDNO']), - 'destination_id': cost_type['RECORDNO'], - 'active': True if cost_type['STATUS'] == 'active' else False, - 'detail': { - 'project_id': cost_type['PROJECTID'], - 'project_name': cost_type['PROJECTNAME'], - 'task_id': cost_type['TASKID'], - 'task_name': cost_type['TASKNAME'], - 'external_id': cost_type['COSTTYPEID'] - } - }) + if dependent_field_setting: + # subtracting 1 day from the last_successful_import_at since time is not involved + latest_synced_timestamp = dependent_field_setting.last_successful_import_at - timedelta(days=1) + args['updated_at'] = latest_synced_timestamp.strftime('%m/%d/%Y') - DestinationAttribute.bulk_create_or_update_destination_attributes( - cost_types_attributes, 'COST_TYPE', self.workspace_id, True) + cost_types_generator = self.connection.cost_types.get_all_generator(**args) + + for cost_types in cost_types_generator: + CostType.bulk_create_or_update(cost_types, self.workspace_id) - return [] def sync_projects(self): """ diff --git a/requirements.txt b/requirements.txt index 801f332c..d4fb7c3c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ django-sendgrid-v5==1.2.0 future==0.18.2 fyle==0.32.0 fyle_accounting_mappings==1.26.1 -fyle-integrations-platform-connector==1.26.1 +fyle-integrations-platform-connector==1.29.0 fyle-rest-auth==1.3.0 gevent==22.10.2 gunicorn==20.1.0 @@ -23,7 +23,7 @@ pytest-cov==3.0.0 pytest-django==4.5.2 pytest-mock==3.8.2 requests==2.25.0 -sageintacctsdk==1.14.2 +sageintacctsdk==1.15.0 sentry-sdk==1.19.1 six==1.13.0 Unidecode==1.1.2 diff --git a/scripts/sql/functions/delete-workspace.sql b/scripts/sql/functions/delete-workspace.sql index a7177d49..defeb0f5 100644 --- a/scripts/sql/functions/delete-workspace.sql +++ b/scripts/sql/functions/delete-workspace.sql @@ -6,6 +6,18 @@ DECLARE BEGIN RAISE NOTICE 'Deleting data from workspace %', _workspace_id; + DELETE + FROM dependent_field_settings dfs + WHERE dfs.workspace_id = _workspace_id; + GET DIAGNOSTICS rcount = ROW_COUNT; + RAISE NOTICE 'Deleted % dependent_field_settings', rcount; + + DELETE + FROM cost_types ct + WHERE ct.workspace_id = _workspace_id; + GET DIAGNOSTICS rcount = ROW_COUNT; + RAISE NOTICE 'Deleted % cost_types', rcount; + DELETE FROM location_entity_mappings lem WHERE lem.workspace_id = _workspace_id; diff --git a/tests/sql_fixtures/reset_db_fixtures/reset_db.sql b/tests/sql_fixtures/reset_db_fixtures/reset_db.sql index c2d0458c..e79c431c 100644 --- a/tests/sql_fixtures/reset_db_fixtures/reset_db.sql +++ b/tests/sql_fixtures/reset_db_fixtures/reset_db.sql @@ -400,6 +400,97 @@ CREATE TABLE public.configurations ( ALTER TABLE public.configurations OWNER TO postgres; +-- +-- Name: cost_types; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.cost_types ( + id integer NOT NULL, + record_number integer NOT NULL, + project_key character varying(255) NOT NULL, + project_id character varying(255) NOT NULL, + project_name character varying(255) NOT NULL, + task_key character varying(255) NOT NULL, + task_id character varying(255) NOT NULL, + status character varying(255), + task_name character varying(255) NOT NULL, + cost_type_id character varying(255) NOT NULL, + name character varying(255) NOT NULL, + when_created character varying(255), + when_modified character varying(255), + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + workspace_id integer NOT NULL +); + + +ALTER TABLE public.cost_types OWNER TO postgres; + +-- +-- Name: cost_types_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres +-- + +CREATE SEQUENCE public.cost_types_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.cost_types_id_seq OWNER TO postgres; + +-- +-- Name: cost_types_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres +-- + +ALTER SEQUENCE public.cost_types_id_seq OWNED BY public.cost_types.id; + + +-- +-- Name: dependent_field_settings; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.dependent_field_settings ( + id integer NOT NULL, + is_import_enabled boolean NOT NULL, + project_field_id integer NOT NULL, + cost_code_field_name character varying(255) NOT NULL, + cost_code_field_id integer NOT NULL, + cost_type_field_name character varying(255) NOT NULL, + cost_type_field_id integer NOT NULL, + last_successful_import_at timestamp with time zone, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + workspace_id integer NOT NULL +); + + +ALTER TABLE public.dependent_field_settings OWNER TO postgres; + +-- +-- Name: dependent_fields_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres +-- + +CREATE SEQUENCE public.dependent_fields_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dependent_fields_id_seq OWNER TO postgres; + +-- +-- Name: dependent_fields_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres +-- + +ALTER SEQUENCE public.dependent_fields_id_seq OWNED BY public.dependent_field_settings.id; + + -- -- Name: destination_attributes; Type: TABLE; Schema: public; Owner: postgres -- @@ -2026,6 +2117,20 @@ ALTER TABLE ONLY public.charge_card_transactions ALTER COLUMN id SET DEFAULT nex ALTER TABLE ONLY public.configurations ALTER COLUMN id SET DEFAULT nextval('public.workspaces_workspacegeneralsettings_id_seq'::regclass); +-- +-- Name: cost_types id; Type: DEFAULT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.cost_types ALTER COLUMN id SET DEFAULT nextval('public.cost_types_id_seq'::regclass); + + +-- +-- Name: dependent_field_settings id; Type: DEFAULT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dependent_field_settings ALTER COLUMN id SET DEFAULT nextval('public.dependent_fields_id_seq'::regclass); + + -- -- Name: destination_attributes id; Type: DEFAULT; Schema: public; Owner: postgres -- @@ -2471,6 +2576,14 @@ COPY public.auth_permission (id, name, content_type_id, codename) FROM stdin; 174 Can change expense filter 44 change_expensefilter 175 Can delete expense filter 44 delete_expensefilter 176 Can view expense filter 44 view_expensefilter +177 Can add dependent field setting 45 add_dependentfieldsetting +178 Can change dependent field setting 45 change_dependentfieldsetting +179 Can delete dependent field setting 45 delete_dependentfieldsetting +180 Can view dependent field setting 45 view_dependentfieldsetting +181 Can add cost type 46 add_costtype +182 Can change cost type 46 change_costtype +183 Can delete cost type 46 delete_costtype +184 Can view cost type 46 view_costtype \. @@ -2542,6 +2655,22 @@ COPY public.configurations (id, reimbursable_expenses_object, created_at, update \. +-- +-- Data for Name: cost_types; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.cost_types (id, record_number, project_key, project_id, project_name, task_key, task_id, status, task_name, cost_type_id, name, when_created, when_modified, created_at, updated_at, workspace_id) FROM stdin; +\. + + +-- +-- Data for Name: dependent_field_settings; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.dependent_field_settings (id, is_import_enabled, project_field_id, cost_code_field_name, cost_code_field_id, cost_type_field_name, cost_type_field_id, last_successful_import_at, created_at, updated_at, workspace_id) FROM stdin; +\. + + -- -- Data for Name: destination_attributes; Type: TABLE DATA; Schema: public; Owner: postgres -- @@ -3550,6 +3679,8 @@ COPY public.django_content_type (id, app_label, model) FROM stdin; 42 mappings locationentitymapping 43 fyle_accounting_mappings expensefield 44 fyle expensefilter +45 fyle dependentfieldsetting +46 sage_intacct costtype \. @@ -3700,6 +3831,12 @@ COPY public.django_migrations (id, app, name, applied) FROM stdin; 139 mappings 0012_auto_20230417_1124 2023-04-17 11:42:04.876255+00 140 fyle 0018_auto_20230427_0355 2023-04-27 03:56:21.411364+00 141 fyle 0019_expense_report_title 2023-04-27 16:57:27.877735+00 +142 fyle 0020_dependentfield 2023-06-15 11:38:34.139106+00 +143 fyle 0021_auto_20230615_0808 2023-06-15 11:38:34.1818+00 +144 fyle_accounting_mappings 0022_auto_20230411_1118 2023-06-15 11:38:34.197521+00 +145 sage_intacct 0020_costtypes 2023-06-15 11:38:34.218491+00 +146 sage_intacct 0021_auto_20230608_1310 2023-06-15 11:38:34.25444+00 +147 sage_intacct 0022_auto_20230615_1509 2023-06-15 15:10:11.18372+00 \. @@ -7631,7 +7768,7 @@ SELECT pg_catalog.setval('public.auth_group_permissions_id_seq', 1, false); -- Name: auth_permission_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- -SELECT pg_catalog.setval('public.auth_permission_id_seq', 176, true); +SELECT pg_catalog.setval('public.auth_permission_id_seq', 184, true); -- @@ -7641,6 +7778,20 @@ SELECT pg_catalog.setval('public.auth_permission_id_seq', 176, true); SELECT pg_catalog.setval('public.category_mappings_id_seq', 140, true); +-- +-- Name: cost_types_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres +-- + +SELECT pg_catalog.setval('public.cost_types_id_seq', 1, false); + + +-- +-- Name: dependent_fields_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres +-- + +SELECT pg_catalog.setval('public.dependent_fields_id_seq', 1, false); + + -- -- Name: django_admin_log_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- @@ -7652,14 +7803,14 @@ SELECT pg_catalog.setval('public.django_admin_log_id_seq', 1, false); -- Name: django_content_type_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- -SELECT pg_catalog.setval('public.django_content_type_id_seq', 44, true); +SELECT pg_catalog.setval('public.django_content_type_id_seq', 46, true); -- -- Name: django_migrations_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- -SELECT pg_catalog.setval('public.django_migrations_id_seq', 141, true); +SELECT pg_catalog.setval('public.django_migrations_id_seq', 147, true); -- @@ -8004,11 +8155,43 @@ ALTER TABLE ONLY public.category_mappings -- --- Name: destination_attributes destination_attributes_destination_id_attribute_dfb58751_uniq; Type: CONSTRAINT; Schema: public; Owner: postgres +-- Name: cost_types cost_types_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.cost_types + ADD CONSTRAINT cost_types_pkey PRIMARY KEY (id); + + +-- +-- Name: cost_types cost_types_record_number_workspace_id_a86dce01_uniq; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.cost_types + ADD CONSTRAINT cost_types_record_number_workspace_id_a86dce01_uniq UNIQUE (record_number, workspace_id); + + +-- +-- Name: dependent_field_settings dependent_fields_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dependent_field_settings + ADD CONSTRAINT dependent_fields_pkey PRIMARY KEY (id); + + +-- +-- Name: dependent_field_settings dependent_fields_workspace_id_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dependent_field_settings + ADD CONSTRAINT dependent_fields_workspace_id_key UNIQUE (workspace_id); + + +-- +-- Name: destination_attributes destination_attributes_destination_id_attribute_d22ab1fe_uniq; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.destination_attributes - ADD CONSTRAINT destination_attributes_destination_id_attribute_dfb58751_uniq UNIQUE (destination_id, attribute_type, workspace_id); + ADD CONSTRAINT destination_attributes_destination_id_attribute_d22ab1fe_uniq UNIQUE (destination_id, attribute_type, workspace_id, display_name); -- @@ -8632,6 +8815,13 @@ CREATE INDEX category_mappings_workspace_id_222ea301 ON public.category_mappings CREATE INDEX charge_card_transaction_li_charge_card_transaction_id_508bf6be ON public.charge_card_transaction_lineitems USING btree (charge_card_transaction_id); +-- +-- Name: cost_types_workspace_id_c71fcac0; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX cost_types_workspace_id_c71fcac0 ON public.cost_types USING btree (workspace_id); + + -- -- Name: django_admin_log_content_type_id_c4bce8eb; Type: INDEX; Schema: public; Owner: postgres -- @@ -9032,6 +9222,22 @@ ALTER TABLE ONLY public.charge_card_transaction_lineitems ADD CONSTRAINT charge_card_transact_expense_id_d662cef7_fk_expenses_ FOREIGN KEY (expense_id) REFERENCES public.expenses(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: cost_types cost_types_workspace_id_c71fcac0_fk_workspaces_id; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.cost_types + ADD CONSTRAINT cost_types_workspace_id_c71fcac0_fk_workspaces_id FOREIGN KEY (workspace_id) REFERENCES public.workspaces(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dependent_field_settings dependent_fields_workspace_id_6b3920cb_fk_workspaces_id; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dependent_field_settings + ADD CONSTRAINT dependent_fields_workspace_id_6b3920cb_fk_workspaces_id FOREIGN KEY (workspace_id) REFERENCES public.workspaces(id) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: django_admin_log django_admin_log_content_type_id_c4bce8eb_fk_django_co; Type: FK CONSTRAINT; Schema: public; Owner: postgres -- diff --git a/tests/test_fyle/test_signals.py b/tests/test_fyle/test_signals.py new file mode 100644 index 00000000..f40143dd --- /dev/null +++ b/tests/test_fyle/test_signals.py @@ -0,0 +1,18 @@ +from django_q.models import Schedule + +from apps.fyle.models import DependentFieldSetting +from apps.fyle.signals import run_post_save_dependent_field_settings_triggers + +def test_run_post_save_dependent_field_settings_triggers(mocker, db): + dependent_field = DependentFieldSetting( + workspace_id=1, + is_import_enabled=True, + project_field_id=123, + cost_code_field_name='Cost Code', + cost_code_field_id=0, + cost_type_field_name='Cost Type', + cost_type_field_id=789 + ) + run_post_save_dependent_field_settings_triggers(None, dependent_field) + + assert Schedule.objects.filter(func='apps.sage_intacct.dependent_fields.import_dependent_fields_to_fyle', args='1').exists() diff --git a/tests/test_mappings/conftest.py b/tests/test_mappings/conftest.py index 477097f5..a8fc4462 100644 --- a/tests/test_mappings/conftest.py +++ b/tests/test_mappings/conftest.py @@ -1,6 +1,9 @@ import pytest from fyle_accounting_mappings.models import MappingSetting, DestinationAttribute +from apps.fyle.models import DependentFieldSetting +from apps.sage_intacct.models import CostType + @pytest.fixture def create_mapping_setting(db): @@ -19,3 +22,40 @@ def create_mapping_setting(db): DestinationAttribute.bulk_create_or_update_destination_attributes( [{'attribute_type': 'COST_CENTER', 'display_name': 'Cost center', 'value': 'sample', 'destination_id': '7b354c1c-cf59-42fc-9449-a65c51988335'}], 'COST_CENTER', 1, True ) + + +@pytest.fixture +def create_dependent_field_setting(db): + created_field, _ = DependentFieldSetting.objects.update_or_create( + workspace_id=1, + defaults={ + 'is_import_enabled': True, + 'project_field_id': 123, + 'cost_code_field_name': 'Cost Code', + 'cost_code_field_id': 456, + 'cost_type_field_name': 'Cost Type', + 'cost_type_field_id': 789 + } + ) + + return created_field + + +@pytest.fixture +def create_cost_type(db): + workspace_id = 1 + CostType.objects.update_or_create( + workspace_id=workspace_id, + defaults={ + 'record_number': 34234, + 'project_key': 34, + 'project_id': 'pro1', + 'project_name': 'pro', + 'task_key': 34, + 'task_id': 'task1', + 'task_name': 'task', + 'status': 'ACTIVE', + 'cost_type_id': 'cost1', + 'name': 'cost' + } + ) diff --git a/tests/test_mappings/test_tasks.py b/tests/test_mappings/test_tasks.py index 51b2aeaa..83d1427d 100644 --- a/tests/test_mappings/test_tasks.py +++ b/tests/test_mappings/test_tasks.py @@ -484,11 +484,11 @@ def test_auto_create_expense_fields_mappings(db, mocker, create_mapping_setting) ) workspace_id = 1 - auto_create_expense_fields_mappings(workspace_id, 'TASK', 'COST_CODES', 12312, None) + auto_create_expense_fields_mappings(workspace_id, 'TASK', 'COST_CODES', None) mappings = Mapping.objects.filter(workspace_id=workspace_id, destination_type='TASK').count() assert mappings == 0 - auto_create_expense_fields_mappings(workspace_id, 'COST_CENTER', 'COST_CENTER', None, 'Select Cost Center') + auto_create_expense_fields_mappings(workspace_id, 'COST_CENTER', 'COST_CENTER', 'Select Cost Center') cost_center = DestinationAttribute.objects.filter(workspace_id=workspace_id, attribute_type='COST_CENTER').count() mappings = Mapping.objects.filter(workspace_id=workspace_id, source_type='COST_CENTER').count() @@ -497,7 +497,7 @@ def test_auto_create_expense_fields_mappings(db, mocker, create_mapping_setting) assert mappings == 0 -def test_sync_sage_intacct_attributes(mocker, db): +def test_sync_sage_intacct_attributes(mocker, db, create_dependent_field_setting, create_cost_type): workspace_id = 1 mocker.patch( 'sageintacctsdk.apis.Locations.get_all', @@ -520,10 +520,16 @@ def test_sync_sage_intacct_attributes(mocker, db): return_value=intacct_data['get_vendors'] ) + mocker.patch( + 'sageintacctsdk.apis.CostTypes.get_all_generator', + return_value=[] + ) + sync_sage_intacct_attributes('DEPARTMENT', workspace_id=workspace_id) sync_sage_intacct_attributes('LOCATION', workspace_id=workspace_id) sync_sage_intacct_attributes('PROJECT', workspace_id=workspace_id) sync_sage_intacct_attributes('VENDOR', workspace_id=workspace_id) + sync_sage_intacct_attributes('COST_TYPE', workspace_id) projects = DestinationAttribute.objects.filter(workspace_id=workspace_id, attribute_type='PROJECT').count() mappings = Mapping.objects.filter(workspace_id=workspace_id, destination_type='PROJECT').count() diff --git a/tests/test_sageintacct/conftest.py b/tests/test_sageintacct/conftest.py index 4126001d..34c62f92 100644 --- a/tests/test_sageintacct/conftest.py +++ b/tests/test_sageintacct/conftest.py @@ -1,8 +1,16 @@ import pytest -from apps.fyle.models import Expense, ExpenseGroup +from datetime import datetime +from apps.fyle.models import ( + ExpenseGroup, DependentFieldSetting, Expense +) from apps.workspaces.models import Configuration -from apps.sage_intacct.models import Bill, BillLineitem, ExpenseReport, ExpenseReportLineitem, JournalEntry, JournalEntryLineitem, \ - SageIntacctReimbursement, SageIntacctReimbursementLineitem, ChargeCardTransaction, ChargeCardTransactionLineitem, APPayment, APPaymentLineitem +from apps.sage_intacct.models import ( + Bill, BillLineitem, ExpenseReport, ExpenseReportLineitem, + JournalEntry, JournalEntryLineitem, SageIntacctReimbursement, + SageIntacctReimbursementLineitem, ChargeCardTransaction, + ChargeCardTransactionLineitem, APPayment, APPaymentLineitem, + CostType +) from apps.mappings.models import GeneralMapping from apps.tasks.models import TaskLog @@ -93,3 +101,87 @@ def create_task_logs(db): 'status': 'READY' } ) + +@pytest.fixture +def create_cost_type(db): + workspace_id = 1 + CostType.objects.update_or_create( + workspace_id=workspace_id, + defaults={ + 'record_number': 34234, + 'project_key': 34, + 'project_id': 'pro1', + 'project_name': 'pro', + 'task_key': 34, + 'task_id': 'task1', + 'task_name': 'task', + 'status': 'ACTIVE', + 'cost_type_id': 'cost1', + 'name': 'cost' + } + ) + +@pytest.fixture +def create_dependent_field_setting(db): + created_field, _ = DependentFieldSetting.objects.update_or_create( + workspace_id=1, + defaults={ + 'is_import_enabled': True, + 'project_field_id': 123, + 'cost_code_field_name': 'Cost Code', + 'cost_code_field_id': 456, + 'cost_type_field_name': 'Cost Type', + 'cost_type_field_id': 789 + } + ) + + return created_field + +@pytest.fixture +def create_expense_group_expense(db): + expense_group = ExpenseGroup.objects.create( + workspace_id=1, + fund_source='PERSONAL', + description={} + ) + + expense, _ = Expense.objects.update_or_create( + expense_id='dummy_id', + defaults={ + 'employee_email': 'employee_email', + 'category': 'category', + 'sub_category': 'sub_category', + 'project': 'pro', + 'expense_number': 'expense_number', + 'org_id': 'org_id', + 'claim_number': 'claim_number', + 'amount': round(123, 2), + 'currency': 'USD', + 'foreign_amount': 123, + 'foreign_currency': 'USD', + 'tax_amount': 123, + 'tax_group_id': 'tax_group_id', + 'settlement_id': 'settlement_id', + 'reimbursable': True, + 'billable': True, + 'state': 'state', + 'vendor': 'vendor', + 'cost_center': 'cost_center', + 'purpose': 'purpose', + 'report_id': 'report_id', + 'report_title': 'report_title', + 'spent_at': datetime.now(), + 'approved_at': datetime.now(), + 'expense_created_at': datetime.now(), + 'expense_updated_at': datetime.now(), + 'fund_source': 'PERSONAL', + 'verified_at': datetime.now(), + 'custom_properties': {'Cost Type': 'cost', 'Cost Code': 'task'}, + 'payment_number': 'payment_number', + 'file_ids': [], + 'corporate_card_id': 'corporate_card_id', + } + ) + expense_group.expenses.add(expense) + + return expense_group, expense diff --git a/tests/test_sageintacct/test_dependent_fields.py b/tests/test_sageintacct/test_dependent_fields.py new file mode 100644 index 00000000..e8b7bef1 --- /dev/null +++ b/tests/test_sageintacct/test_dependent_fields.py @@ -0,0 +1,122 @@ +import logging +from datetime import datetime + +from unittest import mock + +from django_q.models import Schedule + +from fyle_integrations_platform_connector import PlatformConnector + +from sageintacctsdk.exceptions import InvalidTokenError, NoPrivilegeError + +from apps.fyle.models import DependentFieldSetting +from apps.sage_intacct.dependent_fields import ( + schedule_dependent_field_imports, create_dependent_custom_field_in_fyle, + post_dependent_cost_type, post_dependent_cost_code, post_dependent_expense_field_values, + import_dependent_fields_to_fyle +) +from apps.workspaces.models import FyleCredential + +logger = logging.getLogger(__name__) +logger.level = logging.INFO + + +def test_schedule_dependent_field_imports(db): + workspace_id = 1 + schedule_dependent_field_imports(workspace_id, True) + + assert Schedule.objects.filter( + func='apps.sage_intacct.dependent_fields.import_dependent_fields_to_fyle', + args=workspace_id + ).exists() + + schedule_dependent_field_imports(workspace_id, False) + assert not Schedule.objects.filter( + func='apps.sage_intacct.dependent_fields.import_dependent_fields_to_fyle', + args=workspace_id + ).exists() + + +def test_create_dependent_custom_field_in_fyle(mocker, db): + mocker.patch( + 'fyle.platform.apis.v1beta.admin.ExpenseFields.post', + return_value={'id': 123} + ) + workspace_id = 1 + fyle_credentials: FyleCredential = FyleCredential.objects.get(workspace_id=workspace_id) + platform = PlatformConnector(fyle_credentials) + created_field = create_dependent_custom_field_in_fyle(workspace_id, 'Cost Code', platform, 123) + + assert created_field == {'id': 123} + + +def test_post_dependent_cost_type(mocker, db, create_cost_type, create_dependent_field_setting): + workspace_id = 1 + mock = mocker.patch( + 'fyle.platform.apis.v1beta.admin.ExpenseFields.bulk_post_dependent_expense_field_values', + return_value=None + ) + fyle_credentials: FyleCredential = FyleCredential.objects.get(workspace_id=workspace_id) + platform = PlatformConnector(fyle_credentials) + + post_dependent_cost_type(create_dependent_field_setting, platform, {'workspace_id': 1}) + + assert mock.call_count == 1 + + +def test_post_dependent_cost_code(mocker, db, create_cost_type, create_dependent_field_setting): + workspace_id = 1 + mock = mocker.patch( + 'fyle.platform.apis.v1beta.admin.ExpenseFields.bulk_post_dependent_expense_field_values', + return_value=None + ) + fyle_credentials: FyleCredential = FyleCredential.objects.get(workspace_id=workspace_id) + platform = PlatformConnector(fyle_credentials) + + post_dependent_cost_code(create_dependent_field_setting, platform, {'workspace_id': 1}) + + assert mock.call_count == 1 + + +def test_post_dependent_expense_field_values(db, mocker, create_cost_type, create_dependent_field_setting): + workspace_id = 1 + mock = mocker.patch( + 'fyle.platform.apis.v1beta.admin.ExpenseFields.bulk_post_dependent_expense_field_values', + return_value=None + ) + + current_datetime = datetime.now() + post_dependent_expense_field_values(workspace_id, create_dependent_field_setting) + assert DependentFieldSetting.objects.get(id=create_dependent_field_setting.id).last_successful_import_at != current_datetime + + from django.contrib.postgres.aggregates import ArrayAgg + from apps.sage_intacct.models import CostType + projects = CostType.objects.filter(workspace_id=1).values('project_name').annotate(tasks=ArrayAgg('task_name', distinct=True)) + + # There should be 2 post calls, 1 for cost_type and 1 for cost_code + assert mock.call_count == 2 + + create_dependent_field_setting.last_successful_import_at = current_datetime + create_dependent_field_setting.save() + post_dependent_expense_field_values(workspace_id, create_dependent_field_setting) + + # Since we've updated timestamp and there would no new cost_types, the mock call count should still exist as 2 + assert mock.call_count == 2 + + +def test_import_dependent_fields_to_fyle(db, mocker, create_cost_type, create_dependent_field_setting): + workspace_id = 1 + with mock.patch('fyle.platform.apis.v1beta.admin.ExpenseFields.bulk_post_dependent_expense_field_values') as mock_call: + mock_call.side_effect = InvalidTokenError(msg='invalid params', response='invalid params') + import_dependent_fields_to_fyle(workspace_id) + + mock_call.side_effect = Exception('something went wrong') + import_dependent_fields_to_fyle(workspace_id) + + mock_call.side_effect = NoPrivilegeError(msg='no prev', response='invalid login') + import_dependent_fields_to_fyle(workspace_id) + + mock_call.side_effect = None + import_dependent_fields_to_fyle(workspace_id) + + assert mock_call.call_count == 0 diff --git a/tests/test_sageintacct/test_helpers.py b/tests/test_sageintacct/test_helpers.py index a1284909..af382891 100644 --- a/tests/test_sageintacct/test_helpers.py +++ b/tests/test_sageintacct/test_helpers.py @@ -1,4 +1,6 @@ -from apps.sage_intacct.helpers import schedule_payment_sync, check_interval_and_sync_dimension +from apps.sage_intacct.helpers import ( + schedule_payment_sync, check_interval_and_sync_dimension, is_dependent_field_import_enabled +) from apps.workspaces.models import Configuration, Workspace, SageIntacctCredential @@ -20,3 +22,7 @@ def test_check_interval_and_sync_dimension(db): workspace.save() check_interval_and_sync_dimension(workspace, intacct_credentials) + + +def test_is_dependent_field_import_enabled(db, create_dependent_field_setting): + assert is_dependent_field_import_enabled(1) == True diff --git a/tests/test_sageintacct/test_models.py b/tests/test_sageintacct/test_models.py index 1cd2bf66..406fa190 100644 --- a/tests/test_sageintacct/test_models.py +++ b/tests/test_sageintacct/test_models.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) logger.level = logging.INFO -def test_create_bill(db): +def test_create_bill(db, create_expense_group_expense, create_cost_type, create_dependent_field_setting): workspace_id = 1 expense_group = ExpenseGroup.objects.get(id=1) @@ -81,7 +81,7 @@ def test_expense_report(db): logger.info('General mapping not found') -def test_create_journal_entry(db): +def test_create_journal_entry(db, create_expense_group_expense, create_cost_type, create_dependent_field_setting): workspace_id = 1 expense_group = ExpenseGroup.objects.get(id=2) @@ -124,7 +124,7 @@ def test_create_ap_payment(db): assert ap_payment.vendor_id == 'Ashwin' -def test_create_charge_card_transaction(db): +def test_create_charge_card_transaction(db, create_expense_group_expense, create_cost_type, create_dependent_field_setting): workspace_id = 1 expense_group = ExpenseGroup.objects.get(id=1) @@ -795,4 +795,49 @@ def test_get_ccc_account_id(db, mocker): assert cct_id == general_mappings.default_charge_card_id - + +def test_get_cost_type_id_or_none(db, create_expense_group_expense, create_cost_type, create_dependent_field_setting): + expense_group, expense = create_expense_group_expense + cost_type_id = get_cost_type_id_or_none(expense_group, expense, create_dependent_field_setting, 'pro1', 'task1') + + assert cost_type_id == 'cost1' + + +def test_get_task_id_or_none(db, create_expense_group_expense, create_cost_type, create_dependent_field_setting): + expense_group, expense = create_expense_group_expense + task_id = get_task_id_or_none(expense_group, expense, create_dependent_field_setting, 'pro1') + + assert task_id == 'task1' + + +def test_cost_type_bulk_create_or_update(db, create_cost_type): + cost_types = [ + { + 'RECORDNO': 2342341, + 'PROJECTKEY': 'pro1234', + 'PROJECTID': 'pro1234', + 'PROJECTNAME': 'pro1234', + 'TASKKEY': 'task1234', + 'TASKID': 'task1234', + 'TASKNAME': 'task2341', + 'COSTTYPEID': 'cost2341', + 'NAME': 'cost12342', + 'STATUS': 'Active' + }, + { + 'RECORDNO': 34234, + 'PROJECTKEY': 34, + 'PROJECTID': 'pro1', + 'PROJECTNAME': 'pro', + 'TASKKEY': 34, + 'TASKNAME': 'task1', + 'STATUS': 'ACTIVE', + 'COSTTYPEID': 'cost1', + 'NAME': 'costUpdated', + 'TASKID': 'task1' + } + ] + CostType.bulk_create_or_update(cost_types, 1) + + assert CostType.objects.filter(record_number=2342341).exists() + assert CostType.objects.get(record_number=34234).name == 'costUpdated'