feat: implement address normalization and demo

- Add utility to parse raw address strings into structured Province, District, and Municipality data.
- Support both English and Nepali (Devanagari) inputs with smart matching for character variations.
- Implement comprehensive test suite for address normalization logic.
- Add real-time address normalization demo to the example app using HTMX.
- Update README with documentation and usage examples for the new utility.
This commit is contained in:
sankalp committed 2026-01-30 20:23:18 +05:45
1 parent c6301686cc
commit ef8c75bf36
11 files changed
+322 -33

No files matched your search

+16
View File
@@ -27,6 +27,7 @@ Building software for local requirements comes with unique challenges, from hand
- **🔌 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.
- **🔍 Address Normalization**: Utility to extract structured locations from raw strings.
---
@@ -109,6 +110,21 @@ class Address(models.Model):
province = ProvinceField()
district = DistrictField()
municipality = MunicipalityField()
### Address Normalization
Standardize raw strings into structured location data (Province, District, Municipality).
```python
from django_nepkit.utils import normalize_address
# Supports English or Nepali input
result = normalize_address("House 123, Bharatpur, Chitwan")
# Returns: {'province': 'Bagmati Province', 'district': 'Chitawan', 'municipality': 'Bharatpur Metropolitan City'}
result_ne = normalize_address("विराटनगर, कोशी")
# Returns: {'province': 'कोशी प्रदेश', 'district': 'मोरङ', 'municipality': 'विराटनगर महानगरपालिका'}
```
```
### Server Side Chaining (HTMX)
+5 -3
View File
@@ -142,12 +142,14 @@ class NepaliCurrencySerializerField(serializers.Field):
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)
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
@@ -207,4 +209,4 @@ class NepaliLocalizedSerializerMixin:
except Exception:
continue
return ret
return ret
@@ -0,0 +1,68 @@
from django_nepkit.utils import normalize_address
def test_normalize_address_empty():
assert normalize_address("") == {
"province": None,
"district": None,
"municipality": None,
}
assert normalize_address(None) == {
"province": None,
"district": None,
"municipality": None,
}
def test_normalize_address_english_basic():
# Kathmandu is a district and a municipality
result = normalize_address("Kathmandu")
assert result["district"] == "Kathmandu"
assert result["municipality"] == "Kathmandu Metropolitan City"
assert result["province"] == "Bagmati Province"
def test_normalize_address_english_full():
result = normalize_address("Pokhara, Kaski, Gandaki")
assert result["province"] == "Gandaki Province"
assert result["district"] == "Kaski"
assert result["municipality"] == "Pokhara Metropolitan City"
def test_normalize_address_nepali_basic():
result = normalize_address("काठमाडौं")
assert result["district"] == "काठमाडौं"
assert result["municipality"] == "काठमाडौँ महानगरपालिका"
assert result["province"] == "बागमती प्रदेश"
def test_normalize_address_nepali_full():
result = normalize_address("पोखरा, कास्की, गण्डकी")
assert result["province"] == "गण्डकी प्रदेश"
assert result["district"] == "कास्की"
assert result["municipality"] == "पोखरा महानगरपालिका"
def test_normalize_address_koshi_mapping():
# Test "Koshi" mapping to "Province 1" internally and then back to "Koshi Province"
result = normalize_address("Biratnagar, Koshi")
assert result["province"] == "Koshi Province"
assert result["municipality"] == "Biratnagar Metropolitan City"
result_ne = normalize_address("विराटनगर, कोशी")
assert result_ne["province"] == "कोशी प्रदेश"
assert result_ne["municipality"] == "विराटनगर महानगरपालिका"
def test_normalize_address_with_extra_info():
result = normalize_address("House No 123, Ward 4, Bharatpur, Chitwan")
assert result["district"] == "Chitawan"
assert result["municipality"] == "Bharatpur Metropolitan City"
assert result["province"] == "Bagmati Province"
def test_normalize_address_partial_tokens():
# "Lalitpur" matches both district and municipality
result = normalize_address("Lalitpur")
assert result["district"] == "Lalitpur"
assert result["municipality"] == "Lalitpur Metropolitan City"
+30 -23
View File
@@ -1,38 +1,43 @@
from unittest.mock import MagicMock, patch
from django_nepkit.serializers import NepaliCurrencySerializerField, NepaliLocalizedSerializerMixin
from unittest.mock import MagicMock
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':
if name == "amount":
return NepaliCurrencyField()
if name == 'birth_date':
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.
@@ -44,7 +49,7 @@ def test_nepali_localized_serializer_mixin_logic():
# 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'}
return {"amount": 100000, "birth_date": "2080-01-01"}
class RealTestSerializer(NepaliLocalizedSerializerMixin, Base):
def __init__(self, context):
@@ -52,28 +57,29 @@ def test_nepali_localized_serializer_mixin_logic():
self.Meta = MagicMock()
self.Meta.model = MockModel
serializer = RealTestSerializer(context={'ne': True})
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'] == "२०८०-०१-०१"
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')
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")
@@ -82,16 +88,17 @@ def test_currency_range_filter():
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')
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")
+130 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Any, Optional
from nepali.datetime import nepalidate, nepalidatetime
from nepali.locations import districts, provinces
from nepali.locations import districts, municipalities, provinces
from django_nepkit.conf import nepkit_settings
@@ -296,3 +296,132 @@ def english_to_nepali_unicode(text: Any) -> str:
return ""
return english_to_nepali(text)
def normalize_address(address_string: str) -> dict[str, Optional[str]]:
"""
Attempts to normalize a Nepali address string into Province, District, and Municipality.
Returns a dictionary with 'province', 'district', and 'municipality'.
"""
if not address_string:
return {"province": None, "district": None, "municipality": None}
result = {"province": None, "district": None, "municipality": None}
import re
content = address_string.replace(",", " ").replace("-", " ")
def normalize_nepali(text):
if not text:
return text
# Replace Chandrabindu with Anusvara for easier matching
return text.replace("ँ", "ं").replace("ाँ", "ां")
tokens = [t.strip() for t in content.split() if t.strip()]
normalized_tokens = [normalize_nepali(t) for t in tokens]
# We try to match from most specific to least specific
found_municipality = None
found_district = None
found_province = None
# Helper for name matching
def matches(name_eng, name_nep, token, normalized_token):
token_lower = token.lower()
name_nep_norm = normalize_nepali(name_nep)
# Exact matches
if (
token == name_nep
or normalized_token == name_nep_norm
or token_lower == name_eng.lower()
):
return True
# Handle "Province 1" -> "Koshi" mapping
if (name_eng == "Province 1" and "koshi" in token_lower) or (
name_nep_norm == normalize_nepali("प्रदेश नं. १")
and "कोशी" in normalized_token
):
return True
# Partial matches for English (e.g., "Pokhara" in "Pokhara Metropolitan City")
# Only if token is at least 4 characters to avoid too many false positives
if len(token) >= 4:
if token_lower in name_eng.lower():
return True
# Partial matches for Nepali
if len(normalized_token) >= 2:
if normalized_token in name_nep_norm:
return True
return False
# Check for municipality first
for i, token in enumerate(tokens):
nt = normalized_tokens[i]
for m in municipalities:
if matches(m.name, m.name_nepali, token, nt):
found_municipality = m
break
if found_municipality:
break
# Check for district
for i, token in enumerate(tokens):
nt = normalized_tokens[i]
for d in districts:
if matches(d.name, d.name_nepali, token, nt):
found_district = d
break
if found_district:
break
# Check for province
for i, token in enumerate(tokens):
nt = normalized_tokens[i]
for p in provinces:
if matches(p.name, p.name_nepali, token, nt):
found_province = p
break
if found_province:
break
# Fill in the gaps using hierarchy
if found_municipality:
result["municipality"] = found_municipality.name
if not found_district:
found_district = found_municipality.district
if not found_province:
found_province = found_municipality.province
if found_district:
result["district"] = found_district.name
if not found_province:
found_province = found_district.province
if found_province:
# Handle "Province 1" -> "Koshi Province" consistency
name = found_province.name
if name == "Province 1":
name = "Koshi Province"
result["province"] = name
# Check for Nepali context
is_nepali = any(
re.search(r"[\u0900-\u097F]", t) for t in tokens
) # Basic check for Devanagari characters
if is_nepali:
if found_municipality:
result["municipality"] = found_municipality.name_nepali
if found_district:
result["district"] = found_district.name_nepali
if found_province:
name = found_province.name_nepali
if name == "प्रदेश नं. १":
name = "कोशी प्रदेश"
result["province"] = name
return result
+3 -1
View File
@@ -15,7 +15,9 @@ class PersonSerializer(NepaliLocalizedSerializerMixin, serializers.ModelSerializ
fields = "__all__"
class TransactionSerializer(NepaliLocalizedSerializerMixin, serializers.ModelSerializer):
class TransactionSerializer(
NepaliLocalizedSerializerMixin, serializers.ModelSerializer
):
amount_formatted = NepaliCurrencySerializerField(source="amount")
class Meta:
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<title>Address Normalization Demo</title>
<style>
body { font-family: sans-serif; margin: 20px; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; }
input { width: 400px; padding: 8px; }
.result-box { margin-top: 20px; border: 1px solid #ddd; padding: 15px; background: #f9f9f9; }
</style>
<script src="https://unpkg.com/htmx.org@1.9.5"></script>
</head>
<body>
<h1>Address Normalization Demo</h1>
<p>Enter an address string to extract Province, District, and Municipality.</p>
<form hx-get="{% url 'demo:address-normalize' %}" hx-target="#result-area" hx-trigger="keyup changed delay:300ms from:#address-input">
<div class="form-group">
<label for="address-input">Address String:</label>
<input type="text" id="address-input" name="address" placeholder="e.g. Kathmandu or काठमाडौँ" value="{{ address }}">
</div>
</form>
<div id="result-area">
{% include "demo/address_normalize_partial.html" %}
</div>
<br>
<a href="{% url 'demo:person-list' %}">Back to List</a>
</body>
</html>
@@ -0,0 +1,14 @@
{% if result %}
<div class="result-box">
<h3>Normalized Result:</h3>
<ul>
<li><strong>Province:</strong> {{ result.province|default:"Not found" }}</li>
<li><strong>District:</strong> {{ result.district|default:"Not found" }}</li>
<li><strong>Municipality:</strong> {{ result.municipality|default:"Not found" }}</li>
</ul>
</div>
{% elif address %}
<div style="color: darkorange; margin-top: 20px;">
No matching location found for "{{ address }}". Try typing a specific name.
</div>
{% endif %}
+3 -2
View File
@@ -12,8 +12,9 @@
</head>
<body>
<h1>Person List</h1>
<a href="{% url 'demo:person-create' %}">Add New Person</a> |
<a href="{% url 'demo:transaction-create' %}">Add New Transaction</a>
<a href="{% url 'demo:person-create' %}">Add New Person</a> |
<a href="{% url 'demo:transaction-create' %}">Add New Transaction</a> |
<a href="{% url 'demo:address-normalize' %}">Address Normalization Demo</a>
<hr>
<table>
<thead>
+4 -1
View File
@@ -8,10 +8,13 @@ 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")
router.register(
"api/transactions", views.TransactionViewSet, basename="transaction-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"),
path("normalize/", views.address_normalize_demo, name="address-normalize"),
] + router.urls
+17 -2
View File
@@ -11,11 +11,10 @@ from .serializers import (
TransactionSerializer,
)
from django_nepkit.filters import (
NepaliDateYearFilter,
NepaliDateMonthFilter,
NepaliCurrencyRangeFilter,
NepaliDateRangeFilter,
)
from django_nepkit.utils import normalize_address
class PersonForm(forms.ModelForm):
@@ -137,3 +136,19 @@ class TransactionViewSet(viewsets.ModelViewSet):
fields = ["title", "amount", "date"]
filterset_class = TransactionFilter
def address_normalize_demo(request):
address = request.GET.get("address", "")
result = None
if address:
result = normalize_address(address)
if request.headers.get("HX-Request") == "true":
return render(
request, "demo/address_normalize_partial.html", {"result": result}
)
return render(
request, "demo/address_normalize.html", {"address": address, "result": result}
)