Skip to content

Commit

Permalink
feat(api): add api to invoice models
Browse files Browse the repository at this point in the history
  • Loading branch information
sepehr-akbarzadeh committed Oct 17, 2024
1 parent 38bcaf1 commit d375343
Show file tree
Hide file tree
Showing 22 changed files with 464 additions and 52 deletions.
16 changes: 15 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ django-import-export = "^4.1.1"
bandit = { extras = [ "toml" ], version = "^1.7.9" }
django-jsonform = "^2.22.0"
django-sage-tools = "^0.3.5"
djangorestframework = "^3.15.2"

[tool.poetry.group.dev.dependencies]
ruff = "^0.6.1"
Expand Down
27 changes: 27 additions & 0 deletions sage_invoice/api/helpers/versioning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from rest_framework.versioning import BaseVersioning
from rest_framework.exceptions import NotFound

SUPPORTED_VERSIONS = [
'1.0'
]

class HeaderVersioning(BaseVersioning):
def determine_version(self, request, *args, **kwargs):
# Get the version from the 'X-API-Version' header
accept_header = request.META.get('HTTP_X_API_VERSION', '')

# Check if the version header contains 'v'
if 'v' in accept_header:
version = accept_header.split('v')[-1].split('+')[0]
else:
version = None

# If no version is found, default to the latest version
if not version:
return SUPPORTED_VERSIONS[-1] # Latest version

# If version is not supported, raise an error
if version not in SUPPORTED_VERSIONS:
raise NotFound(f"API version '{version}' is not supported.")

return version
1 change: 1 addition & 0 deletions sage_invoice/api/mixins/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .exception import ErrorHandlingMixin
92 changes: 92 additions & 0 deletions sage_invoice/api/mixins/exception.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import logging
import uuid
from django.utils.timezone import now
from rest_framework.views import exception_handler
from rest_framework.exceptions import ValidationError, NotFound, APIException, NotAcceptable
from rest_framework import status
from rest_framework.response import Response

# Get the logger instance
logger = logging.getLogger(__name__)

class ErrorHandlingMixin:
"""
Mixin to provide consistent error handling across APIs,
including structured error responses, logging, and trace IDs.
"""

def handle_exception(self, exc):
"""
Override the default DRF exception handling to provide
a structured error response with logging and trace IDs.
"""
# Call the default DRF exception handler first to get the default response
response = exception_handler(exc, self.get_exception_handler_context())

# Generate a unique trace ID for this request
trace_id = str(uuid.uuid4())

# Path from the current request
path = self.request.path if hasattr(self, 'request') else 'unknown'

# Default error structure
error_response = {
"code": "SERVER_ERROR",
"message": "An unexpected error occurred.",
"details": [],
"path": path,
"traceId": trace_id,
"timestamp": now().isoformat()
}

# Handle specific exceptions for better detail
if isinstance(exc, ValidationError):
error_response['code'] = "VALIDATION_ERROR"
error_response['message'] = "Invalid input parameters"
error_response['details'] = [
{"field": field, "message": msg[0] if isinstance(msg, list) else msg}
for field, msg in exc.detail.items()
]
status_code = status.HTTP_400_BAD_REQUEST

elif isinstance(exc, NotFound):
error_response['code'] = "NOT_FOUND"
error_response['message'] = str(exc)
status_code = status.HTTP_404_NOT_FOUND

elif isinstance(exc, NotAcceptable):
error_response['code'] = "NOT_ACCEPTABLE"
error_response['message'] = str(exc)
status_code = status.HTTP_406_NOT_ACCEPTABLE

elif isinstance(exc, APIException):
error_response['code'] = exc.get_codes() if exc.get_codes() else "API_ERROR"
error_response['message'] = str(exc)
status_code = exc.status_code

else:
# If response is None, this means DRF didn't handle the exception, so we do it ourselves.
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR

# If DRF handled the exception, override its response data with the structured error
if response is not None:
response.data = error_response
response.status_code = status_code
else:
# Create a custom response if DRF couldn't handle the exception
response = Response(error_response, status=status_code)

# Log the error details with trace ID for correlation
logger.error(f"Error occurred [traceId={trace_id}] at {path}: {exc}", exc_info=True)

return response

def get_exception_handler_context(self):
"""
This method returns the context that will be passed to the DRF exception handler.
It's useful to provide additional request-related context (like view, request, args).
"""
return {
'view': self,
'request': self.request,
}
5 changes: 5 additions & 0 deletions sage_invoice/api/serializers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .expense import ExpenseSerializer
from .category import CategorySerializer
from .column import ColumnSerializer
from .item import ItemSerializer
from .invoice import InvoiceSerializer
28 changes: 28 additions & 0 deletions sage_invoice/api/serializers/category.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# serializers.py
from rest_framework import serializers
from sage_invoice.models import Category


class CategorySerializer(serializers.HyperlinkedModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='category-detail',
lookup_field='slug'
)
invoices = serializers.HyperlinkedRelatedField(
many=True,
view_name='invoice-detail',
lookup_field='slug',
read_only=True
)

class Meta:
model = Category
fields = (
"url",
"title",
"description",
"invoices"
)
extra_kwargs = {
'url': {'lookup_field': 'slug'},
}
14 changes: 14 additions & 0 deletions sage_invoice/api/serializers/column.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from rest_framework import serializers
from sage_invoice.models import Column


class ColumnSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Column
fields = '__all__'
lookup_field = "id"
extra_kwargs = {
'url': {'lookup_field': 'id'},
'invoice': {'lookup_field': "slug"},
'item': {'lookup_field': "id"},
}
14 changes: 14 additions & 0 deletions sage_invoice/api/serializers/expense.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# serializers.py
from rest_framework import serializers
from sage_invoice.models import Expense


class ExpenseSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Expense
fields = '__all__'
lookup_field = "id"
extra_kwargs = {
'url': {'lookup_field': 'id'},
'invoice': {'lookup_field': "slug"}
}
80 changes: 80 additions & 0 deletions sage_invoice/api/serializers/invoice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from rest_framework import serializers

from sage_invoice.api.serializers import ItemSerializer, CategorySerializer, ColumnSerializer, ExpenseSerializer
from sage_invoice.models import Invoice, Category

class ContactFieldSerializer(serializers.Serializer):
"""
Serializer to validate the structure of the contacts field (either phone or email).
"""
phone = serializers.RegexField(
regex=r'^\d{10,15}$',
required=False,
help_text="Phone number should only contain digits and be 10-15 characters long."
)
email = serializers.EmailField(
required=False,
help_text="Valid email format."
)

def validate(self, data):
"""
Ensure at least one of 'phone' or 'email' is provided.
"""
if not data.get('phone') and not data.get('email'):
raise serializers.ValidationError("Either phone or email must be provided.")
return data


class NoteFieldSerializer(serializers.Serializer):
"""
Serializer to validate the structure of the notes field (additional fields).
"""
label = serializers.CharField()
content = serializers.CharField()


class InvoiceSerializer(serializers.HyperlinkedModelSerializer):
contacts = ContactFieldSerializer(required=False)
notes = serializers.ListField(
child=serializers.DictField(
child=serializers.CharField()
),
required=False
)
category = CategorySerializer(read_only=True)
items = ItemSerializer(many=True, read_only=True)
columns = ColumnSerializer(many=True, read_only=True)
expense = ExpenseSerializer(read_only=True)

class Meta:
model = Invoice
fields = [
'url',
'slug',
'invoice_date',
'customer_name',
'tracking_code',
'contacts',
'status',
'receipt',
'notes',
'currency',
'due_date',
'template_choice',
'logo',
'signature',
'stamp',
'category',
'items',
'columns',
'expense',
]
extra_kwargs = {
'url': {'lookup_field': 'slug'},
'categories': {'lookup_field': 'slug'},
'items': {'lookup_field': 'id'},
'columns': {'lookup_field': 'id'},
'expense': {'lookup_field': 'id'},
}
read_only_fields = ['slug']
14 changes: 14 additions & 0 deletions sage_invoice/api/serializers/item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# serializers.py
from rest_framework import serializers
from sage_invoice.models import Item


class ItemSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Item
fields = '__all__'
lookup_field = "id"
extra_kwargs = {
'url': {'lookup_field': 'id'},
'invoice': {'lookup_field': "slug"}
}
21 changes: 21 additions & 0 deletions sage_invoice/api/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter

from .views import (
ExpenseViewSet,
InvoiceViewSet,
ItemViewSet,
CategoryViewSet,
ColumnViewSet
)

router = DefaultRouter()
router.register(r'expenses', ExpenseViewSet)
router.register(r'invoices', InvoiceViewSet)
router.register(r'items', ItemViewSet)
router.register(r'categories', CategoryViewSet)
router.register(r'columns', ColumnViewSet)

urlpatterns = [
path('', include(router.urls)),
]
5 changes: 5 additions & 0 deletions sage_invoice/api/views/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .category import CategoryViewSet
from .column import ColumnViewSet
from .expense import ExpenseViewSet
from .invoice import InvoiceViewSet
from .item import ItemViewSet
21 changes: 21 additions & 0 deletions sage_invoice/api/views/category.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from rest_framework import viewsets

from sage_invoice.models import Category
from sage_invoice.api.mixins import ErrorHandlingMixin
from sage_invoice.api.serializers import CategorySerializer
from sage_invoice.api.helpers.versioning import HeaderVersioning


class CategoryViewSet(ErrorHandlingMixin, viewsets.ModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer

versioning_class = HeaderVersioning

def get_serializer_class(self):
version = getattr(self.request, 'version', None)

if version == '1.0':
return CategorySerializer

return super().get_serializer_class()
11 changes: 11 additions & 0 deletions sage_invoice/api/views/column.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# views.py
from rest_framework import viewsets
from sage_invoice.models import Column
from sage_invoice.api.serializers import ColumnSerializer


class ColumnViewSet(viewsets.ModelViewSet):
queryset = Column.objects.all()
serializer_class = ColumnSerializer
lookup_field = "id"
lookup_url_kwarg = "id"
11 changes: 11 additions & 0 deletions sage_invoice/api/views/expense.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# views.py
from rest_framework import viewsets
from sage_invoice.models import Expense
from sage_invoice.api.serializers import ExpenseSerializer


class ExpenseViewSet(viewsets.ModelViewSet):
queryset = Expense.objects.all()
serializer_class = ExpenseSerializer
lookup_field = "id"
lookup_url_kwarg = "id"
Loading

0 comments on commit d375343

Please sign in to comment.