diff --git a/apps/accounting_exports/models.py b/apps/accounting_exports/models.py index e69de29..a0c26f8 100644 --- a/apps/accounting_exports/models.py +++ b/apps/accounting_exports/models.py @@ -0,0 +1,89 @@ +from django.db import models +from django.contrib.postgres.fields import ArrayField + +from fyle_accounting_mappings.models import ExpenseAttribute + +from ms_business_central_api.models.fields import ( + StringNotNullField, + StringNullField, + CustomJsonField, + CustomDateTimeField, + StringOptionsField, + IntegerNullField, + BooleanFalseField, + TextNotNullField +) +from apps.workspaces.models import BaseForeignWorkspaceModel, BaseModel +from apps.fyle.models import Expense + +TYPE_CHOICES = ( + ('INVOICES', 'INVOICES'), + ('DIRECT_COST', 'DIRECT_COST'), + ('FETCHING_REIMBURSABLE_EXPENSES', 'FETCHING_REIMBURSABLE_EXPENSES'), + ('FETCHING_CREDIT_CARD_EXPENENSES', 'FETCHING_CREDIT_CARD_EXPENENSES') +) + +ERROR_TYPE_CHOICES = (('EMPLOYEE_MAPPING', 'EMPLOYEE_MAPPING'), ('CATEGORY_MAPPING', 'CATEGORY_MAPPING'), ('BUSINESS_CENTRAL_ERROR', 'BUSINESS_CENTRAL_ERROR')) + +EXPORT_MODE_CHOICES = ( + ('MANUAL', 'MANUAL'), + ('AUTO', 'AUTO') +) + + +class AccountingExport(BaseForeignWorkspaceModel): + """ + Table to store accounting exports + """ + id = models.AutoField(primary_key=True) + type = StringOptionsField(choices=TYPE_CHOICES, help_text='Task type') + fund_source = StringNotNullField(help_text='Expense fund source') + mapping_errors = ArrayField(help_text='Mapping errors', base_field=models.CharField(max_length=255), blank=True, null=True) + expenses = models.ManyToManyField(Expense, help_text="Expenses under this Expense Group") + task_id = StringNullField(help_text='Fyle Jobs task reference') + description = CustomJsonField(help_text='Description') + status = StringNotNullField(help_text='Task Status') + detail = CustomJsonField(help_text='Task Response') + business_central_errors = CustomJsonField(help_text='Business Central Errors') + exported_at = CustomDateTimeField(help_text='time of export') + + class Meta: + db_table = 'accounting_exports' + + +class AccountingExportSummary(BaseModel): + """ + Table to store accounting export summary + """ + id = models.AutoField(primary_key=True) + last_exported_at = CustomDateTimeField(help_text='Last exported at datetime') + next_export_at = CustomDateTimeField(help_text='next export datetime') + export_mode = StringOptionsField(choices=EXPORT_MODE_CHOICES, help_text='Export mode') + total_accounting_export_count = IntegerNullField(help_text='Total count of accounting export exported') + successful_accounting_export_count = IntegerNullField(help_text='count of successful accounting export') + failed_accounting_export_count = IntegerNullField(help_text='count of failed accounting export') + + class Meta: + db_table = 'accounting_export_summary' + + +class Error(BaseForeignWorkspaceModel): + """ + Table to store errors + """ + id = models.AutoField(primary_key=True) + type = StringOptionsField(max_length=50, choices=ERROR_TYPE_CHOICES, help_text='Error type') + accounting_export = models.ForeignKey( + AccountingExport, on_delete=models.PROTECT, + null=True, help_text='Reference to Expense group' + ) + expense_attribute = models.OneToOneField( + ExpenseAttribute, on_delete=models.PROTECT, + null=True, help_text='Reference to Expense Attribute' + ) + is_resolved = BooleanFalseField(help_text='Is resolved') + error_title = StringNotNullField(help_text='Error title') + error_detail = TextNotNullField(help_text='Error detail') + + class Meta: + db_table = 'errors' diff --git a/apps/accounting_exports/serializers.py b/apps/accounting_exports/serializers.py new file mode 100644 index 0000000..c3a155b --- /dev/null +++ b/apps/accounting_exports/serializers.py @@ -0,0 +1,33 @@ +from rest_framework import serializers + +from apps.accounting_exports.models import AccountingExport, AccountingExportSummary, Error + + +class AccountingExportSerializer(serializers.ModelSerializer): + """ + Accounting Export serializer + """ + + class Meta: + model = AccountingExport + fields = '__all__' + + +class AccountingExportSummarySerializer(serializers.ModelSerializer): + """ + Accounting Export Summary serializer + """ + + class Meta: + model = AccountingExportSummary + fields = '__all__' + + +class ErrorSerializer(serializers.ModelSerializer): + """ + Serializer for the Errors + """ + + class Meta: + model = Error + fields = '__all__' diff --git a/apps/accounting_exports/urls.py b/apps/accounting_exports/urls.py index e69de29..e0b7d05 100644 --- a/apps/accounting_exports/urls.py +++ b/apps/accounting_exports/urls.py @@ -0,0 +1,25 @@ +"""ms_business_central_api URL Configuration +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.urls import path + +from apps.accounting_exports.views import AccountingExportView, AccountingExportCountView, AccountingExportSummaryView, ErrorsView + + +urlpatterns = [ + path('', AccountingExportView.as_view(), name='accounting-exports'), + path('count/', AccountingExportCountView.as_view(), name='accounting-exports-count'), + path('summary/', AccountingExportSummaryView.as_view(), name='accounting-exports-summary'), + path('errors/', ErrorsView.as_view(), name='errors'), +] diff --git a/apps/accounting_exports/views.py b/apps/accounting_exports/views.py index e69de29..16023d1 100644 --- a/apps/accounting_exports/views.py +++ b/apps/accounting_exports/views.py @@ -0,0 +1,55 @@ +import logging + +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import generics +from rest_framework.response import Response + +from ms_business_central_api.utils import LookupFieldMixin +from apps.accounting_exports.serializers import AccountingExportSerializer, AccountingExportSummarySerializer, ErrorSerializer +from apps.accounting_exports.models import AccountingExport, AccountingExportSummary, Error + + +logger = logging.getLogger(__name__) +logger.level = logging.INFO + + +class AccountingExportView(LookupFieldMixin, generics.ListAPIView): + """ + Retrieve or Create Accounting Export + """ + serializer_class = AccountingExportSerializer + queryset = AccountingExport.objects.all().order_by("-updated_at") + filter_backends = (DjangoFilterBackend,) + filterset_fields = {"type": {"in"}, "updated_at": {"lte", "gte"}, "id": {"in"}, "status": {"in"}} + + +class AccountingExportCountView(generics.RetrieveAPIView): + """ + Retrieve Accounting Export Count + """ + + def get(self, request, *args, **kwargs): + params = {"workspace_id": self.kwargs['workspace_id']} + + if request.query_params.get("status__in"): + params["status__in"] = request.query_params.get("status__in").split(",") + + return Response({"count": AccountingExport.objects.filter(**params).count()}) + + +class AccountingExportSummaryView(generics.RetrieveAPIView): + """ + Retrieve Accounting Export Summary + """ + lookup_field = 'workspace_id' + lookup_url_kwarg = 'workspace_id' + + queryset = AccountingExportSummary.objects.filter(last_exported_at__isnull=False, total_accounting_export_count__gt=0) + serializer_class = AccountingExportSummarySerializer + + +class ErrorsView(LookupFieldMixin, generics.ListAPIView): + serializer_class = ErrorSerializer + queryset = Error.objects.all() + filter_backends = (DjangoFilterBackend,) + filterset_fields = {"type": {"exact"}, "is_resolved": {"exact"}} diff --git a/apps/fyle/models.py b/apps/fyle/models.py index e69de29..daa585d 100644 --- a/apps/fyle/models.py +++ b/apps/fyle/models.py @@ -0,0 +1,112 @@ +from django.db import models +from django.contrib.postgres.fields import ArrayField +from ms_business_central_api.models.fields import ( + StringNotNullField, + StringNullField, + BooleanFalseField, + CustomJsonField, + CustomDateTimeField, + CustomEmailField, + FloatNullField, + StringOptionsField, + IntegerOptionsField, +) +from apps.workspaces.models import BaseModel, BaseForeignWorkspaceModel + + +EXPENSE_FILTER_RANK = ( + (1, 1), + (2, 2) +) + +EXPENSE_FILTER_JOIN_BY = ( + ('AND', 'AND'), + ('OR', 'OR') +) + +EXPENSE_FILTER_CUSTOM_FIELD_TYPE = ( + ('SELECT', 'SELECT'), + ('NUMBER', 'NUMBER'), + ('TEXT', 'TEXT') +) + +EXPENSE_FILTER_OPERATOR = ( + ('isnull', 'isnull'), + ('in', 'in'), + ('iexact', 'iexact'), + ('icontains', 'icontains'), + ('lt', 'lt'), + ('lte', 'lte'), + ('not_in', 'not_in') +) + + +class Expense(BaseModel): + """ + Expense + """ + id = models.AutoField(primary_key=True) + employee_email = CustomEmailField(help_text='Email id of the Fyle employee') + employee_name = StringNullField(help_text='Name of the Fyle employee') + category = StringNullField(help_text='Fyle Expense Category') + sub_category = StringNullField(help_text='Fyle Expense Sub-Category') + project = StringNullField(help_text='Project') + expense_id = StringNotNullField(unique=True, help_text='Expense ID') + org_id = StringNullField(help_text='Organization ID') + expense_number = StringNotNullField(help_text='Expense Number') + claim_number = StringNotNullField(help_text='Claim Number') + amount = models.FloatField(help_text='Home Amount') + currency = StringNotNullField(max_length=5, help_text='Home Currency') + foreign_amount = models.FloatField(null=True, help_text='Foreign Amount') + foreign_currency = StringNotNullField(max_length=5, help_text='Foreign Currency') + settlement_id = StringNullField(help_text='Settlement ID') + reimbursable = BooleanFalseField(help_text='Expense reimbursable or not') + state = StringNotNullField(help_text='Expense state') + vendor = StringNotNullField(help_text='Vendor') + cost_center = StringNullField(help_text='Fyle Expense Cost Center') + corporate_card_id = StringNullField(help_text='Corporate Card ID') + purpose = models.TextField(null=True, blank=True, help_text='Purpose') + report_id = StringNotNullField(help_text='Report ID') + billable = BooleanFalseField(help_text='Expense billable or not') + file_ids = ArrayField(base_field=models.CharField(max_length=255), null=True, help_text='File IDs') + spent_at = CustomDateTimeField(help_text='Expense spent at') + approved_at = CustomDateTimeField(help_text='Expense approved at') + posted_at = CustomDateTimeField(help_text='Date when the money is taken from the bank') + expense_created_at = CustomDateTimeField(help_text='Expense created at') + expense_updated_at = CustomDateTimeField(help_text='Expense created at') + fund_source = StringNotNullField(help_text='Expense fund source') + verified_at = CustomDateTimeField(help_text='Report verified at') + custom_properties = CustomJsonField(help_text="Custom Properties") + tax_amount = FloatNullField(help_text='Tax Amount') + tax_group_id = StringNullField(help_text='Tax Group ID') + exported = BooleanFalseField(help_text='Expense reimbursable or not') + previous_export_state = StringNullField(max_length=255, help_text='Previous export state') + accounting_export_summary = CustomJsonField(default=dict, help_text='Accounting Export Summary') + + class Meta: + db_table = 'expenses' + + +class ExpenseFilter(BaseForeignWorkspaceModel): + """ + Reimbursements + """ + id = models.AutoField(primary_key=True) + condition = StringNotNullField(help_text='Condition for the filter') + operator = StringOptionsField(choices=EXPENSE_FILTER_OPERATOR, help_text='Operator for the filter') + values = ArrayField(base_field=models.CharField(max_length=255), null=True, help_text='Values for the operator') + rank = IntegerOptionsField(choices=EXPENSE_FILTER_RANK, help_text='Rank for the filter') + join_by = StringOptionsField(choices=EXPENSE_FILTER_JOIN_BY, max_length=3, help_text='Used to join the filter (AND/OR)') + is_custom = BooleanFalseField(help_text='Custom Field or not') + custom_field_type = StringOptionsField(help_text='Custom field type', choices=EXPENSE_FILTER_CUSTOM_FIELD_TYPE) + + class Meta: + db_table = 'expense_filters' + + +class Reimbursement: + """ + Creating a dummy class to be able to user + fyle_integrations_platform_connector correctly + """ + pass diff --git a/apps/fyle/serializers.py b/apps/fyle/serializers.py new file mode 100644 index 0000000..b6173e1 --- /dev/null +++ b/apps/fyle/serializers.py @@ -0,0 +1,21 @@ +""" +Fyle Serializers +""" +import logging +from rest_framework import serializers + +from apps.fyle.models import ExpenseFilter + +logger = logging.getLogger(__name__) +logger.level = logging.INFO + + +class ExpenseFilterSerializer(serializers.ModelSerializer): + """ + Expense Filter Serializer + """ + + class Meta: + model = ExpenseFilter + fields = '__all__' + read_only_fields = ('id', 'workspace', 'created_at', 'updated_at') diff --git a/apps/fyle/serializers.py.py b/apps/fyle/serializers.py.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/fyle/urls.py b/apps/fyle/urls.py index e69de29..a59f3af 100644 --- a/apps/fyle/urls.py +++ b/apps/fyle/urls.py @@ -0,0 +1,24 @@ +"""fyle_ms_business_central URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: +https://docs.djangoproject.com/en/3.0/topics/http/urls/ +Examples: +Function views +1. Add an import: from my_app import views +2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views +1. Add an import: from other_app.views import Home +2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf +1. Import the include() function: from django.urls import include, path +2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.urls import path +from apps.fyle.views import ExpenseFilterView, ExpenseFilterDeleteView + + +urlpatterns = [ + path('expense_filters//', ExpenseFilterDeleteView.as_view(), name='expense-filters'), + path('expense_filters/', ExpenseFilterView.as_view(), name='expense-filters'), +] diff --git a/apps/fyle/views.py b/apps/fyle/views.py index e69de29..31f1475 100644 --- a/apps/fyle/views.py +++ b/apps/fyle/views.py @@ -0,0 +1,26 @@ +import logging +from rest_framework import generics +from ms_business_central_api.utils import LookupFieldMixin +from apps.fyle.serializers import ExpenseFilterSerializer +from apps.fyle.models import ExpenseFilter + +logger = logging.getLogger(__name__) +logger.level = logging.INFO + + +class ExpenseFilterView(LookupFieldMixin, generics.ListCreateAPIView): + """ + Expense Filter view + """ + + queryset = ExpenseFilter.objects.all() + serializer_class = ExpenseFilterSerializer + + +class ExpenseFilterDeleteView(generics.DestroyAPIView): + """ + Expense Filter Delete view + """ + + queryset = ExpenseFilter.objects.all() + serializer_class = ExpenseFilterSerializer diff --git a/apps/workspaces/models.py b/apps/workspaces/models.py index 36295e8..b925a92 100644 --- a/apps/workspaces/models.py +++ b/apps/workspaces/models.py @@ -91,7 +91,7 @@ class Meta: REIMBURSABLE_EXPENSE_STATE_CHOICES = ( ('PAYMENT_PROCESSING', 'PAYMENT_PROCESSING'), - ('CLOSED', 'CLOSED') + ('PAID', 'PAID') ) REIMBURSABLE_EXPENSES_GROUPED_BY_CHOICES = ( @@ -180,8 +180,8 @@ class ImportSetting(BaseModel): Table to store Import setting """ id = models.AutoField(auto_created=True, primary_key=True, verbose_name='ID', serialize=False) - import_categories = BooleanFalseField(help_text='toggle for import of chart of accounts from sage300') - import_vendors_as_merchants = BooleanFalseField(help_text='toggle for import of vendors as merchant from sage300') + import_categories = BooleanFalseField(help_text='toggle for import of chart of accounts from Business Central') + import_vendors_as_merchants = BooleanFalseField(help_text='toggle for import of vendors as merchant from Business Central') class Meta: db_table = 'import_settings' diff --git a/apps/workspaces/serializers.py b/apps/workspaces/serializers.py index d87e64f..f354bb2 100644 --- a/apps/workspaces/serializers.py +++ b/apps/workspaces/serializers.py @@ -5,6 +5,7 @@ from rest_framework import serializers from fyle_rest_auth.helpers import get_fyle_admin from fyle_rest_auth.models import AuthToken +from fyle_accounting_mappings.models import ExpenseAttribute from ms_business_central_api.utils import assert_valid from apps.workspaces.models import ( @@ -168,3 +169,28 @@ def create(self, validated_data): workspace.save() return advanced_setting + + +class WorkspaceAdminSerializer(serializers.Serializer): + """ + Workspace Admin Serializer + """ + admin_emails = serializers.SerializerMethodField() + + def get_admin_emails(self, validated_data): + """ + Get Workspace Admins + """ + workspace_id = self.context['request'].parser_context.get('kwargs').get('workspace_id') + workspace = Workspace.objects.get(id=workspace_id) + admin_emails = [] + + users = workspace.user.all() + + for user in users: + admin = User.objects.get(user_id=user) + employee = ExpenseAttribute.objects.filter(value=admin.email, workspace_id=workspace_id, attribute_type='EMPLOYEE').first() + if employee: + admin_emails.append({'name': employee.detail['full_name'], 'email': admin.email}) + + return admin_emails diff --git a/apps/workspaces/urls.py b/apps/workspaces/urls.py index 3582581..7a491fd 100644 --- a/apps/workspaces/urls.py +++ b/apps/workspaces/urls.py @@ -1,11 +1,12 @@ -from django.urls import path +from django.urls import path, include from apps.workspaces.views import ( ReadyView, WorkspaceView, ExportSettingView, ImportSettingView, - AdvancedSettingView + AdvancedSettingView, + WorkspaceAdminsView ) @@ -15,9 +16,13 @@ path('/export_settings/', ExportSettingView.as_view(), name='export-settings'), path('/import_settings/', ImportSettingView.as_view(), name='import-settings'), path('/advanced_settings/', AdvancedSettingView.as_view(), name='advanced-settings'), + path('/admins/', WorkspaceAdminsView.as_view(), name='admin'), ] -other_app_paths = [] +other_app_paths = [ + path('/accounting_exports/', include('apps.accounting_exports.urls')), + path('/fyle/', include('apps.fyle.urls')), +] urlpatterns = [] urlpatterns.extend(workspace_app_paths) diff --git a/apps/workspaces/views.py b/apps/workspaces/views.py index 3a9dd7e..3b9c2d3 100644 --- a/apps/workspaces/views.py +++ b/apps/workspaces/views.py @@ -18,7 +18,8 @@ WorkspaceSerializer, ExportSettingsSerializer, ImportSettingsSerializer, - AdvancedSettingSerializer + AdvancedSettingSerializer, + WorkspaceAdminSerializer ) @@ -105,3 +106,11 @@ class AdvancedSettingView(generics.CreateAPIView, generics.RetrieveAPIView): lookup_url_kwarg = 'workspace_id' queryset = AdvancedSetting.objects.all() + + +class WorkspaceAdminsView(generics.ListAPIView): + """ + Retrieve Workspace Admins + """ + serializer_class = WorkspaceAdminSerializer + queryset = Workspace.objects.all() diff --git a/ms_business_central_api/settings.py b/ms_business_central_api/settings.py index 2d0df20..bee8d55 100644 --- a/ms_business_central_api/settings.py +++ b/ms_business_central_api/settings.py @@ -48,7 +48,7 @@ 'corsheaders', 'django_q', 'fyle_rest_auth', - # 'fyle_accounting_mappings', + 'fyle_accounting_mappings', # User Created Apps 'apps.users', diff --git a/ms_business_central_api/tests/settings.py b/ms_business_central_api/tests/settings.py index c5efa63..6b63b44 100644 --- a/ms_business_central_api/tests/settings.py +++ b/ms_business_central_api/tests/settings.py @@ -41,6 +41,7 @@ 'corsheaders', 'django_q', 'fyle_rest_auth', + 'fyle_accounting_mappings', # User Created Apps 'apps.users', diff --git a/tests/conftest.py b/tests/conftest.py index d646eb8..5ee9822 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,8 +13,10 @@ from apps.fyle.helpers import get_access_token from apps.workspaces.models import ( Workspace, - FyleCredential + FyleCredential, ) +from apps.accounting_exports.models import AccountingExport, AccountingExportSummary, Error +from apps.fyle.models import ExpenseFilter from ms_business_central_api.tests import settings from .test_fyle.fixtures import fixtures as fyle_fixtures @@ -139,3 +141,116 @@ def add_fyle_credentials(): workspace_id=workspace_id, cluster_domain='https://dummy_cluster_domain.com', ) + + +@pytest.fixture() +@pytest.mark.django_db(databases=['default']) +def add_accounting_export_expenses(): + """ + Pytest fixture to add accounting export to a workspace + """ + workspace_ids = [ + 1, 2, 3 + ] + for workspace_id in workspace_ids: + AccountingExport.objects.update_or_create( + workspace_id=workspace_id, + type='FETCHING_REIMBURSABLE_EXPENSES', + defaults={ + 'status': 'IN_PROGRESS' + } + ) + + AccountingExport.objects.update_or_create( + workspace_id=workspace_id, + type='FETCHING_CREDIT_CARD_EXPENENSES', + defaults={ + 'status': 'IN_PROGRESS' + } + ) + + +@pytest.fixture() +@pytest.mark.django_db(databases=['default']) +def add_accounting_export_summary(): + """ + Pytest fixture to add accounting export summary to a workspace + """ + workspace_ids = [ + 1, 2, 3 + ] + for workspace_id in workspace_ids: + AccountingExportSummary.objects.create( + workspace_id=workspace_id, + last_exported_at = datetime.now(tz=timezone.utc), + next_export_at = datetime.now(tz=timezone.utc), + export_mode = 'AUTO', + total_accounting_export_count = 10, + successful_accounting_export_count = 5, + failed_accounting_export_count = 5 + ) + + +@pytest.fixture() +@pytest.mark.django_db(databases=['default']) +def add_errors(): + """ + Pytest fixture to add errors to a workspace + """ + workspace_ids = [ + 1, 2, 3 + ] + for workspace_id in workspace_ids: + Error.objects.create( + type='EMPLOYEE_MAPPING', + is_resolved=False, + error_title='Employee Mapping Error', + error_detail='Employee Mapping Error', + workspace_id=workspace_id + ) + Error.objects.create( + type='CATEGORY_MAPPING', + is_resolved=False, + error_title='Category Mapping Error', + error_detail='Category Mapping Error', + workspace_id=workspace_id + ) + Error.objects.create( + type='BUSINESS_CENTRAL_ERROR', + is_resolved=False, + error_title='Business Central Error', + error_detail='Business Central Error', + workspace_id=workspace_id + ) + + +@pytest.fixture() +@pytest.mark.django_db(databases=['default']) +def add_expense_filters(): + """ + Pytest fixture to add expense filters to a workspace + """ + workspace_ids = [ + 1, 2, 3 + ] + for workspace_id in workspace_ids: + ExpenseFilter.objects.create( + condition='employee_email', + operator='in', + values=['ashwinnnnn.t@fyle.in', 'admin1@fyleforleaf.in'], + rank="1", + join_by='AND', + is_custom=False, + custom_field_type='SELECT', + workspace_id=workspace_id + ) + ExpenseFilter.objects.create( + condition='last_spent_at', + operator='lt', + values=['2020-04-20 23:59:59+00'], + rank="2", + join_by=None, + is_custom=False, + custom_field_type='SELECT', + workspace_id=workspace_id + ) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..9a5e2ce --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,38 @@ +import json +from os import path + + +def dict_compare_keys(d1, d2, key_path=''): + """ + Compare two dicts recursively and see if dict1 has any keys that dict2 does not + Returns: list of key paths + """ + res = [] + if not d1: + return res + if not isinstance(d1, dict): + return res + for k in d1: + if k not in d2: + missing_key_path = f'{key_path}->{k}' + res.append(missing_key_path) + else: + if isinstance(d1[k], dict): + key_path1 = f'{key_path}->{k}' + res1 = dict_compare_keys(d1[k], d2[k], key_path1) + res = res + res1 + elif isinstance(d1[k], list): + key_path1 = f'{key_path}->{k}[0]' + dv1 = d1[k][0] if len(d1[k]) > 0 else None + dv2 = d2[k][0] if len(d2[k]) > 0 else None + res1 = dict_compare_keys(dv1, dv2, key_path1) + res = res + res1 + return res + + +def get_response_dict(filename): + basepath = path.dirname(__file__) + filepath = path.join(basepath, filename) + mock_json = open(filepath, 'r').read() + mock_dict = json.loads(mock_json) + return mock_dict diff --git a/apps/accounting_exports/serializers.py.py b/tests/test_accounting_exports/__init__.py similarity index 100% rename from apps/accounting_exports/serializers.py.py rename to tests/test_accounting_exports/__init__.py diff --git a/tests/test_accounting_exports/test_views.py b/tests/test_accounting_exports/test_views.py new file mode 100644 index 0000000..72f5d1a --- /dev/null +++ b/tests/test_accounting_exports/test_views.py @@ -0,0 +1,52 @@ +import json +from django.urls import reverse +from tests.helpers import dict_compare_keys +from tests.test_fyle.fixtures import fixtures as data + + +def test_get_accounting_exports(api_client, test_connection, create_temp_workspace, add_fyle_credentials, add_accounting_export_expenses): + """ + Test get accounting exports + """ + url = reverse('accounting-exports', kwargs={'workspace_id': 1}) + + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token)) + + response = api_client.get(url) + assert response.status_code == 200 + response = json.loads(response.content) + + assert dict_compare_keys(response, data['accounting_export_response']) == [], 'accounting export api return diffs in keys' + + url = reverse('accounting-exports-count', kwargs={'workspace_id': 1}) + + response = api_client.get(url, {'status__in': 'IN_PROGRESS'}) + assert response.status_code == 200 + response = json.loads(response.content) + + assert response['count'] == 2, 'accounting export count api return diffs in keys' + + +def test_get_accounting_export_summary(api_client, test_connection, create_temp_workspace, add_fyle_credentials, add_accounting_export_summary): + url = reverse('accounting-exports-summary', kwargs={'workspace_id': 1}) + + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token)) + + response = api_client.get(url) + assert response.status_code == 200 + response = json.loads(response.content) + assert dict_compare_keys(response, data['accounting_export_summary_response']) == [], 'expense group api return diffs in keys' + + +def test_get_errors(api_client, test_connection, create_temp_workspace, add_fyle_credentials, add_errors): + """ + Test get errors + """ + url = reverse('errors', kwargs={'workspace_id': 1}) + + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token)) + + response = api_client.get(url) + assert response.status_code == 200 + response = json.loads(response.content) + assert dict_compare_keys(response, data['errors_response']) == [], 'expense group api return diffs in keys' diff --git a/tests/test_fyle/fixtures.py b/tests/test_fyle/fixtures.py index 95dea99..c3fdcb8 100644 --- a/tests/test_fyle/fixtures.py +++ b/tests/test_fyle/fixtures.py @@ -23,5 +23,134 @@ }, 'user_id': 'usqywo0f3nBY', } - } + }, + 'accounting_export_response': { + "count":2, + "next":"None", + "previous":"None", + "results":[ + { + "id":2, + "created_at":"2023-10-26T03:24:43.513291Z", + "updated_at":"2023-10-26T03:24:43.513296Z", + "type":"FETCHING_REIMBURSABLE_EXPENSES", + "fund_source":"", + "mapping_errors":"None", + "task_id":"None", + "description":[], + "status":"IN_PROGRESS", + "detail":[], + "business_central_errors":[], + "exported_at":"None", + "workspace":1, + "expenses":[] + }, + { + "id":1, + "created_at":"2023-10-26T03:24:43.511973Z", + "updated_at":"2023-10-26T03:24:43.511978Z", + "type":"FETCHING_CREDIT_CARD_EXPENENSES", + "fund_source":"", + "mapping_errors":"None", + "task_id":"None", + "description":[], + "status":"IN_PROGRESS", + "detail":[], + "business_central_errors":[], + "exported_at":"None", + "workspace":1, + "expenses":[] + } + ] + }, + 'accounting_export_summary_response': { + "id":1, + "created_at":"2023-10-27T04:53:59.287745Z", + "updated_at":"2023-10-27T04:53:59.287750Z", + "last_exported_at":"2023-10-27T04:53:59.287618Z", + "next_export_at":"2023-10-27T04:53:59.287619Z", + "export_mode":"AUTO", + "total_accounting_export_count":10, + "successful_accounting_export_count":5, + "failed_accounting_export_count":5, + "workspace":1 + }, + "errors_response": { + "count":3, + "next":"None", + "previous":"None", + "results":[ + { + "id":1, + "created_at":"2023-10-26T03:47:16.864421Z", + "updated_at":"2023-10-26T03:47:16.864428Z", + "type":"EMPLOYEE_MAPPING", + "is_resolved": "false", + "error_title":"Employee Mapping Error", + "error_detail":"Employee Mapping Error", + "workspace":1, + "accounting_export":"None", + "expense_attribute":"None" + }, + { + "id":2, + "created_at":"2023-10-26T03:47:16.865103Z", + "updated_at":"2023-10-26T03:47:16.865108Z", + "type":"CATEGORY_MAPPING", + "is_resolved": "false", + "error_title":"Category Mapping Error", + "error_detail":"Category Mapping Error", + "workspace":1, + "accounting_export":"None", + "expense_attribute":"None" + }, + { + "id":3, + "created_at":"2023-10-26T03:47:16.865303Z", + "updated_at":"2023-10-26T03:47:16.865307Z", + "type":"BUSINESS_CENTRAL_ERROR", + "is_resolved": "false", + "error_title":"Business Central Error", + "error_detail":"Busienss Central Error", + "workspace":1, + "accounting_export":"None", + "expense_attribute":"None" + } + ] + }, + "expense_filters_response": { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "id": 1, + "condition": "employee_email", + "operator": "in", + "values": ["ashwinnnnn.t@fyle.in", "admin1@fyleforleaf.in"], + "rank": "1", + "workspace": 1, + "join_by": "AND", + "is_custom": False, + "custom_field_type": "SELECT", + "created_at": "2023-01-04T17:48:16.064064Z", + "updated_at": "2023-01-05T08:05:23.660746Z", + "workspace": 1, + }, + { + "id": 2, + "condition": "spent_at", + "operator": "lt", + "values": ['2020-04-20 23:59:59+00'], + "rank": "2", + "workspace": 1, + "join_by": None, + "is_custom": False, + "custom_field_type": "SELECT", + "created_at": "2023-01-04T17:48:16.064064Z", + "updated_at": "2023-01-05T08:05:23.660746Z", + "workspace": 1, + }, + ], + }, } diff --git a/tests/test_fyle/test_views.py b/tests/test_fyle/test_views.py new file mode 100644 index 0000000..becb60f --- /dev/null +++ b/tests/test_fyle/test_views.py @@ -0,0 +1,26 @@ +import json +from django.urls import reverse +from tests.helpers import dict_compare_keys +from .fixtures import fixtures as data + + +def test_expense_filters(api_client, test_connection, create_temp_workspace, add_fyle_credentials, add_expense_filters): + url = reverse('expense-filters', kwargs={'workspace_id': 1}) + + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token)) + response = api_client.get(url) + assert response.status_code == 200 + response = json.loads(response.content) + + assert dict_compare_keys(response, data['expense_filters_response']) == [], 'expense group api return diffs in keys' + + url = reverse('expense-filters', kwargs={'workspace_id': 1, 'pk': 1}) + + response = api_client.delete(url, content_type='application/json') + assert response.status_code == 204 + + url = reverse('expense-filters', kwargs={'workspace_id': 1}) + + response = api_client.get(url) + assert response.status_code == 200 + assert dict_compare_keys(response, data['expense_filters_response']['results'][1]) == [], 'expense group api return diffs in keys' diff --git a/tests/test_workspaces/test_view.py b/tests/test_workspaces/test_view.py index 52592e1..537b611 100644 --- a/tests/test_workspaces/test_view.py +++ b/tests/test_workspaces/test_view.py @@ -273,3 +273,20 @@ def test_advanced_settings(api_client, test_connection): 'email': 'nilesh.p@fylehq.com' }, ] + + +def test_get_workspace_admins(api_client, test_connection): + ''' + Test get workspace admins + ''' + url = reverse('workspaces') + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token)) + response = api_client.post(url) + + workspace_id = response.data['id'] + + url = reverse('admin', kwargs={'workspace_id': workspace_id}) + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token)) + + response = api_client.get(url) + assert response.status_code == 200