Skip to content

Commit

Permalink
Merge branch 'fyle_import_attributes' into accounting_export_apis
Browse files Browse the repository at this point in the history
  • Loading branch information
ruuushhh committed Oct 26, 2023
2 parents 9a2c55e + 543a46a commit 9eafe9f
Show file tree
Hide file tree
Showing 31 changed files with 751 additions and 144 deletions.
30 changes: 30 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Continuous Integration

on:
pull_request:
types: [assigned, opened, synchronize, reopened]

jobs:
pytest:
runs-on: ubuntu-latest
environment: CI Environment
steps:
- uses: actions/checkout@v2
- name: Bring up Services and Run Tests
run: |
docker-compose -f docker-compose-pipeline.yml build
docker-compose -f docker-compose-pipeline.yml up -d
docker-compose -f docker-compose-pipeline.yml exec -T api pytest tests/ --cov --junit-xml=test-reports/report.xml --cov-report=xml --cov-fail-under=70
echo "STATUS=$(cat pytest-coverage.txt | grep 'Required test' | awk '{ print $1 }')" >> $GITHUB_ENV
echo "FAILED=$(cat test-reports/report.xml | awk -F'=' '{print $5}' | awk -F' ' '{gsub(/"/, "", $1); print $1}')" >> $GITHUB_ENV
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v3
- name: Pytest coverage comment
uses: MishaKav/pytest-coverage-comment@main
if: ${{ always() && github.ref != 'refs/heads/master' }}
with:
create-new-comment: true
junitxml-path: ./test-reports/report.xml
- name: Evaluate Coverage
if: ${{ (env.STATUS == 'FAIL') || (env.FAILED > 0) }}
run: exit 1
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Pull python base image
FROM python:3.7.4-slim
FROM python:3.11-slim

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
Expand Down
22 changes: 22 additions & 0 deletions apps/fyle/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
DEFAULT_FYLE_CONDITIONS = [
{
'field_name': 'employee_email',
'type': 'SELECT',
'is_custom': False
},
{
'field_name': 'claim_number',
'type': 'TEXT',
'is_custom': False
},
{
'field_name': 'report_title',
'type': 'TEXT',
'is_custom': False
},
{
'field_name': 'spent_at',
'type': 'DATE',
'is_custom': False
}
]
29 changes: 29 additions & 0 deletions apps/fyle/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
import requests
from django.conf import settings

from fyle_integrations_platform_connector import PlatformConnector

from apps.workspaces.models import FyleCredential
from apps.fyle.constants import DEFAULT_FYLE_CONDITIONS


def post_request(url, body, refresh_token=None):
"""
Expand Down Expand Up @@ -50,3 +55,27 @@ def get_cluster_domain(refresh_token: str) -> str:
cluster_api_url = '{0}/oauth/cluster/'.format(settings.FYLE_BASE_URL)

return post_request(cluster_api_url, {}, refresh_token)['cluster_domain']


def get_expense_fields(workspace_id: int):
"""
Get expense custom fields from fyle
:param workspace_id: (int)
:return: list of custom expense fields
"""

fyle_credentails = FyleCredential.objects.get(workspace_id=workspace_id)
platform = PlatformConnector(fyle_credentails)
custom_fields = platform.expense_custom_fields.list_all()

response = []
response.extend(DEFAULT_FYLE_CONDITIONS)
for custom_field in custom_fields:
if custom_field['type'] in ('SELECT', 'NUMBER', 'TEXT', 'BOOLEAN'):
response.append({
'field_name': custom_field['field_name'],
'type': custom_field['type'],
'is_custom': custom_field['is_custom']
})

return response
2 changes: 1 addition & 1 deletion apps/fyle/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ class Expense(BaseModel):

class Reimbursement:
"""
Creating a dummy class to be able to use
Creating a dummy class to be able to user
fyle_integrations_platform_connector correctly
"""
pass
22 changes: 21 additions & 1 deletion apps/fyle/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
import logging
from rest_framework import serializers
from rest_framework.response import Response
from rest_framework.exceptions import APIException
from rest_framework.views import status
from fyle_integrations_platform_connector import PlatformConnector
from datetime import datetime, timezone
from apps.workspaces.models import Workspace, FyleCredential
from apps.fyle.models import ExpenseFilter
from apps.fyle.helpers import get_expense_fields


logger = logging.getLogger(__name__)
logger.level = logging.INFO
Expand Down Expand Up @@ -52,7 +55,7 @@ def create(self, validated_data):

except Exception as exception:
logger.error('Something unexpected happened workspace_id: %s %s', workspace_id, exception)
raise serializers.ValidationError()
raise APIException("Internal Server Error", code='server_error')


class ExpenseFilterSerializer(serializers.ModelSerializer):
Expand All @@ -71,3 +74,20 @@ def create(self, validated_data):
expense_filter, _ = ExpenseFilter.objects.update_or_create(workspace_id=workspace_id, rank=validated_data['rank'], defaults=validated_data)

return expense_filter


class ExpenseFieldSerializer(serializers.Serializer):
"""
Workspace Admin Serializer
"""
expense_fields = serializers.SerializerMethodField()

def get_expense_fields(self, validated_data):
"""
Get Expense Fields
"""

workspace_id = self.context['request'].parser_context.get('kwargs').get('workspace_id')
expense_fields = get_expense_fields(workspace_id=workspace_id)

return expense_fields
3 changes: 2 additions & 1 deletion apps/fyle/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@
"""

from django.urls import path
from apps.fyle.views import ImportFyleAttributesView, ExpenseFilterView, ExpenseFilterDeleteView
from apps.fyle.views import ImportFyleAttributesView, ExpenseFilterView, ExpenseFilterDeleteView, CustomFieldView


