From b062f1a62ea5882f9410703ae5f858a936c7f0f1 Mon Sep 17 00:00:00 2001 From: ruuushhh <66899387+ruuushhh@users.noreply.github.com> Date: Tue, 14 Nov 2023 17:26:20 +0530 Subject: [PATCH] Business central SDK support added (#24) * Fyle Import attributes API * Fyle fields apis * Fyle expense fields apis * Test case resolved * Test case resolved * Test case resolved * Business central SDK support added * Business central SDK support added * flake8 resolved * flake8 resolved * Business Central creds apis (#25) * Business Central creds apis * Test cases added * Test cases added * code removed --- apps/business_central/utils.py | 128 ++++++++++++++++++++++ apps/workspaces/helpers.py | 97 ++++++++++++++++ apps/workspaces/models.py | 21 ++++ apps/workspaces/serializers.py | 31 ++++++ apps/workspaces/urls.py | 5 +- apps/workspaces/views.py | 14 ++- docker-compose-pipeline.yml | 4 +- ms_business_central_api/settings.py | 7 ++ ms_business_central_api/tests/settings.py | 7 ++ requirements.txt | 4 + tests/test_workspaces/test_view.py | 27 ++++- 11 files changed, 340 insertions(+), 5 deletions(-) create mode 100644 apps/business_central/utils.py create mode 100644 apps/workspaces/helpers.py diff --git a/apps/business_central/utils.py b/apps/business_central/utils.py new file mode 100644 index 0000000..6334b90 --- /dev/null +++ b/apps/business_central/utils.py @@ -0,0 +1,128 @@ +from dynamics.core.client import Dynamics +from fyle_accounting_mappings.models import DestinationAttribute + +from apps.workspaces.models import BusinessCentralCredentials, Workspace +from ms_business_central_api import settings + + +class BusinessCentralConnector: + """ + Business Central Utility Functions + """ + + def __init__(self, credentials_object: BusinessCentralCredentials, workspace_id: int): + client_id = settings.BUSINESS_CENTRAL_CLIENT_ID + client_secret = settings.BUSINESS_CENTRAL_CLIENT_SECRET + environment = settings.BUSINESS_CENTRAL_ENVIRONMENT + refresh_token = credentials_object.refresh_token + + self.connection = Dynamics( + enviroment=environment, + client_id=client_id, + client_secret=client_secret, + refresh_token=refresh_token, + ) + + self.workspace_id = workspace_id + + credentials_object.refresh_token = self.connection.refresh_token + credentials_object.save() + + def _create_destination_attribute(self, attribute_type, display_name, value, destination_id, active, detail): + """ + Create a destination attribute object + :param attribute_type: Type of the attribute + :param display_name: Display name for the attribute + :param value: Value of the attribute + :param destination_id: ID of the destination + :param active: Whether the attribute is active + :param detail: Details related to the attribute + :return: A destination attribute dictionary + """ + return { + 'attribute_type': attribute_type, + 'display_name': display_name, + 'value': value, + 'destination_id': destination_id, + 'active': active, + 'detail': detail + } + + def _sync_data(self, data, attribute_type, display_name, workspace_id, field_names): + """ + Synchronize data from MS Dynamics SDK to your application + :param data: Data to synchronize + :param attribute_type: Type of the attribute + :param display_name: Display name for the data + :param workspace_id: ID of the workspace + :param field_names: Names of fields to include in detail + """ + + destination_attributes = [] + + for item in data: + detail = {field: getattr(item, field) for field in field_names} + destination_attributes.append(self._create_destination_attribute( + attribute_type, + display_name, + item.name, + item.id, + item.is_active, + detail + )) + + DestinationAttribute.bulk_create_or_update_destination_attributes( + destination_attributes, attribute_type, workspace_id, True) + + def sync_companies(self): + """ + sync business central companies + """ + companies = self.connection.companies.get_all() + + self._sync_data(companies, 'COMPANY', 'company', self.workspace_id) + return [] + + def sync_accounts(self): + """ + Synchronize accounts from MS Dynamics SDK to your application + """ + workspace = Workspace.objects.get(id=self.workspace_id) + self.connection.company_id = workspace.business_central_company_id + + accounts = self.connection.accounts.get_all() + self._sync_data(accounts, 'ACCOUNT', 'accounts', self.workspace_id) + return [] + + def sync_vendors(self): + """ + Synchronize vendors from MS Dynamics SDK to your application + """ + workspace = Workspace.objects.get(id=self.workspace_id) + self.connection.company_id = workspace.business_central_company_id + + vendors = self.connection.vendors.get_all() + self._sync_data(vendors, 'VENDOR', 'vendor', self.workspace_id) + return [] + + def sync_employees(self): + """ + Synchronize employees from MS Dynamics SDK to your application + """ + workspace = Workspace.objects.get(id=self.workspace_id) + self.connection.company_id = workspace.business_central_company_id + + employees = self.connection.employees.get_all() + self._sync_data(employees, 'EMPLOYEE', 'employee', self.workspace_id) + return [] + + def sync_locations(self): + """ + Synchronize locations from MS Dynamics SDK to your application + """ + workspace = Workspace.objects.get(id=self.workspace_id) + self.connection.company_id = workspace.business_central_company_id + + locations = self.connection.locations.get_all() + self._sync_data(locations, 'LOCATION', 'location', self.workspace_id) + return [] diff --git a/apps/workspaces/helpers.py b/apps/workspaces/helpers.py new file mode 100644 index 0000000..59e5ed1 --- /dev/null +++ b/apps/workspaces/helpers.py @@ -0,0 +1,97 @@ +import logging +import base64 +import requests +import json + +from django.conf import settings +from future.moves.urllib.parse import urlencode +from dynamics.exceptions.dynamics_exceptions import InternalServerError, InvalidTokenError + +from apps.workspaces.models import BusinessCentralCredentials, Workspace +from apps.business_central.utils import BusinessCentralConnector + + +logger = logging.getLogger(__name__) + + +def generate_token(authorization_code: str, redirect_uri: str = None) -> str: + api_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": settings.BUSINESS_CENTRAL_REDIRECT_URI + if not redirect_uri + else redirect_uri, + } + + auth = "{0}:{1}".format(settings.BUSINESS_CENTRAL_ID, settings.BUSINESS_CENTRAL_SECRET) + auth = base64.b64encode(auth.encode("utf-8")) + + request_header = { + "Accept": "application/json", + "Content-type": "application/x-www-form-urlencoded", + "Authorization": "Basic {0}".format(str(auth.decode())), + } + + token_url = settings.BUSINESS_CENTRAL_TOKEN_URI + response = requests.post( + url=token_url, data=urlencode(api_data), headers=request_header + ) + return response + + +def generate_business_central_refresh_token(authorization_code: str, redirect_uri: str = None) -> str: + """ + Generate Business Central refresh token from authorization code + """ + response = generate_token(authorization_code, redirect_uri) + + if response.status_code == 200: + successful_response = json.loads(response.text) + return successful_response["refresh_token"] + + elif response.status_code == 401: + raise InvalidTokenError( + "Wrong client secret or/and refresh token", response.text + ) + + elif response.status_code == 500: + raise InternalServerError("Internal server error", response.text) + + +def connect_business_central(authorization_code, redirect_uri, workspace_id): + if redirect_uri: + refresh_token = generate_business_central_refresh_token(authorization_code, redirect_uri) + else: + refresh_token = generate_business_central_refresh_token(authorization_code) + business_central_credentials = BusinessCentralCredentials.objects.filter(workspace_id=workspace_id).first() + + workspace = Workspace.objects.get(pk=workspace_id) + + if not business_central_credentials: + business_central_credentials = BusinessCentralCredentials.objects.create( + refresh_token=refresh_token, workspace_id=workspace_id + ) + else: + business_central_credentials.refresh_token = refresh_token + business_central_credentials.is_expired = False + business_central_credentials.save() + + if workspace and not workspace.business_central_company_id: + business_central_connector = BusinessCentralConnector(business_central_credentials, workspace_id=workspace_id) + connections = business_central_connector.connection.connections.get_all() + connection = list( + filter( + lambda connection: connection["id"] == workspace.business_central_company_id, + connections, + ) + ) + + if connection: + workspace.business_central_company_id = connection[0]["id"] + workspace.save() + + if workspace.onboarding_state == "CONNECTION": + workspace.onboarding_state = "EXPORT_SETTINGS" + workspace.save() + + return business_central_credentials diff --git a/apps/workspaces/models.py b/apps/workspaces/models.py index b925a92..36b720c 100644 --- a/apps/workspaces/models.py +++ b/apps/workspaces/models.py @@ -46,6 +46,8 @@ class Workspace(models.Model): help_text='Onboarding status of the workspace' ) ms_business_central_accounts_last_synced_at = CustomDateTimeField(help_text='ms business central accounts last synced at time') + business_central_company_name = StringNullField(help_text='Business Central Company Name') + business_central_company_id = StringNullField(help_text='Business Central Company Id') created_at = models.DateTimeField(auto_now_add=True, help_text='Created at datetime') updated_at = models.DateTimeField(auto_now=True, help_text='Updated at datetime') @@ -128,6 +130,25 @@ class Meta: ) +class BusinessCentralCredentials(BaseModel): + """ + Table to store Business Central credentials + """ + + id = models.AutoField(primary_key=True) + refresh_token = models.TextField(help_text="Stores Business Central refresh token", null=True) + is_expired = models.BooleanField(default=False, help_text="Business Central token expiry flag") + + class Meta: + db_table = "business_central_credentials" + + @staticmethod + def get_active_business_central(workspace_id): + return BusinessCentralCredentials.objects.get( + workspace_id=workspace_id, is_expired=False, refresh_token__isnull=False + ) + + class ExportSetting(BaseModel): """ Table to store export settings diff --git a/apps/workspaces/serializers.py b/apps/workspaces/serializers.py index f354bb2..a77e280 100644 --- a/apps/workspaces/serializers.py +++ b/apps/workspaces/serializers.py @@ -7,10 +7,12 @@ from fyle_rest_auth.models import AuthToken from fyle_accounting_mappings.models import ExpenseAttribute +from apps.workspaces.helpers import connect_business_central from ms_business_central_api.utils import assert_valid from apps.workspaces.models import ( Workspace, FyleCredential, + BusinessCentralCredentials, ExportSetting, ImportSetting, AdvancedSetting @@ -70,6 +72,35 @@ def create(self, validated_data): return workspace +class BusinessCentralCredentialSerializer(serializers.ModelSerializer): + """ + Business Central credential serializer + """ + + class Meta: + model = BusinessCentralCredentials + fields = "__all__" + + def create(self, validated_data): + """ + Create Business Central Credentials + """ + try: + workspace_id = self.context['request'].parser_context.get('kwargs').get('workspace_id') + authorization_code = self.context['request'].data.get('code') + redirect_uri = self.context['request'].data.get('redirect_uri') + + business_central_credentials = connect_business_central( + authorization_code=authorization_code, + redirect_uri=redirect_uri, + workspace_id=workspace_id, + ) + + return business_central_credentials + except Exception as exception: + raise serializers.ValidationError(exception) + + class ExportSettingsSerializer(serializers.ModelSerializer): """ Export Settings serializer diff --git a/apps/workspaces/urls.py b/apps/workspaces/urls.py index 7a491fd..271ecd1 100644 --- a/apps/workspaces/urls.py +++ b/apps/workspaces/urls.py @@ -6,13 +6,16 @@ ExportSettingView, ImportSettingView, AdvancedSettingView, - WorkspaceAdminsView + WorkspaceAdminsView, + ConnectBusinessCentralView ) workspace_app_paths = [ path('', WorkspaceView.as_view(), name='workspaces'), path('ready/', ReadyView.as_view(), name='ready'), + path("/connect_business_central/authorization_code/", ConnectBusinessCentralView.as_view(), name='business-central-authorization-code'), + path("/credentials/business_central/", ConnectBusinessCentralView.as_view(), name='business-central-credentials'), path('/export_settings/', ExportSettingView.as_view(), name='export-settings'), path('/import_settings/', ImportSettingView.as_view(), name='import-settings'), path('/advanced_settings/', AdvancedSettingView.as_view(), name='advanced-settings'), diff --git a/apps/workspaces/views.py b/apps/workspaces/views.py index 3b9c2d3..9b8272b 100644 --- a/apps/workspaces/views.py +++ b/apps/workspaces/views.py @@ -12,10 +12,12 @@ Workspace, ExportSetting, ImportSetting, - AdvancedSetting + AdvancedSetting, + BusinessCentralCredentials ) from apps.workspaces.serializers import ( WorkspaceSerializer, + BusinessCentralCredentialSerializer, ExportSettingsSerializer, ImportSettingsSerializer, AdvancedSettingSerializer, @@ -77,6 +79,16 @@ def get(self, request, *args, **kwargs): ) +class ConnectBusinessCentralView(generics.CreateAPIView, generics.RetrieveAPIView): + """ + Business Central Connect Oauth View + """ + serializer_class = BusinessCentralCredentialSerializer + lookup_field = 'workspace_id' + + queryset = BusinessCentralCredentials.objects.all() + + class ExportSettingView(generics.CreateAPIView, generics.RetrieveAPIView): """ Retrieve or Create Export Settings diff --git a/docker-compose-pipeline.yml b/docker-compose-pipeline.yml index 7246776..3052caf 100644 --- a/docker-compose-pipeline.yml +++ b/docker-compose-pipeline.yml @@ -23,8 +23,8 @@ services: FYLE_CLIENT_ID: 'sample' FYLE_CLIENT_SECRET: 'sample' FYLE_REFRESH_TOKEN: 'sample.sample.sample' - MS_BUSINESS_CENTRAL_USER_PASSWORD: 'sample' - MS_BUSINESS_CENTRAL_USER_SENDER_ID: 'sample' + BUSINESS_CENTRAL_REDIRECT_URI: ${BUSINESS_CENTRAL_REDIRECT_URI} + BUSINESS_CENTRALTOKEN_URI: ${BUSINESS_CENTRALTOKEN_URI} ENCRYPTION_KEY: ${ENCRYPTION_KEY} FYLE_TOKEN_URI: 'https://sample.fyle.tech' FYLE_SERVER_URL: 'https://sample.fyle.tech' diff --git a/ms_business_central_api/settings.py b/ms_business_central_api/settings.py index bee8d55..df3633b 100644 --- a/ms_business_central_api/settings.py +++ b/ms_business_central_api/settings.py @@ -200,6 +200,13 @@ FYLE_APP_URL = os.environ.get('APP_URL') FYLE_EXPENSE_URL = os.environ.get('FYLE_APP_URL') +# Business Central Settings +BUSINESS_CENTRAL_CLIENT_ID = os.environ.get("BUSINESS_CENTRAL_CLIENT_ID") +BUSINESS_CENTRAL_CLIENT_SECRET = os.environ.get("BUSINESS_CENTRAL_CLIENT_SECRET") +BUSINESS_CENTRAL_REDIRECT_URI = os.environ.get("BUSINESS_CENTRAL_REDIRECT_URI") +BUSINESS_CENTRAL_TOKEN_URI = os.environ.get("BUSINESS_CENTRAL_TOKEN_URI") +BUSINESS_CENTRAL_ENVIRONMENT = os.environ.get("BUSINESS_CENTRAL_ENVIRONMENT") + # Internationalization # https://docs.djangoproject.com/en/4.2/topics/i18n/ diff --git a/ms_business_central_api/tests/settings.py b/ms_business_central_api/tests/settings.py index 6b63b44..33fc678 100644 --- a/ms_business_central_api/tests/settings.py +++ b/ms_business_central_api/tests/settings.py @@ -190,6 +190,13 @@ FYLE_APP_URL = os.environ.get('APP_URL') FYLE_EXPENSE_URL = os.environ.get('FYLE_APP_URL') +# Business Central Settings +BUSINESS_CENTRAL_CLIENT_ID = os.environ.get("BUSINESS_CENTRAL_CLIENT_ID") +BUSINESS_CENTRAL_CLIENT_SECRET = os.environ.get("BUSINESS_CENTRAL_CLIENT_SECRET") +BUSINESS_CENTRAL_REDIRECT_URI = os.environ.get("BUSINESS_CENTRAL_REDIRECT_URI") +BUSINESS_CENTRAL_TOKEN_URI = os.environ.get("BUSINESS_CENTRAL_TOKEN_URI") +BUSINESS_CENTRAL_ENVIRONMENT = os.environ.get("BUSINESS_CENTRAL_ENVIRONMENT") + # Internationalization # https://docs.djangoproject.com/en/3.1/topics/i18n/ diff --git a/requirements.txt b/requirements.txt index e71c44d..3c4ec7f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ croniter==1.3.8 # Request Authentication Caching cryptography==38.0.3 +future==0.18.3 # Django and Django REST Framework Django==4.1.2 @@ -26,6 +27,9 @@ gunicorn==20.1.0 # Platform SDK fyle==0.35.0 +# Business central sdk +ms-dynamics-business-central-sdk==1.2.0 + # Reusable Fyle Packages fyle-rest-auth==1.5.0 fyle-accounting-mappings==1.27.3 diff --git a/tests/test_workspaces/test_view.py b/tests/test_workspaces/test_view.py index 537b611..ccb29c7 100644 --- a/tests/test_workspaces/test_view.py +++ b/tests/test_workspaces/test_view.py @@ -5,7 +5,8 @@ Workspace, ExportSetting, ImportSetting, - AdvancedSetting + AdvancedSetting, + BusinessCentralCredentials ) @@ -53,6 +54,30 @@ def test_get_of_workspace(api_client, test_connection): assert response.data['org_id'] == 'orNoatdUnm1w' +def test_get_of_business_central_creds(api_client, test_connection): + ''' + Test get of Business Central Credentials + ''' + url = reverse('workspaces') + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token)) + response = api_client.post(url) + + url = reverse('business-central-credentials', kwargs={'workspace_id': response.data['id']}) + + BusinessCentralCredentials.objects.create( + refresh_token='dummy_refresh_token', + workspace_id=response.data['id'], + is_expired=False + ) + + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token)) + response = api_client.get(url) + + assert response.status_code == 200 + assert response.data['refresh_token'] == 'dummy_refresh_token' + assert response.data['is_expired'] == False + + def test_export_settings(api_client, test_connection): ''' Test export settings