Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add support for skipped expenses api #53

Merged
merged 2 commits into from
Dec 18, 2023

Conversation

NileshPant1999
Copy link
Contributor

@NileshPant1999 NileshPant1999 commented Dec 18, 2023

Summary by CodeRabbit

  • New Features

    • Introduced a new 'Skipped Expenses' feature to track and manage expenses that are skipped.
    • Added a new view for users to access skipped expenses via the /skipped_expenses/ URL.
  • Enhancements

    • Implemented a new ExpenseSerializer to facilitate the representation of expense data.
  • Bug Fixes

    • Adjusted the expense model to include a new field that identifies whether an expense is skipped.
  • Documentation

    • Updated help text for new and existing fields in the Expense model to improve clarity.

Copy link

coderabbitai bot commented Dec 18, 2023

Walkthrough

The latest changes introduce a new feature allowing the application to track skipped expenses. This enhancement spans multiple areas of the codebase, including the model, serializer, URL configuration, and views. The Expense model now incorporates an is_skipped field, enabling the identification of expenses bypassed during processing. Additionally, a corresponding serializer has been implemented, and the views have been updated to facilitate the retrieval of skipped expenses through a dedicated endpoint.

Changes

File Path Change Summary
.../fyle/models.py Added is_skipped field to Expense class.
.../fyle/serializers.py Added ExpenseSerializer class and import statement.
.../fyle/urls.py Added URL path for SkippedExpenseView.
.../fyle/views.py Updated imports, added SkippedExpenseView class.
.../fyle/helpers.py Added imports and functions related to expense filtering.
.../fyle/tasks.py Added functions for filtered expense retrieval and modified imports.

Poem

🐇✨📜
In the code, a leap, a bound,
Skipped expenses now are found.
With each commit, we refine,
Crafted by paws, code so fine.
🎉🌟🐾

Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on X ?


Tips

Chat with CodeRabbit Bot (@coderabbitai)

  • You can reply to a review comment made by CodeRabbit.
  • You can tag CodeRabbit on specific lines of code or files in the PR by tagging @coderabbitai in a comment.
  • You can tag @coderabbitai in a PR comment and ask one-off questions about the PR and the codebase. Use quoted replies to pass the context for follow-up questions.

