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..7fc68f9f --- /dev/null +++ b/apps/internal/actions.py @@ -0,0 +1,29 @@ +from typing import Dict + +from apps.quickbooks_online.utils import QBOConnector +from apps.workspaces.models import Workspace, QBOCredential + + +def get_qbo_connection(query_params: Dict): + org_id = query_params.get('org_id') + + workspace = Workspace.objects.get(fyle_org_id=org_id) + workspace_id = workspace.id + qbo_credentials = QBOCredential.get_active_qbo_credentials(workspace_id=workspace.id) + + return QBOConnector(qbo_credentials, workspace_id=workspace_id) + + +def get_accounting_fields(query_params: Dict): + qbo_connection = get_qbo_connection(query_params) + resource_type = query_params.get('resource_type') + + return qbo_connection.get_accounting_fields(resource_type) + + +def get_exported_entry(query_params: Dict): + qbo_connection = get_qbo_connection(query_params) + resource_type = query_params.get('resource_type') + internal_id = query_params.get('internal_id') + + return qbo_connection.get_exported_entry(resource_type, internal_id) 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..aa7042b9 --- /dev/null +++ b/apps/internal/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +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 new file mode 100644 index 00000000..0687b57d --- /dev/null +++ b/apps/internal/views.py @@ -0,0 +1,65 @@ +import logging +import traceback +from rest_framework import generics +from rest_framework.response import Response +from rest_framework import status + +from apps.workspaces.permissions import IsAuthenticatedForInternalAPI + +from fyle_qbo_api.utils import assert_valid + +from .actions import get_accounting_fields, get_exported_entry + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class AccountingFieldsView(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') + + 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 + ) + + +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( + data={'error': traceback.format_exc()}, + status=status.HTTP_400_BAD_REQUEST + ) diff --git a/apps/quickbooks_online/utils.py b/apps/quickbooks_online/utils.py index cf5874d0..017d8734 100644 --- a/apps/quickbooks_online/utils.py +++ b/apps/quickbooks_online/utils.py @@ -1420,3 +1420,32 @@ def get_or_create_entity(self, expense_group: ExpenseGroup, general_settings: Wo entity_map[lineitem.id] = entity_id return entity_map + + def get_exported_entry(self, resource_type: str, export_id: str): + """ + Retrieve a specific resource by internal ID. + + Args: + resource_type (str): The type of resource to fetch. + export_id (str): The internal ID of the resource. + """ + module = getattr(self.connection, resource_type) + response = getattr(module, 'get_by_id')(export_id) + return json.loads(json.dumps(response, default=str)) + + def get_accounting_fields(self, resource_type: str): + """ + Retrieve accounting fields for a specific resource type and internal ID. + + Args: + resource_type (str): The type of resource to fetch. + + Returns: + list or dict: Parsed JSON representation of the resource data. + """ + module = getattr(self.connection, resource_type) + generator = getattr(module, 'get_all_generator')() + + response = [row for responses in generator for row in responses] + + return json.loads(json.dumps(response, default=str)) diff --git a/apps/workspaces/permissions.py b/apps/workspaces/permissions.py index ed6a893f..6492b746 100644 --- a/apps/workspaces/permissions.py +++ b/apps/workspaces/permissions.py @@ -45,16 +45,16 @@ def has_permission(self, request, view): return self.validate_and_cache(workspace_users, user, workspace_id, request.data, 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 Exception: diff --git a/apps/workspaces/views.py b/apps/workspaces/views.py index 741f3437..e27b76dc 100644 --- a/apps/workspaces/views.py +++ b/apps/workspaces/views.py @@ -20,7 +20,7 @@ update_or_create_workspace, ) from apps.workspaces.models import LastExportDetail, QBOCredential, Workspace, WorkspaceGeneralSettings -from apps.workspaces.permissions import IsAuthenticatedForTest +from apps.workspaces.permissions import IsAuthenticatedForInternalAPI from apps.workspaces.serializers import ( LastExportDetailSerializer, QBOCredentialSerializer, @@ -183,7 +183,7 @@ class SetupE2ETestView(generics.CreateAPIView): """ authentication_classes = [] - permission_classes = [IsAuthenticatedForTest] + permission_classes = [IsAuthenticatedForInternalAPI] def post(self, request, **kwargs): """ diff --git a/fyle_qbo_api/settings.py b/fyle_qbo_api/settings.py index 94c736e9..e3e0a5a8 100644 --- a/fyle_qbo_api/settings.py +++ b/fyle_qbo_api/settings.py @@ -57,6 +57,7 @@ 'apps.fyle', 'apps.quickbooks_online', 'apps.tasks', + 'apps.internal' ] MIDDLEWARE = [ diff --git a/fyle_qbo_api/urls.py b/fyle_qbo_api/urls.py index 0f2dd4ff..2ecc7de6 100644 --- a/fyle_qbo_api/urls.py +++ b/fyle_qbo_api/urls.py @@ -22,4 +22,5 @@ path('api/workspaces/', include('apps.workspaces.urls')), path('api/v2/workspaces/', include('apps.workspaces.apis.urls')), path('api/user/', include('apps.users.urls')), + path('internal_api/', include('apps.internal.urls')), ] diff --git a/requirements.txt b/requirements.txt index 21e654ad..7bad9ac6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,7 +35,7 @@ psycopg2-binary==2.8.4 pylint==2.7.4 python-dateutil==2.8.1 pytz==2019.3 -qbosdk==0.19.0 +qbosdk==0.20.0 redis==3.3.11 requests==2.25.0 sentry-sdk==1.19.1 diff --git a/tests/test_internal/__init__.py b/tests/test_internal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_internal/test_actions.py b/tests/test_internal/test_actions.py new file mode 100644 index 00000000..855b862d --- /dev/null +++ b/tests/test_internal/test_actions.py @@ -0,0 +1,31 @@ +from apps.internal.actions import get_accounting_fields, get_exported_entry +from tests.test_quickbooks_online.fixtures import data + + +def test_get_accounting_fields(db, mocker): + query_params = { + 'org_id': 'or79Cob97KSh', + 'resource_type': 'employees', + } + mocker.patch( + 'qbosdk.apis.Employees.get_all_generator', + return_value=[data['employee_response']] + ) + + 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': 'bills', + 'internal_id': '1' + } + mocker.patch( + 'qbosdk.apis.Bills.get_by_id', + 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..bf05b000 --- /dev/null +++ b/tests/test_internal/test_views.py @@ -0,0 +1,50 @@ +import pytest +from unittest.mock import patch +from django.urls import reverse + +from apps.workspaces.permissions import IsAuthenticatedForInternalAPI + +from tests.test_quickbooks_online.fixtures import data + + +@pytest.mark.django_db(databases=['default']) +@patch.object(IsAuthenticatedForInternalAPI, 'has_permission', return_value=True) +def test_qbo_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 + + mocker.patch( + 'qbosdk.apis.Employees.get_all_generator', + return_value=[data['employee_response']] + ) + + 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': 'bills'}) + assert response.status_code == 400 + + mocker.patch( + 'qbosdk.apis.Bills.get_by_id', + return_value={'summa': 'hehe'} + ) + + response = api_client.get(url, {'org_id': 'or79Cob97KSh', 'resource_type': 'bills', 'internal_id': '1'}) + assert response.status_code == 200 diff --git a/tests/test_workspaces/test_views.py b/tests/test_workspaces/test_views.py index f30cab3a..3b7d3832 100644 --- a/tests/test_workspaces/test_views.py +++ b/tests/test_workspaces/test_views.py @@ -165,7 +165,7 @@ def test_connect_qbo_view_exceptions(api_client, test_connection): def test_prepare_e2e_test_view(mock_db, mocker, api_client, test_connection): url = reverse('setup-e2e-test', kwargs={'workspace_id': 3}) - api_client.credentials(HTTP_X_E2E_Tests_Client_ID='dummy_id') + api_client.credentials(HTTP_X_Internal_API_Client_ID='dummy_id') response = api_client.post(url) assert response.status_code == 403 @@ -175,7 +175,7 @@ def test_prepare_e2e_test_view(mock_db, mocker, api_client, test_connection): mocker.patch('fyle_integrations_platform_connector.fyle_integrations_platform_connector.PlatformConnector.import_fyle_dimensions', return_value=[]) mocker.patch('apps.workspaces.models.QBOCredential.objects.create', return_value=None) - api_client.credentials(HTTP_X_E2E_Tests_Client_ID='dummy_id') + api_client.credentials(HTTP_X_Internal_API_Client_ID='dummy_id') response = api_client.post(url) assert response.status_code == 400 @@ -184,12 +184,12 @@ def test_prepare_e2e_test_view(mock_db, mocker, api_client, test_connection): healthy_token.is_expired = False healthy_token.save() - api_client.credentials(HTTP_X_E2E_Tests_Client_ID='gAAAAABi8oXHBll3lEUPGpMDXnZDhVgSl_LMOkIF0ilfmSCL3wFxZnoTIbpdzwPoOFzS0vFO4qaX51JtAqCG2RBHZaf1e98hug==') + api_client.credentials(HTTP_X_Internal_API_Client_ID='gAAAAABi8oXHBll3lEUPGpMDXnZDhVgSl_LMOkIF0ilfmSCL3wFxZnoTIbpdzwPoOFzS0vFO4qaX51JtAqCG2RBHZaf1e98hug==') response = api_client.post(url) assert response.status_code == 200 url = reverse('setup-e2e-test', kwargs={'workspace_id': 6}) - api_client.credentials(HTTP_X_E2E_Tests_Client_ID='gAAAAABi8oWVoonxF0K_g2TQnFdlpOJvGsBYa9rPtwfgM-puStki_qYbi0PdipWHqIBIMip94MDoaTP4MXOfERDeEGrbARCxPw==') + api_client.credentials(HTTP_X_Internal_API_Client_ID='gAAAAABi8oWVoonxF0K_g2TQnFdlpOJvGsBYa9rPtwfgM-puStki_qYbi0PdipWHqIBIMip94MDoaTP4MXOfERDeEGrbARCxPw==') response = api_client.post(url) assert response.status_code == 400