feat: add Nepali currency field, number to words, and unicode helpers
This commit is contained in:
14 files changed
+347
-12
No files matched your search
@@ -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 %}
|
||||
|
||||
<!-- Comma formatting: Rs. 1,12,000.00 -->
|
||||
<p>{{ 112000 | nepali_currency }}</p>
|
||||
|
||||
<!-- Numbers to Words: एक सय तेईस -->
|
||||
<p>{{ 123 | nepali_words }}</p>
|
||||
|
||||
<!-- English to Nepali Digits: १२३ -->
|
||||
<p>{{ "123" | nepali_unicode }}</p>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🕒 Technical Design
|
||||
|
||||
This library is engineered for performance, data integrity, and local compliance.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
+15
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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),)
|
||||
@@ -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}"
|
||||
@@ -12,7 +12,8 @@
|
||||
</head>
|
||||
<body>
|
||||
<h1>Person List</h1>
|
||||
<a href="{% url 'demo:person-create' %}">Add New Person</a>
|
||||
<a href="{% url 'demo:person-create' %}">Add New Person</a> |
|
||||
<a href="{% url 'demo:transaction-create' %}">Add New Transaction</a>
|
||||
<hr>
|
||||
<table>
|
||||
<thead>
|
||||
@@ -34,16 +35,16 @@
|
||||
{% for person in persons %}
|
||||
<tr>
|
||||
<td>{{ person.name }}</td>
|
||||
<td>{{ person.birth_date_ne }}</td>
|
||||
<td>{{ person.birth_date }}</td>
|
||||
<td>{{ person.birth_date_en }}</td>
|
||||
<td>{{ person.registration_time }}</td>
|
||||
<td>{{ person.phone_number }}</td>
|
||||
<td>{{ person.province_ne }}</td>
|
||||
<td>{{ person.province }}</td>
|
||||
<td>{{ person.province_en }}</td>
|
||||
<td>{{ person.district_ne }}</td>
|
||||
<td>{{ person.district }}</td>
|
||||
<td>{{ person.district_en }}</td>
|
||||
<td>{{ person.municipality_ne }}</td>
|
||||
<td>{{ person.municipality }}</td>
|
||||
<td>{{ person.municipality_en }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
@@ -52,5 +53,46 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<br><hr>
|
||||
|
||||
<h1>Transaction List</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Amount (Raw)</th>
|
||||
<th>Amount (Formatted)</th>
|
||||
<th>Amount (Words)</th>
|
||||
<th>Date</th>
|
||||
<th>Unicode Example</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tx in transactions %}
|
||||
<tr>
|
||||
<td>{{ tx.title }}</td>
|
||||
<td>{{ tx.amount }}</td>
|
||||
<td>{{ tx.amount|nepali_currency }}</td>
|
||||
<td>{{ tx.amount|nepali_words }}</td>
|
||||
<td>{{ tx.date }}</td>
|
||||
<td>{{ tx.title|nepali_unicode }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="6">No transactions found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<br><hr>
|
||||
|
||||
<h2>Quick Helpers Demo</h2>
|
||||
<ul>
|
||||
<li><strong>nepali_currency:</strong> 112000 -> {{ 112000|nepali_currency }}</li>
|
||||
<li><strong>nepali_words:</strong> 1234 -> {{ 1234|nepali_words }}</li>
|
||||
<li><strong>nepali_unicode:</strong> "Total: 12345" -> {{ "Total: 12345"|nepali_unicode }}</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
+26
-2
@@ -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) ---
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user