diff --git a/.pylintrc b/.pylintrc index d3ad7c5d..30dc42aa 100644 --- a/.pylintrc +++ b/.pylintrc @@ -61,6 +61,7 @@ confidence= # no Warning level messages displayed, use "--disable=all --enable=classes # --disable=W". disable=print-statement, + consider-using-f-string, parameter-unpacking, unpacking-in-except, old-raise-syntax, diff --git a/fyle_slack_app/fyle/expenses/views.py b/fyle_slack_app/fyle/expenses/views.py index fc254471..32df7235 100644 --- a/fyle_slack_app/fyle/expenses/views.py +++ b/fyle_slack_app/fyle/expenses/views.py @@ -1,18 +1,280 @@ -from typing import Dict +from typing import Dict, List + +import datetime + +from django.core.cache import cache from fyle.platform.platform import Platform from fyle_slack_app.fyle.utils import get_fyle_sdk_connection from fyle_slack_app.models.users import User +from fyle_slack_app.fyle import utils as fyle_utils +from fyle_slack_app.fyle.notifications.views import FyleNotificationView +from fyle_slack_app.libs import assertions, http +from fyle_slack_app import tracking + +# pylint: disable=too-many-public-methods class FyleExpense: + connection: Platform = None def __init__(self, user: User) -> None: self.connection = get_fyle_sdk_connection(user.fyle_refresh_token) + def get_expense_fields(self, query_params: Dict) -> Dict: + return self.connection.v1beta.spender.expense_fields.list(query_params=query_params) + + + def get_default_expense_fields(self) -> Dict: + default_expense_fields_query_params = { + 'offset': 0, + 'limit': '50', + 'order': 'created_at.desc', + 'column_name': 'in.(purpose, txn_dt, vendor_id, cost_center_id, project_id)', + 'is_enabled': 'eq.{}'.format(True), + 'is_custom': 'eq.{}'.format(False) + } + + return self.get_expense_fields(default_expense_fields_query_params) + + + def get_custom_fields_by_category_id(self, category_id: str) -> Dict: + custom_fields_query_params = { + 'offset': 0, + 'limit': '50', + 'order': 'created_at.desc', + 'column_name': 'not_in.(purpose, txn_dt, spent_at, merchant, vendor_id, cost_center_id)', + 'is_enabled': 'eq.{}'.format(True), + 'category_ids': 'cs.[{}]'.format(int(category_id)) + } + + return self.get_expense_fields(custom_fields_query_params) + + + def get_merchants_expense_field(self) -> Dict: + query_params = { + 'column_name': 'eq.merchant', + 'offset': 0, + 'limit': '50', + 'order': 'created_at.desc', + 'is_enabled': 'eq.{}'.format(True), + 'is_custom': 'eq.{}'.format(False) + } + + return self.get_expense_fields(query_params) + + + def get_categories(self, query_params: Dict) -> Dict: + return self.connection.v1beta.spender.categories.list(query_params=query_params) + + + def get_projects(self, query_params: Dict) -> Dict: + return self.connection.v1beta.spender.projects.list(query_params=query_params) + + def get_merchants(self, query_text: str) -> Dict: + query_params = { + 'offset': 0, + 'limit': '10', + 'order': 'display_name.asc', + 'q': query_text + } + return self.connection.v1beta.spender.merchants.list(query_params=query_params) + + def get_cost_centers(self, query_params: Dict) -> Dict: + return self.connection.v1beta.spender.cost_centers.list(query_params=query_params) + + + def get_expenses(self, query_params: Dict) -> Dict: + return self.connection.v1beta.spender.expenses.list(query_params=query_params) + + + def get_reports(self, query_params: Dict) -> Dict: + return self.connection.v1beta.spender.reports.list(query_params=query_params) + + + def get_employees(self, query_params: Dict) -> Dict: + return self.connection.v1beta.spender.employees.list(query_params=query_params) + + + def get_places_autocomplete(self, query: str) -> Dict: + return self.connection.v1beta.common.places_autocomplete.list(q=query) + + + def get_place_by_place_id(self, place_id: str) -> Dict: + return self.connection.v1beta.common.places.get_by_id(place_id) + + + def get_exchange_rate(self, from_currency: str, to_currency: str) -> Dict: + current_date = datetime.datetime.today().strftime('%Y-%m-%d') + exchange_rate = self.connection.v1beta.common.currencies_exchange_rate.get( + from_currency, to_currency, current_date + ) + return exchange_rate['data']['exchange_rate'] + + + def check_project_availability(self) -> bool: + projects_query_params = { + 'offset': 0, + 'limit': '1', + 'order': 'created_at.desc', + 'is_enabled': 'eq.{}'.format(True) + } + + projects = self.get_projects(projects_query_params) + + is_project_available = True if projects['count'] > 0 else False + + return is_project_available + + + def check_cost_center_availability(self) -> bool: + cost_centers_query_params = { + 'offset': 0, + 'limit': '1', + 'order': 'created_at.desc', + 'is_enabled': 'eq.{}'.format(True) + } + + cost_centers = self.get_cost_centers(cost_centers_query_params) + + is_cost_center_available = True if cost_centers['count'] > 0 else False + + return is_cost_center_available + + + def upsert_expense(self, expense_payload: Dict, refresh_token: str) -> Dict: + access_token = fyle_utils.get_fyle_access_token(refresh_token) + cluster_domain = fyle_utils.get_cluster_domain(refresh_token) + + url = '{}/platform/v1/spender/expenses'.format(cluster_domain) + headers = { + 'content-type': 'application/json', + 'Authorization': 'Bearer {}'.format(access_token) + } + + expense_payload = { + 'data': expense_payload + } + + response = http.post(url, json=expense_payload, headers=headers) + assertions.assert_valid(response.status_code == 200, 'Error creating expense') + return response.json()['data'] + + + @staticmethod + def get_currencies(): + return ['ADP','AED','AFA','ALL','AMD','ANG','AOA','ARS','ATS','AUD','AWG','AZM','BAM','BBD','BDT','BEF','BGL','BGN','BHD','BIF','BMD','BND','BOB','BOV','BRL','BSD','BTN','BWP','BYB','BZD','CAD','CDF','CHF','CLF','CLP','CNY','COP','CRC','CUP','CVE','CYP','CZK','DEM','DJF','DKK','DOP','DZD','ECS','ECV','EEK','EGP','ERN','ESP','ETB','EUR','FIM','FJD','FKP','FRF','GBP','GEL','GHC','GIP','GMD','GNF','GRD','GTQ','GWP','GYD','HKD','HNL','HRK','HTG','HUF','IDE','IDR','IEP','ILS','INR','IQD','IRR','ISK','ITL','JMD','JOD','JPY','KES','KGS','KHR','KMF','KPW','KRW','KWD','KYD','KZT','LAK','LBP','LKR','LRD','LSL','LTL','LUF','LVL','LYD','MAD','MDL','MGF','MKD','MMK','MNT','MOP','MRO','MTL','MUR','MVR','MWK','MXN','MXV','MYR','MZM','NAD','NGN','NIO','NLG','NOK','NPR','NZD','OMR','PAB','PEN','PGK','PHP','PKR','PLN','PTE','PYG','QAR','ROL','RUB','RUR','RWF','RYR','SAR','SBD','SCR','SDP','SEK','SGD','SHP','SIT','SKK','SLL','SOS','SRG','STD','SVC','SYP','SZL','THB','TJR','TMM','TND','TOP','TPE','TRL','TTD','TWD','TZS','UAH','UGX','USD','USN','USS','UYU','UZS','VEB','VND','VUV','WST','XAF','XCD','XDR','XEU','XOF','XPF','YER','YUN','ZAR','ZMK','ZRN','ZWD'] + + + @staticmethod + def get_expense_fields_mandatory_mapping(expense_fields: List[Dict]) -> Dict: + mandatory_fields_mapping = { + 'purpose': False, + 'txn_dt': False, + 'vendor_id': False, + 'project_id': False, + 'cost_center_id': False + } + + for field in expense_fields['data']: + if field['column_name'] in mandatory_fields_mapping: + mandatory_fields_mapping[field['column_name']] = field['is_mandatory'] + + return mandatory_fields_mapping + + + @staticmethod + def get_expense_form_details(user: User, view_id: str) -> Dict: + + fyle_expense = FyleExpense(user) + + fyle_profile = fyle_utils.get_fyle_profile(user.fyle_refresh_token) + + home_currency = fyle_profile['org']['currency'] + + default_expense_fields = fyle_expense.get_default_expense_fields() + + mandatory_fields_mapping = fyle_expense.get_expense_fields_mandatory_mapping(default_expense_fields) + + is_project_available = fyle_expense.check_project_availability() + is_cost_center_available = fyle_expense.check_cost_center_availability() + + # Create a expense fields render property and set them optional in the form + fields_render_property = { + 'project': { + 'is_project_available': is_project_available, + 'is_mandatory': mandatory_fields_mapping['project_id'] + }, + 'cost_center': { + 'is_cost_center_available': is_cost_center_available, + 'is_mandatory': mandatory_fields_mapping['cost_center_id'] + }, + 'purpose': mandatory_fields_mapping['purpose'], + 'transaction_date': mandatory_fields_mapping['txn_dt'], + 'vendor': mandatory_fields_mapping['vendor_id'] + } + + additional_currency_details = { + 'home_currency': home_currency + } + + add_to_report = 'existing_report' + + expense_form_details = { + 'fields_render_property': fields_render_property, + 'additional_currency_details': additional_currency_details, + 'add_to_report': add_to_report + } + + cache_key = '{}.form_metadata'.format(view_id) + cache.set(cache_key, expense_form_details, 3600) + + return expense_form_details + + + @staticmethod + def get_current_expense_form_details(slack_payload: Dict, user: User) -> Dict: + + fyle_expense = FyleExpense(user) + cache_key = '{}.form_metadata'.format(slack_payload['view']['id']) + form_metadata = cache.get(cache_key) + + if form_metadata is not None: + fields_render_property = form_metadata['fields_render_property'] + additional_currency_details = form_metadata.get('additional_currency_details') + add_to_report = form_metadata.get('add_to_report') + project = form_metadata.get('project') + else: + expense_form_details = fyle_expense.get_expense_form_details(user, slack_payload['container']['view_id']) + fields_render_property = expense_form_details['fields_render_property'] + additional_currency_details = expense_form_details['additional_currency_details'] + add_to_report = expense_form_details['add_to_report'] + project = expense_form_details['project'] + + current_ui_blocks = slack_payload['view']['blocks'] + + custom_field_blocks = [] + + for block in current_ui_blocks: + if 'custom_field' in block['block_id'] or 'additional_field' in block['block_id']: + custom_field_blocks.append(block) + + if len(custom_field_blocks) == 0: + custom_field_blocks = None + + current_form_details = { + 'fields_render_property': fields_render_property, + 'selected_project': project, + 'additional_currency_details': additional_currency_details, + 'add_to_report': add_to_report, + 'custom_fields': custom_field_blocks + } + return current_form_details + + def get_expense_by_id(self, expense_id: str) -> Dict: query_params = { 'id': 'eq.{}'.format(expense_id), @@ -23,3 +285,23 @@ def get_expense_by_id(self, expense_id: str) -> Dict: response = self.connection.v1beta.spender.expenses.list(query_params=query_params) expense = response['data'] if response['count'] == 1 else None return expense + + + @staticmethod + def get_expense_creation_tracking_data(user: User, expense_id: str = None) -> Dict: + event_data = FyleNotificationView.get_event_data(user) + event_data['org_id'] = user.fyle_org_id + if expense_id is not None: + event_data['expense_id'] = expense_id + + return event_data + + + @staticmethod + def track_expense_creation(user: User, event_name: str, expense_id: str=None) -> Dict: + event_data = FyleExpense.get_expense_creation_tracking_data(user, expense_id) + + tracking.identify_user(user.email) + tracking.track_event(user.email, event_name, event_data) + + return event_data diff --git a/fyle_slack_app/fyle/utils.py b/fyle_slack_app/fyle/utils.py index d624a6e8..159995b9 100644 --- a/fyle_slack_app/fyle/utils.py +++ b/fyle_slack_app/fyle/utils.py @@ -238,3 +238,18 @@ def is_receipt_file_supported(file_info: Dict) -> Union[bool, str]: is_receipt_supported = False return is_receipt_supported, response_message + + +def extract_expense_from_receipt(receipt_payload: Dict, refresh_token: str) -> Dict: + access_token = get_fyle_access_token(refresh_token) + cluster_domain = get_cluster_domain(refresh_token) + + url = '{}/platform/v1/spender/expenses/create_from_receipt'.format(cluster_domain) + headers = { + 'content-type': 'application/json', + 'Authorization': 'Bearer {}'.format(access_token) + } + + response = http.post(url, json=receipt_payload, headers=headers) + assertions.assert_valid(response.status_code == 200, 'Error while creating an expense from receipt') + return response.json()['data'] diff --git a/fyle_slack_app/libs/utils.py b/fyle_slack_app/libs/utils.py index 356ef013..0cf4d74a 100644 --- a/fyle_slack_app/libs/utils.py +++ b/fyle_slack_app/libs/utils.py @@ -27,6 +27,9 @@ def get_or_none(model: Model, **kwargs: Any) -> Union[None, Model]: def get_formatted_datetime(datetime_value: datetime, required_format: str) -> str: + # Enable support for parsing arbitrary ISO 8601 strings ('Z' strings specifically) + datetime_value = datetime_value.replace('Z', '') + datetime_value = datetime.datetime.fromisoformat(datetime_value) formatted_datetime = datetime_value.strftime(required_format) return formatted_datetime diff --git a/fyle_slack_app/slack/commands/handlers.py b/fyle_slack_app/slack/commands/handlers.py index 37880b97..6f413335 100644 --- a/fyle_slack_app/slack/commands/handlers.py +++ b/fyle_slack_app/slack/commands/handlers.py @@ -6,14 +6,15 @@ from fyle.platform import exceptions -from fyle_slack_app import tracking from fyle_slack_app.libs import utils, assertions, logger from fyle_slack_app.fyle.utils import get_fyle_oauth_url, get_fyle_profile from fyle_slack_app.models import User, NotificationPreference from fyle_slack_app.slack.ui.common_messages import IN_PROGRESS_MESSAGE from fyle_slack_app.slack.ui.dashboard import messages as dashboard_messages from fyle_slack_app.slack.ui.notifications import preference_messages as notification_preference_messages +from fyle_slack_app.slack.ui.expenses import messages as expense_messages from fyle_slack_app.slack import utils as slack_utils +from fyle_slack_app import tracking logger = logger.get_logger(__name__) @@ -26,10 +27,11 @@ class SlackCommandHandler: def _initialize_command_handlers(self): self._command_handlers = { 'fyle_unlink_account': self.handle_fyle_unlink_account, - 'fyle_notification_preferences': self.handle_fyle_notification_preferences + 'fyle_notification_preferences': self.handle_fyle_notification_preferences, + 'expense_form': self.handle_expense_form } - def handle_invalid_command(self, user_id: str, team_id: str, user_dm_channel_id: str) -> JsonResponse: + def handle_invalid_command(self, user_id: str, team_id: str, user_dm_channel_id: str, trigger_id: str) -> JsonResponse: slack_client = slack_utils.get_slack_client(team_id) slack_client.chat_postMessage( @@ -39,17 +41,17 @@ def handle_invalid_command(self, user_id: str, team_id: str, user_dm_channel_id: return JsonResponse({}, status=200) - def handle_slack_command(self, command: str, user_id: str, team_id: str, user_dm_channel_id: str) -> Callable: + def handle_slack_command(self, command: str, user_id: str, team_id: str, user_dm_channel_id: str, trigger_id: str) -> Callable: # Initialize slack command handlers self._initialize_command_handlers() handler = self._command_handlers.get(command, self.handle_invalid_command) - return handler(user_id, team_id, user_dm_channel_id) + return handler(user_id, team_id, user_dm_channel_id, trigger_id) - def handle_fyle_unlink_account(self, user_id: str, team_id: str, user_dm_channel_id: str) -> JsonResponse: + def handle_fyle_unlink_account(self, user_id: str, team_id: str, user_dm_channel_id: str, trigger_id: str) -> JsonResponse: message_block = [IN_PROGRESS_MESSAGE[slack_utils.AsyncOperation.UNLINKING_ACCOUNT.value]] slack_client = slack_utils.get_slack_client(team_id) @@ -86,7 +88,7 @@ def update_home_tab_with_pre_auth_message(self, user_id: str, team_id: str) -> N slack_client.views_publish(user_id=user_id, view=pre_auth_message_view) - def handle_fyle_notification_preferences(self, user_id: str, team_id: str, user_dm_channel_id: str) -> JsonResponse: + def handle_fyle_notification_preferences(self, user_id: str, team_id: str, user_dm_channel_id: str, trigger_id: str) -> JsonResponse: user = utils.get_or_none(User, slack_user_id=user_id) assertions.assert_found(user, 'Slack user not found') @@ -110,6 +112,25 @@ def handle_fyle_notification_preferences(self, user_id: str, team_id: str, user_ return JsonResponse({}, status=200) + def handle_expense_form(self, user_id: str, team_id: str, user_dm_channel_id: str, trigger_id: str): + user = utils.get_or_none(User, slack_user_id=user_id) + + slack_client = slack_utils.get_slack_client(team_id) + + loading_modal = expense_messages.expense_form_loading_modal(title='Create Expense', loading_message='Loading the best expense form :zap:') + + response = slack_client.views_open(user=user_id, view=loading_modal, trigger_id=trigger_id) + + async_task( + 'fyle_slack_app.slack.commands.tasks.open_expense_form', + user, + team_id, + response['view']['id'] + ) + + return JsonResponse({}, status=200) + + def track_fyle_account_unlinked(self, user: User) -> None: event_data = { 'asset': 'SLACK_APP', diff --git a/fyle_slack_app/slack/commands/tasks.py b/fyle_slack_app/slack/commands/tasks.py index 27f2085e..10c4dde9 100644 --- a/fyle_slack_app/slack/commands/tasks.py +++ b/fyle_slack_app/slack/commands/tasks.py @@ -6,6 +6,8 @@ from fyle_slack_app.models import User, UserSubscriptionDetail from fyle_slack_app.models.user_subscription_details import SubscriptionType from fyle_slack_app.slack.commands.handlers import SlackCommandHandler +from fyle_slack_app.fyle.expenses.views import FyleExpense +from fyle_slack_app.slack.ui.expenses import messages as expense_messages logger = logger.get_logger(__name__) @@ -98,3 +100,18 @@ def fyle_unlink_account(user_id: str, team_id: str, user_dm_channel_id: str, mes blocks=message_block, ts=message_ts ) + + +def open_expense_form(user: User, team_id: str, view_id: str) -> None: + + slack_client = slack_utils.get_slack_client(team_id) + + expense_form_details = FyleExpense.get_expense_form_details(user, view_id) + + expense_form = expense_messages.expense_dialog_form( + **expense_form_details + ) + + FyleExpense(user).track_expense_creation(user, 'User opened Expense Form modal using Slack slash command') + + slack_client.views_update(view=expense_form, view_id=view_id) diff --git a/fyle_slack_app/slack/commands/views.py b/fyle_slack_app/slack/commands/views.py index fc385839..8942c3bc 100644 --- a/fyle_slack_app/slack/commands/views.py +++ b/fyle_slack_app/slack/commands/views.py @@ -10,8 +10,9 @@ def post(self, request: HttpRequest, command: str) -> HttpResponse: team_id = request.POST['team_id'] user_id = request.POST['user_id'] user_dm_channel_id = request.POST['channel_id'] + trigger_id = request.POST['trigger_id'] - self.handle_slack_command(command, user_id, team_id, user_dm_channel_id) + self.handle_slack_command(command, user_id, team_id, user_dm_channel_id, trigger_id) # Empty "" HttpResponse beacause for slash commands slack return the response as message to user return HttpResponse("", status=200) diff --git a/fyle_slack_app/slack/events/tasks.py b/fyle_slack_app/slack/events/tasks.py index 6123005a..5d586744 100644 --- a/fyle_slack_app/slack/events/tasks.py +++ b/fyle_slack_app/slack/events/tasks.py @@ -1,8 +1,8 @@ +import base64 from typing import Dict, Union from slack_sdk import WebClient from django.conf import settings - from fyle_slack_app.fyle.expenses.views import FyleExpense from fyle_slack_app.fyle.utils import get_fyle_oauth_url @@ -10,10 +10,10 @@ from fyle_slack_app.models import Team, User, UserSubscriptionDetail from fyle_slack_app.models.user_subscription_details import SubscriptionType from fyle_slack_app.fyle import utils as fyle_utils - from fyle_slack_app.slack.interactives.block_action_handlers import BlockActionHandler from fyle_slack_app.slack import utils as slack_utils from fyle_slack_app.slack.ui.authorization import messages +from fyle_slack_app.slack.ui.expenses import messages as expense_messages from fyle_slack_app.slack.ui import common_messages @@ -109,6 +109,7 @@ def handle_file_shared(file_id: str, user_id: str, team_id: str): slack_client = slack_utils.get_slack_client(team_id) user = utils.get_or_none(User, slack_user_id=user_id) file_info, file_content, file_message_details = gather_shared_file_data(user, slack_client, file_id) + encoded_file = base64.b64encode(file_content).decode('utf-8') # If thread_ts is present in message, this means file has been shared in a thread if 'thread_ts' in file_message_details: @@ -143,6 +144,27 @@ def handle_file_shared(file_id: str, user_id: str, team_id: str): # This else block means file has been shared as a new message and an expense will be created with the file as receipt # i.e. data extraction flow else: + expense_creation_message = ':hourglass_flowing_sand: Creating an expense and uploading receipt :zap:' + expense_creation_message_block = common_messages.get_custom_text_section_block(expense_creation_message) + message_ts = file_message_details['ts'] + slack_client.chat_postMessage(channel=user.slack_dm_channel_id, blocks=expense_creation_message_block, thread_ts=message_ts, reply_broadcast=True) + receipt_payload = { + "data": { + "file_name": file_info['file']['name'], + "file_content": encoded_file, + "source": "SLACK" + } + } + try: + expense = fyle_utils.extract_expense_from_receipt(receipt_payload, user.fyle_refresh_token) + view_expense_message = expense_messages.view_expense_message(expense, user) + slack_client.chat_postMessage(channel=user.slack_dm_channel_id, blocks=view_expense_message, thread_ts=message_ts, reply_broadcast=True) + except assertions.InvalidUsage: + error_message = 'Seems like something went wrong while creating an expense, please try again or contact support@fylehq.com' + slack_client.chat_postMessage(channel=user.slack_dm_channel_id, text=error_message, thread_ts=message_ts, reply_broadcast=True) + + FyleExpense(user).track_expense_creation(user, 'Expense created from uploading Receipt in Slack', expense['id']) + return None diff --git a/fyle_slack_app/slack/interactives/block_action_handlers.py b/fyle_slack_app/slack/interactives/block_action_handlers.py index f66c05ce..ac116cb2 100644 --- a/fyle_slack_app/slack/interactives/block_action_handlers.py +++ b/fyle_slack_app/slack/interactives/block_action_handlers.py @@ -1,14 +1,15 @@ from typing import Callable, Dict +from django.core.cache import cache from django.http import JsonResponse from django_q.tasks import async_task -from fyle_slack_app.models import User, NotificationPreference, UserFeedback +from fyle_slack_app.fyle.expenses.views import FyleExpense from fyle_slack_app.models.notification_preferences import NotificationType from fyle_slack_app.libs import assertions, utils, logger - -from fyle_slack_app.fyle.expenses.views import FyleExpense - +from fyle_slack_app.slack.utils import get_slack_client +from fyle_slack_app.slack.ui.expenses import messages as expense_messages +from fyle_slack_app.models import User, NotificationPreference, UserFeedback from fyle_slack_app.slack.ui.feedbacks import messages as feedback_messages from fyle_slack_app.slack.ui.modals import messages as modal_messages from fyle_slack_app.slack.ui import common_messages @@ -18,7 +19,7 @@ logger = logger.get_logger(__name__) - +# pylint: disable=too-many-public-methods class BlockActionHandler: _block_action_handlers: Dict = {} @@ -40,6 +41,16 @@ def _initialize_block_action_handlers(self): 'report_paid_notification_preference': self.handle_notification_preference_selection, 'report_commented_notification_preference': self.handle_notification_preference_selection, 'expense_commented_notification_preference': self.handle_notification_preference_selection, + 'edit_expense': self.handle_edit_expense, + 'category_id': self.handle_category_selection, + 'project_id': self.handle_project_selection, + 'currency': self.handle_currency_selection, + 'claim_amount': self.handle_amount_entered, + 'add_to_report': self.handle_add_to_report, + 'add_expense_to_report': self.handle_add_expense_to_report, + 'add_expense_to_report_selection': self.handle_add_expense_to_report_selection, + 'open_submit_report_dialog': self.handle_submit_report_dialog, + 'expense_accessory': self.handle_expense_accessory, 'expense_mandatory_receipt_missing_notification_preference': self.handle_notification_preference_selection, 'open_feedback_dialog': self.handle_feedback_dialog, 'sent_back_reports_viewed_in_fyle': self.handle_tasks_viewed_in_fyle, @@ -87,6 +98,7 @@ def handle_pre_auth_mock_button(self, slack_payload: Dict, user_id: str, team_id # Empty function because slack still sends an interactive event on button click and expects a 200 response return JsonResponse({}, status=200) + def link_fyle_account(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: # Empty function because slack still sends an interactive event on button click and expects a 200 response return JsonResponse({}, status=200) @@ -170,6 +182,214 @@ def handle_notification_preference_selection(self, slack_payload: Dict, user_id: return JsonResponse({}, status=200) + def handle_expense_accessory(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + expense_accessory_value = slack_payload['actions'][0]['selected_option']['value'] + accessory_type, expense_id = expense_accessory_value.split('.') + + if accessory_type == 'open_in_fyle_accessory': + self.track_view_in_fyle_action(user_id, 'Expense Viewed in Fyle', {'expense_id': expense_id}) + + elif accessory_type == 'edit_expense_accessory': + slack_payload['actions'][0]['value'] = expense_id + self.handle_edit_expense(slack_payload, user_id, team_id) + + return JsonResponse({}) + + + def handle_project_selection(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + if slack_payload['actions'][0]['selected_option'] is None: + project_id = None + else: + project_id = slack_payload['actions'][0]['selected_option']['value'] + + view_id = slack_payload['container']['view_id'] + user = utils.get_or_none(User, slack_user_id=user_id) + + project = None + fyle_expense = FyleExpense(user) + + if project_id is not None: + project_query_params = { + 'offset': 0, + 'limit': '1', + 'order': 'created_at.desc', + 'id': 'eq.{}'.format(int(project_id)), + 'is_enabled': 'eq.{}'.format(True) + } + project = fyle_expense.get_projects(project_query_params) + project = project['data'][0] + project = { + 'id': project['id'], + 'name': project['name'], + 'display_name': project['display_name'], + 'sub_project': project['sub_project'] + } + + expense_form_details = { + 'project': project + } + + cache_key = '{}.form_metadata'.format(view_id) + form_metadata = cache.get(cache_key) + if form_metadata is None: + cache.set(cache_key, expense_form_details) + else: + form_metadata['project'] = project + cache.set(cache_key, form_metadata) + + current_view = expense_messages.expense_form_loading_modal(title='Create Expense', loading_message='Loading the best expense form :zap:') + current_view['submit'] = {'type': 'plain_text', 'text': 'Add Expense', 'emoji': True} + + blocks = slack_payload['view']['blocks'] + + # Adding loading info below project input element + project_block_index = next((index for (index, d) in enumerate(blocks) if d['block_id'] == 'project_block'), None) + + project_loading_block = { + 'type': 'context', + 'block_id': 'project_loading_block', + 'elements': [ + { + 'type': 'mrkdwn', + 'text': 'Loading categories for this project' + } + ] + } + + blocks.insert(project_block_index + 1, project_loading_block) + + current_view['blocks'] = blocks + + slack_client = get_slack_client(team_id) + + slack_client.views_update(view_id=view_id, view=current_view) + + async_task( + 'fyle_slack_app.slack.interactives.tasks.handle_project_selection', + user, + team_id, + project, + view_id, + slack_payload + ) + + return JsonResponse({}) + + + def handle_category_selection(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + + category_id = slack_payload['actions'][0]['selected_option']['value'] + + view_id = slack_payload['container']['view_id'] + + user = utils.get_or_none(User, slack_user_id=user_id) + + current_view = expense_messages.expense_form_loading_modal(title='Create Expense', loading_message='Loading the best expense form :zap:') + current_view['submit'] = {'type': 'plain_text', 'text': 'Add Expense', 'emoji': True} + + blocks = slack_payload['view']['blocks'] + + # Adding loading info below category input element + category_block_index = next((index for (index, d) in enumerate(blocks) if d['block_id'] == 'category_block'), None) + + category_loading_block = { + 'type': 'context', + 'block_id': 'category_loading_block', + 'elements': [ + { + 'type': 'mrkdwn', + 'text': 'Loading additional fields for this category if any' + } + ] + } + + blocks.insert(category_block_index + 1, category_loading_block) + + current_view['blocks'] = blocks + + slack_client = get_slack_client(team_id) + + slack_client.views_update(view_id=view_id, view=current_view) + + async_task( + 'fyle_slack_app.slack.interactives.tasks.handle_category_selection', + user, + team_id, + category_id, + view_id, + slack_payload + ) + + return JsonResponse({}, status=200) + + + def handle_currency_selection(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + + user = utils.get_or_none(User, slack_user_id=user_id) + + selected_currency = slack_payload['actions'][0]['selected_option']['value'] + + view_id = slack_payload['container']['view_id'] + + async_task( + 'fyle_slack_app.slack.interactives.tasks.handle_currency_selection', + user, + selected_currency, + view_id, + team_id, + slack_payload + ) + + return JsonResponse({}) + + + def handle_amount_entered(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + + user = utils.get_or_none(User, slack_user_id=user_id) + + amount_entered = slack_payload['actions'][0]['value'] + + view_id = slack_payload['container']['view_id'] + + async_task( + 'fyle_slack_app.slack.interactives.tasks.handle_amount_entered', + user, + amount_entered, + view_id, + team_id, + slack_payload + ) + + return JsonResponse({}) + + + def handle_add_to_report(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + + add_to_report = slack_payload['actions'][0]['selected_option']['value'] + + view_id = slack_payload['container']['view_id'] + + slack_client = get_slack_client(team_id) + + user = utils.get_or_none(User, slack_user_id=user_id) + current_expense_form_details = FyleExpense.get_current_expense_form_details(slack_payload, user) + + cache_key = '{}.form_metadata'.format(slack_payload['view']['id']) + form_metadata = cache.get(cache_key) + + current_expense_form_details['add_to_report'] = add_to_report + + form_metadata['add_to_report'] = add_to_report + + cache.set(cache_key, form_metadata) + + expense_form = expense_messages.expense_dialog_form( + **current_expense_form_details + ) + + slack_client.views_update(view_id=view_id, view=expense_form) + + def handle_feedback_dialog(self, slack_payload: Dict, user_id: str, team_id: str) -> None: slack_client = slack_utils.get_slack_client(team_id) @@ -209,6 +429,128 @@ def handle_feedback_dialog(self, slack_payload: Dict, user_id: str, team_id: str return JsonResponse({}) + def handle_add_expense_to_report_selection(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + + user = utils.get_or_none(User, slack_user_id=user_id) + + add_to_report = slack_payload['actions'][0]['selected_option']['value'] + + view_id = slack_payload['container']['view_id'] + + slack_client = get_slack_client(team_id) + + expense_id = slack_payload['view']['private_metadata'] + + fyle_expense = FyleExpense(user) + + expense_query_params = { + 'offset': 0, + 'limit': '1', + 'order': 'created_at.desc', + 'id': 'eq.{}'.format(expense_id) + } + + expense = fyle_expense.get_expenses(query_params=expense_query_params) + + add_expense_to_report_dialog = expense_messages.get_add_expense_to_report_dialog(expense=expense['data'][0], add_to_report=add_to_report) + + slack_client.views_update(view_id=view_id, view=add_expense_to_report_dialog) + + return JsonResponse({}) + + + def handle_add_expense_to_report(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + + user = utils.get_or_none(User, slack_user_id=user_id) + + expense_id = slack_payload['actions'][0]['value'] + + trigger_id = slack_payload['trigger_id'] + + slack_client = get_slack_client(team_id) + + fyle_expense = FyleExpense(user) + + expense_query_params = { + 'offset': 0, + 'limit': '1', + 'order': 'created_at.desc', + 'id': 'eq.{}'.format(expense_id) + } + + expense = fyle_expense.get_expenses(query_params=expense_query_params) + + add_expense_to_report_dialog = expense_messages.get_add_expense_to_report_dialog(expense=expense['data'][0], add_to_report='existing_report') + + add_expense_to_report_dialog['private_metadata'] = expense_id + + slack_client.views_open(trigger_id=trigger_id, user=user_id, view=add_expense_to_report_dialog) + + return JsonResponse({}) + + + def handle_edit_expense(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + + loading_modal = expense_messages.expense_form_loading_modal(title='Edit Expense', loading_message='Loading expense details :receipt: ') + + slack_client = get_slack_client(team_id) + + user = utils.get_or_none(User, slack_user_id=user_id) + + expense_id = slack_payload['actions'][0]['value'] + + response = slack_client.views_open(view=loading_modal, trigger_id=slack_payload['trigger_id']) + view_id = response['view']['id'] + cache_key = '{}.form_metadata'.format(view_id) + form_metadata = cache.get(cache_key) + # Add additional metadata to differentiate create and edit expense + # message_ts to update message in edit case + if form_metadata is None: + form_metadata = { + 'expense_id': expense_id, + 'message_ts': slack_payload['container']['message_ts'] + } + else: + form_metadata['expense_id'] = expense_id + form_metadata['message_ts'] = slack_payload['container']['message_ts'] + cache.set(cache_key, form_metadata) + + async_task( + 'fyle_slack_app.slack.interactives.tasks.handle_edit_expense', + user, + expense_id, + team_id, + view_id, + slack_payload + ) + + return JsonResponse({}) + + + def handle_submit_report_dialog(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + + user = utils.get_or_none(User, slack_user_id=user_id) + + loading_modal = expense_messages.expense_form_loading_modal(title='Report Details', loading_message='Loading report details :open_file_folder: ') + + slack_client = get_slack_client(team_id) + + report_id = slack_payload['actions'][0]['value'] + + report_id = 'rpKJGi7nRzMF' + + response = slack_client.views_open(view=loading_modal, trigger_id=slack_payload['trigger_id']) + + async_task( + 'fyle_slack_app.slack.interactives.tasks.handle_submit_report_dialog', + user, + team_id, + report_id, + response['view']['id'] + ) + + return JsonResponse({}) + def handle_tasks_viewed_in_fyle(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: user = utils.get_or_none(User, slack_user_id=user_id) task_name = slack_payload['actions'][0]['value'] diff --git a/fyle_slack_app/slack/interactives/block_suggestion_handlers.py b/fyle_slack_app/slack/interactives/block_suggestion_handlers.py new file mode 100644 index 00000000..b141da21 --- /dev/null +++ b/fyle_slack_app/slack/interactives/block_suggestion_handlers.py @@ -0,0 +1,323 @@ +from typing import Dict, List + +from django.core.cache import cache +from django.http import JsonResponse + +from fyle_slack_service.sentry import Sentry + +from fyle_slack_app.models.users import User +from fyle_slack_app.fyle.expenses.views import FyleExpense +from fyle_slack_app.libs import logger, utils +from fyle_slack_app.slack import utils as slack_utils + + +logger = logger.get_logger(__name__) + + +class BlockSuggestionHandler: + + _block_suggestion_handlers: Dict = {} + + # Maps action_id with it's respective function + def _initialize_block_suggestion_handlers(self): + self._block_suggestion_handlers = { + 'category_id': self.handle_category_suggestion, + 'project_id': self.handle_project_suggestion, + 'cost_center_id': self.handle_cost_center_suggestion, + 'currency': self.handle_currency_suggestion, + 'existing_report': self.handle_existing_report_suggestion, + 'user_list': self.handle_user_list_suggestion, + 'places_autocomplete': self.handle_places_autocomplete_suggestion, + 'merchant': self.handle_merchant_suggestion + } + + + # Gets called when function with an action is not found + def _handle_invalid_block_suggestions(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + slack_client = slack_utils.get_slack_client(team_id) + + user_dm_channel_id = slack_utils.get_slack_user_dm_channel_id(slack_client, user_id) + slack_client.chat_postMessage( + channel=user_dm_channel_id, + text='Looks like something went wrong :zipper_mouth_face: \n Please try again' + ) + + Sentry.capture_exception('Invalid block suggestion -> {}'.format(slack_payload['action_id'])) + + return JsonResponse({}, status=200) + + + # Handle all the block_suggestions from slack + def handle_block_suggestions(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + ''' + Check if any function is associated with the action + If present handler will call the respective function + If not present call `handle_invalid_block_suggestions` to send a prompt to user + ''' + + # Initialize handlers + self._initialize_block_suggestion_handlers() + + action_id = slack_payload['action_id'] + + handler = self._block_suggestion_handlers.get(action_id, self._handle_invalid_block_suggestions) + + options = handler(slack_payload, user_id, team_id) + + return JsonResponse({'options': options}) + + + def handle_category_suggestion(self, slack_payload: Dict, user_id: str, team_id: str) -> List: + + user = utils.get_or_none(User, slack_user_id=user_id) + category_value_entered = slack_payload['value'] + + fyle_expense = FyleExpense(user) + + category_query_params = { + 'offset': 0, + 'limit': '10', + 'order': 'display_name.asc', + 'display_name': 'ilike.%{}%'.format(category_value_entered), + 'system_category': 'not_in.(Unspecified, Per Diem, Mileage, Activity)', + 'is_enabled': 'eq.{}'.format(True) + } + + cache_key = '{}.form_metadata'.format(slack_payload['view']['id']) + form_metadata = cache.get(cache_key) + + if form_metadata is not None: + project = form_metadata.get('project') + if project is not None: + category_query_params['restricted_project_ids'] = 'csn.[{}]'.format(project['id']) + + suggested_categories = fyle_expense.get_categories(category_query_params) + + category_options = [] + if suggested_categories['count'] > 0: + for category in suggested_categories['data']: + + option = { + 'text': { + 'type': 'plain_text', + 'text': category['display_name'] + }, + 'value': str(category['id']), + } + category_options.append(option) + + return category_options + + + def handle_currency_suggestion(self, slack_payload: Dict, user_id: str, team_id: str) -> List: + + currencies = FyleExpense.get_currencies() + + currency_value_entered = slack_payload['value'] + + currency_options = [] + for currency in currencies: + if currency.startswith(currency_value_entered.upper()): + option = { + 'text': { + 'type': 'plain_text', + 'text': currency + }, + 'value': currency, + } + currency_options.append(option) + + return currency_options + + + def handle_project_suggestion(self, slack_payload: Dict, user_id: str, team_id: str) -> List: + + user = utils.get_or_none(User, slack_user_id=user_id) + project_value_entered = slack_payload['value'] + query_params = { + 'offset': 0, + 'limit': '10', + 'order': 'display_name.asc', + 'display_name': 'ilike.%{}%'.format(project_value_entered), + 'is_enabled': 'eq.{}'.format(True) + } + + fyle_expense = FyleExpense(user) + suggested_projects = fyle_expense.get_projects(query_params) + + project_options = [] + if suggested_projects['count'] > 0: + for project in suggested_projects['data']: + + option = { + 'text': { + 'type': 'plain_text', + 'text': project['display_name'] + }, + 'value': str(project['id']), + } + project_options.append(option) + + return project_options + + + def handle_cost_center_suggestion(self, slack_payload: Dict, user_id: str, team_id: str) -> List: + + user = utils.get_or_none(User, slack_user_id=user_id) + cost_center_value_entered = slack_payload['value'] + query_params = { + 'offset': 0, + 'limit': '10', + 'order': 'name.asc', + 'name': 'ilike.%{}%'.format(cost_center_value_entered), + 'is_enabled': 'eq.{}'.format(True) + } + + fyle_expense = FyleExpense(user) + suggested_cost_centers = fyle_expense.get_cost_centers(query_params) + + cost_center_options = [] + if suggested_cost_centers['count'] > 0: + for cost_center in suggested_cost_centers['data']: + option = { + 'text': { + 'type': 'plain_text', + 'text': cost_center['name'] + }, + 'value': str(cost_center['id']), + } + cost_center_options.append(option) + + return cost_center_options + + + def handle_existing_report_suggestion(self, slack_payload: Dict, user_id: str, team_id: str) -> List: + user = utils.get_or_none(User, slack_user_id=user_id) + report_name_value_entered = slack_payload['value'] + query_params = { + 'offset': 0, + 'limit': '10', + 'order': 'state.asc', + 'purpose': 'ilike.%{}%'.format(report_name_value_entered), + 'state': 'in.(DRAFT, APPROVER_PENDING, APPROVER_INQUIRY)' + } + + fyle_expense = FyleExpense(user) + suggested_reports = fyle_expense.get_reports(query_params) + + report_state_emoji_text_mapping = { + 'DRAFT': ':mailbox: Draft', + 'APPROVER_PENDING': ':outbox_tray: Reported', + 'APPROVER_INQUIRY': ':back: Sent Back' + } + + report_options = [] + if suggested_reports['count'] > 0: + for report in suggested_reports['data']: + report_display_text = '{} ({} expenses) •'.format(report['purpose'], report['num_expenses']) + report_emoji = report_state_emoji_text_mapping[report['state']] + report_display_text = '{} {}'.format(report_display_text, report_emoji) + option = { + 'text': { + 'type': 'plain_text', + 'text': report_display_text + }, + 'value': str(report['id']), + } + report_options.append(option) + + return report_options + + + def handle_user_list_suggestion(self, slack_payload: Dict, user_id: str, team_id: str) -> List: + + user = utils.get_or_none(User, slack_user_id=user_id) + user_value_entered = slack_payload['value'] + + fyle_expense = FyleExpense(user) + + query_params = { + 'offset': 0, + 'limit': '10', + 'order': 'email.asc', + 'email': 'ilike.{}%'.format(user_value_entered), + } + + suggested_users = fyle_expense.get_employees(query_params) + + user_options = [] + if suggested_users['count'] > 0: + for user in suggested_users['data']: + option = { + 'text': { + 'type': 'plain_text', + 'text': '{} ({})'.format(user['full_name'], user['email']) + }, + 'value': user['email'], + } + user_options.append(option) + + return user_options + + + def handle_places_autocomplete_suggestion(self, slack_payload: Dict, user_id: str, team_id: str) -> List: + + user = utils.get_or_none(User, slack_user_id=user_id) + place_value_entered = slack_payload['value'] + + fyle_expense = FyleExpense(user) + + suggested_places = fyle_expense.get_places_autocomplete(query=place_value_entered) + + place_options = [] + + if suggested_places['count'] > 0: + for place in suggested_places['data']: + option = { + 'text': { + 'type': 'plain_text', + 'text': '{}'.format(place['formatted_address']) + }, + 'value': place['id'], + } + place_options.append(option) + + return place_options + + + def handle_merchant_suggestion(self, slack_payload: Dict, user_id: str, team_id: str) -> List: + + user = utils.get_or_none(User, slack_user_id=user_id) + merchant_value_entered = slack_payload['value'] + merchant_options = [] + fyle_expense = FyleExpense(user) + + # Fetch all the options (choices) from Merchant expense field + merchants_expense_field = fyle_expense.get_merchants_expense_field() + + if len(merchants_expense_field['data'][0]['options']) > 0: + suggested_merchants = merchants_expense_field['data'][0]['options'] + + else: + # Fetch the merchant list from merchants table in DB + suggested_merchants = fyle_expense.get_merchants(merchant_value_entered) + + if suggested_merchants['count'] > 0: + # Show merchants suggestions from merchants list + suggested_merchants = [merchant['display_name'] for merchant in suggested_merchants['data']] + else: + # Else, show the suggestion as it is, what the user has entered + # In this case, this user entered text will get stored as a new merchant in merchants table + suggested_merchants = [merchant_value_entered] + + for merchant in suggested_merchants: + option = { + 'text': { + 'type': 'plain_text', + 'text': '{}'.format(merchant) + }, + 'value': merchant + } + merchant_options.append(option) + + return merchant_options diff --git a/fyle_slack_app/slack/interactives/shortcut_handlers.py b/fyle_slack_app/slack/interactives/shortcut_handlers.py index daf7db65..1b1c50c0 100644 --- a/fyle_slack_app/slack/interactives/shortcut_handlers.py +++ b/fyle_slack_app/slack/interactives/shortcut_handlers.py @@ -57,6 +57,8 @@ def handle_notification_preferences(self, slack_payload: Dict, user_id: str, tea user_dm_channel_id = slack_utils.get_slack_user_dm_channel_id(slack_client, user_id) - SlackCommandHandler().handle_fyle_notification_preferences(user_id, team_id, user_dm_channel_id) + trigger_id = slack_payload['trigger_id'] + + SlackCommandHandler().handle_fyle_notification_preferences(user_id, team_id, user_dm_channel_id, trigger_id) return JsonResponse({}, status=200) diff --git a/fyle_slack_app/slack/interactives/tasks.py b/fyle_slack_app/slack/interactives/tasks.py index 79865d5f..aab045db 100644 --- a/fyle_slack_app/slack/interactives/tasks.py +++ b/fyle_slack_app/slack/interactives/tasks.py @@ -1,9 +1,13 @@ -from typing import Dict +from typing import Dict, List +from django.core.cache import cache from fyle.platform import exceptions +from fyle_slack_app.fyle.expenses.views import FyleExpense +from fyle_slack_app.slack.utils import get_slack_client +from fyle_slack_app.slack.ui.expenses import messages as expense_messages +from fyle_slack_app.libs import utils, logger, assertions from fyle_slack_app.fyle.report_approvals.views import FyleReportApproval - from fyle_slack_app.models import User, UserFeedbackResponse from fyle_slack_app.slack import utils as slack_utils from fyle_slack_app.slack.ui.feedbacks import messages as feedback_messages @@ -11,10 +15,261 @@ from fyle_slack_app.slack.ui import common_messages from fyle_slack_app import tracking -from fyle_slack_app.libs import utils, logger logger = logger.get_logger(__name__) +def get_additional_currency_details(amount: int, home_currency: str, selected_currency: str, exchange_rate: float) -> Dict: + + if amount is None or len(amount) == 0: + amount = 0 + else: + try: + amount = round(float(amount), 2) + except ValueError: + amount = 0 + + additional_currency_details = { + 'foreign_currency': selected_currency, + 'home_currency': home_currency, + 'claim_amount': amount, + 'total_amount': round(exchange_rate * amount, 2) + } + + return additional_currency_details + + +def handle_project_selection(user: User, team_id: str, project: Dict, view_id: str, slack_payload: Dict) -> None: + slack_client = get_slack_client(team_id) + fyle_expense = FyleExpense(user) + + current_expense_form_details = fyle_expense.get_current_expense_form_details(slack_payload, user) + + cache_key = '{}.form_metadata'.format(slack_payload['view']['id']) + form_metadata = cache.get(cache_key) + + # Removing custom fields when project is selected + current_expense_form_details['custom_fields'] = None + + current_expense_form_details['selected_project'] = project + + current_ui_blocks = slack_payload['view']['blocks'] + + # Removing loading info from below project input element + project_loading_block_index = next((index for (index, d) in enumerate(current_ui_blocks) if d['block_id'] == 'project_loading_block'), None) + current_ui_blocks.pop(project_loading_block_index) + + form_metadata['project'] = project + + cache.set(cache_key, form_metadata) + + new_expense_dialog_form = expense_messages.expense_dialog_form( + **current_expense_form_details + ) + + slack_client.views_update(view_id=view_id, view=new_expense_dialog_form) + + +def handle_category_selection(user: User, team_id: str, category_id: str, view_id: str, slack_payload: str) -> None: + + slack_client = get_slack_client(team_id) + + fyle_expense = FyleExpense(user) + + custom_fields = fyle_expense.get_custom_fields_by_category_id(category_id) + + current_expense_form_details = fyle_expense.get_current_expense_form_details(slack_payload, user) + + current_expense_form_details['custom_fields'] = custom_fields + + current_ui_blocks = slack_payload['view']['blocks'] + + # Removing loading info from below category input element + category_loading_block_index = next((index for (index, d) in enumerate(current_ui_blocks) if d['block_id'] == 'category_loading_block'), None) + current_ui_blocks.pop(category_loading_block_index) + + new_expense_dialog_form = expense_messages.expense_dialog_form( + **current_expense_form_details + ) + + slack_client.views_update(view_id=view_id, view=new_expense_dialog_form) + + +def handle_currency_selection(user: User, selected_currency: str, view_id: str, team_id: str, slack_payload: str) -> None: + + slack_client = get_slack_client(team_id) + + fyle_expense = FyleExpense(user) + + current_expense_form_details = fyle_expense.get_current_expense_form_details(slack_payload, user) + + cache_key = '{}.form_metadata'.format(slack_payload['view']['id']) + form_metadata = cache.get(cache_key) + + additional_currency_details = current_expense_form_details['additional_currency_details'] + + home_currency = additional_currency_details['home_currency'] + + additional_currency_details = { + 'home_currency': home_currency + } + + if home_currency != selected_currency: + form_current_state = slack_payload['view']['state']['values'] + exchange_rate = fyle_expense.get_exchange_rate(selected_currency, home_currency) + amount = form_current_state['NUMBER_default_field_amount_block']['claim_amount']['value'] + additional_currency_details = get_additional_currency_details(amount, home_currency, selected_currency, exchange_rate) + + current_expense_form_details['additional_currency_details'] = additional_currency_details + + form_metadata['additional_currency_details'] = additional_currency_details + + cache.set(cache_key, form_metadata) + + expense_form = expense_messages.expense_dialog_form( + **current_expense_form_details + ) + + slack_client.views_update(view_id=view_id, view=expense_form) + + +def handle_amount_entered(user: User, amount_entered: float, view_id: str, team_id: str, slack_payload: str) -> None: + + slack_client = get_slack_client(team_id) + + fyle_expense = FyleExpense(user) + + form_current_state = slack_payload['view']['state']['values'] + + selected_currency = form_current_state['SELECT_default_field_currency_block']['currency']['selected_option']['value'] + + current_expense_form_details = fyle_expense.get_current_expense_form_details(slack_payload, user) + + cache_key = '{}.form_metadata'.format(slack_payload['view']['id']) + form_metadata = cache.get(cache_key) + + home_currency = current_expense_form_details['additional_currency_details']['home_currency'] + + exchange_rate = fyle_expense.get_exchange_rate(selected_currency, home_currency) + + additional_currency_details = get_additional_currency_details(amount_entered, home_currency, selected_currency, exchange_rate) + + current_expense_form_details['additional_currency_details'] = additional_currency_details + + form_metadata['additional_currency_details'] = additional_currency_details + + cache.set(cache_key, form_metadata) + + expense_form = expense_messages.expense_dialog_form( + **current_expense_form_details + ) + + slack_client.views_update(view_id=view_id, view=expense_form) + + +def handle_edit_expense(user: User, expense_id: str, team_id: str, view_id: str, slack_payload: List[Dict]) -> None: + slack_client = get_slack_client(team_id) + fyle_expense = FyleExpense(user) + + expense_query_params = { + 'offset': 0, + 'limit': '1', + 'order': 'created_at.desc', + 'id': 'eq.{}'.format(expense_id) + } + + expense = fyle_expense.get_expenses(query_params=expense_query_params) + + expense = expense['data'][0] + + custom_fields = fyle_expense.get_custom_fields_by_category_id(expense['category_id']) + + expense_form_details = FyleExpense.get_expense_form_details(user, view_id) + + cache_key = '{}.form_metadata'.format(view_id) + form_metadata = cache.get(cache_key) + + # Add additional metadata to differentiate create and edit expense + # message_ts to update message in edit case + if form_metadata is not None: + form_metadata['expense_id'] = expense_id + form_metadata['message_ts'] = slack_payload['container']['message_ts'] + + cache.set(cache_key, form_metadata) + + expense_form = expense_messages.expense_dialog_form( + expense=expense, + custom_fields=custom_fields, + **expense_form_details + ) + + slack_client.views_update(view=expense_form, view_id=view_id) + + fyle_expense.track_expense_creation(user, 'User clicked on Complete Expense button', expense['id']) + + +def handle_submit_report_dialog(user: User, team_id: str, report_id: str, view_id: str): + + slack_client = get_slack_client(team_id) + + fyle_expense = FyleExpense(user) + + expense_query_params = { + 'offset': 0, + 'limit': '30', + 'order': 'created_at.desc', + 'report_id': 'eq.{}'.format(report_id) + } + + expenses = fyle_expense.get_expenses(query_params=expense_query_params) + + report_query_params = { + 'offset': 0, + 'limit': '1', + 'order': 'created_at.desc', + 'id': 'eq.{}'.format(report_id) + } + + report = fyle_expense.get_reports(query_params=report_query_params) + + add_expense_to_report_dialog = expense_messages.get_view_report_details_dialog(user, report=report['data'][0], expenses=expenses['data']) + + add_expense_to_report_dialog['private_metadata'] = report_id + + slack_client.views_update(view_id=view_id, view=add_expense_to_report_dialog) + + +def handle_upsert_expense(user: User, view_id: str, team_id: str, expense_payload: Dict, expense_id: str, message_ts: str): + slack_client = get_slack_client(team_id) + fyle_expense = FyleExpense(user) + + cache_key = '{}.form_metadata'.format(view_id) + form_metadata = cache.get(cache_key) + + if form_metadata and 'additional_currency_details' in form_metadata and form_metadata['additional_currency_details'] and 'foreign_currency' in form_metadata['additional_currency_details']: + expense_payload['foreign_currency'] = form_metadata['additional_currency_details']['foreign_currency'] + expense_payload['foreign_amount'] = expense_payload['claim_amount'] + expense_payload['claim_amount'] = form_metadata['additional_currency_details']['total_amount'] + + if form_metadata and 'project' in form_metadata and form_metadata['project']: + expense_payload['project_id'] = form_metadata['project']['id'] + + if expense_id is not None: + expense_payload['id'] = expense_id + + try: + expense = fyle_expense.upsert_expense(expense_payload, user.fyle_refresh_token) + view_expense_message = expense_messages.view_expense_message(expense, user) + + if expense_id is None or message_ts is None: + slack_client.chat_postMessage(channel=user.slack_dm_channel_id, blocks=view_expense_message) + else: + slack_client.chat_update(channel=user.slack_dm_channel_id, blocks=view_expense_message, ts=message_ts) + except assertions.InvalidUsage: + error_message = 'Seems like something went wrong while creating an expense, please try again or contact support@fylehq.com' + slack_client.chat_postMessage(channel=user.slack_dm_channel_id, text=error_message) + + fyle_expense.track_expense_creation(user, 'Expense created from Expense Form modal', expense_id) + def handle_feedback_submission(user: User, team_id: str, form_values: Dict, private_metadata: Dict): user_feedback_id = private_metadata['user_feedback_id'] diff --git a/fyle_slack_app/slack/interactives/view_submission_handlers.py b/fyle_slack_app/slack/interactives/view_submission_handlers.py index c738eeee..02d3f3c4 100644 --- a/fyle_slack_app/slack/interactives/view_submission_handlers.py +++ b/fyle_slack_app/slack/interactives/view_submission_handlers.py @@ -1,13 +1,21 @@ -from typing import Callable, Dict +import datetime + +from typing import Callable, List, Dict, Union +from dateutil.parser import parse from django.http.response import JsonResponse +from django.core.cache import cache from django_q.tasks import async_task -from fyle_slack_app.models.users import User +from fyle_slack_app.fyle.expenses.views import FyleExpense +from fyle_slack_app.models import User from fyle_slack_app.slack import utils as slack_utils -from fyle_slack_app.slack.interactives.block_action_handlers import BlockActionHandler from fyle_slack_app.libs import utils +from fyle_slack_app.slack.ui.expenses import messages as expense_messages +from fyle_slack_app.slack.interactives.block_action_handlers import BlockActionHandler + + class ViewSubmissionHandler: @@ -17,6 +25,9 @@ class ViewSubmissionHandler: # Maps action_id with it's respective function def _initialize_view_submission_handlers(self): self._view_submission_handlers = { + 'upsert_expense': self.handle_upsert_expense, + 'submit_report': self.handle_submit_report, + 'add_expense_to_report': self.handle_add_expense_to_report, 'feedback_submission': self.handle_feedback_submission, 'report_approval_from_modal': self.handle_report_approval_from_modal } @@ -52,10 +63,88 @@ def handle_view_submission(self, slack_payload: Dict, user_id: str, team_id: str return handler(slack_payload, user_id, team_id) - def handle_feedback_submission(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + def handle_upsert_expense(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + user = utils.get_or_none(User, slack_user_id=user_id) + + form_values = slack_payload['view']['state']['values'] + + expense_payload, validation_errors = self.extract_form_values_and_validate(user, form_values) + cache_key = '{}.form_metadata'.format(slack_payload['view']['id']) + form_metadata = cache.get(cache_key) + + expense_payload['source'] = 'SLACK' + expense_id = None + message_ts = None + if form_metadata is not None: + expense_id = form_metadata.get('expense_id') + message_ts = form_metadata.get('message_ts') + + # If valdiation errors are present then return errors + if bool(validation_errors) is True: + return JsonResponse({ + 'response_action': 'errors', + 'errors': validation_errors + }) + + async_task( + 'fyle_slack_app.slack.interactives.tasks.handle_upsert_expense', + user, + slack_payload['view']['id'], + team_id, + expense_payload, + expense_id, + message_ts + ) + + return JsonResponse({}) + + + def handle_submit_report(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: user = utils.get_or_none(User, slack_user_id=user_id) + report_id = slack_payload['view']['private_metadata'] + + fyle_expense = FyleExpense(user) + + report_query_params = { + 'offset': 0, + 'limit': '1', + 'order': 'created_at.desc', + 'id': 'eq.{}'.format(report_id) + } + + report = fyle_expense.get_reports(query_params=report_query_params) + + slack_client = slack_utils.get_slack_client(team_id) + + report_submitted_message = expense_messages.report_submitted_message(user, report['data'][0]) + + slack_client.chat_postMessage(channel=user.slack_dm_channel_id, blocks=report_submitted_message) + + return JsonResponse({}) + + + def handle_add_expense_to_report(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + + add_expense_to_report_form_values = slack_payload['view']['state']['values'] + # pylint: disable=unused-variable + expense_id = slack_payload['view']['private_metadata'] + + if 'TEXT_add_to_new_report_block' in add_expense_to_report_form_values: + # pylint: disable=unused-variable + report_name = add_expense_to_report_form_values['TEXT_add_to_new_report_block']['report_name']['value'] + + elif 'SELECT_add_to_existing_report_block' in add_expense_to_report_form_values: + # pylint: disable=unused-variable + existing_report_id = add_expense_to_report_form_values['SELECT_add_to_existing_report_block']['existing_report']['selected_option']['value'] + + encoded_private_metadata = slack_payload['view']['private_metadata'] + # pylint: disable=unused-variable + private_metadata = utils.decode_state(encoded_private_metadata) + + def handle_feedback_submission(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: + user = utils.get_or_none(User, slack_user_id=user_id) form_values = slack_payload['view']['state']['values'] encoded_private_metadata = slack_payload['view']['private_metadata'] private_metadata = utils.decode_state(encoded_private_metadata) @@ -71,6 +160,156 @@ def handle_feedback_submission(self, slack_payload: Dict, user_id: str, team_id: return JsonResponse({}) + def get_travel_class_list(self, expense_payload: Dict, block_id: str, form_value: str) -> List[Dict]: + # This method will give you the travel class object - travel_classes field + travel_classes = [None, None] + + if 'travel_classes' in expense_payload: + travel_classes = expense_payload['travel_classes'] + + if '_journey_travel_class' in block_id: + travel_classes[0] = form_value + elif '_return_travel_class' in block_id: + if len(travel_classes) == 1: + travel_classes.append(form_value) + else: + travel_classes[1] = form_value + + return travel_classes + + + def append_into_expense_payload_for_upsert_expense(self, expense_payload: Dict, expense_field_key: str, form_value: any, block_id: str) -> Dict: + # expense_payload is used as the payload which is sent to POST request of /spender/expenses API + # Only single expense field will be appended to the expense payload at a time + # Can refer the expense post payload structure from here: https://docs.fylehq.com/docs/fyle-platform-docs/ -> Spender APIs -> Expenses -> Crreate or upadte expense (POST) + + if 'from_dt' in block_id: + expense_payload['started_at'] = form_value + + elif 'to_dt' in block_id: + expense_payload['ended_at'] = form_value + + elif 'custom_field' in block_id: + custom_field = { + 'name': expense_field_key, + 'value': form_value + } + if 'custom_fields' in expense_payload: + expense_payload['custom_fields'].append(custom_field) + else: + expense_payload['custom_fields'] = [custom_field] + + elif 'LOCATION' in block_id: + if 'locations' in expense_payload: + expense_payload['locations'].append(form_value) + else: + expense_payload['locations'] = [form_value] + + elif 'travel_class' in block_id: + travel_classes = self.get_travel_class_list(expense_payload, block_id, form_value) + expense_payload['travel_classes'] = travel_classes + + else: + expense_payload[expense_field_key] = form_value + + return expense_payload + + + def extract_form_values_and_validate(self, user, form_values: Dict) -> Union[Dict, Dict]: + expense_payload = {} + validation_errors = {} + + fyle_expense = FyleExpense(user) + for block_id, value in form_values.items(): + for expense_field_key, form_detail in value.items(): + form_value = None + if form_detail['type'] in ['static_select', 'external_select']: + expense_field_key, form_value = self.extract_select_field_detail( + expense_field_key, + form_detail, + block_id, + fyle_expense + ) + + if form_detail['type'] in ['multi_static_select', 'multi_external_select']: + expense_field_key, form_value = self.extract_multi_select_field(expense_field_key, form_detail, block_id) + + elif form_detail['type'] == 'datepicker': + form_value, validation_errors = self.extract_and_validate_date_field(form_detail, block_id, validation_errors) + + elif form_detail['type'] == 'plain_text_input': + form_value, validation_errors = self.extract_and_validate_text_field(form_detail, block_id, validation_errors) + + elif form_detail['type'] == 'checkboxes': + form_value = self.extract_checkbox_field(form_detail) + + if form_value is not None: + expense_payload = self.append_into_expense_payload_for_upsert_expense( + expense_payload, + expense_field_key, + form_value, + block_id + ) + + return expense_payload, validation_errors + + def extract_select_field_detail(self, expense_field_key: str, form_detail: Dict, block_id: str, fyle_expense: FyleExpense): + form_value = None + if form_detail['selected_option'] is not None: + form_value = form_detail['selected_option']['value'] + + if 'LOCATION' in block_id: + _ , expense_field_key = block_id.split('__') + place_id = form_detail['selected_option']['value'] if form_detail['selected_option'] is not None else None + if place_id is not None: + location = fyle_expense.get_place_by_place_id(place_id) + form_value = location + form_value['display'] = location['formatted_address'] + + if 'travel_class' in block_id: + travel_class = form_detail['selected_option']['value'] if form_detail['selected_option'] is not None else None + form_value = travel_class + + return expense_field_key, form_value + + def extract_multi_select_field(self, expense_field_key: str, form_detail: Dict, block_id: str): + if 'USER_LIST' in block_id: + _ , expense_field_key = block_id.split('__') + values_list = [] + for val in form_detail['selected_options']: + values_list.append(val['value']) + form_value = values_list + return expense_field_key, form_value + + def extract_and_validate_date_field(self, form_detail: Dict, block_id: str, validation_errors: Dict): + if form_detail['selected_date'] is not None and datetime.datetime.strptime(form_detail['selected_date'], '%Y-%m-%d') > datetime.datetime.now(): + validation_errors[block_id] = 'Date selected cannot be in future' + form_value = form_detail['selected_date'] + if form_value is not None: + form_value = parse(form_value).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + return form_value, validation_errors + + def extract_and_validate_text_field(self, form_detail: Dict, block_id: str, validation_errors: Dict): + if 'TEXT' in block_id: + form_value = form_detail['value'].strip() if form_detail['value'] is not None else None + + elif 'NUMBER' in block_id: + form_value = form_detail['value'] + try: + form_value = float(form_detail['value']) if form_detail['value'] is not None else None + if form_value is not None and form_value < 0: + validation_errors[block_id] = 'Negative numbers are not allowed' + form_value = round(form_value, 2) if form_value is not None else None + except ValueError: + validation_errors[block_id] = 'Only numbers are allowed in this fields' + return form_value, validation_errors + + def extract_checkbox_field(self, form_detail: Dict): + form_value = False + if len(form_detail['selected_options']) > 0: + form_value = True + return form_value + def handle_report_approval_from_modal(self, slack_payload: Dict, user_id: str, team_id: str) -> JsonResponse: encoded_private_metadata = slack_payload['view']['private_metadata'] private_metadata = utils.decode_state(encoded_private_metadata) diff --git a/fyle_slack_app/slack/interactives/views.py b/fyle_slack_app/slack/interactives/views.py index 671526f8..f275e9af 100644 --- a/fyle_slack_app/slack/interactives/views.py +++ b/fyle_slack_app/slack/interactives/views.py @@ -6,6 +6,7 @@ from fyle_slack_app.slack.interactives.block_action_handlers import BlockActionHandler from fyle_slack_app.slack.interactives.shortcut_handlers import ShortcutHandler from fyle_slack_app.slack.interactives.view_submission_handlers import ViewSubmissionHandler +from fyle_slack_app.slack.interactives.block_suggestion_handlers import BlockSuggestionHandler class SlackInteractiveView(SlackView): @@ -33,4 +34,8 @@ def post(self, request: HttpRequest) -> JsonResponse: # Call handler function from ViewSubmissionHandler return ViewSubmissionHandler().handle_view_submission(slack_payload, user_id, team_id) + elif event_type == 'block_suggestion': + # Call handler function from BlockActionHandler + return BlockSuggestionHandler().handle_block_suggestions(slack_payload, user_id, team_id) + return JsonResponse({}, status=200) diff --git a/fyle_slack_app/slack/ui/expenses/__init__.py b/fyle_slack_app/slack/ui/expenses/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fyle_slack_app/slack/ui/expenses/messages.py b/fyle_slack_app/slack/ui/expenses/messages.py new file mode 100644 index 00000000..3dbf433b --- /dev/null +++ b/fyle_slack_app/slack/ui/expenses/messages.py @@ -0,0 +1,1228 @@ +# pylint: disable=too-many-lines +# pylint: disable=too-many-statements +from typing import Any, Dict, List + +import datetime + +from fyle_slack_app.models import User +from fyle_slack_app.libs import utils +from fyle_slack_app.fyle import utils as fyle_utils + + + +def get_custom_field_value(custom_fields: List, action_id: str, field_type: str) -> Any: + value = None + for custom_field in custom_fields: + if custom_field['name'] == action_id: + value = custom_field['value'] + break + return value + + +def get_additional_field_value(expense: Dict, action_id: str) -> Any: + value = None + if 'flight_journey_travel_class' in action_id or 'train_travel_class' in action_id or 'bus_travel_class' in action_id: + value = expense['travel_classes'][0] if 'travel_classes' in expense and expense['travel_classes'] else None + elif 'flight_return_travel_class' in action_id: + value = expense['travel_classes'][1] if 'travel_classes' in expense and expense['travel_classes'] else None + elif 'from_dt' in action_id: + value = expense['started_at'] if 'started_at' in expense and expense['started_at'] else None + elif 'to_dt' in action_id: + value = expense['ended_at'] if 'ended_at' in expense and expense['ended_at'] else None + elif 'location1' in action_id and len(expense['locations']) > 0: + value = {} + if 'locations' in expense and expense['locations'][0] and expense['locations'][0]['formatted_address']: + value = expense['locations'][0] + else: + value = None + elif 'location2' in action_id and len(expense['locations']) > 0: + value = {} + if 'locations' in expense and expense['locations'][1] and expense['locations'][1]['formatted_address']: + value = expense['locations'][1] + else: + value = None + else: + value = str(expense[action_id]) if action_id in expense else None + return value + + +# pylint: disable=too-many-branches +# is_additional_field is for fields which are not custom fields but are part of specific categories +def generate_custom_fields_ui(field_details: Dict, is_additional_field: bool = False, expense: Dict = None) -> Dict: + + block_id = '{}_block'.format(field_details['column_name']) + action_id = field_details['column_name'] + + custom_field = None + + custom_field_value = None + + # We need to define addtional fields as custom fields so that we can clear them out in form when category is changed + if field_details['is_custom'] is True or is_additional_field is True: + + # block_id for additional field + block_id = '{}_additional_field_{}_block'.format(field_details['type'], field_details['column_name']) + + if field_details['is_custom'] is True: + block_id = '{}_custom_field_{}_block'.format(field_details['type'], field_details['column_name']) + action_id = '{}'.format(field_details['field_name']) + + # If already exisiting expense is passed then get the custom field value for that expense and add it to input fields + if expense is not None: + if is_additional_field is True: + custom_field_value = get_additional_field_value(expense, action_id) + + elif field_details['is_custom'] is True and len(expense['custom_fields']) > 0: + custom_field_value = get_custom_field_value(expense['custom_fields'], field_details['field_name'], field_details['type']) + + if field_details['type'] in ['NUMBER', 'TEXT']: + custom_field = { + 'type': 'input', + 'block_id': block_id, + 'optional': not field_details['is_mandatory'], + 'label': { + 'type': 'plain_text', + 'text': '{}'.format(field_details['field_name']), + }, + 'element': { + 'type': 'plain_text_input', + 'action_id': action_id, + 'placeholder': { + 'type': 'plain_text', + 'text': '{}'.format(field_details['placeholder']), + } + } + } + + if custom_field_value is not None: + custom_field['element']['initial_value'] = custom_field_value + + elif field_details['type'] in ['SELECT', 'MULTI_SELECT']: + + if field_details['type'] == 'SELECT': + field_type = 'static_select' + elif field_details['type'] == 'MULTI_SELECT': + field_type = 'multi_static_select' + + custom_field = { + 'type': 'input', + 'label': { + 'type': 'plain_text', + 'text': '{}'.format(field_details['field_name']), + }, + 'block_id': block_id, + 'optional': not field_details['is_mandatory'], + 'element': { + 'type': field_type, + 'placeholder': { + 'type': 'plain_text', + 'text': '{}'.format(field_details['placeholder']), + }, + 'action_id': action_id, + } + } + + custom_field['element']['options'] = [] + + for option in field_details['options']: + custom_field['element']['options'].append( + { + 'text': { + 'type': 'plain_text', + 'text': option, + }, + 'value': option, + } + ) + + if custom_field_value is not None: + if field_details['type'] == 'SELECT': + custom_field['element']['initial_option'] = { + 'text': { + 'type': 'plain_text', + 'text': custom_field_value, + }, + 'value': custom_field_value, + } + elif field_details['type'] == 'MULTI_SELECT': + initial_options = [] + for value in custom_field_value: + initial_options.append( + { + 'text': { + 'type': 'plain_text', + 'text': value, + }, + 'value': value, + } + ) + + if len(custom_field_value) > 0: + custom_field['element']['initial_options'] = initial_options + + elif field_details['type'] == 'BOOLEAN': + checkbox_option = { + 'text': { + 'type': 'plain_text', + 'text': '{}'.format(field_details['field_name']), + } + } + custom_field = { + 'type': 'input', + 'block_id': block_id, + 'optional': True, + 'element': { + 'type': 'checkboxes', + 'options': [checkbox_option], + 'action_id': action_id, + }, + 'label': { + 'type': 'plain_text', + 'text': '{}'.format(field_details['field_name']), + } + } + + if custom_field_value is not None: + checkbox_option['value'] = field_details['field_name'] + custom_field['element']['initial_options'] = [checkbox_option] + + elif field_details['type'] == 'DATE': + custom_field = { + 'type': 'input', + 'block_id': block_id, + 'optional': not field_details['is_mandatory'], + 'element': { + 'type': 'datepicker', + 'placeholder': { + 'type': 'plain_text', + 'text': '{}'.format(field_details['placeholder']), + }, + 'action_id': action_id, + }, + 'label': { + 'type': 'plain_text', + 'text': '{}'.format(field_details['field_name']), + } + } + + if custom_field_value is not None: + custom_field['element']['initial_date'] = utils.get_formatted_datetime(custom_field_value, '%Y-%m-%d') + + elif field_details['type'] == 'USER_LIST': + block_id = '{}__{}'.format(block_id, field_details['field_name']) + custom_field = { + 'type': 'input', + 'label': { + 'type': 'plain_text', + 'text': '{}'.format(field_details['field_name']), + }, + 'block_id': block_id, + 'optional': not field_details['is_mandatory'], + 'element': { + 'min_query_length': 1, + 'type': 'multi_external_select', + 'placeholder': { + 'type': 'plain_text', + 'text': '{}'.format(field_details['placeholder']), + }, + 'action_id': 'user_list', + } + } + + if custom_field_value is not None: + initial_options = [] + for value in custom_field_value: + initial_options.append( + { + 'text': { + 'type': 'plain_text', + 'text': value, + }, + 'value': value, + } + ) + + if len(custom_field_value) > 0: + custom_field['element']['initial_options'] = initial_options + + elif field_details['type'] == 'LOCATION': + block_id = '{}__{}'.format(block_id, field_details['field_name']) + custom_field = { + 'type': 'input', + 'label': { + 'type': 'plain_text', + 'text': '{}'.format(field_details['field_name']), + }, + 'block_id': block_id, + 'optional': not field_details['is_mandatory'], + 'element': { + 'min_query_length': 1, + 'type': 'external_select', + 'placeholder': { + 'type': 'plain_text', + 'text': '{}'.format(field_details['placeholder']), + }, + 'action_id': 'places_autocomplete', + } + } + + if custom_field_value is not None: + location_id = custom_field_value['id'] if 'id' in custom_field_value else 'None' + custom_field['element']['initial_option'] = { + 'text': { + 'type': 'plain_text', + 'text': custom_field_value['formatted_address'], + }, + 'value': location_id, + } + + return custom_field + + +# Amount and currency block as individual function since Fyle has foreign amount and currency business logic +def get_amount_and_currency_block(additional_currency_details: Dict = None, expense: Dict = None) -> List: + blocks = [] + + currency_block = { + 'type': 'input', + 'block_id': 'SELECT_default_field_currency_block', + 'dispatch_action': True, + 'element': { + 'type': 'external_select', + 'placeholder': { + 'type': 'plain_text', + 'text': 'Select Currency', + }, + 'min_query_length': 1, + 'initial_option': { + 'text': { + 'type': 'plain_text', + 'text': additional_currency_details['home_currency'], + }, + 'value': additional_currency_details['home_currency'], + }, + 'action_id': 'currency', + }, + 'label': {'type': 'plain_text', 'text': 'Currency'}, + } + + if expense is not None and expense['currency'] is not None: + currency_block['element']['initial_option']['text']['text'] = expense['currency'] + currency_block['element']['initial_option']['value'] = expense['currency'] + + blocks.append(currency_block) + + currency_context_block = None + total_amount_block = None + amount_block = { + 'type': 'input', + 'block_id': 'NUMBER_default_field_amount_block', + 'element': { + 'type': 'plain_text_input', + 'placeholder': { + 'type': 'plain_text', + 'text': 'Enter Amount', + }, + 'action_id': 'claim_amount', + }, + 'label': {'type': 'plain_text', 'text': 'Amount'}, + } + + if expense is not None and expense['claim_amount'] is not None: + amount_block['element']['initial_value'] = str(expense['claim_amount']) + + blocks.append(amount_block) + + if expense is not None and expense['foreign_currency'] is not None: + additional_currency_details = { + 'home_currency': expense['currency'], + 'foreign_currency': expense['foreign_currency'], + 'total_amount': str(expense['foreign_amount']) + } + + if 'foreign_currency' in additional_currency_details: + amount_block['dispatch_action'] = True + amount_block['element']['dispatch_action_config'] = { + 'trigger_actions_on': [ + 'on_character_entered' + ] + } + + amount_block['element']['placeholder']['text'] = 'Enter Amount {}'.format(additional_currency_details['foreign_currency']) + + currency_context_block = { + 'type': 'context', + 'block_id': 'TEXT_default_field_currency_context_block', + 'elements': [ + { + 'type': 'mrkdwn', + 'text': ':information_source: Amount ({}) x Exchange Rate = Total ({})'.format(additional_currency_details['foreign_currency'], additional_currency_details['home_currency']) + } + ] + } + + blocks.insert(1, currency_context_block) + + total_amount_block = { + 'type': 'input', + 'block_id': 'NUMBER_default_field_total_amount_block', + 'element': { + 'type': 'plain_text_input', + 'placeholder': { + 'type': 'plain_text', + 'text': 'Enter Total Amount {}'.format(additional_currency_details['home_currency']), + }, + 'action_id': 'foreign_amount', + }, + 'label': {'type': 'plain_text', 'text': 'Total Amount'}, + } + + if float(additional_currency_details['total_amount']) != 0: + total_amount_block['element']['initial_value'] = str(additional_currency_details['total_amount']) + + blocks.insert(3, total_amount_block) + + return blocks + + +def get_default_fields_blocks(additional_currency_details: Dict = None, expense: Dict = None) -> List: + + default_fields_blocks = get_amount_and_currency_block(additional_currency_details, expense) + + date_of_spend_block = { + 'type': 'input', + 'block_id': 'DATE_default_field_date_of_spend_block', + 'element': { + 'type': 'datepicker', + 'initial_date': datetime.datetime.today().strftime('%Y-%m-%d'), + 'placeholder': { + 'type': 'plain_text', + 'text': 'Select a date', + }, + 'action_id': 'spent_at', + }, + 'label': {'type': 'plain_text', 'text': 'Date of Spend'}, + } + + if expense is not None and expense['spent_at'] is not None: + date_of_spend_block['element']['initial_date'] = utils.get_formatted_datetime(expense['spent_at'], '%Y-%m-%d') + + default_fields_blocks.append(date_of_spend_block) + + purpose_block = { + 'type': 'input', + 'block_id': 'TEXT_default_field_purpose_block', + 'element': { + 'type': 'plain_text_input', + 'placeholder': { + 'type': 'plain_text', + 'text': 'Eg. Client Meeting', + }, + 'action_id': 'purpose', + }, + 'label': {'type': 'plain_text', 'text': 'Purpose'}, + } + + if expense is not None and expense['purpose'] is not None: + purpose_block['element']['initial_value'] = expense['purpose'] + + default_fields_blocks.append(purpose_block) + + merchant_block = { + 'type': 'input', + 'block_id': 'SELECT_default_field_merchant_block', + 'element': { + 'type': 'external_select', + 'min_query_length': 1, + 'placeholder': { + 'type': 'plain_text', + 'text': 'Eg. Uber', + }, + 'action_id': 'merchant', + }, + 'label': {'type': 'plain_text', 'text': 'Merchant'}, + } + + if expense is not None and expense['merchant'] is not None: + initial_option = { + 'text': { + 'type': 'plain_text', + 'text': expense['merchant'], + }, + 'value': expense['merchant'], + } + merchant_block['element']['initial_option'] = initial_option + + default_fields_blocks.append(merchant_block) + + return default_fields_blocks + + +def get_projects_and_billable_block(selected_project: Dict = None, expense: Dict = None) -> Dict: + billable_block = None + + project_block = { + 'type': 'input', + 'block_id': 'project_block', + 'dispatch_action': True, + 'element': { + 'min_query_length': 0, + 'type': 'external_select', + 'placeholder': { + 'type': 'plain_text', + 'text': 'Eg. Travel', + }, + + 'action_id': 'project_id', + }, + 'label': {'type': 'plain_text', 'text': 'Project'}, + } + + if expense is not None and expense['project'] is not None: + project_block['element']['initial_option'] = { + 'text': { + 'type': 'plain_text', + 'text': expense['project']['name'], + }, + 'value': str(expense['project']['id']), + } + elif selected_project is not None: + + project_display_name = selected_project['display_name'] + if selected_project['name'] == selected_project['sub_project']: + project_display_name = selected_project['name'] + project_block['element']['initial_option'] = { + 'text': { + 'type': 'plain_text', + 'text': project_display_name, + }, + 'value': str(selected_project['id']), + } + + # Render billable block only when project is selected + billable_block = { + 'type': 'input', + 'block_id': 'billable_block', + 'optional': True, + 'element': { + 'type': 'checkboxes', + 'options': [ + { + 'text': { + 'type': 'plain_text', + 'text': 'Billable', + } + } + ], + 'action_id': 'is_billable' + }, + 'label': {'type': 'plain_text', 'text': 'Billable'}, + } + + return project_block, billable_block + + +def get_categories_block(expense: Dict = None) -> Dict: + category_block = { + 'type': 'input', + 'block_id': 'category_block', + 'dispatch_action': True, + 'element': { + 'type': 'external_select', + 'min_query_length': 0, + 'placeholder': { + 'type': 'plain_text', + 'text': 'Eg. Food', + }, + 'action_id': 'category_id', + }, + 'label': {'type': 'plain_text', 'text': 'Category'}, + } + + if expense is not None and expense['category'] is not None: + category_block['element']['initial_option'] = { + 'text': { + 'type': 'plain_text', + 'text': expense['category']['name'], + }, + 'value': str(expense['category']['id']), + } + + return category_block + + +def get_cost_centers_block(expense: Dict = None) -> Dict: + cost_centers_block = { + 'type': 'input', + 'block_id': 'cost_center_block', + 'element': { + 'type': 'external_select', + 'min_query_length': 0, + 'placeholder': { + 'type': 'plain_text', + 'text': 'Eg. Accounting', + }, + 'action_id': 'cost_center_id', + }, + 'label': {'type': 'plain_text', 'text': 'Cost Center'}, + } + + if expense is not None and expense['cost_center'] is not None: + cost_centers_block['element']['initial_option'] = { + 'text': { + 'type': 'plain_text', + 'text': expense['cost_center']['name'], + }, + 'value': str(expense['cost_center']['id']), + } + return cost_centers_block + + +def expense_form_loading_modal(title: str, loading_message: str) -> Dict: + loading_modal = { + 'type': 'modal', + 'callback_id': 'upsert_expense', + 'title': {'type': 'plain_text', 'text': '{}'.format(title)}, + 'close': {'type': 'plain_text', 'text': 'Cancel'}, + 'blocks': [ + { + 'type': 'section', + 'text': { + 'type': 'mrkdwn', + 'text': '{}'.format(loading_message) + } + } + ] + } + + return loading_modal + + +def get_add_to_report_blocks(add_to_report: str, action_id: str) -> Dict: + + is_report_block_optional = False + if action_id == 'add_to_report': + is_report_block_optional = True + + blocks = [] + add_to_existing_report_option = { + 'text': { + 'type': 'plain_text', + 'text': 'Add to Existing Report', + }, + 'value': 'existing_report' + } + + add_to_new_report_option = { + 'text': { + 'type': 'plain_text', + 'text': 'Add to New Report', + }, + 'value': 'new_report' + } + add_to_report_block = { + 'type': 'input', + 'block_id': 'add_to_report_block', + 'dispatch_action': True, + 'optional': is_report_block_optional, + 'element': { + 'type': 'radio_buttons', + 'options': [add_to_existing_report_option, add_to_new_report_option], + 'action_id': action_id + }, + 'label': { + 'type': 'plain_text', + 'text': 'Add to Report', + } + } + blocks.append(add_to_report_block) + + # Mapping of input UI to generate based on `Add to Exisiting Report` or `Add to New Report` selection + add_to_report_mapping = { + 'new_report': { + 'ui': { + 'type': 'input', + 'block_id': 'TEXT_add_to_new_report_block', + 'optional': is_report_block_optional, + 'element': { + 'type': 'plain_text_input', + 'placeholder': { + 'type': 'plain_text', + 'text': 'Enter Report Name', + }, + 'action_id': 'report_name' + }, + 'label': { + 'type': 'plain_text', + 'text': 'Report Name', + } + }, + 'option': add_to_new_report_option + }, + 'existing_report': { + 'ui': { + 'type': 'input', + 'optional': is_report_block_optional, + 'block_id': 'SELECT_add_to_existing_report_block', + 'element': { + 'type': 'external_select', + 'min_query_length': 0, + 'placeholder': { + 'type': 'plain_text', + 'text': 'Select a Report', + }, + 'action_id': 'existing_report' + }, + 'label': { + 'type': 'plain_text', + 'text': 'Select Report', + } + }, + 'option': add_to_existing_report_option + } + } + + if add_to_report is not None: + add_to_report_details = add_to_report_mapping[add_to_report] + add_to_report_additional_block = add_to_report_details['ui'] + selected_report_option = add_to_report_details['option'] + add_to_report_block['element']['initial_option'] = selected_report_option + blocks.append(add_to_report_additional_block) + + return blocks + + +def expense_dialog_form( + fields_render_property: Dict, + selected_project: Dict = None, + custom_fields: Dict = None, + additional_currency_details: Dict = None, + add_to_report: str = None, + expense : Dict = None + ) -> Dict: + + view = { + 'type': 'modal', + 'callback_id': 'upsert_expense', + 'title': {'type': 'plain_text', 'text': 'Create Expense', 'emoji': True}, + 'submit': {'type': 'plain_text', 'text': 'Add Expense', 'emoji': True}, + 'close': {'type': 'plain_text', 'text': 'Cancel', 'emoji': True} + } + + view['blocks'] = [] + + view['blocks'] = get_default_fields_blocks(additional_currency_details, expense) + + if fields_render_property['project']['is_project_available'] is True: + + project_block, billable_block = get_projects_and_billable_block(selected_project, expense) + + project_block['optional'] = not fields_render_property['project']['is_mandatory'] + + view['blocks'].append(project_block) + + if billable_block is not None: + view['blocks'].append(billable_block) + + category_block = get_categories_block(expense) + + view['blocks'].append(category_block) + + + # If custom fields are present, render them in the form + if custom_fields is not None: + + # If cached custom fields are present, render/ add them to UI directly + # Cached custom fields come from slack request payload which sends UI blocks on interaction + if isinstance(custom_fields, list): + view['blocks'].extend(custom_fields) + + # Generated custom fields UI and then add them to UI + elif 'count' in custom_fields and custom_fields['count'] > 0: + for field in custom_fields['data']: + + # Additional fields are field which are not custom fields but are dependent on categories + is_additional_field = False + if field['is_custom'] is False: + is_additional_field = True + + custom_field = generate_custom_fields_ui(field, is_additional_field=is_additional_field, expense=expense) + if custom_field is not None: + view['blocks'].append(custom_field) + + # Putting cost center block at end to maintain Fyle expense form order + if fields_render_property['cost_center']['is_cost_center_available'] is True: + + cost_center_block = get_cost_centers_block(expense) + + cost_center_block['optional'] = not fields_render_property['cost_center']['is_mandatory'] + + view['blocks'].append(cost_center_block) + + + # Divider for add to report section + view['blocks'].append({ + 'type': 'divider' + }) + + # Add to report section + # if add_to_report is not None: + # add_to_report_blocks = get_add_to_report_blocks(add_to_report, action_id='add_to_report') + + # view['blocks'].extend(add_to_report_blocks) + + return view + + +def get_expense_message_details_section(expense: Dict, expense_url: str, actions: List[Dict], receipt_message: str, report_message: str) -> List[Dict]: + + spent_at = utils.get_formatted_datetime(expense['spent_at'], '%B %d, %Y') if expense['spent_at'] is not None else 'Not Specified' + amount = expense['amount'] if expense['amount'] is not None else 0.00 + expense_details = expense['purpose'] if expense['purpose'] is not None else 'Not Specified' + if expense['category']['name'] is not None: + expense_details = '{} ({})'.format(expense_details, expense['category']['name']) + + expense_message_details_section = [ + { + 'type': 'section', + 'block_id': 'expense_id.{}'.format(expense['id']), + 'text': { + 'type': 'mrkdwn', + 'text': ':money_with_wings: An expense of *{} {}* has been created!'.format(expense['currency'], amount) + }, + 'accessory': { + 'type': 'overflow', + 'options': [ + { + 'text': { + 'type': 'plain_text', + 'text': ':pencil: Edit', + }, + 'value': 'edit_expense_accessory.{}'.format(expense['id']) + }, + { + 'text': { + 'type': 'plain_text', + 'text': ':arrow_upper_right: Open in Fyle', + }, + 'url': expense_url, + 'value': 'open_in_fyle_accessory.{}'.format(expense['id']) + } + ], + 'action_id': 'expense_accessory' + } + }, + { + 'type': 'section', + 'fields': [ + { + 'type': 'mrkdwn', + 'text': '*Date of spend: * \n {}'.format(spent_at) + }, + { + 'type': 'mrkdwn', + 'text': '*Receipt: * \n {}'.format(receipt_message) + } + ] + }, + { + 'type': 'section', + 'fields': [ + { + 'type': 'mrkdwn', + 'text': '*Report: * \n {}'.format(report_message) + }, + { + 'type': 'mrkdwn', + 'text': '*Expense Details: * \n {}'.format(expense_details) + } + ] + }, + { + 'type': 'actions', + 'elements': actions + } + ] + + return expense_message_details_section + + +def view_expense_message(expense: Dict, user: User) -> Dict: + + actions = [] + + receipt_message = ':x: Not Attached' + if len(expense['file_ids']) > 0: + receipt_message = ':white_check_mark: Attached' + else: + + attach_receipt_cta = { + 'type': 'button', + 'style': 'primary', + 'text': { + 'type': 'plain_text', + 'text': 'Attach Receipt', + }, + 'value': expense['id'], + 'action_id': 'attach_receipt' + } + + actions.append(attach_receipt_cta) + + report_message = ':x: Not Added' + if expense['report_id'] is not None: + report_message = ':white_check_mark: Added' + + # if expense['report']['state'] in ['DRAFT', 'APPROVER_INQUIRY']: + + # submit_report_cta = { + # 'type': 'button', + # 'style': 'primary', + # 'text': { + # 'type': 'plain_text', + # 'text': 'Submit Report', + # }, + # 'value': expense['report_id'], + # 'action_id': 'open_submit_report_dialog' + # } + + # actions.append(submit_report_cta) + + # else: + + # add_to_report_cta = { + # 'type': 'button', + # 'style': 'primary', + # 'text': { + # 'type': 'plain_text', + # 'text': 'Add to Report', + # }, + # 'value': expense['id'], + # 'action_id': 'add_expense_to_report' + # } + + # actions.append(add_to_report_cta) + + complete_expense_cta = { + 'type': 'button', + 'text': { + 'type': 'plain_text', + 'text': 'Complete Expense', + }, + 'value': expense['id'], + 'action_id': 'edit_expense', + } + + if expense['state'] == 'DRAFT': + actions.insert(0, complete_expense_cta) + + view_in_fyle_cta = { + 'type': 'button', + 'text': { + 'type': 'plain_text', + 'text': 'View in Fyle', + }, + 'url': fyle_utils.get_fyle_resource_url(user.fyle_refresh_token, expense, 'EXPENSE'), + 'value': expense['id'], + 'action_id': 'expense_view_in_fyle', + } + + if len(actions) == 0: + actions.append(view_in_fyle_cta) + + expense_url = fyle_utils.get_fyle_resource_url(user.fyle_refresh_token, expense, 'EXPENSE') + + view_expense_blocks = get_expense_message_details_section(expense, expense_url, actions, receipt_message, report_message) + + return view_expense_blocks + + +def get_expense_details_block(expense: Dict, receipt_message: str, report_message: str) -> List[Dict]: + expense_details = [ + { + 'type': 'section', + 'text': { + 'type': 'mrkdwn', + 'text': '\n' + }, + }, + { + 'type': 'section', + 'text': { + 'type': 'mrkdwn', + 'text': ':page_facing_up: *Expense Details*' + }, + }, + { + 'type': 'divider' + }, + { + 'type': 'section', + 'fields': [ + { + 'type': 'mrkdwn', + 'text': '*Amount* \n {} {}'.format(expense['currency'], expense['amount']) + }, + { + 'type': 'mrkdwn', + 'text': '*Date of Spend* \n {}'.format(utils.get_formatted_datetime(expense['spent_at'], '%B %d, %Y')) + } + ] + }, + { + 'type': 'section', + 'fields': [ + { + 'type': 'mrkdwn', + 'text': '*Report* \n {}'.format(report_message) + }, + { + 'type': 'mrkdwn', + 'text': '*Receipt* \n {}'.format(receipt_message) + } + ] + }, + { + 'type': 'section', + 'fields': [ + { + 'type': 'mrkdwn', + 'text': '*Category* \n {}'.format(expense['category']['name']) + }, + ] + }, + { + 'type': 'divider' + } + ] + + if expense['project'] is not None: + expense_details[5]['fields'].append({ + 'type': 'mrkdwn', + 'text': '*Project* \n {}'.format(expense['project']['name']) + }) + + return expense_details + + +def get_add_expense_to_report_dialog(expense: Dict, add_to_report: str = None) -> Dict: + + report_message = ':x: Not Added' + if expense['report_id'] is not None: + report_message = ':white_check_mark: Added' + + + receipt_message = ':x: Not Attached' + if len(expense['file_ids']) > 0: + receipt_message = ':white_check_mark: Attached' + + add_to_report_blocks = get_add_to_report_blocks(add_to_report=add_to_report, action_id='add_expense_to_report_selection') + + add_to_report_dialog = { + 'title': { + 'type': 'plain_text', + 'text': ':mailbox: Add to Report', + }, + 'submit': { + 'type': 'plain_text', + 'text': 'Add', + }, + 'type': 'modal', + 'callback_id': 'add_expense_to_report', + 'private_metadata': expense['id'], + 'close': { + 'type': 'plain_text', + 'text': 'Cancel', + }, + } + + add_to_report_dialog['blocks'] = [] + + add_to_report_dialog['blocks'] = add_to_report_blocks + + expense_details_block = get_expense_details_block(expense, receipt_message, report_message) + + add_to_report_dialog['blocks'].extend(expense_details_block) + + return add_to_report_dialog + + + +def get_minimal_expense_details(expense: Dict, expense_url: str, receipt_message: str) -> List[Dict]: + minimal_expense_details = [ + { + 'type': 'section', + 'text': { + 'type': 'mrkdwn', + 'text': '*{} ({})* expense of :money_with_wings: *{} {}*'.format(expense['purpose'], expense['merchant'], expense['currency'], str(expense['amount'])) + }, + 'accessory': { + 'type': 'overflow', + 'options': [ + { + 'text': { + 'type': 'plain_text', + 'text': ':arrow_upper_right: Open in Fyle', + }, + 'url': expense_url, + 'value': 'open_in_fyle_accessory.{}'.format(expense['id']) + } + ], + 'action_id': 'expense_accessory' + } + }, + { + 'type': 'section', + 'fields': [ + { + 'type': 'mrkdwn', + 'text': '*Date of Spend* \n {}'.format(utils.get_formatted_datetime(expense['spent_at'], '%B %d, %Y')) + }, + { + 'type': 'mrkdwn', + 'text': '*Receipt* \n {}'.format(receipt_message) + } + ] + }, + { + 'type': 'divider' + } + ] + + return minimal_expense_details + + +def get_report_details_section(report: Dict) -> List[Dict]: + report_details_section = [ + { + 'type': 'divider' + }, + { + 'type': 'section', + 'fields': [ + { + 'type': 'mrkdwn', + 'text': '*Report Name* \n {}'.format(report['purpose']) + }, + { + 'type': 'mrkdwn', + 'text': '*Amount* \n {} {}'.format(report['currency'], str(report['amount'])) + } + ] + }, + { + 'type': 'section', + 'fields': [ + { + 'type': 'mrkdwn', + 'text': '*Expenses* \n {}'.format(str(report['num_expenses'])) + }, + { + 'type': 'mrkdwn', + 'text': '*Created On* \n {}'.format(utils.get_formatted_datetime(report['created_at'], '%B %d, %Y')) + } + ] + }, + { + 'type': 'section', + 'text': { + 'type': 'mrkdwn', + 'text': ':page_facing_up: *Expenses*' + } + }, + { + 'type': 'divider' + } + ] + return report_details_section + + +def get_view_report_details_dialog(user: User, report: Dict, expenses: List[Dict]) -> Dict: + view_report_dialog = { + 'type': 'modal', + 'callback_id': 'submit_report', + 'private_metadata': report['id'], + 'title': { + 'type': 'plain_text', + 'text': 'Report Details', + }, + 'submit': { + 'type': 'plain_text', + 'text': 'Submit Report', + }, + 'close': { + 'type': 'plain_text', + 'text': 'Cancel', + } + } + + view_report_dialog['blocks'] = [] + view_report_dialog['blocks'] = get_report_details_section(report) + + expenses_list = [] + for expense in expenses: + + expense_url = fyle_utils.get_fyle_resource_url(user.fyle_refresh_token, expense, 'EXPENSE') + + receipt_message = ':x: Not Attached' + if len(expense['file_ids']) > 0: + receipt_message = ':white_check_mark: Attached' + + minimal_expense_detail = get_minimal_expense_details(expense, expense_url, receipt_message) + expenses_list.extend(minimal_expense_detail) + + view_report_dialog['blocks'].extend(expenses_list) + + return view_report_dialog + + +def report_submitted_message(user: User, report: Dict) -> List[Dict]: + report_url = fyle_utils.get_fyle_resource_url(user.fyle_refresh_token, report, 'REPORT') + report_message_blocks = [ + { + 'type': 'section', + 'text': { + 'type': 'mrkdwn', + 'text': ':open_file_folder: An expense report *{}* has been submitted'.format(report['purpose']) + } + }, + { + 'type': 'section', + 'fields': [ + { + 'type': 'mrkdwn', + 'text': '*Amount* \n {} {}'.format(report['currency'], report['amount']) + }, + { + 'type': 'mrkdwn', + 'text': '*Expenses* \n {}'.format(report['num_expenses']) + } + ] + }, + { + 'type': 'context', + 'elements': [ + { + 'type': 'mrkdwn', + 'text': ':bell: You will be notified when any action is taken by your approver' + } + ] + }, + { + 'type': 'actions', + 'elements': [ + { + 'type': 'button', + 'text': { + 'type': 'plain_text', + 'text': 'View in Fyle', + }, + 'url': report_url, + 'value': report['id'], + 'action_id': 'review_report_in_fyle' + } + ] + } + ] + + return report_message_blocks diff --git a/fyle_slack_app/slack/utils.py b/fyle_slack_app/slack/utils.py index d2ce884e..eea40eb8 100644 --- a/fyle_slack_app/slack/utils.py +++ b/fyle_slack_app/slack/utils.py @@ -38,6 +38,13 @@ def get_user_display_name(slack_client: WebClient, user_details: Dict) -> str: return user_display_name +def get_file_content_from_slack(url: str, bot_access_token: str) -> str: + headers = { + 'Authorization': 'Bearer {}'.format(bot_access_token) + } + file = http.get(url, headers=headers) + return file.content + def get_currency_symbol(currency: str) -> str: c = CurrencyCodes() @@ -52,15 +59,6 @@ def get_currency_symbol(currency: str) -> str: return symbol - -def get_file_content_from_slack(url: str, bot_access_token: str) -> str: - headers = { - 'Authorization': 'Bearer {}'.format(bot_access_token) - } - file = http.get(url, headers=headers) - return file.content - - def get_slack_latest_parent_message(user: User, slack_client: WebClient, thread_ts: str) -> Dict: message_history = slack_client.conversations_history(channel=user.slack_dm_channel_id, latest=thread_ts, inclusive=True, limit=1) parent_message = message_history['messages'][0] if message_history['messages'] and message_history['messages'][0] else {} diff --git a/fyle_slack_service/sentry.py b/fyle_slack_service/sentry.py index 2f73bd6f..d8e7f58c 100644 --- a/fyle_slack_service/sentry.py +++ b/fyle_slack_service/sentry.py @@ -1,6 +1,7 @@ import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk import capture_exception, capture_message from django.conf import settings @@ -26,3 +27,9 @@ def traces_sampler(sampling_context): return 0 return 0.2 + + + @staticmethod + def capture_exception(message=None): + error = Exception(message) + capture_exception(error) diff --git a/fyle_slack_service/settings.py b/fyle_slack_service/settings.py index be0d9f9e..993abc95 100644 --- a/fyle_slack_service/settings.py +++ b/fyle_slack_service/settings.py @@ -203,7 +203,7 @@ 'level': 'ERROR', 'propagate': False }, - 'fyle_slack_app ': { + 'fyle_slack_app': { 'handlers': ['debug_logs'], 'level': 'ERROR', 'propagate': False