urlpatterns = [
path('import_attributes/', ImportFyleAttributesView.as_view(), name='import-fyle-attributes'),
path('expense_filters/<int:pk>/', ExpenseFilterDeleteView.as_view(), name='expense-filters'),
path('expense_filters/', ExpenseFilterView.as_view(), name='expense-filters'),
path('expense_fields/', CustomFieldView.as_view(), name='fyle-expense-fields'),
]
12 changes: 11 additions & 1 deletion apps/fyle/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import logging
from rest_framework import generics
from sage_desktop_api.utils import LookupFieldMixin
from apps.fyle.serializers import ImportFyleAttributesSerializer, ExpenseFilterSerializer
from apps.workspaces.models import Workspace
from apps.fyle.serializers import ImportFyleAttributesSerializer, ExpenseFilterSerializer, ExpenseFieldSerializer
from apps.fyle.models import ExpenseFilter

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -32,3 +33,12 @@ class ExpenseFilterDeleteView(generics.DestroyAPIView):

queryset = ExpenseFilter.objects.all()
serializer_class = ExpenseFilterSerializer


class CustomFieldView(generics.ListAPIView):
"""
Custom Field view
"""

serializer_class = ExpenseFieldSerializer
queryset = Workspace.objects.all()
61 changes: 61 additions & 0 deletions apps/sage300/helpers.py
Original file line number Diff line number Diff line change
@@ -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, Sage300Credential


logger = logging.getLogger(__name__)
logger.level = logging.INFO


# Import your Workspace and Sage300Credential models here
# Also, make sure you have 'logger' defined and imported from a logging module
def check_interval_and_sync_dimension(workspace: Workspace, sage300_credential: Sage300Credential) -> bool:
"""
Check the synchronization interval and trigger dimension synchronization if needed.
:param workspace: Workspace Instance
:param sage300_credential: Sage300Credential 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(sage300_credential, workspace.id)
return True

return False


def sync_dimensions(sage300_credential: Sage300Credential, workspace_id: int) -> None:
"""
Synchronize various dimensions with Sage 300 using the provided credentials.
:param sage300_credential: Sage300Credential Instance
:param workspace_id: ID of the workspace
This function syncs dimensions like accounts, vendors, commitments, jobs, categories, and cost codes.
"""

# Initialize the Sage 300 connection using the provided credentials and workspace ID
sage300_connection = import_string('apps.sage300.utils.SageDesktopConnector')(sage300_credential, workspace_id)

# List of dimensions to sync
dimensions = ['accounts', 'vendors', 'commitments', 'jobs', 'categories', 'cost_codes']

for dimension in dimensions:
try:
# Dynamically call the sync method based on the dimension
sync = getattr(sage300_connection, 'sync_{}'.format(dimension))
sync()
except Exception as exception:
# Log any exceptions that occur during synchronization
logger.info(exception)
105 changes: 105 additions & 0 deletions apps/sage300/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import logging
from django.db.models import Q
from datetime import datetime
from rest_framework import serializers
from rest_framework.response import Response
from rest_framework.views import status

from fyle_accounting_mappings.models import DestinationAttribute

from apps.workspaces.models import Workspace, Sage300Credential
from apps.sage300.helpers import sync_dimensions, check_interval_and_sync_dimension


logger = logging.getLogger(__name__)
logger.level = logging.INFO


class ImportSage300AttributesSerializer(serializers.Serializer):
"""
Import Sage300 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 Sage 300 credentials
workspace = Workspace.objects.get(pk=workspace_id)
sage_intacct_credentials = Sage300Credential.objects.get(
workspace_id=workspace.id
)

if refresh_dimension:
# If 'refresh' is true, perform a full sync of dimensions
sync_dimensions(sage_intacct_credentials, workspace.id)
else:
# If 'refresh' is false, check the interval and sync dimension accordingly
check_interval_and_sync_dimension(workspace, sage_intacct_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 Sage300Credential.DoesNotExist:
# Handle the case when Sage 300 credentials are not found or invalid
raise serializers.ValidationError(
{'message': 'Sage300 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


class DestinationAttributeSerializer(serializers.Serializer):
attribute_type = serializers.CharField()
display_name = serializers.CharField()


class Sage300FieldSerializer(serializers.Serializer):
"""
Sage300 Expense Fields Serializer
"""

attribute_type = serializers.CharField()
display_name = serializers.CharField()

def format_sage300_fields(self, workspace_id):
attribute_types = [
"VENDOR",
"ACCOUNT",
"JOB",
"CATEGORY",
"COST_CODE",
"PAYMENT",
]
attributes = (
DestinationAttribute.objects.filter(
~Q(attribute_type__in=attribute_types),
workspace_id=workspace_id,
)
.values("attribute_type", "display_name")
.distinct()
)

serialized_attributes = Sage300FieldSerializer(attributes, many=True).data

# Adding "Job" by default since it can be supported even if it doesn't exist
attributes_list = list(serialized_attributes)
attributes_list.append({"attribute_type": "JOB", "display_name": "Job"})

return attributes_list
13 changes: 13 additions & 0 deletions apps/sage300/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.urls import path

from apps.sage300.views import ImportSage300AttributesView, Sage300FieldsView


urlpatterns = [
path(
"import_attributes/",
ImportSage300AttributesView.as_view(),
name="import-sage300-attributes",
),
path("fields/", Sage300FieldsView.as_view(), name="sage300-fields"),
]
Loading

0 comments on commit 9eafe9f

Please sign in to comment.