CodeRabbit Commands (invoked as PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger a review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai help to get help.

Additionally, you can add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.

CodeRabbit Configration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • The JSON schema for the configuration file is available here.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/coderabbit-overrides.v2.json

Copy link

Tests Skipped Failures Errors Time
47 0 💤 0 ❌ 0 🔥 4.534s ⏱️

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Status

Actionable comments generated: 7

Configuration used: CodeRabbit UI

Commits Files that changed from the base of the PR and between 14b6729 and 55cfe3a.
Files selected for processing (4)
  • apps/fyle/models.py (1 hunks)
  • apps/fyle/serializers.py (2 hunks)
  • apps/fyle/urls.py (2 hunks)
  • apps/fyle/views.py (2 hunks)
Additional comments: 2
apps/fyle/urls.py (2)
  • 29-29: The addition of SkippedExpenseView to the views import list is consistent with the PR's objective to support skipped expenses.

  • 42-42: The URL pattern for SkippedExpenseView is correctly defined and follows Django's convention for class-based views.

Comment on lines +135 to +136
model = Expense
fields = ['updated_at', 'claim_number', 'employee_email', 'employee_name', 'fund_source', 'expense_number', 'payment_number', 'vendor', 'category', 'amount', 'report_id', 'settlement_id', 'expense_id']
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ExpenseSerializer fields list is missing the is_skipped field, which is crucial for the skipped expenses feature. It should be added to ensure the new functionality is properly serialized.

class Meta:
    model = Expense
    fields = [
        'updated_at', 'claim_number', 'employee_email', 'employee_name', 
        'fund_source', 'expense_number', 'payment_number', 'vendor', 
        'category', 'amount', 'report_id', 'settlement_id', 'expense_id',
+       'is_skipped'
    ]

Committable suggestion

IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
model = Expense
fields = ['updated_at', 'claim_number', 'employee_email', 'employee_name', 'fund_source', 'expense_number', 'payment_number', 'vendor', 'category', 'amount', 'report_id', 'settlement_id', 'expense_id']
model = Expense
fields = ['updated_at', 'claim_number', 'employee_email', 'employee_name', 'fund_source', 'expense_number', 'payment_number', 'vendor', 'category', 'amount', 'report_id', 'settlement_id', 'expense_id', 'is_skipped']

@@ -79,6 +79,7 @@ class Expense(BaseForeignWorkspaceModel):
spent_at = CustomDateTimeField(help_text='Expense spent at')
approved_at = CustomDateTimeField(help_text='Expense approved at')
posted_at = CustomDateTimeField(help_text='Date when the money is taken from the bank')
is_skipped = models.BooleanField(null=True, default=False, help_text='Expense is skipped or not')
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The addition of the is_skipped field to the Expense model is consistent with the PR's objective to track skipped expenses. However, consider whether the field should allow null values. If an expense can only be either skipped or not, it might be more appropriate to use a non-nullable BooleanField with default=False.

Comment on lines 79 to 85
spent_at = CustomDateTimeField(help_text='Expense spent at')
approved_at = CustomDateTimeField(help_text='Expense approved at')
posted_at = CustomDateTimeField(help_text='Date when the money is taken from the bank')
is_skipped = models.BooleanField(null=True, default=False, help_text='Expense is skipped or not')
expense_created_at = CustomDateTimeField(help_text='Expense created at')
expense_updated_at = CustomDateTimeField(help_text='Expense created at')
fund_source = StringNotNullField(help_text='Expense fund source')
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The create_expense_objects method needs to be updated to handle the is_skipped field when creating or updating Expense objects. Currently, the is_skipped field is not included in the defaults dictionary passed to update_or_create, which means it will not be set when expenses are created or updated.

# Inside the defaults dictionary of update_or_create method, add:
'is_skipped': expense.get('is_skipped', False),

@@ -38,6 +39,7 @@
path('expense_filters/', ExpenseFilterView.as_view(), name='expense-filters'),
path('fields/', FyleFieldsView.as_view(), name='fyle-fields'),
path('expense_fields/', CustomFieldView.as_view(), name='fyle-expense-fields'),
path('skipped_expenses/', SkippedExpenseView.as_view(), name='expenses'),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The URL name 'expenses' for SkippedExpenseView is generic and could lead to confusion or conflicts with other expense-related views. Consider renaming it to something more specific, like 'skipped-expenses', to clearly indicate its purpose.

def get_queryset(self):
start_date = self.request.query_params.get('start_date', None)
end_date = self.request.query_params.get('end_date', None)
org_id = Workspace.objects.get(id=self.kwargs['workspace_id']).org_id
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding error handling for the case where Workspace.objects.get(id=self.kwargs['workspace_id']) does not find a matching workspace, which would raise a Workspace.DoesNotExist exception.

+        try:
+            org_id = Workspace.objects.get(id=self.kwargs['workspace_id']).org_id
+        except Workspace.DoesNotExist:
+            return Response(
+                data={'error': 'Workspace not found.'},
+                status=status.HTTP_404_NOT_FOUND
+            )

Committable suggestion

IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
org_id = Workspace.objects.get(id=self.kwargs['workspace_id']).org_id
try:
org_id = Workspace.objects.get(id=self.kwargs['workspace_id']).org_id
except Workspace.DoesNotExist:
return Response(
data={'error': 'Workspace not found.'},
status=status.HTTP_404_NOT_FOUND
)

Comment on lines +119 to +120
if start_date and end_date:
filters['updated_at__range'] = [start_date, end_date]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensure that the start_date and end_date query parameters are validated and converted into the correct date format before using them in the updated_at__range filter.

+        from django.core.exceptions import ValidationError
+        from django.utils.dateparse import parse_date
+        if start_date and end_date:
+            try:
+                start_date = parse_date(start_date)
+                end_date = parse_date(end_date)
+                if not start_date or not end_date:
+                    raise ValidationError
+            except ValidationError:
+                return Response(
+                    data={'error': 'Invalid date format for start_date or end_date.'},
+                    status=status.HTTP_400_BAD_REQUEST
+                )
+            filters['updated_at__range'] = [start_date, end_date]

Committable suggestion

IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
if start_date and end_date:
filters['updated_at__range'] = [start_date, end_date]
from django.core.exceptions import ValidationError
from django.utils.dateparse import parse_date
if start_date and end_date:
try:
start_date = parse_date(start_date)
end_date = parse_date(end_date)
if not start_date or not end_date:
raise ValidationError
except ValidationError:
return Response(
data={'error': 'Invalid date format for start_date or end_date.'},
status=status.HTTP_400_BAD_REQUEST
)
filters['updated_at__range'] = [start_date, end_date]

@codecov-commenter
Copy link

Codecov Report

Attention: 8 lines in your changes are missing coverage. Please review.

Comparison is base (14b6729) 91.98% compared to head (55cfe3a) 91.71%.

Files Patch % Lines
apps/fyle/views.py 38.46% 8 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master      #53      +/-   ##
==========================================
- Coverage   91.98%   91.71%   -0.28%     
==========================================
  Files          51       51              
  Lines        2434     2451      +17     
==========================================
+ Hits         2239     2248       +9     
- Misses        195      203       +8     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@NileshPant1999 NileshPant1999 merged commit 553495e into master Dec 18, 2023
1 check passed
Copy link

Tests Skipped Failures Errors Time
47 0 💤 0 ❌ 0 🔥 4.024s ⏱️

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Status

Actionable comments generated: 5

Configuration used: CodeRabbit UI

Commits Files that changed from the base of the PR and between 55cfe3a and db238b0.
Files selected for processing (3)
  • apps/fyle/helpers.py (1 hunks)
  • apps/fyle/tasks.py (3 hunks)
  • apps/fyle/urls.py (2 hunks)
Additional comments: 3
apps/fyle/helpers.py (1)
  • 3-5: The addition of imports for List and Q is appropriate as they are used in the newly added functions.
apps/fyle/tasks.py (2)
  • 9-10: The imports of Expense, ExpenseFilter, and construct_expense_filter_query align with the new functionality for handling skipped expenses.

  • 79-90: The integration of the new get_filtered_expenses function within import_expenses is consistent with the PR objectives and appears to be correctly implemented, assuming that the potential issue with an empty expense_objects list is handled.

Comment on lines +15 to +76
def construct_expense_filter(expense_filter):
constructed_expense_filter = {}
# If the expense filter is a custom field
if expense_filter.is_custom:
# If the operator is not isnull
if expense_filter.operator != 'isnull':
# If the custom field is of type SELECT and the operator is not_in
if expense_filter.custom_field_type == 'SELECT' and expense_filter.operator == 'not_in':
# Construct the filter for the custom property
filter1 = {
f'custom_properties__{expense_filter.condition}__in': expense_filter.values
}
# Invert the filter using the ~Q operator and assign it to the constructed expense filter
constructed_expense_filter = ~Q(**filter1)
else:
# If the custom field is of type NUMBER, convert the values to integers
if expense_filter.custom_field_type == 'NUMBER':
expense_filter.values = [int(value) for value in expense_filter.values]
# If the expense filter is a custom field and the operator is yes or no(checkbox)
if expense_filter.custom_field_type == 'BOOLEAN':
expense_filter.values[0] = True if expense_filter.values[0] == 'true' else False
# Construct the filter for the custom property
filter1 = {
f'custom_properties__{expense_filter.condition}__{expense_filter.operator}':
expense_filter.values[0] if len(expense_filter.values) == 1 and expense_filter.operator != 'in'
else expense_filter.values
}
# Assign the constructed filter to the constructed expense filter
constructed_expense_filter = Q(**filter1)

# If the expense filter is a custom field and the operator is isnull
elif expense_filter.operator == 'isnull':
# Determine the value for the isnull filter based on the first value in the values list
expense_filter_value: bool = True if expense_filter.values[0].lower() == 'true' else False
# Construct the isnull filter for the custom property
filter1 = {
f'custom_properties__{expense_filter.condition}__isnull': expense_filter_value
}
# Construct the exact filter for the custom property
filter2 = {
f'custom_properties__{expense_filter.condition}__exact': None
}
if expense_filter_value:
# If the isnull filter value is True, combine the two filters using the | operator and assign it to the constructed expense filter
constructed_expense_filter = Q(**filter1) | Q(**filter2)
else:
# If the isnull filter value is False, invert the exact filter using the ~Q operator and assign it to the constructed expense filter
constructed_expense_filter = ~Q(**filter2)

# For all non-custom fields
else:
# Construct the filter for the non-custom field
filter1 = {
f'{expense_filter.condition}__{expense_filter.operator}':
expense_filter.values[0] if len(expense_filter.values) == 1 and expense_filter.operator != 'in'
else expense_filter.values
}
# Assign the constructed filter to the constructed expense filter
constructed_expense_filter = Q(**filter1)

# Return the constructed expense filter
return constructed_expense_filter
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The construct_expense_filter function correctly handles the construction of filters for different custom field types and operators. However, consider the following points:

  1. For the BOOLEAN type, the conversion from string to boolean should be case-insensitive.
  2. Ensure that the expense_filter.values list always contains the expected data types before performing operations like integer conversion or boolean checks.
  3. Validate that the expense_filter.condition and expense_filter.operator values are sanitized and do not introduce any security vulnerabilities such as SQL injection.

Comment on lines +79 to +99
def construct_expense_filter_query(expense_filters: List[ExpenseFilter]):
final_filter = None
join_by = None
for expense_filter in expense_filters:
constructed_expense_filter = construct_expense_filter(expense_filter)

# If this is the first filter, set it as the final filter
if expense_filter.rank == 1:
final_filter = (constructed_expense_filter)

# If join by is AND, OR
elif expense_filter.rank != 1:
if join_by == 'AND':
final_filter = final_filter & (constructed_expense_filter)
else:
final_filter = final_filter | (constructed_expense_filter)

# Set the join type for the additonal filter
join_by = expense_filter.join_by

return final_filter
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The construct_expense_filter_query function aggregates filters correctly. However, ensure that:

  1. The final_filter is initialized properly before being used. The current logic assumes that expense_filter.rank == 1 will always be present, which might not be the case.
  2. The join_by variable is set correctly before it's used in the loop. Consider initializing it outside of the loop to a default value if necessary.
  3. Error handling is in place in case the expense_filters list is empty or if any of the filters are malformed.

Comment on lines +22 to +44
def get_filtered_expenses(workspace: int, expense_objects: list, expense_filters: list):
"""
function to get filtered expense objects
"""

expenses_object_ids = [expense_object.id for expense_object in expense_objects]
final_query = construct_expense_filter_query(expense_filters)

Expense.objects.filter(
final_query,
id__in=expenses_object_ids,
accountingexport__isnull=True,
org_id=workspace.org_id
).update(is_skipped=True)

filtered_expenses = Expense.objects.filter(
is_skipped=False,
id__in=expenses_object_ids,
accountingexport__isnull=True,
org_id=workspace.org_id
)

return filtered_expenses
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider combining the update and retrieval of Expense objects into a single query to improve performance and reduce database round trips. The current implementation updates the is_skipped field and then retrieves the expenses in two separate queries, which could be optimized.

Comment on lines +82 to +85
expense_objects = Expense.create_expense_objects(expenses, workspace_id)
expense_filters = ExpenseFilter.objects.filter(workspace_id=workspace_id).order_by('rank')
if expense_filters:
expense_objects = get_filtered_expenses(workspace, expense_objects, expense_filters)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verify that overwriting expense_objects with the result from get_filtered_expenses does not lead to issues when expense_objects is empty after filtering. If there are no expenses after filtering, the subsequent call to AccountingExport.create_accounting_export might fail or behave unexpectedly.

@@ -38,6 +39,7 @@
path('expense_filters/', ExpenseFilterView.as_view(), name='expense-filters'),
path('fields/', FyleFieldsView.as_view(), name='fyle-fields'),
path('expense_fields/', CustomFieldView.as_view(), name='fyle-expense-fields'),
path('expenses/', SkippedExpenseView.as_view(), name='expenses'),
]

fyle_dimension_paths = [
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of itertools.chain to combine URL patterns is unconventional in Django. Consider using simple list concatenation or extension, which is more idiomatic and easier to read.

- urlpatterns = list(itertools.chain(accounting_exports_path, fyle_dimension_paths, other_paths))
+ urlpatterns = accounting_exports_path + fyle_dimension_paths + other_paths

Committable suggestion

IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
fyle_dimension_paths = [
urlpatterns = accounting_exports_path + fyle_dimension_paths + other_paths

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants