From 4bf6487c76c2a8a98b4c030cb7677087134036c9 Mon Sep 17 00:00:00 2001 From: ashwin1111 Date: Mon, 30 Sep 2024 14:05:57 +0530 Subject: [PATCH 1/4] feat: NetSuite internal APIs --- apps/internal/__init__.py | 0 apps/internal/actions.py | 17 ++++++++++++++++ apps/internal/apps.py | 6 ++++++ apps/internal/migrations/__init__.py | 0 apps/internal/urls.py | 9 +++++++++ apps/internal/views.py | 29 ++++++++++++++++++++++++++++ apps/netsuite/connector.py | 10 ++++++++++ fyle_netsuite_api/settings.py | 1 + fyle_netsuite_api/urls.py | 1 + 9 files changed, 73 insertions(+) create mode 100644 apps/internal/__init__.py create mode 100644 apps/internal/actions.py create mode 100644 apps/internal/apps.py create mode 100644 apps/internal/migrations/__init__.py create mode 100644 apps/internal/urls.py create mode 100644 apps/internal/views.py diff --git a/apps/internal/__init__.py b/apps/internal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/internal/actions.py b/apps/internal/actions.py new file mode 100644 index 00000000..40df7d25 --- /dev/null +++ b/apps/internal/actions.py @@ -0,0 +1,17 @@ +from typing import Dict + +from apps.netsuite.connector import NetSuiteConnector +from apps.workspaces.models import Workspace, NetSuiteCredentials + + +def get_accounting_fields(query_params: Dict): + org_id = query_params.get('org_id') + resource_type = query_params.get('resource_type') + + workspace = Workspace.objects.get(fyle_org_id=org_id) + workspace_id = workspace.id + ns_credentials = NetSuiteCredentials.objects.get(workspace_id=workspace.id) + + ns_connection = NetSuiteConnector(netsuite_credentials=ns_credentials, workspace_id=workspace_id) + + return ns_connection.get_accounting_fields(resource_type) diff --git a/apps/internal/apps.py b/apps/internal/apps.py new file mode 100644 index 00000000..ec00b049 --- /dev/null +++ b/apps/internal/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class InternalConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.internal' diff --git a/apps/internal/migrations/__init__.py b/apps/internal/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/internal/urls.py b/apps/internal/urls.py new file mode 100644 index 00000000..f96b5a6f --- /dev/null +++ b/apps/internal/urls.py @@ -0,0 +1,9 @@ +import itertools + +from django.urls import path + +from .views import AccountingFieldsView + +urlpatterns = [ + path('accounting_fields/', AccountingFieldsView.as_view(), name='accounting-fields'), +] diff --git a/apps/internal/views.py b/apps/internal/views.py new file mode 100644 index 00000000..5df0964f --- /dev/null +++ b/apps/internal/views.py @@ -0,0 +1,29 @@ +import logging +import traceback +from rest_framework import generics +from rest_framework.response import Response +from rest_framework import status + +from .actions import get_accounting_fields + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class AccountingFieldsView(generics.GenericAPIView): + authentication_classes = [] + permission_classes = [] + + def get(self, request, *args, **kwargs): + try: + response = get_accounting_fields(request.query_params) + return Response( + data={'data': response}, + status=status.HTTP_200_OK + ) + except Exception: + logger.info(f"Error in AccountingFieldsView: {traceback.format_exc()}") + return Response( + data={'error': traceback.format_exc()}, + status=status.HTTP_400_BAD_REQUEST + ) diff --git a/apps/netsuite/connector.py b/apps/netsuite/connector.py index 015ea28d..fc4268fb 100644 --- a/apps/netsuite/connector.py +++ b/apps/netsuite/connector.py @@ -1106,6 +1106,16 @@ def sync_customers(self): return [] + def get_accounting_fields(self, resource_type: str): + method = getattr(self.connection, resource_type) + generator = method.get_all_generator() + fields = [] + for resources in generator: + for resource in resources: + fields.append(resource) + + return json.loads(json.dumps(fields, default=str).replace('\\n', '')) + def construct_bill_lineitems( self, bill_lineitems: List[BillLineitem], diff --git a/fyle_netsuite_api/settings.py b/fyle_netsuite_api/settings.py index 8f752cad..51888875 100644 --- a/fyle_netsuite_api/settings.py +++ b/fyle_netsuite_api/settings.py @@ -59,6 +59,7 @@ 'apps.netsuite', 'django_q', 'django_filters', + 'apps.internal' ] MIDDLEWARE = [ diff --git a/fyle_netsuite_api/urls.py b/fyle_netsuite_api/urls.py index 526b49ea..95c629dc 100644 --- a/fyle_netsuite_api/urls.py +++ b/fyle_netsuite_api/urls.py @@ -22,4 +22,5 @@ path('api/workspaces/', include('apps.workspaces.urls')), path('api/user/', include('apps.users.urls')), path('api/v2/workspaces/', include('apps.workspaces.apis.urls')), + path('api/internal/', include('apps.internal.urls')), ] From 67f362243e293872975e83e4ef97a08a376f361a Mon Sep 17 00:00:00 2001 From: ashwin1111 Date: Fri, 15 Nov 2024 16:18:05 +0530 Subject: [PATCH 2/4] rename --- apps/internal/views.py | 4 +++- apps/workspaces/permissions.py | 6 +++--- apps/workspaces/views.py | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/internal/views.py b/apps/internal/views.py index 5df0964f..be315de0 100644 --- a/apps/internal/views.py +++ b/apps/internal/views.py @@ -4,6 +4,8 @@ from rest_framework.response import Response from rest_framework import status +from apps.workspaces.permissions import IsAuthenticatedForInternalAPI + from .actions import get_accounting_fields logger = logging.getLogger(__name__) @@ -12,7 +14,7 @@ class AccountingFieldsView(generics.GenericAPIView): authentication_classes = [] - permission_classes = [] + permission_classes = [IsAuthenticatedForInternalAPI] def get(self, request, *args, **kwargs): try: diff --git a/apps/workspaces/permissions.py b/apps/workspaces/permissions.py index beb77f28..18228b00 100644 --- a/apps/workspaces/permissions.py +++ b/apps/workspaces/permissions.py @@ -34,15 +34,15 @@ def has_permission(self, request, view): workspace_users = Workspace.objects.filter(pk=workspace_id).values_list('user', flat=True) return self.validate_and_cache(workspace_users, user, workspace_id, True) -class IsAuthenticatedForTest(permissions.BasePermission): +class IsAuthenticatedForInternalAPI(permissions.BasePermission): """ - Custom auth for preparing a workspace for e2e tests + Custom auth for internal APIs """ def has_permission(self, request, view): # Client sends a token in the header, which we decrypt and compare with the Client Secret cipher_suite = Fernet(settings.ENCRYPTION_KEY) try: - decrypted_password = cipher_suite.decrypt(request.headers['X-E2E-Tests-Client-ID'].encode('utf-8')).decode('utf-8') + decrypted_password = cipher_suite.decrypt(request.headers['X-Internal-API-Client-ID'].encode('utf-8')).decode('utf-8') if decrypted_password == settings.E2E_TESTS_CLIENT_SECRET: return True except: diff --git a/apps/workspaces/views.py b/apps/workspaces/views.py index 89e98d48..38e03968 100644 --- a/apps/workspaces/views.py +++ b/apps/workspaces/views.py @@ -33,7 +33,7 @@ from apps.workspaces.actions import export_to_netsuite from .serializers import LastExportDetailSerializer, WorkspaceSerializer, FyleCredentialSerializer, NetSuiteCredentialSerializer, \ ConfigurationSerializer, WorkspaceScheduleSerializer -from .permissions import IsAuthenticatedForTest +from .permissions import IsAuthenticatedForInternalAPI logger = logging.getLogger(__name__) @@ -484,7 +484,7 @@ class SetupE2ETestView(viewsets.ViewSet): NetSuite Workspace """ authentication_classes = [] - permission_classes = [IsAuthenticatedForTest] + permission_classes = [IsAuthenticatedForInternalAPI] def post(self, request, **kwargs): """ From 9e549bbc6b499c0bbe23dd0635d6c308e72d0093 Mon Sep 17 00:00:00 2001 From: ashwin1111 Date: Fri, 22 Nov 2024 20:26:19 +0530 Subject: [PATCH 3/4] custom segment support --- apps/internal/actions.py | 3 ++- apps/internal/views.py | 10 ++++++++++ apps/netsuite/connector.py | 38 +++++++++++++++++++++++++++++--------- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/apps/internal/actions.py b/apps/internal/actions.py index 40df7d25..1b7629d6 100644 --- a/apps/internal/actions.py +++ b/apps/internal/actions.py @@ -7,6 +7,7 @@ def get_accounting_fields(query_params: Dict): org_id = query_params.get('org_id') resource_type = query_params.get('resource_type') + internal_id = query_params.get('internal_id') workspace = Workspace.objects.get(fyle_org_id=org_id) workspace_id = workspace.id @@ -14,4 +15,4 @@ def get_accounting_fields(query_params: Dict): ns_connection = NetSuiteConnector(netsuite_credentials=ns_credentials, workspace_id=workspace_id) - return ns_connection.get_accounting_fields(resource_type) + return ns_connection.get_accounting_fields(resource_type, internal_id) diff --git a/apps/internal/views.py b/apps/internal/views.py index be315de0..699e4438 100644 --- a/apps/internal/views.py +++ b/apps/internal/views.py @@ -6,6 +6,8 @@ from apps.workspaces.permissions import IsAuthenticatedForInternalAPI +from fyle_netsuite_api.utils import assert_valid + from .actions import get_accounting_fields logger = logging.getLogger(__name__) @@ -18,6 +20,14 @@ class AccountingFieldsView(generics.GenericAPIView): def get(self, request, *args, **kwargs): try: + params = request.query_params + + assert_valid(params.get('org_id') is not None, 'Org ID is required') + assert_valid(params.get('resource_type') is not None, 'Resource Type is required') + + if params.get('resource_type') in ('custom_segments', 'custom_lists', 'custom_record_types'): + assert_valid(params.get('internal_id') is not None, 'Internal ID is required') + response = get_accounting_fields(request.query_params) return Response( data={'data': response}, diff --git a/apps/netsuite/connector.py b/apps/netsuite/connector.py index 3c8c5967..254c52b2 100644 --- a/apps/netsuite/connector.py +++ b/apps/netsuite/connector.py @@ -1158,15 +1158,35 @@ def sync_customers(self): return [] - def get_accounting_fields(self, resource_type: str): - method = getattr(self.connection, resource_type) - generator = method.get_all_generator() - fields = [] - for resources in generator: - for resource in resources: - fields.append(resource) - - return json.loads(json.dumps(fields, default=str).replace('\\n', '')) + def get_accounting_fields(self, resource_type: str, internal_id: str): + """ + Retrieve accounting fields for a specific resource type and internal ID. + + Args: + resource_type (str): The type of resource to fetch. + internal_id (str): The internal ID of the resource. + + Returns: + list or dict: Parsed JSON representation of the resource data. + """ + module = getattr(self.connection, resource_type) + method_map = { + 'currencies': 'get_all', + 'custom_segments': 'get', + 'custom_lists': 'get', + 'custom_record_types': 'get_all_by_id', + } + method = method_map.get(resource_type, 'get_all_generator') + + if method in ('get', 'get_all_by_id'): + response = getattr(module, method)(internal_id) + else: + response = getattr(module, method)() + + if method == 'get_all_generator': + response = [row for responses in response for row in responses] + + return json.loads(json.dumps(response, default=str)) def construct_bill_lineitems( self, From 7d3dd36f521ebda6b81d8fdfdec883ee46927dad Mon Sep 17 00:00:00 2001 From: Ashwin Thanaraj <37061471+ashwin1111@users.noreply.github.com> Date: Tue, 26 Nov 2024 11:41:55 +0530 Subject: [PATCH 4/4] test: get accounting field internal API (#678) * tests: get accounting field internal API * feat: Add internal API for get exported entry (#679) * feat: Add internal API for get exported entry * add tests --- apps/internal/actions.py | 20 ++++++++-- apps/internal/urls.py | 3 +- apps/internal/views.py | 29 +++++++++++++- apps/netsuite/connector.py | 5 +++ tests/test_internal/test_actions.py | 42 ++++++++++++++++++++ tests/test_internal/test_views.py | 61 +++++++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 tests/test_internal/test_actions.py create mode 100644 tests/test_internal/test_views.py diff --git a/apps/internal/actions.py b/apps/internal/actions.py index 1b7629d6..9b8dce8b 100644 --- a/apps/internal/actions.py +++ b/apps/internal/actions.py @@ -4,15 +4,27 @@ from apps.workspaces.models import Workspace, NetSuiteCredentials -def get_accounting_fields(query_params: Dict): +def get_netsuite_connection(query_params: Dict): org_id = query_params.get('org_id') - resource_type = query_params.get('resource_type') - internal_id = query_params.get('internal_id') workspace = Workspace.objects.get(fyle_org_id=org_id) workspace_id = workspace.id ns_credentials = NetSuiteCredentials.objects.get(workspace_id=workspace.id) - ns_connection = NetSuiteConnector(netsuite_credentials=ns_credentials, workspace_id=workspace_id) + return NetSuiteConnector(netsuite_credentials=ns_credentials, workspace_id=workspace_id) + + +def get_accounting_fields(query_params: Dict): + ns_connection = get_netsuite_connection(query_params) + resource_type = query_params.get('resource_type') + internal_id = query_params.get('internal_id') return ns_connection.get_accounting_fields(resource_type, internal_id) + + +def get_exported_entry(query_params: Dict): + ns_connection = get_netsuite_connection(query_params) + resource_type = query_params.get('resource_type') + internal_id = query_params.get('internal_id') + + return ns_connection.get_exported_entry(resource_type, internal_id) diff --git a/apps/internal/urls.py b/apps/internal/urls.py index f96b5a6f..c3bb26c5 100644 --- a/apps/internal/urls.py +++ b/apps/internal/urls.py @@ -2,8 +2,9 @@ from django.urls import path -from .views import AccountingFieldsView +from .views import AccountingFieldsView, ExportedEntryView urlpatterns = [ path('accounting_fields/', AccountingFieldsView.as_view(), name='accounting-fields'), + path('exported_entry/', ExportedEntryView.as_view(), name='exported-entry'), ] diff --git a/apps/internal/views.py b/apps/internal/views.py index 699e4438..c0edaa66 100644 --- a/apps/internal/views.py +++ b/apps/internal/views.py @@ -8,7 +8,7 @@ from fyle_netsuite_api.utils import assert_valid -from .actions import get_accounting_fields +from .actions import get_accounting_fields, get_exported_entry logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -33,6 +33,33 @@ def get(self, request, *args, **kwargs): data={'data': response}, status=status.HTTP_200_OK ) + + except Exception: + logger.info(f"Error in AccountingFieldsView: {traceback.format_exc()}") + return Response( + data={'error': traceback.format_exc()}, + status=status.HTTP_400_BAD_REQUEST + ) + + +class ExportedEntryView(generics.GenericAPIView): + authentication_classes = [] + permission_classes = [IsAuthenticatedForInternalAPI] + + def get(self, request, *args, **kwargs): + try: + params = request.query_params + assert_valid(params.get('org_id') is not None, 'Org ID is required') + assert_valid(params.get('resource_type') is not None, 'Resource Type is required') + assert_valid(params.get('internal_id') is not None, 'Internal ID is required') + + response = get_exported_entry(request.query_params) + + return Response( + data={'data': response}, + status=status.HTTP_200_OK + ) + except Exception: logger.info(f"Error in AccountingFieldsView: {traceback.format_exc()}") return Response( diff --git a/apps/netsuite/connector.py b/apps/netsuite/connector.py index 6c94b8ce..99f51701 100644 --- a/apps/netsuite/connector.py +++ b/apps/netsuite/connector.py @@ -1193,6 +1193,11 @@ def get_accounting_fields(self, resource_type: str, internal_id: str): return json.loads(json.dumps(response, default=str)) + def get_exported_entry(self, resource_type: str, export_id: str): + module = getattr(self.connection, resource_type) + response = getattr(module, 'get')(export_id) + return json.loads(json.dumps(response, default=str)) + def handle_taxed_line_items(self, base_line, line, workspace_id, export_module, general_mapping: GeneralMapping): """ Handle line items where tax is applied or modified by the user. diff --git a/tests/test_internal/test_actions.py b/tests/test_internal/test_actions.py new file mode 100644 index 00000000..5b3ea1b2 --- /dev/null +++ b/tests/test_internal/test_actions.py @@ -0,0 +1,42 @@ +from apps.internal.actions import get_accounting_fields, get_exported_entry +from tests.test_netsuite.fixtures import data + +def test_get_accounting_fields(db, mocker): + query_params = { + 'org_id': 'or79Cob97KSh', + 'resource_type': 'employees', + } + mocker.patch( + 'netsuitesdk.api.employees.Employees.get_all_generator', + return_value=data['get_all_employees'] + ) + + mocker.patch('netsuitesdk.api.currencies.Currencies.get_all') + + mocker.patch( + 'netsuitesdk.api.custom_lists.CustomLists.get', + return_value=data['get_custom_list'] + ) + + fields = get_accounting_fields(query_params) + assert fields is not None + + query_params['resource_type'] = 'custom_lists' + query_params['internal_id'] = '1' + fields = get_accounting_fields(query_params) + assert fields is not None + + +def test_get_exported_entry(db, mocker): + query_params = { + 'org_id': 'or79Cob97KSh', + 'resource_type': 'vendor_bills', + 'internal_id': '1' + } + mocker.patch( + 'netsuitesdk.api.vendor_bills.VendorBills.get', + return_value={'summa': 'hehe'} + ) + + entry = get_exported_entry(query_params) + assert entry is not None diff --git a/tests/test_internal/test_views.py b/tests/test_internal/test_views.py new file mode 100644 index 00000000..3a3ba5de --- /dev/null +++ b/tests/test_internal/test_views.py @@ -0,0 +1,61 @@ +import pytest +from unittest.mock import patch +from django.urls import reverse + +from apps.workspaces.permissions import IsAuthenticatedForInternalAPI + +from tests.test_netsuite.fixtures import data + + +@pytest.mark.django_db(databases=['default']) +@patch.object(IsAuthenticatedForInternalAPI, 'has_permission', return_value=True) +def test_netsutie_fields_view(db, api_client, mocker): + url = reverse('accounting-fields') + + response = api_client.get(url) + assert response.status_code == 400 + + response = api_client.get(url, {'org_id': 'or79Cob97KSh'}) + assert response.status_code == 400 + + response = api_client.get(url, {'org_id': 'or79Cob97KSh', 'resource_type': 'custom_segments'}) + assert response.status_code == 400 + + mocker.patch( + 'netsuitesdk.api.custom_lists.CustomLists.get', + return_value=data['get_custom_list'] + ) + + response = api_client.get(url, {'org_id': 'or79Cob97KSh', 'resource_type': 'custom_lists', 'internal_id': '1'}) + assert response.status_code == 200 + + mocker.patch( + 'netsuitesdk.api.employees.Employees.get_all_generator', + return_value=data['get_all_employees'] + ) + + response = api_client.get(url, {'org_id': 'or79Cob97KSh', 'resource_type': 'employees'}) + assert response.status_code == 200 + + +@pytest.mark.django_db(databases=['default']) +@patch.object(IsAuthenticatedForInternalAPI, 'has_permission', return_value=True) +def test_exported_entry_view(db, api_client, mocker): + url = reverse('exported-entry') + + response = api_client.get(url) + assert response.status_code == 400 + + response = api_client.get(url, {'org_id': 'or79Cob97KSh'}) + assert response.status_code == 400 + + response = api_client.get(url, {'org_id': 'or79Cob97KSh', 'resource_type': 'vendor_bills'}) + assert response.status_code == 400 + + mocker.patch( + 'netsuitesdk.api.vendor_bills.VendorBills.get', + return_value={'summa': 'hehe'} + ) + + response = api_client.get(url, {'org_id': 'or79Cob97KSh', 'resource_type': 'vendor_bills', 'internal_id': '1'}) + assert response.status_code == 200