From e606a2b4900e2fad51a549918df799fc851a16df Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Fri, 30 Jan 2026 19:50:26 +0545 Subject: [PATCH] feat: add Nepali currency field, number to words, and unicode helpers --- README.md | 32 +++++ django_nepkit/__init__.py | 2 + django_nepkit/admin.py | 16 ++- django_nepkit/models.py | 9 ++ django_nepkit/templatetags/nepali.py | 21 ++++ django_nepkit/tests/test_admin_currency.py | 24 ++++ .../tests/test_currency_and_words.py | 38 ++++++ django_nepkit/utils.py | 112 ++++++++++++++++++ example/demo/admin.py | 8 +- example/demo/models.py | 10 ++ example/demo/templates/demo/person_list.html | 52 +++++++- example/demo/urls.py | 1 + example/demo/views.py | 28 ++++- todo.md | 6 +- 14 files changed, 347 insertions(+), 12 deletions(-) create mode 100644 django_nepkit/tests/test_admin_currency.py create mode 100644 django_nepkit/tests/test_currency_and_words.py diff --git a/README.md b/README.md index 18e205d..e40b32e 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Building software for local requirements comes with unique challenges, from hand - **ЁЯУЕ BS Date Support**: Model fields for `nepalidate` and `nepalidatetime` objects. - **ЁЯЧ║я╕П Regional Locations**: Pre-defined Provinces, Districts, and Municipalities. - **ЁЯУ▒ Phone Validation**: Patterns for local mobile and landline numbers. +- **ЁЯТ░ Currency Formatting**: `NepaliCurrencyField` with automatic Lakhs/Crores comma placement. +- **ЁЯФд Numbers to Words**: Convert digits into Nepali text representation. - **ЁЯФМ Admin Integration**: Automatic setup for datepickers and localized list displays. - **ЁЯЪА API Support**: DRF Serializers and Filtering backends for BS searching and ordering. - **тЪб Location Chaining**: Address linking via client side JS or server driven HTMX. @@ -137,6 +139,36 @@ class ProfileFilter(filters.FilterSet): --- +## ЁЯТ░ Formatting & Helpers + +### 1. Currency & Numbers +Use `NepaliCurrencyField` for automatic formatting in the admin and templates. + +```python +from django_nepkit import NepaliCurrencyField + +class Transaction(models.Model): + amount = NepaliCurrencyField() # Defaults to 19 digits, 2 decimals +``` + +### 2. Template Filters +Load the tags to use localized formatting in your templates. + +```html +{% load nepali %} + + +

{{ 112000 | nepali_currency }}

+ + +

{{ 123 | nepali_words }}

+ + +

{{ "123" | nepali_unicode }}

+``` + +--- + ## ЁЯХТ Technical Design This library is engineered for performance, data integrity, and local compliance. diff --git a/django_nepkit/__init__.py b/django_nepkit/__init__.py index ef69dac..e1d590c 100644 --- a/django_nepkit/__init__.py +++ b/django_nepkit/__init__.py @@ -6,6 +6,7 @@ from .models import ( ProvinceField, DistrictField, MunicipalityField, + NepaliCurrencyField, ) from .admin import ( NepaliDateFilter, @@ -30,4 +31,5 @@ __all__ = [ "format_nepali_datetime", "NepaliModelAdmin", "NepaliAdminMixin", + "NepaliCurrencyField", ] diff --git a/django_nepkit/admin.py b/django_nepkit/admin.py index 45f6d11..5a953cb 100644 --- a/django_nepkit/admin.py +++ b/django_nepkit/admin.py @@ -3,10 +3,11 @@ from django.utils.translation import gettext_lazy as _ from nepali.datetime import nepalidate, nepalidatetime from django_nepkit.conf import nepkit_settings -from django_nepkit.models import NepaliDateField, NepaliDateTimeField +from django_nepkit.models import NepaliDateField, NepaliDateTimeField, NepaliCurrencyField from django_nepkit.utils import ( try_parse_nepali_date, try_parse_nepali_datetime, + format_nepali_currency, ) from django_nepkit.utils import BS_DATE_FORMAT @@ -226,6 +227,13 @@ class NepaliAdminMixin: return format_nepali_datetime(datetime_value, format_string, ne=ne) + def format_nepali_currency(self, value, currency_symbol="Rs.", **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) + class NepaliModelAdmin(NepaliAdminMixin, admin.ModelAdmin): """ @@ -272,6 +280,9 @@ class NepaliModelAdmin(NepaliAdminMixin, admin.ModelAdmin): def _make_nepali_datetime_display(self, field_name): return self._make_nepali_display(field_name, self.format_nepali_datetime) + def _make_nepali_currency_display(self, field_name): + return self._make_nepali_display(field_name, self.format_nepali_currency) + def get_list_display(self, request): list_display = super().get_list_display(request) result = [] @@ -287,6 +298,9 @@ class NepaliModelAdmin(NepaliAdminMixin, admin.ModelAdmin): if isinstance(field, NepaliDateTimeField): result.append(self._make_nepali_datetime_display(item)) continue + if isinstance(field, NepaliCurrencyField): + result.append(self._make_nepali_currency_display(item)) + continue except Exception: pass result.append(item) diff --git a/django_nepkit/models.py b/django_nepkit/models.py index 308b2da..3ff3713 100644 --- a/django_nepkit/models.py +++ b/django_nepkit/models.py @@ -239,3 +239,12 @@ class MunicipalityField(BaseLocationField): description = _("Nepali Municipality") source = municipalities widget_class = MunicipalitySelectWidget + + +class NepaliCurrencyField(models.DecimalField): + description = _("Nepali Currency") + + def __init__(self, *args, **kwargs): + kwargs.setdefault("max_digits", 19) + kwargs.setdefault("decimal_places", 2) + super().__init__(*args, **kwargs) diff --git a/django_nepkit/templatetags/nepali.py b/django_nepkit/templatetags/nepali.py index ee99b83..6f1a302 100644 --- a/django_nepkit/templatetags/nepali.py +++ b/django_nepkit/templatetags/nepali.py @@ -66,3 +66,24 @@ def nepali_humanize(value, threshold=None, format_str=None): kwargs["format"] = format_str return nepalihumanize(value, **kwargs) + + +@register.filter +def nepali_currency(value, currency_symbol="Rs."): + """Format a number with Nepali-style commas and optional currency symbol.""" + from django_nepkit.utils import format_nepali_currency + return format_nepali_currency(value, currency_symbol=currency_symbol) + + +@register.filter +def nepali_words(value): + """Convert a number to Nepali words.""" + from django_nepkit.utils import number_to_nepali_words + return number_to_nepali_words(value) + + +@register.filter +def nepali_unicode(value): + """Convert English digits to Nepali Unicode.""" + from django_nepkit.utils import english_to_nepali_unicode + return english_to_nepali_unicode(value) diff --git a/django_nepkit/tests/test_admin_currency.py b/django_nepkit/tests/test_admin_currency.py new file mode 100644 index 0000000..1a78425 --- /dev/null +++ b/django_nepkit/tests/test_admin_currency.py @@ -0,0 +1,24 @@ +import pytest +from django_nepkit.admin import NepaliModelAdmin +from django_nepkit.models import NepaliCurrencyField +from django.db import models +from django.contrib.admin.sites import AdminSite + +class MockModel(models.Model): + amount = NepaliCurrencyField() + class Meta: + app_label = 'django_nepkit' + +class MockAdmin(NepaliModelAdmin): + list_display = ('amount',) + +def test_admin_currency_formatting(): + admin = MockAdmin(MockModel, AdminSite()) + displays = admin.get_list_display(None) + + # The first item should now be a callable (the display function) + assert callable(displays[0]) + + # Test the formatter directly + obj = MockModel(amount=1234567) + assert admin.format_nepali_currency(obj.amount) == "Rs. 12,34,567" diff --git a/django_nepkit/tests/test_currency_and_words.py b/django_nepkit/tests/test_currency_and_words.py new file mode 100644 index 0000000..c60fb09 --- /dev/null +++ b/django_nepkit/tests/test_currency_and_words.py @@ -0,0 +1,38 @@ +import pytest +from django_nepkit.utils import format_nepali_currency, number_to_nepali_words, english_to_nepali_unicode +from django_nepkit.models import NepaliCurrencyField +from django.db import models + +def test_format_nepali_currency(): + assert format_nepali_currency(1234567) == "Rs. 12,34,567.00" + assert format_nepali_currency(112000, currency_symbol="") == "1,12,000.00" + assert format_nepali_currency(1234567, currency_symbol="") == "12,34,567.00" + assert format_nepali_currency(100.5) == "Rs. 100.50" + assert format_nepali_currency(None) == "" + +def test_number_to_nepali_words(): + assert number_to_nepali_words(0) == "рд╢реВрдиреНрдп" + assert number_to_nepali_words(1) == "рдПрдХ" + assert number_to_nepali_words(10) == "рджрд╢" + assert number_to_nepali_words(25) == "рдкрдЪреНрдЪреАрд╕" + assert number_to_nepali_words(100) == "рдПрдХ рд╕рдп" + assert number_to_nepali_words(123) == "рдПрдХ рд╕рдп рддреЗрдИрд╕" + assert number_to_nepali_words(1000) == "рдПрдХ рд╣рдЬрд╛рд░" + assert number_to_nepali_words(1234) == "рдПрдХ рд╣рдЬрд╛рд░ рджреБрдИ рд╕рдп рдЪреМрдВрддреАрд╕" + assert number_to_nepali_words(100000) == "рдПрдХ рд▓рд╛рдЦ" + assert number_to_nepali_words(1234567) == "рдмрд╛рд╣реНрд░ рд▓рд╛рдЦ рдЪреМрдВрддреАрд╕ рд╣рдЬрд╛рд░ рдкрд╛рдБрдЪ рд╕рдп рд╕рддрд╕рдЯреНрдареА" + # Note: 12,34,567 -> 12 Lakhs 34 Thousand 5 Hundred 67 + +def test_english_to_nepali_unicode(): + assert english_to_nepali_unicode("123") == "резреирей" + assert english_to_nepali_unicode("Price: 100") == "Price: резрежреж" + assert english_to_nepali_unicode(None) == "" + +def test_nepali_currency_field(): + field = NepaliCurrencyField(max_digits=10, decimal_places=2) + assert field.max_digits == 10 + assert field.decimal_places == 2 + + default_field = NepaliCurrencyField() + assert default_field.max_digits == 19 + assert default_field.decimal_places == 2 diff --git a/django_nepkit/utils.py b/django_nepkit/utils.py index 08e53a5..40e0a82 100644 --- a/django_nepkit/utils.py +++ b/django_nepkit/utils.py @@ -87,3 +87,115 @@ def get_districts_by_province(province_name, ne=False, en=True): def get_municipalities_by_district(district_name, ne=False, en=True): """Get all municipalities for a district.""" return _get_location_children(districts, district_name, "municipalities", ne=ne) + + +def format_nepali_currency(number: Any, currency_symbol: str = "Rs.") -> str: + """ + Formats a number with Nepali-style commas and optional currency symbol. + Eg. 1234567 -> Rs. 12,34,567 + """ + from nepali.number import add_comma + + if number is None: + return "" + + try: + # Convert to string and split by decimal point + num_str = f"{float(number):.2f}" + if "." in num_str: + integer_part, decimal_part = num_str.split(".") + else: + integer_part, decimal_part = num_str, "" + + # Format integer part with commas + formatted_integer = add_comma(int(integer_part)) + + # Join back + res = formatted_integer + if decimal_part: + res = f"{res}.{decimal_part}" + + if currency_symbol: + return f"{currency_symbol} {res}" + return res + except Exception: + return str(number) + + +def number_to_nepali_words(number: Any) -> str: + """ + Converts a number to Nepali words. + Eg. 123 -> рдПрдХ рд╕рдп рддреЗрдИрд╕ + """ + if number is None: + return "" + + # Basic implementation for now, can be expanded + # Mapping for numbers to words (simplified) + # This is a complex task for a full implementation, + # but I'll provide a robust enough version for common usage. + + try: + num = int(float(number)) + except (ValueError, TypeError): + return str(number) + + if num == 0: + return "рд╢реВрдиреНрдп" + + ones = ["", "рдПрдХ", "рджреБрдИ", "рддреАрди", "рдЪрд╛рд░", "рдкрд╛рдБрдЪ", "рдЫ", "рд╕рд╛рдд", "рдЖрда", "рдиреМ", "рджрд╢", + "рдПрдШрд╛рд░", "рдмрд╛рд╣реНрд░", "рддреЗрд╣реНрд░", "рдЪреМрдз", "рдкрдиреНрдзреНрд░", "рд╕реЛрд╣реНрд░", "рд╕рддреНрд░", "рдЕрдард╛рд░", "рдЙрдиреНрдирд╛рдЗрд╕", "рдмреАрд╕", + "рдПрдХрд╛рдЗрд╕", "рдмрд╛рдЗрд╕", "рддреЗрдИрд╕", "рдЪреМрдмреАрд╕", "рдкрдЪреНрдЪреАрд╕", "рдЫрдмреНрдмреАрд╕", "рд╕рддреНрддрд╛рдЗрд╕", "рдЕрдард╛рдЗрд╕", "рдЙрдирдиреНрддреАрд╕", "рддреАрд╕", + "рдПрдХрддреАрд╕", "рдмрддреНрддреАрд╕", "рддреЗрддреНрддреАрд╕", "рдЪреМрдВрддреАрд╕", "рдкреИрдВрддреАрд╕", "рдЫрддреНрддреАрд╕", "рд╕реИрдВрддреАрд╕", "рдЕрдарддреАрд╕", "рдЙрдирдиреНрдЪрд╛рд▓реАрд╕", "рдЪрд╛рд▓реАрд╕", + "рдПрдХрдЪрд╛рд▓реАрд╕", "рдмрдпрд╛рд▓реАрд╕", "рддреНрд░рд┐рдЪрд╛рд▓реАрд╕", "рдЪрд╡рд╛рд▓реАрд╕", "рдкреИрдВрддрд╛рд▓реАрд╕", "рдЫрдпрд╛рд▓реАрд╕", "рд╕рддреНрддрд╛рд▓реАрд╕", "рдЕрдардЪрд╛рд▓реАрд╕", "рдЙрдирдиреНрдкрдЪрд╛рд╕", "рдкрдЪрд╛рд╕", + "рдПрдХрд╛рдЙрдиреНрди", "рдмрд╛рдЙрдиреНрди", "рддреНрд░рд┐рдкрдиреНрди", "рдЪрдЙрдиреНрди", "рдкрдЪрдкрдиреНрди", "рдЫрдкрдиреНрди", "рд╕рдиреНрддрд╛рдЙрдиреНрди", "рдЕрдиреНрдард╛рдЙрдиреНрди", "рдЙрдирдиреНрд╕рд╛рдареА", "рд╕рд╛рдареА", + "рдПрдХрд╕рд╛рдареА", "рдмрд╛рд╕рдЯреНрдареА", "рддреНрд░рд┐рд╕рдЯреНрдареА", "рдЪреМрд╕рдЯреНрдареА", "рдкреИрдВрд╕рдЯреНрдареА", "рдЫрдпрд╕рдЯреНрдареА", "рд╕рддрд╕рдЯреНрдареА", "рдЕрдард╕рдЯреНрдареА", "рдЙрдирдиреНрд╕рддреНрддрд░реА", "рд╕рддреНрддрд░реА", + "рдПрдХрд╣рддреНрддрд░", "рдмрд╛рд╣рддреНрддрд░", "рддреНрд░рд┐рд╣рддреНрддрд░", "рдЪреМрд░рд╣рддреНрддрд░", "рдкрдЪрд╣рддреНрддрд░", "рдЫрдпрд╣рддреНрддрд░", "рд╕рддрд╣рддреНрддрд░", "рдЕрдард╣рддреНрддрд░", "рдЙрдирдиреНрдЕрд╕реА", "рдЕрд╕реА", + "рдПрдХрд╛рд╕реА", "рдмрдпрд╛рд╕реА", "рддреНрд░рд┐рдпрд╛рд╕реА", "рдЪреМрд░рд╛рд╕реА", "рдкрдЪрд╛рд╕реА", "рдЫрдпрд╛рд╕реА", "рд╕рддрд╛рд╕реА", "рдЕрдард╛рд╕реА", "рдЙрдирдиреНрдирдмреНрдмреЗ", "рдирдмреНрдмреЗ", + "рдПрдХрд╛рдирдмреНрдмреЗ", "рдмрдпрд╛рдирдмреНрдмреЗ", "рддреНрд░рд┐рдпрд╛рдирдмреНрдмреЗ", "рдЪреМрд░рд╛рдирдмреНрдмреЗ", "рдкрдЮреНрдЪрд╛рдирдмреНрдмреЗ", "рдЫреНрдпрд╛рдирдмреНрдмреЗ", "рд╕рдиреНрддрд╛рдирдмреНрдмреЗ", "рдЕрдиреНрдард╛рдирдмреНрдмреЗ", "рдЙрдирдиреНрд╕рдп"] + + units = [ + ("", ""), + (100, "рд╕рдп"), + (1000, "рд╣рдЬрд╛рд░"), + (100000, "рд▓рд╛рдЦ"), + (10000000, "рдХрд░реЛрдб"), + (1000000000, "рдЕрд░рдм"), + (100000000000, "рдЦрд░рдм"), + ] + + def _convert(n): + if n == 0: + return "" + if n < 100: + return ones[n] + + for i in range(len(units) - 1, 0, -1): + div, unit_name = units[i] + if n >= div: + prefix_val = n // div + remainder = n % div + + # For 'рд╕рдп' (100), we use ones[prefix_val] + # For others, we might need recursive calls if prefix_val >= 100 + prefix_words = _convert(prefix_val) + res = f"{prefix_words} {unit_name}" + if remainder > 0: + res += f" {_convert(remainder)}" + return res.strip() + return "" + + return _convert(num) + + +def english_to_nepali_unicode(text: Any) -> str: + """ + Converts English text/numbers to Nepali Unicode. + Currently focuses on numbers. + """ + from nepali.number import english_to_nepali + + if text is None: + return "" + + return english_to_nepali(text) diff --git a/example/demo/admin.py b/example/demo/admin.py index 70ab46e..1bfebb6 100644 --- a/example/demo/admin.py +++ b/example/demo/admin.py @@ -7,7 +7,7 @@ from django_nepkit import ( NepaliMonthFilter, ) -from .models import Person, Citizen, AuditedPerson +from .models import Person, Citizen, AuditedPerson, Transaction # Example 1: Basic setup @@ -58,3 +58,9 @@ class CitizenAdmin(NepaliModelAdmin): list_display = ("name", "province", "district", "municipality") # Filters still work with HTMX list_filter = ("province", "district") + + +@admin.register(Transaction) +class TransactionAdmin(NepaliModelAdmin): + list_display = ("title", "amount", "date") + list_filter = (("date", NepaliDateFilter),) diff --git a/example/demo/models.py b/example/demo/models.py index a2e8280..bc2a3bb 100644 --- a/example/demo/models.py +++ b/example/demo/models.py @@ -7,6 +7,7 @@ from django_nepkit import ( ProvinceField, DistrictField, MunicipalityField, + NepaliCurrencyField, ) @@ -52,3 +53,12 @@ class AuditedPerson(models.Model): def __str__(self): return self.name + + +class Transaction(models.Model): + title = models.CharField(max_length=100) + amount = NepaliCurrencyField() + date = NepaliDateField(auto_now_add=True) + + def __str__(self): + return f"{self.title} - {self.amount}" diff --git a/example/demo/templates/demo/person_list.html b/example/demo/templates/demo/person_list.html index c632c35..0bc560d 100644 --- a/example/demo/templates/demo/person_list.html +++ b/example/demo/templates/demo/person_list.html @@ -12,7 +12,8 @@

Person List

- Add New Person + Add New Person | + Add New Transaction
@@ -34,16 +35,16 @@ {% for person in persons %} + - + - + - + - {% empty %} @@ -52,5 +53,46 @@ {% endfor %}
{{ person.name }}{{ person.birth_date_ne }} {{ person.birth_date }}{{ person.birth_date_en }} {{ person.registration_time }} {{ person.phone_number }}{{ person.province_ne }} {{ person.province }}{{ person.province_en }}{{ person.district_ne }} {{ person.district }}{{ person.district_en }}{{ person.municipality_ne }} {{ person.municipality }}{{ person.municipality_en }}
+ +

+ +

Transaction List

+ + + + + + + + + + + + + {% for tx in transactions %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
TitleAmount (Raw)Amount (Formatted)Amount (Words)DateUnicode Example
{{ tx.title }}{{ tx.amount }}{{ tx.amount|nepali_currency }}{{ tx.amount|nepali_words }}{{ tx.date }}{{ tx.title|nepali_unicode }}
No transactions found.
+ +

+ +

Quick Helpers Demo

+ diff --git a/example/demo/urls.py b/example/demo/urls.py index 25ec7ec..a2700e4 100644 --- a/example/demo/urls.py +++ b/example/demo/urls.py @@ -12,4 +12,5 @@ router.register("api/audited", views.AuditedPersonViewSet, basename="audited-api urlpatterns = [ path("", views.person_list, name="person-list"), path("add/", views.person_create, name="person-create"), + path("transactions/add/", views.transaction_create, name="transaction-create"), ] + router.urls diff --git a/example/demo/views.py b/example/demo/views.py index 06ce20b..1268671 100644 --- a/example/demo/views.py +++ b/example/demo/views.py @@ -3,7 +3,7 @@ from django.shortcuts import render, redirect 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 +from .models import Person, Citizen, AuditedPerson, Transaction from .serializers import PersonSerializer, CitizenSerializer, AuditedPersonSerializer @@ -26,7 +26,12 @@ class PersonForm(forms.ModelForm): def person_list(request): persons = Person.objects.all() - return render(request, "demo/person_list.html", {"persons": persons}) + transactions = Transaction.objects.all() + return render( + request, + "demo/person_list.html", + {"persons": persons, "transactions": transactions}, + ) def person_create(request): @@ -40,6 +45,25 @@ def person_create(request): return render(request, "demo/person_form.html", {"form": form}) +class TransactionForm(forms.ModelForm): + class Meta: + model = Transaction + fields = ["title", "amount"] + + +def transaction_create(request): + if request.method == "POST": + form = TransactionForm(request.POST) + if form.is_valid(): + form.save() + return redirect("demo:person-list") + else: + form = TransactionForm() + return render( + request, "demo/person_form.html", {"form": form, "title": "Add Transaction"} + ) + + # --- API Support (DRF) --- diff --git a/todo.md b/todo.md index 0a97ffe..f1e667e 100644 --- a/todo.md +++ b/todo.md @@ -1,9 +1,9 @@ # Features TODO ## Localization & Formatting -- [ ] **Nepali Currency Formatter**: Template tag for formatting money using the Nepali numbering system (Lakhs, Crores). -- [ ] **Numbers to Words**: Convert digits like `1234` into Nepali text (`рдПрдХ рд╣рдЬрд╛рд░ рджреБрдИ рд╕рдп рдЪреМрдВрддреАрд╕`). -- [ ] **Unicode Helpers**: Built-in filters for converting common English text/numbers into Nepali Unicode equivalent. +- [x] **Nepali Currency Formatter**: Template tag for formatting money using the Nepali numbering system (Lakhs, Crores). +- [x] **Numbers to Words**: Convert digits like `1234` into Nepali text (`рдПрдХ рд╣рдЬрд╛рд░ рджреБрдИ рд╕рдп рдЪреМрдВрддреАрд╕`). +- [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.