From 7e135c5f0232e51ba5889b2182eb86a2e0f3d047 Mon Sep 17 00:00:00 2001 From: ruuushhh <66899387+ruuushhh@users.noreply.github.com> Date: Wed, 29 Nov 2023 12:08:37 +0530 Subject: [PATCH] Expense Model updated, comments fixed (#29) * Business central models added * flake8 resolved * models removed, commentrs resolved * bug fixed * Add Pre commit hook for linting (#30) * Import Business Central Attributes API added (#27) * Exportable expense api (#31) * Exportable expense groups api * test cases added * test cases resolved --- .pre-commit-config.yaml | 42 +++++++++++ apps/business_central/serializers.py | 11 ++- apps/business_central/urls.py | 3 +- apps/business_central/views.py | 9 +-- apps/fyle/helpers.py | 27 ++++++- apps/fyle/models.py | 85 +++++++++++++++++++++-- apps/fyle/urls.py | 24 ++++++- apps/fyle/views.py | 30 +++++++- bandit.yaml | 1 + tests/conftest.py | 68 ++++++++++++++---- tests/test_business_central/test_views.py | 5 +- tests/test_fyle/test_views.py | 14 +++- 12 files changed, 274 insertions(+), 45 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 bandit.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..077c587 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,42 @@ +default_stages: [commit] +fail_fast: true +exclude: "^(sql/|(.*\/)?migrations\/)" +default_language_version: # noqa + python: python3 + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: check-ast + - id: check-merge-conflict + - id: debug-statements + - id: detect-private-key + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/timothycrosley/isort + rev: 5.12.0 + hooks: + - id: isort + args: ["--profile", "black", "--trailing-comma", "--line-length=125"] # Fixed the syntax error here + + - repo: https://github.com/pycqa/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + additional_dependencies: [flake8-isort, flake8-tidy-imports] + + - repo: https://github.com/Lucas-C/pre-commit-hooks-bandit + rev: v1.0.5 + hooks: + - id: python-bandit-vulnerability-check + args: ["-lll", "--recursive", "-c", "bandit.yaml", "."] # Fixed the syntax error here + files: '\.py$' # Fixed the regex syntax here + + - repo: local + hooks: + - id: flake8 + name: flake8 + entry: flake8 + language: system diff --git a/apps/business_central/serializers.py b/apps/business_central/serializers.py index f9362c9..74d9033 100644 --- a/apps/business_central/serializers.py +++ b/apps/business_central/serializers.py @@ -1,15 +1,14 @@ import logging -from django.db.models import Q from datetime import datetime + +from django.db.models import Q +from fyle_accounting_mappings.models import DestinationAttribute from rest_framework import serializers from rest_framework.response import Response from rest_framework.views import status -from fyle_accounting_mappings.models import DestinationAttribute - -from apps.workspaces.models import Workspace, BusinessCentralCredentials -from apps.business_central.helpers import sync_dimensions, check_interval_and_sync_dimension - +from apps.business_central.helpers import check_interval_and_sync_dimension, sync_dimensions +from apps.workspaces.models import BusinessCentralCredentials, Workspace logger = logging.getLogger(__name__) logger.level = logging.INFO diff --git a/apps/business_central/urls.py b/apps/business_central/urls.py index 4d5fe36..10ec578 100644 --- a/apps/business_central/urls.py +++ b/apps/business_central/urls.py @@ -1,7 +1,6 @@ from django.urls import path -from apps.business_central.views import ImportBusinessCentralAttributesView, BusinessCentralFieldsView - +from apps.business_central.views import BusinessCentralFieldsView, ImportBusinessCentralAttributesView urlpatterns = [ path( diff --git a/apps/business_central/views.py b/apps/business_central/views.py index 8b515ea..94fbcb8 100644 --- a/apps/business_central/views.py +++ b/apps/business_central/views.py @@ -1,12 +1,6 @@ -import logging - from rest_framework import generics -from apps.business_central.serializers import ImportBusinessCentralAttributesSerializer, BusinessCentralFieldSerializer - - -logger = logging.getLogger(__name__) -logger.level = logging.INFO +from apps.business_central.serializers import BusinessCentralFieldSerializer, ImportBusinessCentralAttributesSerializer class ImportBusinessCentralAttributesView(generics.CreateAPIView): @@ -21,6 +15,7 @@ class BusinessCentralFieldsView(generics.ListAPIView): Business Central Fields View """ serializer_class = BusinessCentralFieldSerializer + pagination_class = None def get_queryset(self): return BusinessCentralFieldSerializer().format_business_central_fields(self.kwargs["workspace_id"]) diff --git a/apps/fyle/helpers.py b/apps/fyle/helpers.py index a65641d..062216a 100644 --- a/apps/fyle/helpers.py +++ b/apps/fyle/helpers.py @@ -1,11 +1,12 @@ import json + import requests from django.conf import settings - from fyle_integrations_platform_connector import PlatformConnector -from apps.workspaces.models import FyleCredential +from apps.accounting_exports.models import AccountingExport from apps.fyle.constants import DEFAULT_FYLE_CONDITIONS +from apps.workspaces.models import ExportSetting, FyleCredential def post_request(url, body, refresh_token=None): @@ -79,3 +80,25 @@ def get_expense_fields(workspace_id: int): }) return response + + +def get_exportable_accounting_exports_ids(workspace_id: int): + """ + Get List of accounting exports ids + """ + + export_setting = ExportSetting.objects.get(workspace_id=workspace_id) + fund_source = [] + + if export_setting.reimbursable_expenses_export_type: + fund_source.append('PERSONAL') + if export_setting.credit_card_expense_export_type: + fund_source.append('CCC') + + accounting_export_ids = AccountingExport.objects.filter( + workspace_id=workspace_id, + exported_at__isnull=True, + fund_source__in=fund_source + ).values_list('id', flat=True) + + return accounting_export_ids diff --git a/apps/fyle/models.py b/apps/fyle/models.py index daa585d..70f1c01 100644 --- a/apps/fyle/models.py +++ b/apps/fyle/models.py @@ -1,3 +1,5 @@ +from typing import List, Dict + from django.db import models from django.contrib.postgres.fields import ArrayField from ms_business_central_api.models.fields import ( @@ -11,7 +13,7 @@ StringOptionsField, IntegerOptionsField, ) -from apps.workspaces.models import BaseModel, BaseForeignWorkspaceModel +from apps.workspaces.models import BaseForeignWorkspaceModel EXPENSE_FILTER_RANK = ( @@ -40,8 +42,13 @@ ('not_in', 'not_in') ) +SOURCE_ACCOUNT_MAP = { + 'PERSONAL_CASH_ACCOUNT': 'PERSONAL', + 'PERSONAL_CORPORATE_CREDIT_CARD_ACCOUNT': 'CCC' +} + -class Expense(BaseModel): +class Expense(BaseForeignWorkspaceModel): """ Expense """ @@ -55,14 +62,14 @@ class Expense(BaseModel): 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') + amount = FloatNullField(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') + foreign_amount = FloatNullField(help_text='Foreign Amount') + foreign_currency = StringNullField(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') + vendor = StringNullField(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') @@ -77,15 +84,79 @@ class Expense(BaseModel): fund_source = StringNotNullField(help_text='Expense fund source') verified_at = CustomDateTimeField(help_text='Report verified at') custom_properties = CustomJsonField(help_text="Custom Properties") + report_title = models.TextField(null=True, blank=True, help_text='Report title') + payment_number = StringNullField(max_length=55, help_text='Expense payment number') 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' + @staticmethod + def create_expense_objects(expenses: List[Dict], workspace_id: int): + """ + Bulk create expense objects + """ + + # Create an empty list to store expense objects + expense_objects = [] + + for expense in expenses: + # Iterate through custom property fields and handle empty values + for custom_property_field in expense['custom_properties']: + if expense['custom_properties'][custom_property_field] == '': + expense['custom_properties'][custom_property_field] = None + + # Create or update an Expense object based on expense_id + expense_object, _ = Expense.objects.update_or_create( + expense_id=expense['id'], + defaults={ + 'employee_email': expense['employee_email'], + 'employee_name': expense['employee_name'], + 'category': expense['category'], + 'sub_category': expense['sub_category'], + 'project': expense['project'], + 'expense_number': expense['expense_number'], + 'org_id': expense['org_id'], + 'claim_number': expense['claim_number'], + 'amount': round(expense['amount'], 2), + 'currency': expense['currency'], + 'foreign_amount': expense['foreign_amount'], + 'foreign_currency': expense['foreign_currency'], + 'tax_amount': expense['tax_amount'], + 'tax_group_id': expense['tax_group_id'], + 'settlement_id': expense['settlement_id'], + 'reimbursable': expense['reimbursable'], + 'billable': expense['billable'] if expense['billable'] else False, + 'state': expense['state'], + 'vendor': expense['vendor'][:250] if expense['vendor'] else None, + 'cost_center': expense['cost_center'], + 'purpose': expense['purpose'], + 'report_id': expense['report_id'], + 'report_title': expense['report_title'], + 'spent_at': expense['spent_at'], + 'approved_at': expense['approved_at'], + 'posted_at': expense['posted_at'], + 'expense_created_at': expense['expense_created_at'], + 'expense_updated_at': expense['expense_updated_at'], + 'fund_source': SOURCE_ACCOUNT_MAP[expense['source_account_type']], + 'verified_at': expense['verified_at'], + 'custom_properties': expense['custom_properties'], + 'payment_number': expense['payment_number'], + 'file_ids': expense['file_ids'], + 'corporate_card_id': expense['corporate_card_id'], + 'workspace_id': workspace_id + } + ) + + # Check if an AccountingExport related to the expense object already exists + if not Expense.objects.filter(accountingexport__isnull=False).distinct(): + expense_objects.append(expense_object) + + return expense_objects + class ExpenseFilter(BaseForeignWorkspaceModel): """ diff --git a/apps/fyle/urls.py b/apps/fyle/urls.py index f913444..5149973 100644 --- a/apps/fyle/urls.py +++ b/apps/fyle/urls.py @@ -14,14 +14,32 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +import itertools + from django.urls import path -from apps.fyle.views import ExpenseFilterView, ExpenseFilterDeleteView, ImportFyleAttributesView, FyleFieldsView, CustomFieldView +from apps.fyle.views import ( + CustomFieldView, + ExpenseFilterDeleteView, + ExpenseFilterView, + ExportableExpenseGroupsView, + FyleFieldsView, + ImportFyleAttributesView, +) + +accounting_exports_path = [ + path('exportable_accounting_exports/', ExportableExpenseGroupsView.as_view(), name='exportable-accounting-exports') +] -urlpatterns = [ +other_paths = [ path('expense_filters//', ExpenseFilterDeleteView.as_view(), name='expense-filters'), path('expense_filters/', ExpenseFilterView.as_view(), name='expense-filters'), - path('import_attributes/', ImportFyleAttributesView.as_view(), name='import-fyle-attributes'), path('fields/', FyleFieldsView.as_view(), name='fyle-fields'), path('expense_fields/', CustomFieldView.as_view(), name='fyle-expense-fields'), ] + +fyle_dimension_paths = [ + path('import_attributes/', ImportFyleAttributesView.as_view(), name='import-fyle-attributes') +] + +urlpatterns = list(itertools.chain(accounting_exports_path, fyle_dimension_paths, other_paths)) diff --git a/apps/fyle/views.py b/apps/fyle/views.py index 71d2e84..8eef36f 100644 --- a/apps/fyle/views.py +++ b/apps/fyle/views.py @@ -1,9 +1,19 @@ import logging + from rest_framework import generics -from ms_business_central_api.utils import LookupFieldMixin -from apps.workspaces.models import Workspace -from apps.fyle.serializers import ExpenseFilterSerializer, ImportFyleAttributesSerializer, FyleFieldsSerializer, ExpenseFieldSerializer +from rest_framework.response import Response +from rest_framework.views import status + +from apps.fyle.helpers import get_exportable_accounting_exports_ids from apps.fyle.models import ExpenseFilter +from apps.fyle.serializers import ( + ExpenseFieldSerializer, + ExpenseFilterSerializer, + FyleFieldsSerializer, + ImportFyleAttributesSerializer, +) +from apps.workspaces.models import Workspace +from ms_business_central_api.utils import LookupFieldMixin logger = logging.getLogger(__name__) logger.level = logging.INFO @@ -52,3 +62,17 @@ class CustomFieldView(generics.ListAPIView): serializer_class = ExpenseFieldSerializer queryset = Workspace.objects.all() + + +class ExportableExpenseGroupsView(generics.RetrieveAPIView): + """ + List Exportable Expense Groups + """ + def get(self, request, *args, **kwargs): + + exportable_ids = get_exportable_accounting_exports_ids(workspace_id=kwargs['workspace_id']) + + return Response( + data={'exportable_expense_group_ids': exportable_ids}, + status=status.HTTP_200_OK + ) diff --git a/bandit.yaml b/bandit.yaml new file mode 100644 index 0000000..4505dcc --- /dev/null +++ b/bandit.yaml @@ -0,0 +1 @@ +exclude_dirs: ['./venv', './.venv'] # noqa diff --git a/tests/conftest.py b/tests/conftest.py index c817824..57d60fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,25 +2,20 @@ Fixture configuration for all the tests """ from datetime import datetime, timezone - from unittest import mock -import pytest -from rest_framework.test import APIClient +import pytest from fyle.platform.platform import Platform -from fyle_rest_auth.models import User, AuthToken +from fyle_accounting_mappings.models import DestinationAttribute +from fyle_rest_auth.models import AuthToken, User +from rest_framework.test import APIClient -from apps.fyle.helpers import get_access_token -from apps.workspaces.models import ( - Workspace, - FyleCredential, - BusinessCentralCredentials -) from apps.accounting_exports.models import AccountingExport, AccountingExportSummary, Error +from apps.fyle.helpers import get_access_token from apps.fyle.models import ExpenseFilter +from apps.workspaces.models import BusinessCentralCredentials, ExportSetting, FyleCredential, Workspace from ms_business_central_api.tests import settings - -from .test_fyle.fixtures import fixtures as fyle_fixtures +from tests.test_fyle.fixtures import fixtures as fyle_fixtures @pytest.fixture() @@ -272,3 +267,52 @@ def add_business_central_creds(): is_expired = False, workspace_id = workspace_id ) + + +@pytest.fixture() +@pytest.mark.django_db(databases=['default']) +def add_destination_attributes(): + """ + Pytest fixture to add destination attributes to a workspace + """ + workspace_ids = [ + 1, 2, 3 + ] + for workspace_id in workspace_ids: + DestinationAttribute.objects.create( + attribute_type='DUMMY_ATTRIBUTE_TYPE', + display_name='dummy_attribute_name', + value='dummy_attribute_value', + destination_id='dummy_destination_id', + active=True, + workspace_id=workspace_id + ) + + +@pytest.fixture() +@pytest.mark.django_db(databases=['default']) +def add_export_settings(): + """ + Pytest fixtue to add export_settings to a workspace + """ + + workspace_ids = [ + 1, 2, 3 + ] + + for workspace_id in workspace_ids: + ExportSetting.objects.create( + workspace_id=workspace_id, + reimbursable_expenses_export_type='BILL' if workspace_id in [1, 2] else 'JOURNAL_ENTRY', + default_bank_account_name='Accounts Payable', + default_back_account_id='1', + reimbursable_expense_state='PAYMENT_PROCESSING', + reimbursable_expense_date='current_date' if workspace_id == 1 else 'last_spent_at', + reimbursable_expense_grouped_by='REPORT' if workspace_id == 1 else 'EXPENSE', + credit_card_expense_export_type='CREDIT_CARD_PURCHASE' if workspace_id in [1, 2] else 'JOURNAL_ENTRY', + credit_card_expense_state='PAYMENT_PROCESSING', + default_ccc_credit_card_account_name='Visa', + default_ccc_credit_card_account_id='12', + credit_card_expense_grouped_by='EXPENSE' if workspace_id == 3 else 'REPORT', + credit_card_expense_date='spent_at' + ) diff --git a/tests/test_business_central/test_views.py b/tests/test_business_central/test_views.py index 2459930..0cd41eb 100644 --- a/tests/test_business_central/test_views.py +++ b/tests/test_business_central/test_views.py @@ -1,4 +1,5 @@ import json + from django.urls import reverse from apps.workspaces.models import BusinessCentralCredentials @@ -27,7 +28,7 @@ def test_sync_dimensions(api_client, test_connection, mocker, create_temp_worksp assert response['message'] == 'Business Central credentials not found / invalid in workspace' -def test_sage300_fields(api_client, test_connection): +def test_business_central_fields(api_client, test_connection, create_temp_workspace, add_fyle_credentials, add_destination_attributes): workspace_id = 1 access_token = test_connection.access_token @@ -38,4 +39,4 @@ def test_sage300_fields(api_client, test_connection): response = api_client.get(url) assert response.status_code == 200 - assert response.data['results'] == [] + assert response.data == [{'attribute_type': 'DUMMY_ATTRIBUTE_TYPE', 'display_name': 'dummy_attribute_name'}] diff --git a/tests/test_fyle/test_views.py b/tests/test_fyle/test_views.py index 62ff5f4..2b3d0b7 100644 --- a/tests/test_fyle/test_views.py +++ b/tests/test_fyle/test_views.py @@ -1,9 +1,11 @@ import json from unittest import mock + from django.urls import reverse + from apps.workspaces.models import FyleCredential, Workspace from tests.helpers import dict_compare_keys -from .fixtures import fixtures as data +from tests.test_fyle.fixtures import fixtures as data def test_expense_filters(api_client, test_connection, create_temp_workspace, add_fyle_credentials, add_expense_filters): @@ -100,3 +102,13 @@ def test_fyle_expense_fields(api_client, test_connection, create_temp_workspace, assert ( dict_compare_keys(response['results'], data['fyle_expense_custom_fields']) == [] ), 'expense group api return diffs in keys' + + +def test_exportable_expense_group_view(api_client, test_connection, create_temp_workspace, add_export_settings): + + access_token = test_connection.access_token + url = reverse('exportable-accounting-exports', kwargs={'workspace_id': 1}) + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(access_token)) + + response = api_client.get(url) + assert response.status_code == 200