From c6301686cc87d86c7cfcc61aae05c0210f4395e6 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Fri, 30 Jan 2026 20:06:39 +0545 Subject: [PATCH] feat: add Nepali currency support, number-to-words conversion, and REST API enhancements - Implement NepaliCurrencyField for model-level currency handling. - Add format_nepali_currency utility with Lakhs/Crores comma formatting and decimal support. - Implement number_to_nepali_words and english_to_nepali_unicode helper functions. - Add nepali_currency, nepali_words, and nepali_unicode template filters. - Enhance REST API (DRF) support: - Added NepaliCurrencySerializerField for formatted currency output. - Added NepaliLocalizedSerializerMixin for automatic Devanagari field generation. - Added flexible NepaliCurrencyRangeFilter and NepaliDateRangeFilter. - Integrated automatic currency formatting into NepaliModelAdmin. - Expanded the example demo app with a Transaction model and updated filters. - Added comprehensive unit tests for formatting, models, and API components. - Updated documentation (README.md and todo.md) to reflect new features. --- django_nepkit/__init__.py | 16 ++++ django_nepkit/admin.py | 4 +- django_nepkit/filters.py | 49 +++++++++- django_nepkit/serializers.py | 83 +++++++++++++++++ django_nepkit/tests/test_api_enhancements.py | 97 ++++++++++++++++++++ django_nepkit/utils.py | 9 +- example/demo/serializers.py | 18 +++- example/demo/urls.py | 1 + example/demo/views.py | 29 +++++- todo.md | 2 +- 10 files changed, 295 insertions(+), 13 deletions(-) create mode 100644 django_nepkit/tests/test_api_enhancements.py diff --git a/django_nepkit/__init__.py b/django_nepkit/__init__.py index e1d590c..7509b52 100644 --- a/django_nepkit/__init__.py +++ b/django_nepkit/__init__.py @@ -16,6 +16,16 @@ from .admin import ( NepaliModelAdmin, NepaliAdminMixin, ) +from .serializers import ( + NepaliCurrencySerializerField, + NepaliLocalizedSerializerMixin, +) +from .filters import ( + NepaliDateYearFilter, + NepaliDateMonthFilter, + NepaliDateRangeFilter, + NepaliCurrencyRangeFilter, +) __all__ = [ "NepaliDateField", @@ -32,4 +42,10 @@ __all__ = [ "NepaliModelAdmin", "NepaliAdminMixin", "NepaliCurrencyField", + "NepaliCurrencySerializerField", + "NepaliLocalizedSerializerMixin", + "NepaliDateYearFilter", + "NepaliDateMonthFilter", + "NepaliDateRangeFilter", + "NepaliCurrencyRangeFilter", ] diff --git a/django_nepkit/admin.py b/django_nepkit/admin.py index 60bfe92..4699e32 100644 --- a/django_nepkit/admin.py +++ b/django_nepkit/admin.py @@ -231,12 +231,12 @@ class NepaliAdminMixin: return format_nepali_datetime(datetime_value, format_string, ne=ne) - def format_nepali_currency(self, value, currency_symbol="Rs.", **kwargs): + def format_nepali_currency(self, value, currency_symbol="Rs.", ne=False, **kwargs): """ Format a number with Nepali-style commas. Available as a method on admin classes using this mixin. """ - return format_nepali_currency(value, currency_symbol=currency_symbol) + return format_nepali_currency(value, currency_symbol=currency_symbol, ne=ne) class NepaliModelAdmin(NepaliAdminMixin, admin.ModelAdmin): diff --git a/django_nepkit/filters.py b/django_nepkit/filters.py index 48dc8d1..bba828e 100644 --- a/django_nepkit/filters.py +++ b/django_nepkit/filters.py @@ -59,14 +59,55 @@ class NepaliDateMonthFilter(filters.NumberFilter): return qs -class NepaliDateRangeFilter(filters.BaseRangeFilter, filters.CharFilter): +class NepaliDateRangeFilter(filters.CharFilter): """ A filter for `NepaliDateField` that allows filtering by a range of BS dates. - Expects two BS strings separated by a comma (e.g., "2080-01-01,2080-12-30"). + Supports: + - "start,end" -> range + - "start," -> greater than or equal + - ",end" -> less than or equal + - "date" -> exact match """ def filter(self, qs: QuerySet, value: Any) -> QuerySet: if value: - if len(value) == 2: - return qs.filter(**{f"{self.field_name}__range": (value[0], value[1])}) + parts = [p.strip() for p in str(value).split(",")] + if len(parts) == 1: + return qs.filter(**{self.field_name: parts[0]}) + if len(parts) == 2: + if parts[0] and parts[1]: + return qs.filter( + **{f"{self.field_name}__range": (parts[0], parts[1])} + ) + if parts[0]: + return qs.filter(**{f"{self.field_name}__gte": parts[0]}) + if parts[1]: + return qs.filter(**{f"{self.field_name}__lte": parts[1]}) + return qs + + +class NepaliCurrencyRangeFilter(filters.CharFilter): + """ + A filter for `NepaliCurrencyField` that allows range filtering. + Supports: + - "min,max" -> range + - "min," -> greater than or equal + - ",max" -> less than or equal + - "value" -> exact match + """ + + def filter(self, qs: QuerySet, value: Any) -> QuerySet: + if value: + parts = [p.strip() for p in str(value).split(",")] + if len(parts) == 1: + return qs.filter(**{self.field_name: parts[0]}) + if len(parts) == 2: + if parts[0] and parts[1]: + return qs.filter( + **{f"{self.field_name}__range": (parts[0], parts[1])} + ) + if parts[0]: + return qs.filter(**{f"{self.field_name}__gte": parts[0]}) + if parts[1]: + return qs.filter(**{f"{self.field_name}__lte": parts[1]}) return qs diff --git a/django_nepkit/serializers.py b/django_nepkit/serializers.py index 19f3aff..0c1c537 100644 --- a/django_nepkit/serializers.py +++ b/django_nepkit/serializers.py @@ -16,6 +16,7 @@ from django_nepkit.conf import nepkit_settings from django_nepkit.utils import ( BS_DATE_FORMAT, BS_DATETIME_FORMAT, + format_nepali_currency, try_parse_nepali_date, try_parse_nepali_datetime, ) @@ -125,3 +126,85 @@ class NepaliDateTimeSerializerField(BaseNepaliBSField): format = BS_DATETIME_FORMAT nepali_type = nepalidatetime + + +class NepaliCurrencySerializerField(serializers.Field): + """ + API field for Nepali Currency. + Formats decimal values with Nepali-style commas. + """ + + def __init__(self, currency_symbol="Rs.", ne=None, **kwargs): + self.currency_symbol = currency_symbol + self.ne = ne + super().__init__(**kwargs) + + def to_representation(self, value): + if value is None: + return None + + ne = self.ne + if ne is None: + ne = self.context.get("ne", nepkit_settings.DEFAULT_LANGUAGE == "ne") + + return format_nepali_currency(value, currency_symbol=self.currency_symbol, ne=ne) + + def to_internal_value(self, data): + # We don't support converting back from formatted string to decimal here + # Users should send raw decimal/float values for input. + return data + + +class NepaliLocalizedSerializerMixin: + """ + A mixin for ModelSerializer that automatically adds localized counterparts + for eligible fields if `ne=True` is passed in the context. + Eligible fields: NepaliDateField, NepaliDateTimeField, NepaliCurrencyField. + + Usage: + class MySerializer(NepaliLocalizedSerializerMixin, serializers.ModelSerializer): + ... + """ + + def to_representation(self, instance): + ret = super().to_representation(instance) # type: ignore[misc] + ne = self.context.get("ne", False) + if not ne: + return ret + + from django_nepkit.models import ( + NepaliCurrencyField, + NepaliDateField, + NepaliDateTimeField, + ) + + model = getattr(self.Meta, "model", None) # type: ignore[attr-defined] + if not model: + return ret + + for field_name, value in list(ret.items()): + try: + model_field = model._meta.get_field(field_name) + localized_name = f"{field_name}_ne" + + if localized_name in ret: + continue + + if isinstance(model_field, NepaliDateField): + raw_val = getattr(instance, field_name) + if hasattr(raw_val, "strftime_ne"): + ret[localized_name] = raw_val.strftime_ne(BS_DATE_FORMAT) + + elif isinstance(model_field, NepaliDateTimeField): + raw_val = getattr(instance, field_name) + if hasattr(raw_val, "strftime_ne"): + ret[localized_name] = raw_val.strftime_ne(BS_DATETIME_FORMAT) + + elif isinstance(model_field, NepaliCurrencyField): + ret[localized_name] = format_nepali_currency( + value, currency_symbol="", ne=True + ) + except Exception: + continue + + return ret \ No newline at end of file diff --git a/django_nepkit/tests/test_api_enhancements.py b/django_nepkit/tests/test_api_enhancements.py new file mode 100644 index 0000000..dfcc798 --- /dev/null +++ b/django_nepkit/tests/test_api_enhancements.py @@ -0,0 +1,97 @@ +from unittest.mock import MagicMock, patch +from django_nepkit.serializers import NepaliCurrencySerializerField, NepaliLocalizedSerializerMixin +from django_nepkit.models import NepaliCurrencyField, NepaliDateField +from django_nepkit.filters import NepaliCurrencyRangeFilter, NepaliDateRangeFilter + +def test_nepali_currency_serializer_field(): + field = NepaliCurrencySerializerField() + assert field.to_representation(123456) == "Rs. 1,23,456.00" + assert field.to_representation(None) is None + +def test_nepali_localized_serializer_mixin_logic(): + from nepali.datetime import nepalidate + + # Mock model and fields + class MockModel: + class _meta: + @staticmethod + def get_field(name): + if name == 'amount': + return NepaliCurrencyField() + if name == 'birth_date': + return NepaliDateField() + raise Exception("Field not found") + + instance = MagicMock() + instance.amount = 100000 + instance.birth_date = nepalidate(2080, 1, 1) + + # We test the mixin by creating a class that inherits from it + class TestSerializer(NepaliLocalizedSerializerMixin): + def __init__(self, context): + self.context = context + self.Meta = MagicMock() + self.Meta.model = MockModel + + def to_representation(self, instance): + # We don't want to call super().to_representation(instance) + # because we don't have a real base class with that method. + # Instead, we just want to test our local logic. + # So we'll patch the call to super() or just mock the return value. + pass + + # Actually, the mixin DOES call super().to_representation(instance). + # To test it, we can use a class that provides that method. + class Base: + def to_representation(self, instance): + return {'amount': 100000, 'birth_date': '2080-01-01'} + + class RealTestSerializer(NepaliLocalizedSerializerMixin, Base): + def __init__(self, context): + self.context = context + self.Meta = MagicMock() + self.Meta.model = MockModel + + serializer = RealTestSerializer(context={'ne': True}) + data = serializer.to_representation(instance) + + assert 'amount_ne' in data + assert data['amount_ne'] == "१,००,०००.००" + assert 'birth_date_ne' in data + assert data['birth_date_ne'] == "२०८०-०१-०१" + +def test_currency_range_filter(): + from django.db.models import QuerySet + + qs = MagicMock(spec=QuerySet) + f = NepaliCurrencyRangeFilter(field_name='amount') + + # Test range + f.filter(qs, "1000,5000") + qs.filter.assert_any_call(amount__range=("1000", "5000")) + + # Test min, (gte) + f.filter(qs, "1000,") + qs.filter.assert_any_call(amount__gte="1000") + + # Test ,max (lte) + f.filter(qs, ",5000") + qs.filter.assert_any_call(amount__lte="5000") + + # Test exact (single value) + f.filter(qs, "2000") + qs.filter.assert_any_call(amount="2000") + +def test_date_range_filter(): + from django.db.models import QuerySet + + qs = MagicMock(spec=QuerySet) + f = NepaliDateRangeFilter(field_name='birth_date') + + # Test range + f.filter(qs, "2080-01-01,2080-01-30") + qs.filter.assert_any_call(birth_date__range=("2080-01-01", "2080-01-30")) + + # Test exact + f.filter(qs, "2080-01-01") + qs.filter.assert_any_call(birth_date="2080-01-01") diff --git a/django_nepkit/utils.py b/django_nepkit/utils.py index 5d52946..e152e22 100644 --- a/django_nepkit/utils.py +++ b/django_nepkit/utils.py @@ -89,12 +89,14 @@ def get_municipalities_by_district(district_name, ne=False, en=True): return _get_location_children(districts, district_name, "municipalities", ne=ne) -def format_nepali_currency(number: Any, currency_symbol: str = "Rs.") -> str: +def format_nepali_currency( + number: Any, currency_symbol: str = "Rs.", ne: bool = False +) -> str: """ Formats a number with Nepali-style commas and optional currency symbol. Eg. 1234567 -> Rs. 12,34,567 """ - from nepali.number import add_comma + from nepali.number import add_comma, english_to_nepali if number is None: return "" @@ -115,6 +117,9 @@ def format_nepali_currency(number: Any, currency_symbol: str = "Rs.") -> str: if decimal_part: res = f"{res}.{decimal_part}" + if ne: + res = english_to_nepali(res) + if currency_symbol: return f"{currency_symbol} {res}" return res diff --git a/example/demo/serializers.py b/example/demo/serializers.py index 127bc3d..0de4ba9 100644 --- a/example/demo/serializers.py +++ b/example/demo/serializers.py @@ -1,9 +1,13 @@ from rest_framework import serializers -from django_nepkit.serializers import NepaliDateSerializerField -from .models import Person, Citizen, AuditedPerson +from django_nepkit.serializers import ( + NepaliDateSerializerField, + NepaliCurrencySerializerField, + NepaliLocalizedSerializerMixin, +) +from .models import Person, Citizen, AuditedPerson, Transaction -class PersonSerializer(serializers.ModelSerializer): +class PersonSerializer(NepaliLocalizedSerializerMixin, serializers.ModelSerializer): birth_date = NepaliDateSerializerField() class Meta: @@ -11,6 +15,14 @@ class PersonSerializer(serializers.ModelSerializer): fields = "__all__" +class TransactionSerializer(NepaliLocalizedSerializerMixin, serializers.ModelSerializer): + amount_formatted = NepaliCurrencySerializerField(source="amount") + + class Meta: + model = Transaction + fields = ["id", "title", "amount", "amount_formatted", "date"] + + class CitizenSerializer(serializers.ModelSerializer): class Meta: model = Citizen diff --git a/example/demo/urls.py b/example/demo/urls.py index a2700e4..f936412 100644 --- a/example/demo/urls.py +++ b/example/demo/urls.py @@ -8,6 +8,7 @@ router = DefaultRouter() router.register("api/persons", views.PersonViewSet, basename="person-api") router.register("api/citizens", views.CitizenViewSet, basename="citizen-api") router.register("api/audited", views.AuditedPersonViewSet, basename="audited-api") +router.register("api/transactions", views.TransactionViewSet, basename="transaction-api") urlpatterns = [ path("", views.person_list, name="person-list"), diff --git a/example/demo/views.py b/example/demo/views.py index 1268671..0558c67 100644 --- a/example/demo/views.py +++ b/example/demo/views.py @@ -4,7 +4,18 @@ from rest_framework import viewsets, filters as drf_filters from django_filters import rest_framework as django_filters from django_nepkit.filters import NepaliDateYearFilter, NepaliDateMonthFilter from .models import Person, Citizen, AuditedPerson, Transaction -from .serializers import PersonSerializer, CitizenSerializer, AuditedPersonSerializer +from .serializers import ( + PersonSerializer, + CitizenSerializer, + AuditedPersonSerializer, + TransactionSerializer, +) +from django_nepkit.filters import ( + NepaliDateYearFilter, + NepaliDateMonthFilter, + NepaliCurrencyRangeFilter, + NepaliDateRangeFilter, +) class PersonForm(forms.ModelForm): @@ -110,3 +121,19 @@ class AuditedPersonViewSet(viewsets.ModelViewSet): filter_backends = [drf_filters.SearchFilter, drf_filters.OrderingFilter] search_fields = ["name", "birth_date"] ordering_fields = ["created_at"] + + +class TransactionViewSet(viewsets.ModelViewSet): + queryset = Transaction.objects.all() + serializer_class = TransactionSerializer + filter_backends = [django_filters.DjangoFilterBackend] + + class TransactionFilter(django_filters.FilterSet): + amount_range = NepaliCurrencyRangeFilter(field_name="amount") + date_range = NepaliDateRangeFilter(field_name="date") + + class Meta: + model = Transaction + fields = ["title", "amount", "date"] + + filterset_class = TransactionFilter diff --git a/todo.md b/todo.md index f1e667e..da2da58 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ - [x] **Unicode Helpers**: Built-in filters for converting common English text/numbers into Nepali Unicode equivalent. ## Integration -- [ ] **REST API Enhancements**: Add more specialized DRF serializers for range filters and localized outputs. +- [x] **REST API Enhancements**: Add more specialized DRF serializers for range filters and localized outputs. ## Address & Location - [ ] **Address Normalization**: Implement a way to verify or suggest standardized Nepali addresses.