Skip to content

Commit

Permalink
Business central SDK support added (#24)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ruuushhh authored Nov 14, 2023
1 parent 851cd5c commit b062f1a
Show file tree
Hide file tree
Showing 11 changed files with 340 additions and 5 deletions.
128 changes: 128 additions & 0 deletions apps/business_central/utils.py
Original file line number Diff line number Diff line change
@@ -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 []
97 changes: 97 additions & 0 deletions apps/workspaces/helpers.py
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions apps/workspaces/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions apps/workspaces/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion apps/workspaces/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<int:workspace_id>/connect_business_central/authorization_code/", ConnectBusinessCentralView.as_view(), name='business-central-authorization-code'),
path("<int:workspace_id>/credentials/business_central/", ConnectBusinessCentralView.as_view(), name='business-central-credentials'),
path('<int:workspace_id>/export_settings/', ExportSettingView.as_view(), name='export-settings'),
path('<int:workspace_id>/import_settings/', ImportSettingView.as_view(), name='import-settings'),
path('<int:workspace_id>/advanced_settings/', AdvancedSettingView.as_view(), name='advanced-settings'),
Expand Down
14 changes: 13 additions & 1 deletion apps/workspaces/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
Workspace,
ExportSetting,
ImportSetting,
AdvancedSetting
AdvancedSetting,
BusinessCentralCredentials
)
from apps.workspaces.serializers import (
WorkspaceSerializer,
BusinessCentralCredentialSerializer,
ExportSettingsSerializer,
ImportSettingsSerializer,
AdvancedSettingSerializer,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docker-compose-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 7 additions & 0 deletions ms_business_central_api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
7 changes: 7 additions & 0 deletions ms_business_central_api/tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
Loading

0 comments on commit b062f1a

Please sign in to comment.