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:
11 files changed
+322
-33
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
)
|
||||
Reference in new issue
Block a user