From 6155790040f5be24f868d7777ce70886f5234e01 Mon Sep 17 00:00:00 2001 From: ruuushhh <66899387+ruuushhh@users.noreply.github.com> Date: Tue, 7 Nov 2023 14:58:39 +0530 Subject: [PATCH] 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 | 51 ++++++++++- apps/accounting_exports/serializers.py | 22 ++++- apps/accounting_exports/urls.py | 6 +- apps/accounting_exports/views.py | 22 ++++- apps/fyle/models.py | 50 ++++++++++- 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 | 6 +- apps/workspaces/urls.py | 1 + tests/conftest.py | 89 ++++++++++++++++++- tests/test_accounting_exports/test_views.py | 25 ++++++ tests/test_fyle/fixtures.py | 94 ++++++++++++++++++++- tests/test_fyle/test_views.py | 26 ++++++ 15 files changed, 446 insertions(+), 17 deletions(-) create mode 100644 apps/fyle/serializers.py delete mode 100644 apps/fyle/serializers.py.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 4389814..a0c26f8 100644 --- a/apps/accounting_exports/models.py +++ b/apps/accounting_exports/models.py @@ -1,14 +1,19 @@ 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 + StringOptionsField, + IntegerNullField, + BooleanFalseField, + TextNotNullField ) -from apps.workspaces.models import BaseForeignWorkspaceModel +from apps.workspaces.models import BaseForeignWorkspaceModel, BaseModel from apps.fyle.models import Expense TYPE_CHOICES = ( @@ -18,7 +23,7 @@ ('FETCHING_CREDIT_CARD_EXPENENSES', 'FETCHING_CREDIT_CARD_EXPENENSES') ) -ERROR_TYPE_CHOICES = (('EMPLOYEE_MAPPING', 'EMPLOYEE_MAPPING'), ('CATEGORY_MAPPING', 'CATEGORY_MAPPING'), ('SAGE300_ERROR', 'SAGE300_ERROR')) +ERROR_TYPE_CHOICES = (('EMPLOYEE_MAPPING', 'EMPLOYEE_MAPPING'), ('CATEGORY_MAPPING', 'CATEGORY_MAPPING'), ('BUSINESS_CENTRAL_ERROR', 'BUSINESS_CENTRAL_ERROR')) EXPORT_MODE_CHOICES = ( ('MANUAL', 'MANUAL'), @@ -39,8 +44,46 @@ class AccountingExport(BaseForeignWorkspaceModel): description = CustomJsonField(help_text='Description') status = StringNotNullField(help_text='Task Status') detail = CustomJsonField(help_text='Task Response') - sage_300_errors = CustomJsonField(help_text='Sage 300 Errors') + 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 index 7abe701..c3a155b 100644 --- a/apps/accounting_exports/serializers.py +++ b/apps/accounting_exports/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from apps.accounting_exports.models import AccountingExport +from apps.accounting_exports.models import AccountingExport, AccountingExportSummary, Error class AccountingExportSerializer(serializers.ModelSerializer): @@ -11,3 +11,23 @@ class AccountingExportSerializer(serializers.ModelSerializer): 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 2187f5d..e0b7d05 100644 --- a/apps/accounting_exports/urls.py +++ b/apps/accounting_exports/urls.py @@ -1,4 +1,4 @@ -"""sage_desktop_api URL Configuration +"""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: @@ -14,10 +14,12 @@ """ from django.urls import path -from apps.accounting_exports.views import AccountingExportView, AccountingExportCountView +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 d3e4b88..16023d1 100644 --- a/apps/accounting_exports/views.py +++ b/apps/accounting_exports/views.py @@ -5,8 +5,8 @@ from rest_framework.response import Response from ms_business_central_api.utils import LookupFieldMixin -from apps.accounting_exports.serializers import AccountingExportSerializer -from apps.accounting_exports.models import AccountingExport +from apps.accounting_exports.serializers import AccountingExportSerializer, AccountingExportSummarySerializer, ErrorSerializer +from apps.accounting_exports.models import AccountingExport, AccountingExportSummary, Error logger = logging.getLogger(__name__) @@ -35,3 +35,21 @@ def get(self, request, *args, **kwargs): 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 0682430..daa585d 100644 --- a/apps/fyle/models.py +++ b/apps/fyle/models.py @@ -7,9 +7,38 @@ CustomJsonField, CustomDateTimeField, CustomEmailField, - FloatNullField + 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') ) -from apps.workspaces.models import BaseModel class Expense(BaseModel): @@ -58,6 +87,23 @@ 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 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/urls.py b/apps/workspaces/urls.py index cab0e2f..7a491fd 100644 --- a/apps/workspaces/urls.py +++ b/apps/workspaces/urls.py @@ -21,6 +21,7 @@ other_app_paths = [ path('/accounting_exports/', include('apps.accounting_exports.urls')), + path('/fyle/', include('apps.fyle.urls')), ] urlpatterns = [] diff --git a/tests/conftest.py b/tests/conftest.py index 24d9a59..5ee9822 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,8 @@ Workspace, FyleCredential, ) -from apps.accounting_exports.models import AccountingExport +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 @@ -167,3 +168,89 @@ def add_accounting_export_expenses(): '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/test_accounting_exports/test_views.py b/tests/test_accounting_exports/test_views.py index 6669d17..72f5d1a 100644 --- a/tests/test_accounting_exports/test_views.py +++ b/tests/test_accounting_exports/test_views.py @@ -25,3 +25,28 @@ def test_get_accounting_exports(api_client, test_connection, create_temp_workspa 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 a746907..c3fdcb8 100644 --- a/tests/test_fyle/fixtures.py +++ b/tests/test_fyle/fixtures.py @@ -40,7 +40,7 @@ "description":[], "status":"IN_PROGRESS", "detail":[], - "sage_300_errors":[], + "business_central_errors":[], "exported_at":"None", "workspace":1, "expenses":[] @@ -56,11 +56,101 @@ "description":[], "status":"IN_PROGRESS", "detail":[], - "sage_300_errors":[], + "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'