From ef8c75bf36b5da1f810c9369ec476213a4041db3 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Fri, 30 Jan 2026 20:23:18 +0545 Subject: [PATCH] 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. --- README.md | 16 +++ django_nepkit/serializers.py | 8 +- .../tests/test_address_normalization.py | 68 +++++++++ django_nepkit/tests/test_api_enhancements.py | 53 ++++--- django_nepkit/utils.py | 131 +++++++++++++++++- example/demo/serializers.py | 4 +- .../templates/demo/address_normalize.html | 32 +++++ .../demo/address_normalize_partial.html | 14 ++ example/demo/templates/demo/person_list.html | 5 +- example/demo/urls.py | 5 +- example/demo/views.py | 19 ++- 11 files changed, 322 insertions(+), 33 deletions(-) create mode 100644 django_nepkit/tests/test_address_normalization.py create mode 100644 example/demo/templates/demo/address_normalize.html create mode 100644 example/demo/templates/demo/address_normalize_partial.html diff --git a/README.md b/README.md index e40b32e..6d6a834 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/django_nepkit/serializers.py b/django_nepkit/serializers.py index 0c1c537..39fc943 100644 --- a/django_nepkit/serializers.py +++ b/django_nepkit/serializers.py @@ -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 \ No newline at end of file + return ret diff --git a/django_nepkit/tests/test_address_normalization.py b/django_nepkit/tests/test_address_normalization.py new file mode 100644 index 0000000..642a74c --- /dev/null +++ b/django_nepkit/tests/test_address_normalization.py @@ -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" diff --git a/django_nepkit/tests/test_api_enhancements.py b/django_nepkit/tests/test_api_enhancements.py index dfcc798..fdc102e 100644 --- a/django_nepkit/tests/test_api_enhancements.py +++ b/django_nepkit/tests/test_api_enhancements.py @@ -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") diff --git a/django_nepkit/utils.py b/django_nepkit/utils.py index e152e22..c95c46f 100644 --- a/django_nepkit/utils.py +++ b/django_nepkit/utils.py @@ -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 diff --git a/example/demo/serializers.py b/example/demo/serializers.py index 0de4ba9..a805705 100644 --- a/example/demo/serializers.py +++ b/example/demo/serializers.py @@ -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: diff --git a/example/demo/templates/demo/address_normalize.html b/example/demo/templates/demo/address_normalize.html new file mode 100644 index 0000000..7598036 --- /dev/null +++ b/example/demo/templates/demo/address_normalize.html @@ -0,0 +1,32 @@ + + + + Address Normalization Demo + + + + +

Address Normalization Demo

+

Enter an address string to extract Province, District, and Municipality.

+ +
+
+ + +
+
+ +
+ {% include "demo/address_normalize_partial.html" %} +
+ +
+ Back to List + + diff --git a/example/demo/templates/demo/address_normalize_partial.html b/example/demo/templates/demo/address_normalize_partial.html new file mode 100644 index 0000000..3b1857a --- /dev/null +++ b/example/demo/templates/demo/address_normalize_partial.html @@ -0,0 +1,14 @@ +{% if result %} +
+

Normalized Result:

+ +
+{% elif address %} +
+ No matching location found for "{{ address }}". Try typing a specific name. +
+{% endif %} diff --git a/example/demo/templates/demo/person_list.html b/example/demo/templates/demo/person_list.html index 0bc560d..5b25121 100644 --- a/example/demo/templates/demo/person_list.html +++ b/example/demo/templates/demo/person_list.html @@ -12,8 +12,9 @@

Person List

- Add New Person | - Add New Transaction + Add New Person | + Add New Transaction | + Address Normalization Demo
diff --git a/example/demo/urls.py b/example/demo/urls.py index f936412..c0a5fd6 100644 --- a/example/demo/urls.py +++ b/example/demo/urls.py @@ -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 diff --git a/example/demo/views.py b/example/demo/views.py index 0558c67..f8b7ea1 100644 --- a/example/demo/views.py +++ b/example/demo/views.py @@ -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} + )