From 1e07ae5553603823f2f24c0fcbccb7751a349ba5 Mon Sep 17 00:00:00 2001 From: ruuushhh Date: Fri, 17 Nov 2023 05:42:15 +0530 Subject: [PATCH 1/7] Business central models added --- apps/business_central/models.py | 116 ++++++++++++++++++++++++++++++++ apps/fyle/models.py | 83 +++++++++++++++++++++-- 2 files changed, 193 insertions(+), 6 deletions(-) diff --git a/apps/business_central/models.py b/apps/business_central/models.py index e69de29..acd57c7 100644 --- a/apps/business_central/models.py +++ b/apps/business_central/models.py @@ -0,0 +1,116 @@ +from django.db import models + +from apps.workspaces.models import BaseModel +from ms_business_central_api.models.fields import ( + StringNotNullField, + CustomDateTimeField, + FloatNullField, + IntegerNullField, + TextNotNullField +) +from apps.accounting_exports.models import AccountingExport + + +class PurchaseInvoice(BaseModel): + """ + Purchase_Invoice Table Model Class + + Example Data -> + amount: 12.31, + date: '2021-04-26', + accounting_date: 'Accounts Payable', + description: 'Reimbursable Expenses by Shwetabh', + tax_amount: 1.32, + vendor_id: '12312123123' + """ + + id = models.AutoField(primary_key=True) + amount = FloatNullField(help_text='Invoice amount') + accounting_date = StringNotNullField(help_text='Accounting date') + description = TextNotNullField(help_text='Invoice description') + tax_amount = FloatNullField(help_text='Tax amount') + accounting_export = models.OneToOneField(AccountingExport, on_delete=models.PROTECT, help_text='Reference to AccountingExport model') + vendor_id = StringNotNullField(help_text='Vendor ID') + code = StringNotNullField(max_length=15, help_text="unique key for each document") + + class Meta: + db_table = 'purchase_invoice' + + +class PurchaseInvoiceLineitems(BaseModel): + """ + Purchase Invoice Table Model Class + + Example Data -> + amount: 12.31, + accounts_payable_account_id: '123123', + expense_account_id: '1231231', + description: 'Reimbursable Expenses by Shwetabh', + job_id: '123123', + cost_code_id: '12312123123' + category_id: '123' + """ + + id = models.AutoField(primary_key=True) + amount = FloatNullField(help_text='Invoice lineitem amount') + accounts_payable_account_id = StringNotNullField(help_text='Accounts Payable Account Id') + description = TextNotNullField(help_text='Invoice lineitem description') + expense_account_id = StringNotNullField(help_text='Expense Account Id') + category_id = StringNotNullField(help_text='Category Id') + invoice_id = models.ForeignKey(PurchaseInvoice, on_delete=models.PROTECT, help_text='Reference to Invoice model') + + class Meta: + db_table = 'purchase_invoice_lineitems' + + +class JournalEntry(BaseModel): + """ + Journal Entry Table Model Class + + Example Data -> + amount: 12.31, + date: '2021-04-26', + accounting_date: 'Accounts Payable', + description: 'Reimbursable Expenses by Shwetabh', + tax_amount: 1.32, + vendor_id: '12312123123' + """ + + id = models.AutoField(primary_key=True) + amount = FloatNullField(help_text='Invoice amount') + accounting_date = StringNotNullField(help_text='Accounting date') + description = TextNotNullField(help_text='Invoice description') + tax_amount = FloatNullField(help_text='Tax amount') + accounting_export = models.OneToOneField(AccountingExport, on_delete=models.PROTECT, help_text='Reference to AccountingExport model') + vendor_id = StringNotNullField(help_text='Vendor ID') + code = StringNotNullField(max_length=15, help_text="unique key for each document") + + class Meta: + db_table = 'journal_entry' + + +class JournalEntryLineItem(BaseModel): + """ + Journal Entry Line Item Table Model Class + + Example Data -> + amount: 12.31, + accounts_payable_account_id: '123123', + expense_account_id: '1231231', + description: 'Reimbursable Expenses by Shwetabh', + category_id: '123' + """ + + id = models.AutoField(primary_key=True) + amount = FloatNullField(help_text='Invoice amount') + code = StringNotNullField(help_text='Code Id') + category_id = StringNotNullField(help_text='Category Id') + accounting_export = models.OneToOneField(AccountingExport, on_delete=models.PROTECT, help_text='Reference to AccountingExport model') + credit_card_account_id = StringNotNullField(help_text='Credit Card Account Id') + debit_card_account_id = StringNotNullField(help_text='Debit Card Account Id') + transaction_date = CustomDateTimeField(help_text='Transaction Date') + description = TextNotNullField(help_text='Direct Costs description') + transaction_type = IntegerNullField(help_text='Transaction Type') + + class Meta: + db_table = 'journal_entry_lineitems' diff --git a/apps/fyle/models.py b/apps/fyle/models.py index daa585d..3ebcc76 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 ( @@ -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): """ From 32384e563573c9e0cf158929938a3abf04a27de7 Mon Sep 17 00:00:00 2001 From: ruuushhh Date: Fri, 17 Nov 2023 05:45:13 +0530 Subject: [PATCH 2/7] flake8 resolved --- apps/fyle/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/fyle/models.py b/apps/fyle/models.py index 3ebcc76..70f1c01 100644 --- a/apps/fyle/models.py +++ b/apps/fyle/models.py @@ -13,7 +13,7 @@ StringOptionsField, IntegerOptionsField, ) -from apps.workspaces.models import BaseModel, BaseForeignWorkspaceModel +from apps.workspaces.models import BaseForeignWorkspaceModel EXPENSE_FILTER_RANK = ( From e39b202fba46f97af7105fab1da3de74630cf3cb Mon Sep 17 00:00:00 2001 From: ruuushhh Date: Mon, 20 Nov 2023 12:52:51 +0530 Subject: [PATCH 3/7] models removed, commentrs resolved --- apps/business_central/models.py | 116 ---------------------- apps/business_central/views.py | 6 -- tests/conftest.py | 22 ++++ tests/test_business_central/test_views.py | 4 +- 4 files changed, 24 insertions(+), 124 deletions(-) diff --git a/apps/business_central/models.py b/apps/business_central/models.py index acd57c7..e69de29 100644 --- a/apps/business_central/models.py +++ b/apps/business_central/models.py @@ -1,116 +0,0 @@ -from django.db import models - -from apps.workspaces.models import BaseModel -from ms_business_central_api.models.fields import ( - StringNotNullField, - CustomDateTimeField, - FloatNullField, - IntegerNullField, - TextNotNullField -) -from apps.accounting_exports.models import AccountingExport - - -class PurchaseInvoice(BaseModel): - """ - Purchase_Invoice Table Model Class - - Example Data -> - amount: 12.31, - date: '2021-04-26', - accounting_date: 'Accounts Payable', - description: 'Reimbursable Expenses by Shwetabh', - tax_amount: 1.32, - vendor_id: '12312123123' - """ - - id = models.AutoField(primary_key=True) - amount = FloatNullField(help_text='Invoice amount') - accounting_date = StringNotNullField(help_text='Accounting date') - description = TextNotNullField(help_text='Invoice description') - tax_amount = FloatNullField(help_text='Tax amount') - accounting_export = models.OneToOneField(AccountingExport, on_delete=models.PROTECT, help_text='Reference to AccountingExport model') - vendor_id = StringNotNullField(help_text='Vendor ID') - code = StringNotNullField(max_length=15, help_text="unique key for each document") - - class Meta: - db_table = 'purchase_invoice' - - -class PurchaseInvoiceLineitems(BaseModel): - """ - Purchase Invoice Table Model Class - - Example Data -> - amount: 12.31, - accounts_payable_account_id: '123123', - expense_account_id: '1231231', - description: 'Reimbursable Expenses by Shwetabh', - job_id: '123123', - cost_code_id: '12312123123' - category_id: '123' - """ - - id = models.AutoField(primary_key=True) - amount = FloatNullField(help_text='Invoice lineitem amount') - accounts_payable_account_id = StringNotNullField(help_text='Accounts Payable Account Id') - description = TextNotNullField(help_text='Invoice lineitem description') - expense_account_id = StringNotNullField(help_text='Expense Account Id') - category_id = StringNotNullField(help_text='Category Id') - invoice_id = models.ForeignKey(PurchaseInvoice, on_delete=models.PROTECT, help_text='Reference to Invoice model') - - class Meta: - db_table = 'purchase_invoice_lineitems' - - -class JournalEntry(BaseModel): - """ - Journal Entry Table Model Class - - Example Data -> - amount: 12.31, - date: '2021-04-26', - accounting_date: 'Accounts Payable', - description: 'Reimbursable Expenses by Shwetabh', - tax_amount: 1.32, - vendor_id: '12312123123' - """ - - id = models.AutoField(primary_key=True) - amount = FloatNullField(help_text='Invoice amount') - accounting_date = StringNotNullField(help_text='Accounting date') - description = TextNotNullField(help_text='Invoice description') - tax_amount = FloatNullField(help_text='Tax amount') - accounting_export = models.OneToOneField(AccountingExport, on_delete=models.PROTECT, help_text='Reference to AccountingExport model') - vendor_id = StringNotNullField(help_text='Vendor ID') - code = StringNotNullField(max_length=15, help_text="unique key for each document") - - class Meta: - db_table = 'journal_entry' - - -class JournalEntryLineItem(BaseModel): - """ - Journal Entry Line Item Table Model Class - - Example Data -> - amount: 12.31, - accounts_payable_account_id: '123123', - expense_account_id: '1231231', - description: 'Reimbursable Expenses by Shwetabh', - category_id: '123' - """ - - id = models.AutoField(primary_key=True) - amount = FloatNullField(help_text='Invoice amount') - code = StringNotNullField(help_text='Code Id') - category_id = StringNotNullField(help_text='Category Id') - accounting_export = models.OneToOneField(AccountingExport, on_delete=models.PROTECT, help_text='Reference to AccountingExport model') - credit_card_account_id = StringNotNullField(help_text='Credit Card Account Id') - debit_card_account_id = StringNotNullField(help_text='Debit Card Account Id') - transaction_date = CustomDateTimeField(help_text='Transaction Date') - description = TextNotNullField(help_text='Direct Costs description') - transaction_type = IntegerNullField(help_text='Transaction Type') - - class Meta: - db_table = 'journal_entry_lineitems' diff --git a/apps/business_central/views.py b/apps/business_central/views.py index 8b515ea..84c7bf2 100644 --- a/apps/business_central/views.py +++ b/apps/business_central/views.py @@ -1,14 +1,8 @@ -import logging - from rest_framework import generics from apps.business_central.serializers import ImportBusinessCentralAttributesSerializer, BusinessCentralFieldSerializer -logger = logging.getLogger(__name__) -logger.level = logging.INFO - - class ImportBusinessCentralAttributesView(generics.CreateAPIView): """ Import Business Central Attributes View diff --git a/tests/conftest.py b/tests/conftest.py index c817824..6e79682 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,8 @@ from fyle.platform.platform import Platform from fyle_rest_auth.models import User, AuthToken +from fyle_accounting_mappings.models import DestinationAttribute + from apps.fyle.helpers import get_access_token from apps.workspaces.models import ( Workspace, @@ -272,3 +274,23 @@ 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 + ) diff --git a/tests/test_business_central/test_views.py b/tests/test_business_central/test_views.py index 2459930..9569658 100644 --- a/tests/test_business_central/test_views.py +++ b/tests/test_business_central/test_views.py @@ -27,7 +27,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 +38,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['results'] == [{'attribute_type': 'DUMMY_ATTRIBUTE_TYPE', 'display_name': 'dummy_attribute_name'}] From 6eb13ba79a44901a9e7848ea166621554d1e3274 Mon Sep 17 00:00:00 2001 From: ruuushhh Date: Mon, 20 Nov 2023 13:09:32 +0530 Subject: [PATCH 4/7] bug fixed --- apps/business_central/views.py | 1 + tests/test_business_central/test_views.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/business_central/views.py b/apps/business_central/views.py index 84c7bf2..08a087a 100644 --- a/apps/business_central/views.py +++ b/apps/business_central/views.py @@ -15,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/tests/test_business_central/test_views.py b/tests/test_business_central/test_views.py index 9569658..d036f09 100644 --- a/tests/test_business_central/test_views.py +++ b/tests/test_business_central/test_views.py @@ -38,4 +38,4 @@ def test_business_central_fields(api_client, test_connection, create_temp_worksp response = api_client.get(url) assert response.status_code == 200 - assert response.data['results'] == [{'attribute_type': 'DUMMY_ATTRIBUTE_TYPE', 'display_name': 'dummy_attribute_name'}] + assert response.data == [{'attribute_type': 'DUMMY_ATTRIBUTE_TYPE', 'display_name': 'dummy_attribute_name'}] From 7488c3678d5c3d134329542f0283373201007347 Mon Sep 17 00:00:00 2001 From: ruuushhh <66899387+ruuushhh@users.noreply.github.com> Date: Mon, 20 Nov 2023 17:34:12 +0530 Subject: [PATCH 5/7] Add Pre commit hook for linting (#30) --- .pre-commit-config.yaml | 42 +++++++++++++++++++++++++++++++++++++++++ bandit.yaml | 1 + 2 files changed, 43 insertions(+) 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/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 From a968568b0a565756ccf20e8b8c4b6ab7ea665fd6 Mon Sep 17 00:00:00 2001 From: ruuushhh <66899387+ruuushhh@users.noreply.github.com> Date: Tue, 28 Nov 2023 12:25:06 +0530 Subject: [PATCH 6/7] Import Business Central Attributes API added (#27) --- apps/business_central/helpers.py | 61 ++++++++++++++++++ apps/business_central/serializers.py | 62 +++++++++++++++++++ apps/business_central/urls.py | 12 ++++ apps/business_central/views.py | 16 +++++ apps/workspaces/urls.py | 1 + docker-compose-pipeline.yml | 2 +- tests/conftest.py | 18 ++++++ .../test_business_central/__init__.py | 0 tests/test_business_central/test_views.py | 27 ++++++++ 9 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 apps/business_central/helpers.py create mode 100644 apps/business_central/serializers.py rename apps/business_central/serializers.py.py => tests/test_business_central/__init__.py (100%) create mode 100644 tests/test_business_central/test_views.py diff --git a/apps/business_central/helpers.py b/apps/business_central/helpers.py new file mode 100644 index 0000000..6a7db1b --- /dev/null +++ b/apps/business_central/helpers.py @@ -0,0 +1,61 @@ + +from datetime import datetime, timezone +import logging + +from django.utils.module_loading import import_string + +from apps.workspaces.models import Workspace, BusinessCentralCredentials + + +logger = logging.getLogger(__name__) +logger.level = logging.INFO + + +# Import your Workspace and BusinessCentralCredentials models here +# Also, make sure you have 'logger' defined and imported from a logging module +def check_interval_and_sync_dimension(workspace: Workspace, business_central_credential: BusinessCentralCredentials) -> bool: + """ + Check the synchronization interval and trigger dimension synchronization if needed. + + :param workspace: Workspace Instance + :param business_central_credential: BusinessCentralCredentials Instance + + :return: True if synchronization is triggered, False if not + """ + + if workspace.destination_synced_at: + # Calculate the time interval since the last destination sync + time_interval = datetime.now(timezone.utc) - workspace.destination_synced_at + + if workspace.destination_synced_at is None or time_interval.days > 0: + # If destination_synced_at is None or the time interval is greater than 0 days, trigger synchronization + sync_dimensions(business_central_credential, workspace.id) + return True + + return False + + +def sync_dimensions(business_central_credential: BusinessCentralCredentials, workspace_id: int) -> None: + """ + Synchronize various dimensions with Business Central using the provided credentials. + + :param business_central_credential: BusinessCentralCredentials Instance + :param workspace_id: ID of the workspace + + This function syncs dimensions like accounts, vendors, commitments, jobs, categories, and cost codes. + """ + + # Initialize the Business Central connection using the provided credentials and workspace ID + business_central_connection = import_string('apps.business_central.utils.BusinessCentralConnector')(business_central_credential, workspace_id) + + # List of dimensions to sync + dimensions = ['accounts', 'vendors', 'employees', 'locations'] + + for dimension in dimensions: + try: + # Dynamically call the sync method based on the dimension + sync = getattr(business_central_connection, 'sync_{}'.format(dimension)) + sync() + except Exception as exception: + # Log any exceptions that occur during synchronization + logger.info(exception) diff --git a/apps/business_central/serializers.py b/apps/business_central/serializers.py new file mode 100644 index 0000000..13fa351 --- /dev/null +++ b/apps/business_central/serializers.py @@ -0,0 +1,62 @@ +import logging +from datetime import datetime +from rest_framework import serializers +from rest_framework.response import Response +from rest_framework.views import status + +from apps.workspaces.models import Workspace, BusinessCentralCredentials +from apps.business_central.helpers import sync_dimensions, check_interval_and_sync_dimension + + +logger = logging.getLogger(__name__) +logger.level = logging.INFO + + +class ImportBusinessCentralAttributesSerializer(serializers.Serializer): + """ + Import Business Central Attributes serializer + """ + + def create(self, validated_data): + try: + # Get the workspace ID from the URL kwargs + workspace_id = self.context['request'].parser_context['kwargs']['workspace_id'] + + # Check if the 'refresh' field is provided in the request data + refresh_dimension = self.context['request'].data.get('refresh', False) + + # Retrieve the workspace and Business Central credentials + workspace = Workspace.objects.get(pk=workspace_id) + business_central_credentials = BusinessCentralCredentials.objects.get( + workspace_id=workspace.id + ) + + if refresh_dimension: + # If 'refresh' is true, perform a full sync of dimensions + sync_dimensions(business_central_credentials, workspace.id) + else: + # If 'refresh' is false, check the interval and sync dimension accordingly + check_interval_and_sync_dimension(workspace, business_central_credentials) + + # Update the destination_synced_at field and save the workspace + workspace.destination_synced_at = datetime.now() + workspace.save(update_fields=['destination_synced_at']) + + # Return a success response + return Response(status=status.HTTP_200_OK) + + except BusinessCentralCredentials.DoesNotExist: + # Handle the case when business central credentials are not found or invalid + raise serializers.ValidationError( + {'message': 'Business Central credentials not found / invalid in workspace'} + ) + + except Exception as exception: + # Handle unexpected exceptions and log the error + logger.error( + 'Something unexpected happened workspace_id: %s %s', + workspace_id, + exception, + ) + # Raise a custom exception or re-raise the original exception + raise diff --git a/apps/business_central/urls.py b/apps/business_central/urls.py index e69de29..c665a68 100644 --- a/apps/business_central/urls.py +++ b/apps/business_central/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from apps.business_central.views import ImportBusinessCentralAttributesView + + +urlpatterns = [ + path( + "import_attributes/", + ImportBusinessCentralAttributesView.as_view(), + name="import-business-central-attributes", + ) +] diff --git a/apps/business_central/views.py b/apps/business_central/views.py index e69de29..5062d50 100644 --- a/apps/business_central/views.py +++ b/apps/business_central/views.py @@ -0,0 +1,16 @@ +import logging + +from rest_framework import generics + +from apps.business_central.serializers import ImportBusinessCentralAttributesSerializer + + +logger = logging.getLogger(__name__) +logger.level = logging.INFO + + +class ImportBusinessCentralAttributesView(generics.CreateAPIView): + """ + Import Business Central Attributes View + """ + serializer_class = ImportBusinessCentralAttributesSerializer diff --git a/apps/workspaces/urls.py b/apps/workspaces/urls.py index 271ecd1..cef5585 100644 --- a/apps/workspaces/urls.py +++ b/apps/workspaces/urls.py @@ -25,6 +25,7 @@ other_app_paths = [ path('/accounting_exports/', include('apps.accounting_exports.urls')), path('/fyle/', include('apps.fyle.urls')), + path('/business_central/', include('apps.business_central.urls')), ] urlpatterns = [] diff --git a/docker-compose-pipeline.yml b/docker-compose-pipeline.yml index 3052caf..a9cc7ad 100644 --- a/docker-compose-pipeline.yml +++ b/docker-compose-pipeline.yml @@ -24,7 +24,7 @@ services: FYLE_CLIENT_SECRET: 'sample' FYLE_REFRESH_TOKEN: 'sample.sample.sample' BUSINESS_CENTRAL_REDIRECT_URI: ${BUSINESS_CENTRAL_REDIRECT_URI} - BUSINESS_CENTRALTOKEN_URI: ${BUSINESS_CENTRALTOKEN_URI} + BUSINESS_CENTRAL_TOKEN_URI: ${BUSINESS_CENTRAL_TOKEN_URI} ENCRYPTION_KEY: ${ENCRYPTION_KEY} FYLE_TOKEN_URI: 'https://sample.fyle.tech' FYLE_SERVER_URL: 'https://sample.fyle.tech' diff --git a/tests/conftest.py b/tests/conftest.py index 5ee9822..c817824 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ from apps.workspaces.models import ( Workspace, FyleCredential, + BusinessCentralCredentials ) from apps.accounting_exports.models import AccountingExport, AccountingExportSummary, Error from apps.fyle.models import ExpenseFilter @@ -254,3 +255,20 @@ def add_expense_filters(): custom_field_type='SELECT', workspace_id=workspace_id ) + + +@pytest.fixture() +@pytest.mark.django_db(databases=['default']) +def add_business_central_creds(): + """ + Pytest fixture to add business central credentials to a workspace + """ + workspace_ids = [ + 1, 2, 3 + ] + for workspace_id in workspace_ids: + BusinessCentralCredentials.objects.create( + refresh_token = 'dummy_refresh_token', + is_expired = False, + workspace_id = workspace_id + ) diff --git a/apps/business_central/serializers.py.py b/tests/test_business_central/__init__.py similarity index 100% rename from apps/business_central/serializers.py.py rename to tests/test_business_central/__init__.py diff --git a/tests/test_business_central/test_views.py b/tests/test_business_central/test_views.py new file mode 100644 index 0000000..1877195 --- /dev/null +++ b/tests/test_business_central/test_views.py @@ -0,0 +1,27 @@ +import json +from django.urls import reverse + +from apps.workspaces.models import BusinessCentralCredentials + + +def test_sync_dimensions(api_client, test_connection, mocker, create_temp_workspace, add_business_central_creds): + workspace_id = 1 + + access_token = test_connection.access_token + url = reverse('import-business-central-attributes', kwargs={'workspace_id': workspace_id}) + + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(access_token)) + + mocker.patch('apps.business_central.helpers.sync_dimensions', return_value=None) + + response = api_client.post(url) + assert response.status_code == 201 + + business_central_credentials = BusinessCentralCredentials.objects.get(workspace_id=workspace_id) + business_central_credentials.delete() + + response = api_client.post(url) + assert response.status_code == 400 + + response = json.loads(response.content) + assert response['message'] == 'Business Central credentials not found / invalid in workspace' From 25ee8ff2f8287f1304206c580b9ea506d238e082 Mon Sep 17 00:00:00 2001 From: ruuushhh <66899387+ruuushhh@users.noreply.github.com> Date: Wed, 29 Nov 2023 12:06:44 +0530 Subject: [PATCH 7/7] Exportable expense api (#31) * Exportable expense groups api * test cases added * test cases resolved --- apps/fyle/helpers.py | 27 +++++++++++++++++++++++++-- apps/fyle/urls.py | 24 +++++++++++++++++++++--- apps/fyle/views.py | 30 +++++++++++++++++++++++++++--- tests/conftest.py | 31 ++++++++++++++++++++++++++++++- tests/test_fyle/test_views.py | 14 +++++++++++++- 5 files changed, 116 insertions(+), 10 deletions(-) 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/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/tests/conftest.py b/tests/conftest.py index b60805c..57d60fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ 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, FyleCredential, Workspace +from apps.workspaces.models import BusinessCentralCredentials, ExportSetting, FyleCredential, Workspace from ms_business_central_api.tests import settings from tests.test_fyle.fixtures import fixtures as fyle_fixtures @@ -287,3 +287,32 @@ def add_destination_attributes(): 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_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