From 3fb46c21440326096205fc64dbe3723cd3bbcda1 Mon Sep 17 00:00:00 2001 From: ruuushhh Date: Tue, 31 Oct 2023 11:22:00 +0530 Subject: [PATCH 1/2] Import settings APIs : --- apps/workspaces/models.py | 15 ++++++++++++- apps/workspaces/serializers.py | 32 +++++++++++++++++++++++++++- apps/workspaces/urls.py | 4 +++- apps/workspaces/views.py | 16 ++++++++++++-- tests/test_workspaces/test_view.py | 34 +++++++++++++++++++++++++++++- 5 files changed, 95 insertions(+), 6 deletions(-) diff --git a/apps/workspaces/models.py b/apps/workspaces/models.py index f1852c6..5c7af04 100644 --- a/apps/workspaces/models.py +++ b/apps/workspaces/models.py @@ -7,7 +7,8 @@ StringOptionsField, TextNotNullField, StringNullField, - BooleanTrueField + BooleanTrueField, + BooleanFalseField ) User = get_user_model() @@ -169,3 +170,15 @@ class ExportSetting(BaseModel): class Meta: db_table = 'export_settings' + + +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') + + class Meta: + db_table = 'import_settings' diff --git a/apps/workspaces/serializers.py b/apps/workspaces/serializers.py index 744c8dc..4ff06d9 100644 --- a/apps/workspaces/serializers.py +++ b/apps/workspaces/serializers.py @@ -10,7 +10,8 @@ from apps.workspaces.models import ( Workspace, FyleCredential, - ExportSetting + ExportSetting, + ImportSetting ) from apps.users.models import User from apps.fyle.helpers import get_cluster_domain @@ -96,3 +97,32 @@ def create(self, validated_data): workspace.save() return export_settings + + +class ImportSettingsSerializer(serializers.ModelSerializer): + """ + Import Settings serializer + """ + class Meta: + model = ImportSetting + fields = '__all__' + read_only_fields = ('id', 'workspace', 'created_at', 'updated_at') + + def create(self, validated_data): + """ + Create Import Settings + """ + + workspace_id = self.context['request'].parser_context.get('kwargs').get('workspace_id') + import_settings, _ = ImportSetting.objects.update_or_create( + workspace_id=workspace_id, + defaults=validated_data + ) + + # Update workspace onboarding state + workspace = import_settings.workspace + if workspace.onboarding_state == 'IMPORT_SETTINGS': + workspace.onboarding_state = 'ADVANCED_SETTINGS' + workspace.save() + + return import_settings diff --git a/apps/workspaces/urls.py b/apps/workspaces/urls.py index d7a216b..577fa86 100644 --- a/apps/workspaces/urls.py +++ b/apps/workspaces/urls.py @@ -3,7 +3,8 @@ from apps.workspaces.views import ( ReadyView, WorkspaceView, - ExportSettingView + ExportSettingView, + ImportSettingView ) @@ -11,6 +12,7 @@ path('', WorkspaceView.as_view(), name='workspaces'), path('ready/', ReadyView.as_view(), name='ready'), path('/export_settings/', ExportSettingView.as_view(), name='export-settings'), + path('/import_settings/', ImportSettingView.as_view(), name='import-settings'), ] diff --git a/apps/workspaces/views.py b/apps/workspaces/views.py index 4117e55..15bd93a 100644 --- a/apps/workspaces/views.py +++ b/apps/workspaces/views.py @@ -10,11 +10,13 @@ from ms_business_central_api.utils import assert_valid from apps.workspaces.models import ( Workspace, - ExportSetting + ExportSetting, + ImportSetting ) from apps.workspaces.serializers import ( WorkspaceSerializer, - ExportSettingsSerializer + ExportSettingsSerializer, + ImportSettingsSerializer ) @@ -80,3 +82,13 @@ class ExportSettingView(generics.CreateAPIView, generics.RetrieveAPIView): lookup_field = 'workspace_id' queryset = ExportSetting.objects.all() + + +class ImportSettingView(generics.CreateAPIView, generics.RetrieveAPIView): + """ + Retrieve or Create Import Settings + """ + serializer_class = ImportSettingsSerializer + lookup_field = 'workspace_id' + + queryset = ImportSetting.objects.all() diff --git a/tests/test_workspaces/test_view.py b/tests/test_workspaces/test_view.py index 82e3f9d..af41e99 100644 --- a/tests/test_workspaces/test_view.py +++ b/tests/test_workspaces/test_view.py @@ -3,7 +3,8 @@ from django.urls import reverse from apps.workspaces.models import ( Workspace, - ExportSetting + ExportSetting, + ImportSetting ) @@ -129,3 +130,34 @@ def test_export_settings(api_client, test_connection): assert export_settings.default_reimbursable_credit_card_account_id == '342' assert export_settings.default_vendor_name == 'Nilesh' assert export_settings.default_vendor_id == '123' + + +def test_import_settings(api_client, test_connection): + ''' + Test import settings + ''' + 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( + 'import-settings', + kwargs={'workspace_id': workspace_id} + ) + + payload = { + 'import_categories': True, + 'import_vendors_as_merchants': True + } + response = api_client.post(url, payload) + + import_settings = ImportSetting.objects.filter(workspace_id=workspace_id).first() + + assert response.status_code == 201 + assert import_settings.import_categories is True + assert import_settings.import_vendors_as_merchants is True + + response = api_client.get(url) + assert response.status_code == 200 + assert import_settings.import_categories is True + assert import_settings.import_vendors_as_merchants is True From cf3a9984c92ff5db9cb152a9764c69aaf5e1de1b Mon Sep 17 00:00:00 2001 From: ruuushhh <66899387+ruuushhh@users.noreply.github.com> Date: Tue, 7 Nov 2023 14:59:49 +0530 Subject: [PATCH 2/2] Advanced settings apis (#13) * Advanced settings apis * Resolved * Resolved * Workspace admin apis (#14) * Workspace admin apis * Fyle accounting mappings enabled * Accounting exports api (#15) * Accounting exports api * Accounting Exports count API (#16) * Accounting Exports count API * Accounting exports summary APIs (#17) * Accounting exports summary APIs * Errors APIs (#18) * Errors APIs * Expense filter api (#19) * Expense Filter APIs * Expense Filter APIs * Expense Filter APIs * Resolved --- apps/accounting_exports/models.py | 89 ++++++++++++ apps/accounting_exports/serializers.py | 33 +++++ apps/accounting_exports/urls.py | 25 ++++ apps/accounting_exports/views.py | 55 ++++++++ apps/fyle/models.py | 112 +++++++++++++++ apps/fyle/serializers.py | 21 +++ apps/fyle/serializers.py.py | 0 apps/fyle/urls.py | 24 ++++ apps/fyle/views.py | 26 ++++ apps/workspaces/models.py | 31 ++++- apps/workspaces/serializers.py | 70 +++++++++- apps/workspaces/urls.py | 14 +- apps/workspaces/views.py | 26 +++- ms_business_central_api/settings.py | 2 +- ms_business_central_api/tests/settings.py | 1 + tests/conftest.py | 117 +++++++++++++++- tests/helpers.py | 38 +++++ .../test_accounting_exports/__init__.py | 0 tests/test_accounting_exports/test_views.py | 52 +++++++ tests/test_fyle/fixtures.py | 131 +++++++++++++++++- tests/test_fyle/test_views.py | 26 ++++ tests/test_workspaces/test_view.py | 131 +++++++++++++++++- 22 files changed, 1009 insertions(+), 15 deletions(-) create mode 100644 apps/accounting_exports/serializers.py create mode 100644 apps/fyle/serializers.py delete mode 100644 apps/fyle/serializers.py.py create mode 100644 tests/helpers.py rename apps/accounting_exports/serializers.py.py => tests/test_accounting_exports/__init__.py (100%) create mode 100644 tests/test_accounting_exports/test_views.py create mode 100644 tests/test_fyle/test_views.py 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 5c7af04..b925a92 100644 --- a/apps/workspaces/models.py +++ b/apps/workspaces/models.py @@ -1,5 +1,6 @@ from django.db import models from django.contrib.auth import get_user_model +from django.contrib.postgres.fields import ArrayField from ms_business_central_api.models.fields import ( StringNotNullField, @@ -8,7 +9,9 @@ TextNotNullField, StringNullField, BooleanTrueField, - BooleanFalseField + BooleanFalseField, + IntegerNullField, + CustomJsonField ) User = get_user_model() @@ -88,7 +91,7 @@ class Meta: REIMBURSABLE_EXPENSE_STATE_CHOICES = ( ('PAYMENT_PROCESSING', 'PAYMENT_PROCESSING'), - ('CLOSED', 'CLOSED') + ('PAID', 'PAID') ) REIMBURSABLE_EXPENSES_GROUPED_BY_CHOICES = ( @@ -177,8 +180,28 @@ 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' + + +class AdvancedSetting(BaseModel): + """ + Table to store advanced setting + """ + id = models.AutoField(auto_created=True, primary_key=True, verbose_name='ID', serialize=False) + expense_memo_structure = ArrayField( + models.CharField(max_length=255), help_text='Array of fields in memo', null=True + ) + schedule_is_enabled = BooleanFalseField(help_text='Boolean to check if schedule is enabled') + schedule_start_datetime = CustomDateTimeField(help_text='Schedule start date and time') + schedule_id = StringNullField(help_text='Schedule id') + interval_hours = IntegerNullField(help_text='Interval in hours') + emails_selected = CustomJsonField(help_text='Emails Selected For Email Notification') + emails_added = CustomJsonField(help_text='Emails Selected For Email Notification') + auto_create_vendor = BooleanFalseField(help_text='Auto create vendor') + + class Meta: + db_table = 'advanced_settings' diff --git a/apps/workspaces/serializers.py b/apps/workspaces/serializers.py index 4ff06d9..f354bb2 100644 --- a/apps/workspaces/serializers.py +++ b/apps/workspaces/serializers.py @@ -5,13 +5,15 @@ 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 ( Workspace, FyleCredential, ExportSetting, - ImportSetting + ImportSetting, + AdvancedSetting ) from apps.users.models import User from apps.fyle.helpers import get_cluster_domain @@ -126,3 +128,69 @@ def create(self, validated_data): workspace.save() return import_settings + + +class AdvancedSettingSerializer(serializers.ModelSerializer): + """ + Advanced Settings serializer + """ + class Meta: + model = AdvancedSetting + fields = '__all__' + read_only_fields = ('id', 'workspace', 'created_at', 'updated_at') + + def create(self, validated_data): + """ + Create Advanced Settings + """ + workspace_id = self.context['request'].parser_context.get('kwargs').get('workspace_id') + advanced_setting = AdvancedSetting.objects.filter( + workspace_id=workspace_id).first() + + if not advanced_setting: + if 'expense_memo_structure' not in validated_data: + validated_data['expense_memo_structure'] = [ + 'employee_email', + 'merchant', + 'purpose', + 'report_number' + ] + + advanced_setting, _ = AdvancedSetting.objects.update_or_create( + workspace_id=workspace_id, + defaults=validated_data + ) + + # Update workspace onboarding state + workspace = advanced_setting.workspace + + if workspace.onboarding_state == 'ADVANCED_SETTINGS': + workspace.onboarding_state = 'COMPLETE' + 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 577fa86..7a491fd 100644 --- a/apps/workspaces/urls.py +++ b/apps/workspaces/urls.py @@ -1,10 +1,12 @@ -from django.urls import path +from django.urls import path, include from apps.workspaces.views import ( ReadyView, WorkspaceView, ExportSettingView, - ImportSettingView + ImportSettingView, + AdvancedSettingView, + WorkspaceAdminsView ) @@ -13,10 +15,14 @@ path('ready/', ReadyView.as_view(), name='ready'), 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 15bd93a..3b9c2d3 100644 --- a/apps/workspaces/views.py +++ b/apps/workspaces/views.py @@ -11,12 +11,15 @@ from apps.workspaces.models import ( Workspace, ExportSetting, - ImportSetting + ImportSetting, + AdvancedSetting ) from apps.workspaces.serializers import ( WorkspaceSerializer, ExportSettingsSerializer, - ImportSettingsSerializer + ImportSettingsSerializer, + AdvancedSettingSerializer, + WorkspaceAdminSerializer ) @@ -92,3 +95,22 @@ class ImportSettingView(generics.CreateAPIView, generics.RetrieveAPIView): lookup_field = 'workspace_id' queryset = ImportSetting.objects.all() + + +class AdvancedSettingView(generics.CreateAPIView, generics.RetrieveAPIView): + """ + Retrieve or Create Advanced Settings + """ + serializer_class = AdvancedSettingSerializer + lookup_field = 'workspace_id' + 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 af41e99..537b611 100644 --- a/tests/test_workspaces/test_view.py +++ b/tests/test_workspaces/test_view.py @@ -4,7 +4,8 @@ from apps.workspaces.models import ( Workspace, ExportSetting, - ImportSetting + ImportSetting, + AdvancedSetting ) @@ -161,3 +162,131 @@ def test_import_settings(api_client, test_connection): assert response.status_code == 200 assert import_settings.import_categories is True assert import_settings.import_vendors_as_merchants is True + + +def test_advanced_settings(api_client, test_connection): + ''' + Test advanced settings + ''' + 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('advanced-settings', kwargs={'workspace_id': workspace_id}) + + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token)) + + payload = { + 'expense_memo_structure': [ + 'employee_email', + 'merchant', + 'purpose', + 'report_number', + 'expense_link' + ], + 'schedule_is_enabled': False, + 'interval_hours': 12, + 'emails_selected': json.dumps([ + { + 'name': 'Shwetabh Kumar', + 'email': 'shwetabh.kumar@fylehq.com' + }, + { + 'name': 'Netra Ballabh', + 'email': 'nilesh.p@fylehq.com' + }, + ]), + 'auto_create_vendor': True + } + + response = api_client.post(url, payload) + + assert response.status_code == 201 + assert response.data['expense_memo_structure'] == [ + 'employee_email', + 'merchant', + 'purpose', + 'report_number', + 'expense_link' + ] + assert response.data['schedule_is_enabled'] is False + assert response.data['schedule_id'] is None + assert response.data['emails_selected'] == [ + { + 'name': 'Shwetabh Kumar', + 'email': 'shwetabh.kumar@fylehq.com' + }, + { + 'name': 'Netra Ballabh', + 'email': 'nilesh.p@fylehq.com' + }, + ] + assert response.data['auto_create_vendor'] == True + + response = api_client.get(url) + + assert response.status_code == 200 + assert response.data['expense_memo_structure'] == [ + 'employee_email', + 'merchant', + 'purpose', + 'report_number', + 'expense_link' + ] + assert response.data['schedule_is_enabled'] is False + assert response.data['schedule_id'] is None + assert response.data['emails_selected'] == [ + { + 'name': 'Shwetabh Kumar', + 'email': 'shwetabh.kumar@fylehq.com' + }, + { + 'name': 'Netra Ballabh', + 'email': 'nilesh.p@fylehq.com' + }, + ] + + del payload['expense_memo_structure'] + + AdvancedSetting.objects.filter(workspace_id=workspace_id).first().delete() + + response = api_client.post(url, payload) + + assert response.status_code == 201 + assert response.data['expense_memo_structure'] == [ + 'employee_email', + 'merchant', + 'purpose', + 'report_number' + ] + assert response.data['schedule_is_enabled'] is False + assert response.data['schedule_id'] is None + assert response.data['emails_selected'] == [ + { + 'name': 'Shwetabh Kumar', + 'email': 'shwetabh.kumar@fylehq.com' + }, + { + 'name': 'Netra Ballabh', + '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