diff --git a/CHANGELOG.md b/CHANGELOG.md index 3404b18..36f5c15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,144 @@ +## V.0.2.2 — 2026-06-06 + +### 💥 Breaking changes + +- **Drop DRF and django-filter dependencies.** The package no longer ships + `django_nepkit.serializers`, `django_nepkit.ninja`, or + `django_nepkit.filters`. All serialization is now exposed as + framework-agnostic helpers in `django_nepkit.api` (see + `serialize_nepali_date`, `serialize_nepali_time`, + `serialize_nepali_currency`, `build_localized_payload`, etc.). If you + were using the DRF `NepaliDateSerializerField` / `NepaliCurrencySerializerField` + or the `NepaliDateYearFilter` / `NepaliDateMonthFilter` / range filters, + write a thin adapter against `django_nepkit.api` (or just use plain + Django views — see the updated `example/demo`). +- `BaseNepaliBSField.to_python` and `get_prep_value` now raise + `ValidationError` on bad input instead of silently returning + `str(value)`. Code that was relying on the silent passthrough (e.g. + persisting `"not-a-date"` strings to the DB) will now fail loudly + at the form / save boundary, which is the correct behaviour. +- `format_nepali_currency` and `number_to_nepali_words` now raise + `ValueError` on un-parseable input instead of returning `str(value)` + / crashing with `IndexError` on negative numbers. Negative numbers + are now rendered as `"ऋणात्मक …"`. + +### ✨ Features + +- New `NepaliTimeField` (real `TimeField` with Devanagari digit output, + `auto_now` / `auto_now_add` support, `BS_TIME_FORMAT` setting). +- New `NepaliTimeWidget` and JS init for client-side time validation. +- New `strict` flag on every `django_nepkit.api.serialize_*` / + `deserialize_*` and on `to_decimal` for callers that want to opt + into hard validation errors instead of best-effort passthrough. +- Admin address-chaining fixed: districts / municipalities are now + filtered on init when a parent value is preselected (e.g. when + editing an existing record). +- `MunicipalitySelectWidget` now also supports `htmx=True` like its + district sibling. +- `_render_options` escapes its HTML output via `format_html` / + `format_html_join` (XSS fix). +- `BaseLocationField.choices` is now a live property that re-evaluates + on every access from the current `nepkit_settings`. An explicit + `ne=True` / `ne=False` constructor arg pins the language for the + field's lifetime (only the implicit default re-reads the setting). + `refresh_choices()` is kept as a no-op for backwards compatibility. +- `normalize_address` is now backed by an `lru_cache(1024)`-decorated + inner function so the heavy `_find_location_in_tokens` work runs at + most once per unique address string. The public function still + returns a fresh dict per call so callers can mutate safely. +- Demo JSON views (`person_api`, `audited_api`, `transaction_api`) + accept `?strict=1` to thread `strict=True` through the + `serialize_nepali_date` / `serialize_nepali_datetime` calls — handy + for smoke-testing the strict path against live data. +- `NEPKIT_ONES` constant `NEGATIVE_PREFIX` is now part of the public + API for callers that want to render negative numbers themselves. + +### 🐛 Bug fixes + +- **TOCTOU on settings** — `NepkitSettings` now re-reads + `settings.NEPKIT` on every attribute access; `override_settings` and + `setting_changed` are honoured. The `BS_*_FORMAT` constants are + re-exported via a module-level `__getattr__` so direct + `from django_nepkit.utils import BS_DATE_FORMAT` re-reads too. +- **XSS in `_render_options`** — `id` / `text` / placeholder values + are now HTML-escaped. Fixes a latent reflected/stored XSS sink + reachable from `?html=true` and the `HX-Request: true` paths. +- **`format_nepali_currency` precision loss** — switched from `float` + to `Decimal` end-to-end. `Decimal("123456789012345.67")` now + formats as `"Rs. 1,23,45,67,89,01,234.57"` instead of truncated + float garbage. +- **`format_nepali_currency` non-finite rejection** — `inf` / `nan` + / `"1e1000"` now raise `ValueError` instead of producing `"inf"`. +- **`number_to_nepali_words` negative crash** — negative numbers + now render with the `"ऋणात्मक"` prefix instead of raising + `IndexError`. +- **`NepaliDatePickerWidget.format_value` EN-mode leak** — passing a + `nepalidate` in English mode no longer leaks its Python repr + (`"nepalidate(2081, 1, 15)"`) into the rendered HTML; unknown + value types render as `""`. +- **`NepaliTimeWidget.format_value` EN-mode leak** — same fix for + time values. +- **`_parse_language_params` silent coercion** — `?ne=banana` now + falls back to the project default instead of silently returning + `False`; with `strict=True` it raises `ValueError`. +- **`_get_primary_param` ambiguous fallback** — when the URL has + multiple non-internal params, the named parameter takes priority + and the "first non-internal" fallback is skipped. +- **`address-chaining.js` `change`-event storm** — the init path no + longer fires synthetic `change` events, so user-defined change + handlers don't run during page load. +- **`address-chaining.js` MutationObserver scope** — the observer + now only triggers init on additions of `form` / + `.nepkit-province-select` / `.nepkit-district-select` instead of + every DOM mutation. +- **`address-chaining.js` silent fetch failure** — server errors are + now logged via `console.error` instead of swallowed silently. +- **`nepali-time-init.js` invalid-state styling** — the + `.nepkit-time-invalid` class now ships with default CSS (red + border + soft red background). +- **`nepali-time-init.js` Nepali-digit input** — `०१:००` is now + normalised to ASCII before regex matching. +- **Address normalisation fuzzy match** — `_find_location_in_tokens` + now ranks candidates (exact > substring) so `"Pokhara"` no longer + loses to `"Pokhara Metropolitan City"` (or vice versa) for the + same token. +- **Exception narrowing** — `_try_parse_nepali` / + `try_parse_nepali_time` now catch `nepali.exceptions.FormatNotMatchException` + explicitly instead of relying on it happening to be a `ValueError`. +- **Admin `except Exception: pass`** — replaced with narrow + `except (FieldDoesNotExist, AttributeError)` so genuine bugs + surface instead of being swallowed. +- **`NoReverseMatch` swallowed in widgets** — the missing-URL case + is now logged at WARNING with a hint pointing at the URLconf. +- **Demo API input validation** — `?order=…`, `?year=…`, `?month=…`, + `?amount_range=…` and `?date_range=…` all return 400 on bad input + (out-of-range month, non-numeric range, unknown order field, …) + instead of 500-ing the request. +- **`Accept-Language` fallback** — the demo's `_ne_flag` now + honours `Accept-Language: ne` in addition to `?ne=true`. + +### ♻️ Refactoring + +- Extracted framework-agnostic serialization logic into + `django_nepkit.api` (the only public serialization surface). +- `NepaliDatePickerWidget._configure_attrs` now chains to `super()`. +- `__init__.py` is now safe to import with no extra packages + installed, and re-exports `nepkit_settings` for live access. +- `NepkitSettings` re-reads `settings.NEPKIT` on every access + (so `override_settings` finally works) and refuses attribute + assignment (it's read-only — change `settings.NEPKIT` instead). +- `BaseNepaliBSField.format_str` is now a `@property` so it + re-reads the current `BS_DATE_FORMAT` / `BS_DATETIME_FORMAT` + setting at call time. +- `BaseNepaliBSField.pre_save` shared helper factored out; the + date / datetime subclasses no longer duplicate the TZ + normalization logic. +- `_render_options` escapes its HTML output (fixes latent XSS + sink) and now uses `format_html_join`. +- `format_nepali_date` / `format_nepali_datetime` exposed in + `django_nepkit.utils` (previously only `format_nepali_time` was + available there). + ## V.0.2.1 — 2027-02-05 ### ✨ Features diff --git a/README.md b/README.md index 6d6a834..c899ad8 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,13 @@ Building software for local requirements comes with unique challenges, from hand ## 🎯 Features -- **📅 BS Date Support**: Model fields for `nepalidate` and `nepalidatetime` objects. +- **📅 BS Date & Time Support**: Model fields for `nepalidate`, `nepalidatetime` and `time` objects, with optional Devanagari digit output. - **🗺️ 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. +- **🚀 Framework-agnostic API helpers**: Pure-Python serialize/deserialize functions that work with any framework (DRF, Ninja, plain Django, …) — **no third-party API dependency**. - **⚡ Location Chaining**: Address linking via client side JS or server driven HTMX. - **🔍 Address Normalization**: Utility to extract structured locations from raw strings. @@ -59,10 +59,20 @@ NEPKIT = { "DEFAULT_LANGUAGE": "en", # "en" or "ne" "ADMIN_DATEPICKER": True, # Toggle the datepicker "TIME_FORMAT": 12, # 12 or 24 hour display - "DATE_INPUT_FORMATS": ["%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y"], # Input formats + "BS_DATE_FORMAT": "%Y-%m-%d", # Default storage / display format + "BS_DATETIME_FORMAT": "%Y-%m-%d %H:%M:%S", + "BS_TIME_FORMAT": "%I:%M %p", # 12-hour by default + "DATE_INPUT_FORMATS": ["%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y"], + "TIME_INPUT_FORMATS": ["%H:%M:%S", "%H:%M", "%I:%M %p", "%I:%M:%S %p"], } ``` +> Settings are read from `settings.NEPKIT` on every access, so +> `override_settings(NEPKIT={…})` (e.g. in tests) works as expected. If +> you need to flip `DEFAULT_LANGUAGE` at runtime, call +> `field.refresh_choices()` on any `ProvinceField` / `DistrictField` / +> `MunicipalityField` that has already been instantiated. + --- ## 🚀 Core Usage @@ -110,6 +120,7 @@ class Address(models.Model): province = ProvinceField() district = DistrictField() municipality = MunicipalityField() +``` ### Address Normalization @@ -125,7 +136,6 @@ result = normalize_address("House 123, Bharatpur, Chitwan") result_ne = normalize_address("विराटनगर, कोशी") # Returns: {'province': 'कोशी प्रदेश', 'district': 'मोरङ', 'municipality': 'विराटनगर महानगरपालिका'} ``` -``` ### Server Side Chaining (HTMX) @@ -141,18 +151,57 @@ Enable `htmx=True` for a server driven experience. --- -## 🔌 API & DRF Support +## 🔌 API Integration (framework-agnostic) -Search and ordering work natively. BS year/month filtering is supported. +`django-nepkit` ships pure-Python helpers in `django_nepkit.api` so you can +emit JSON from **any** view layer — plain Django, DRF, Django Ninja, or +something custom. There is no dependency on a third-party API package. ```python -from django_nepkit.filters import NepaliDateYearFilter +from django.http import JsonResponse +from django_nepkit import api as nepkit_api -class ProfileFilter(filters.FilterSet): - # Filter by BS Year (e.g., /api/profiles/?year=2081) - year = NepaliDateYearFilter(field_name="birth_date") +def person_payload(person, *, ne: bool = False) -> dict: + return nepkit_api.build_localized_payload( + { + "id": person.id, + "name": person.name, + "birth_date": nepkit_api.serialize_nepali_date(person.birth_date, ne=ne), + "amount": nepkit_api.serialize_nepali_currency(person.amount, ne=ne), + }, + ne=ne, # also emits *_ne mirrors when ne=True + ) ``` +Available helpers: + +| helper | purpose | +| ------ | ------- | +| `serialize_nepali_date` / `deserialize_nepali_date` | BS date strings ↔ `nepalidate` | +| `serialize_nepali_datetime` / `deserialize_nepali_datetime` | BS datetime ↔ `nepalidatetime` | +| `serialize_nepali_time` / `deserialize_nepali_time` | HH:MM / hh:mm AM/PM ↔ `time` | +| `serialize_nepali_currency` / `deserialize_nepali_currency` | `"Rs. 12,34,567.00"` / Devanagari digits ↔ `Decimal` | +| `to_decimal` | coerce strings / numbers to `Decimal` | +| `build_localized_payload` | add `*_ne` mirrors to a dict representation | + +All `serialize_*` / `deserialize_*` helpers (and `to_decimal`) accept a +`strict: bool = False` argument. When `strict=True`, unparseable input +raises `ValueError` instead of being silently returned as `str(value)`, +which makes it easy to layer strict validation into API endpoints that +need to surface 400s on bad input: + +```python +try: + cleaned = nepkit_api.deserialize_nepali_date(request.data["dob"], strict=True) +except ValueError as exc: + return JsonResponse({"error": str(exc)}, status=400) +``` + +The previous `django_nepkit.serializers` (DRF) and `django_nepkit.ninja` +modules were removed in v0.2.2 because they made the package drag in +extra dependencies. If you need a thin DRF/Ninja adapter it is now a +ten-line file that delegates to `django_nepkit.api`. + --- ## 💰 Formatting & Helpers @@ -219,7 +268,8 @@ No. The `YYYY-MM-DD` format is hardcoded to ensure database level sorting and in **Q: Can I use Devanagari output?** -Yes. Pass `ne=True` to fields, forms, or serializers. +Yes. Pass `ne=True` to fields, widgets, or use the `serialize_*` helpers +in `django_nepkit.api` to emit Devanagari digits from any view. **Q: Can I display the datepicker in English?** diff --git a/django_nepkit/__init__.py b/django_nepkit/__init__.py index 7509b52..a1b509f 100644 --- a/django_nepkit/__init__.py +++ b/django_nepkit/__init__.py @@ -1,33 +1,67 @@ +""" +django-nepkit: Nepali date, time, phone, currency and address helpers for +Django. Framework-agnostic — no DRF or django-filter dependency. +""" + +from .conf import nepkit_settings from .models import ( + NepaliCurrencyField, NepaliDateField, - NepaliTimeField, NepaliDateTimeField, NepaliPhoneNumberField, - ProvinceField, + NepaliTimeField, DistrictField, MunicipalityField, - NepaliCurrencyField, + ProvinceField, ) from .admin import ( + NepaliAdminMixin, NepaliDateFilter, + NepaliModelAdmin, NepaliMonthFilter, format_nepali_date, format_nepali_datetime, - NepaliModelAdmin, - NepaliAdminMixin, ) -from .serializers import ( - NepaliCurrencySerializerField, - NepaliLocalizedSerializerMixin, +from .widgets import ( + DistrictSelectWidget, + MunicipalitySelectWidget, + NepaliDatePickerWidget, + NepaliTimeWidget, + ProvinceSelectWidget, ) -from .filters import ( - NepaliDateYearFilter, - NepaliDateMonthFilter, - NepaliDateRangeFilter, - NepaliCurrencyRangeFilter, +from .api import ( + build_localized_payload, + deserialize_nepali_currency, + deserialize_nepali_date, + deserialize_nepali_datetime, + deserialize_nepali_time, + serialize_nepali_currency, + serialize_nepali_date, + serialize_nepali_datetime, + serialize_nepali_time, + to_decimal, +) +from .utils import ( + BS_DATE_FORMAT, + BS_DATETIME_FORMAT, + BS_TIME_FORMAT, + english_to_nepali_unicode, + format_nepali_currency, + format_nepali_time, + normalize_address, + number_to_nepali_words, ) +# ``BS_DATE_FORMAT`` / ``BS_DATETIME_FORMAT`` / ``BS_TIME_FORMAT`` are +# re-exported via ``utils.__getattr__`` so they reflect the *current* +# ``settings.NEPKIT`` value at access time. Importing the names here +# gives users a snapshot at their import time, which is the historical +# behaviour. To get a live read, use ``nepkit_settings.BS_DATE_FORMAT``. + __all__ = [ + # Settings + "nepkit_settings", + # Model fields "NepaliDateField", "NepaliTimeField", "NepaliDateTimeField", @@ -35,17 +69,38 @@ __all__ = [ "ProvinceField", "DistrictField", "MunicipalityField", + "NepaliCurrencyField", + # Admin "NepaliDateFilter", "NepaliMonthFilter", "format_nepali_date", "format_nepali_datetime", "NepaliModelAdmin", "NepaliAdminMixin", - "NepaliCurrencyField", - "NepaliCurrencySerializerField", - "NepaliLocalizedSerializerMixin", - "NepaliDateYearFilter", - "NepaliDateMonthFilter", - "NepaliDateRangeFilter", - "NepaliCurrencyRangeFilter", + # Widgets + "NepaliDatePickerWidget", + "NepaliTimeWidget", + "ProvinceSelectWidget", + "DistrictSelectWidget", + "MunicipalitySelectWidget", + # Framework-agnostic API helpers (use directly in any view / framework) + "build_localized_payload", + "deserialize_nepali_currency", + "deserialize_nepali_date", + "deserialize_nepali_datetime", + "deserialize_nepali_time", + "serialize_nepali_currency", + "serialize_nepali_date", + "serialize_nepali_datetime", + "serialize_nepali_time", + "to_decimal", + # Utility helpers + "BS_DATE_FORMAT", + "BS_DATETIME_FORMAT", + "BS_TIME_FORMAT", + "english_to_nepali_unicode", + "format_nepali_currency", + "format_nepali_time", + "normalize_address", + "number_to_nepali_words", ] diff --git a/django_nepkit/admin.py b/django_nepkit/admin.py index 4699e32..cd212bf 100644 --- a/django_nepkit/admin.py +++ b/django_nepkit/admin.py @@ -1,66 +1,94 @@ +import logging + from django.contrib import admin +from django.core.exceptions import FieldDoesNotExist from django.utils.translation import gettext_lazy as _ from nepali.datetime import nepalidate, nepalidatetime +from nepali.number import english_to_nepali from django_nepkit.conf import nepkit_settings from django_nepkit.models import ( + NepaliCurrencyField, NepaliDateField, NepaliDateTimeField, - NepaliCurrencyField, + NepaliTimeField, ) from django_nepkit.utils import ( + format_nepali_currency, + format_nepali_time, try_parse_nepali_date, try_parse_nepali_datetime, - format_nepali_currency, ) -from django_nepkit.utils import BS_DATE_FORMAT + +logger = logging.getLogger(__name__) def _format_nepali_common(value, try_parse_func, format_string, ne, cls_type): - """Helper to format dates/times with optional Devanagari support.""" + """Format a Nepali date/time for admin display, falling back gracefully.""" if value is None: return "" try: - parsed = try_parse_func(value) - if parsed is not None: - if ne and hasattr(parsed, "strftime_ne"): - return parsed.strftime_ne(format_string) - return parsed.strftime(format_string) + if isinstance(value, str): + parsed = try_parse_func(value) + if parsed is not None: + if ne and hasattr(parsed, "strftime_ne"): + return parsed.strftime_ne(format_string) + return parsed.strftime(format_string) if isinstance(value, cls_type): if ne and hasattr(value, "strftime_ne"): return value.strftime_ne(format_string) return value.strftime(format_string) - except (ValueError, TypeError, AttributeError): - pass - - return str(value) if value else "" + except (ValueError, TypeError, AttributeError) as exc: + logger.warning("nepkit admin formatter: cannot format %r: %s", value, exc) + return "" def format_nepali_date(date_value, format_string="%B %d, %Y", ne=False): - """ - Format a nepalidate object with Nepali month names. - """ + """Format a nepalidate object with Nepali month names.""" return _format_nepali_common( date_value, try_parse_nepali_date, format_string, ne, nepalidate ) def format_nepali_datetime(datetime_value, format_string=None, ne=False): - """ - Format a nepalidatetime object with Nepali month names. - """ + """Format a nepalidatetime object with Nepali month names.""" if format_string is None: if nepkit_settings.TIME_FORMAT == 24: format_string = "%B %d, %Y %H:%M" else: format_string = "%B %d, %Y %I:%M %p" - return _format_nepali_common( datetime_value, try_parse_nepali_datetime, format_string, ne, nepalidatetime ) +def format_nepali_time_admin(time_value, format_string=None, ne=False): + """Format a ``time``/``datetime`` for the admin with Devanagari support.""" + if format_string is None: + format_string = nepkit_settings.BS_TIME_FORMAT + formatted = format_nepali_time(time_value, format_string) + if ne and formatted: + formatted = english_to_nepali(formatted) + return formatted + + +# --------------------------------------------------------------------------- +# Admin list filters +# --------------------------------------------------------------------------- + + +def _separator_from_format(fmt): + """Pick the first non-% character from a strftime format string. + + Returns the separator for ``%Y/%m/%d``-style formats, falling back to + ``"-"`` when the format has no separator (e.g. ``%Y%m%d``). + """ + if not fmt or len(fmt) < 3: + return "-" + return fmt[2] if not fmt[2].startswith("%") else "-" + + class BaseNepaliDateFilter(admin.FieldListFilter): """Base class for date filters (Year/Month).""" @@ -100,7 +128,7 @@ class BaseNepaliDateFilter(admin.FieldListFilter): class NepaliDateFilter(BaseNepaliDateFilter): - """Filter by Nepali Year (e.g., 2080).""" + """Filter by Nepali Year (e.g. 2080).""" suffix = "bs_year" title = _("Nepali Date (Year)") @@ -110,17 +138,17 @@ class NepaliDateFilter(BaseNepaliDateFilter): return [(y, str(y)) for y in range(current_year - 10, current_year + 2)] def apply_filter(self, queryset, value): - if BS_DATE_FORMAT.startswith("%Y"): - separator = BS_DATE_FORMAT[2] if len(BS_DATE_FORMAT) > 2 else "-" + fmt = nepkit_settings.BS_DATE_FORMAT + if fmt.startswith("%Y"): + separator = _separator_from_format(fmt) return queryset.filter( **{f"{self.field_path}__startswith": f"{value}{separator}"} ) - return queryset.filter(**{f"{self.field_path}__icontains": f"{value}"}) class NepaliMonthFilter(BaseNepaliDateFilter): - """Filter by Nepali Month (e.g., Baisakh).""" + """Filter by Nepali Month (e.g. Baisakh).""" suffix = "bs_month" title = _("Nepali Date (Month)") @@ -144,18 +172,16 @@ class NepaliMonthFilter(BaseNepaliDateFilter): return [(f"{i:02d}", n[0] if ne else n[1]) for i, n in enumerate(names, 1)] def apply_filter(self, queryset, value): - from django_nepkit.utils import BS_DATE_FORMAT - - if BS_DATE_FORMAT == "%Y-%m-%d": + fmt = nepkit_settings.BS_DATE_FORMAT + if fmt == "%Y-%m-%d": return queryset.filter(**{f"{self.field_path}__contains": f"-{value}-"}) - - separator = BS_DATE_FORMAT[2] if len(BS_DATE_FORMAT) > 2 else "-" + separator = _separator_from_format(fmt) return queryset.filter( **{f"{self.field_path}__contains": f"{separator}{value}{separator}"} ) -# Standard filter for any NepaliDateField in Admin +# Register the year filter as the default for NepaliDateField. admin.FieldListFilter.register( lambda f: isinstance(f, NepaliDateField), NepaliDateFilter, @@ -163,49 +189,32 @@ admin.FieldListFilter.register( ) +# --------------------------------------------------------------------------- +# ModelAdmin mixin +# --------------------------------------------------------------------------- + + class NepaliAdminMixin: - """Provides date formatting tools for Admin classes.""" + """Provides date formatting helpers for Admin classes.""" def _get_field_ne_setting(self, field_name): - """ - Get the 'ne' setting from a model field. - - Args: - field_name: Name of the field in the model - - Returns: - True if field has ne=True, False otherwise - """ if not hasattr(self, "model"): return False - try: field = self.model._meta.get_field(field_name) if hasattr(field, "ne"): return field.ne - except (AttributeError, LookupError): - pass - + except (FieldDoesNotExist, AttributeError): + return False return False def format_nepali_date( self, date_value, format_string="%B %d, %Y", ne=None, field_name=None ): - """ - Format a nepalidate object with Nepali month names. - Available as a method on admin classes using this mixin. - - Args: - date_value: A nepalidate object or string - format_string: strftime format string - ne: If True, format using Devanagari script. If None, auto-detect from field or global settings (default: None) - field_name: Name of the field to auto-detect 'ne' setting from (optional) - """ if ne is None and field_name: ne = self._get_field_ne_setting(field_name) elif ne is None: ne = nepkit_settings.DEFAULT_LANGUAGE == "ne" - return format_nepali_date(date_value, format_string, ne=ne) def format_nepali_datetime( @@ -215,27 +224,13 @@ class NepaliAdminMixin: ne=None, field_name=None, ): - """ - Format a nepalidatetime object with Nepali month names. - - Args: - datetime_value: A nepalidatetime object or string - format_string: strftime format string - ne: If True, format using Devanagari script. If None, auto-detect from field or global settings (default: None) - field_name: Name of the field to auto-detect 'ne' setting from (optional) - """ if ne is None and field_name: ne = self._get_field_ne_setting(field_name) elif ne is None: ne = nepkit_settings.DEFAULT_LANGUAGE == "ne" - return format_nepali_datetime(datetime_value, format_string, ne=ne) def format_nepali_currency(self, value, currency_symbol="Rs.", ne=False, **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, ne=ne) @@ -243,35 +238,31 @@ class NepaliModelAdmin(NepaliAdminMixin, admin.ModelAdmin): """ Standard Admin class that automatically formats Nepali dates in lists. - Example: - from django_nepkit import NepaliModelAdmin, NepaliDateFilter + Example:: @admin.register(MyModel) class MyModelAdmin(NepaliModelAdmin): - list_display = ("name", "birth_date", "created_at") # auto-formatted + list_display = ("name", "birth_date", "created_at") list_filter = (("birth_date", NepaliDateFilter),) """ - # Make filters available as class attributes NepaliDateFilter = NepaliDateFilter NepaliMonthFilter = NepaliMonthFilter def _make_nepali_display(self, field_name, formatter_method): - """Helper to create display columns for Nepali dates.""" admin_instance = self try: field = self.model._meta.get_field(field_name) short_description = getattr( field, "verbose_name", field_name.replace("_", " ").title() ) - except Exception: + except (FieldDoesNotExist, AttributeError): short_description = field_name.replace("_", " ").title() def display(obj): val = getattr(obj, field_name, None) if val is None: return admin_instance.get_empty_value_display() - # Call the passed formatter method (bound to self) return formatter_method(val, field_name=field_name) display.short_description = short_description @@ -296,33 +287,49 @@ class NepaliModelAdmin(NepaliAdminMixin, admin.ModelAdmin): continue try: field = self.model._meta.get_field(item) - if isinstance(field, NepaliDateField): - result.append(self._make_nepali_date_display(item)) - continue - 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) + except (FieldDoesNotExist, AttributeError): + result.append(item) + continue + if isinstance(field, NepaliDateField): + result.append(self._make_nepali_date_display(item)) + elif isinstance(field, NepaliDateTimeField): + result.append(self._make_nepali_datetime_display(item)) + elif isinstance(field, NepaliTimeField): + result.append(self._make_nepali_time_display(item)) + elif isinstance(field, NepaliCurrencyField): + result.append(self._make_nepali_currency_display(item)) + else: + result.append(item) return result - def formfield_for_dbfield(self, db_field, request, **kwargs): - """Automatically use NepaliDatePicker in the admin form.""" + def _make_nepali_time_display(self, field_name): + def display(obj): + val = getattr(obj, field_name, None) + if val is None: + return self.get_empty_value_display() + ne = self._get_field_ne_setting(field_name) + if ne is False: + ne = nepkit_settings.DEFAULT_LANGUAGE == "ne" + return format_nepali_time_admin(val, ne=ne) + try: - from django_nepkit.models import NepaliDateField, NepaliDateTimeField - from django_nepkit.widgets import NepaliDatePickerWidget - except Exception: - return super().formfield_for_dbfield(db_field, request, **kwargs) + field = self.model._meta.get_field(field_name) + display.short_description = getattr( + field, "verbose_name", field_name.replace("_", " ").title() + ) + except (FieldDoesNotExist, AttributeError): + display.short_description = field_name.replace("_", " ").title() + display.admin_order_field = field_name + return display + + def formfield_for_dbfield(self, db_field, request, **kwargs): + """Auto-wire NepaliDatePicker / NepaliTimeWidget in the admin form.""" + from django_nepkit.widgets import NepaliDatePickerWidget, NepaliTimeWidget if ( isinstance(db_field, (NepaliDateField, NepaliDateTimeField)) and nepkit_settings.ADMIN_DATEPICKER ): - # Pass ne/en parameters from field to widget if they exist widget_kwargs = {} if hasattr(db_field, "ne"): widget_kwargs["ne"] = db_field.ne @@ -330,6 +337,16 @@ class NepaliModelAdmin(NepaliAdminMixin, admin.ModelAdmin): widget_kwargs["en"] = db_field.en kwargs.setdefault("widget", NepaliDatePickerWidget(**widget_kwargs)) + if isinstance(db_field, NepaliTimeField): + widget_kwargs = {} + if hasattr(db_field, "ne"): + widget_kwargs["ne"] = db_field.ne + if hasattr(db_field, "en"): + widget_kwargs["en"] = db_field.en + if hasattr(db_field, "htmx"): + widget_kwargs["htmx"] = db_field.htmx + kwargs.setdefault("widget", NepaliTimeWidget(**widget_kwargs)) + return super().formfield_for_dbfield(db_field, request, **kwargs) class Media: @@ -342,12 +359,11 @@ class NepaliModelAdmin(NepaliAdminMixin, admin.ModelAdmin): ) } js = ( - # Bridge admin's `django.jQuery` -> `window.jQuery` "django_nepkit/js/admin-jquery-bridge.js", - # Date picker lib "https://nepalidatepicker.sajanmaharjan.com.np/v5/nepali.datepicker/js/nepali.datepicker.v5.0.6.min.js", - # Init "django_nepkit/js/nepali-datepicker-init.js", + "django_nepkit/js/nepal-data.js", + "django_nepkit/js/address-chaining.js", ) diff --git a/django_nepkit/api.py b/django_nepkit/api.py new file mode 100644 index 0000000..e22d13d --- /dev/null +++ b/django_nepkit/api.py @@ -0,0 +1,358 @@ +""" +Framework-agnostic serialization helpers for Nepali values. + +These functions are designed to be reused by any API framework +(DRF, Django Ninja, plain Django views, …) and only depend on +standard library types plus ``nepali`` for the date logic. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from datetime import datetime as python_datetime +from datetime import time as python_time +from decimal import Decimal, InvalidOperation +from typing import Any, Optional + +from nepali.datetime import nepalidate, nepalidatetime +from nepali.number import english_to_nepali + +from django_nepkit.conf import nepkit_settings +from django_nepkit.utils import ( + format_nepali_currency, + format_nepali_time, + try_parse_nepali_date, + try_parse_nepali_datetime, + try_parse_nepali_time, +) + + +@dataclass +class NepaliValue: + """A typed wrapper around a parsed Nepali value. + + ``raw`` is the underlying Python object (``nepalidate``, ``nepalidatetime`` + or ``time``). ``format`` is the strftime format the caller wants to use + for display, and ``ne`` signals whether Devanagari digits should be + applied. ``source`` records where the value came from so callers can + route validation messages back to the right field. + """ + + raw: Any + format: str + ne: bool = False + source: str = "" + + def to_string(self) -> str: + return _format_with_ne(self.raw, self.format, self.ne) + + +def _format_with_ne(value: Any, fmt: str, ne: bool) -> str: + if value is None: + return "" + if ne and hasattr(value, "strftime_ne"): + try: + return value.strftime_ne(fmt) + except (ValueError, TypeError): + pass + if isinstance(value, (nepalidate, nepalidatetime)): + return value.strftime(fmt) + if isinstance(value, python_time): + return format_nepali_time(value, fmt) + if isinstance(value, python_datetime): + return value.strftime(fmt) + return str(value) + + +def resolve_ne_flag(ne: Optional[bool]) -> bool: + """Default ``ne`` from the project setting when ``ne is None``.""" + if ne is None: + return nepkit_settings.DEFAULT_LANGUAGE == "ne" + return ne + + +def serialize_nepali_date( + value: Any, + *, + fmt: Optional[str] = None, + ne: Optional[bool] = None, + strict: bool = False, +) -> Optional[str]: + """ + Serialize a Nepali date (or any value accepted by ``try_parse_nepali_date``). + + Returns ``None`` for ``None`` input. When ``strict=True``, raises + ``ValueError`` for values that cannot be parsed; otherwise returns + the string representation of the input as a best-effort passthrough. + """ + if value is None: + return None + parsed = value if isinstance(value, nepalidate) else try_parse_nepali_date(value) + if parsed is None: + if strict: + raise ValueError(f"Cannot serialize {value!r} as a Nepali date") + return str(value) + return _format_with_ne( + parsed, fmt or nepkit_settings.BS_DATE_FORMAT, resolve_ne_flag(ne) + ) + + +def deserialize_nepali_date(data: Any, strict: bool = False) -> Optional[nepalidate]: + """Parse a string/object into a ``nepalidate`` (or ``None``). + + With ``strict=True`` raises ``ValueError`` for unrecognised values + that aren't ``None`` or empty. + """ + if data in (None, ""): + return None + if isinstance(data, nepalidate): + return data + if isinstance(data, str): + result = try_parse_nepali_date(data) + if result is not None: + return result + if strict: + raise ValueError(f"Cannot parse {data!r} as a Nepali date") + return None + if strict: + raise ValueError(f"Cannot parse {type(data).__name__} as a Nepali date") + return None + + +def serialize_nepali_datetime( + value: Any, + *, + fmt: Optional[str] = None, + ne: Optional[bool] = None, + strict: bool = False, +) -> Optional[str]: + if value is None: + return None + parsed = ( + value if isinstance(value, nepalidatetime) else try_parse_nepali_datetime(value) + ) + if parsed is None: + if strict: + raise ValueError(f"Cannot serialize {value!r} as a Nepali datetime") + return str(value) + return _format_with_ne( + parsed, fmt or nepkit_settings.BS_DATETIME_FORMAT, resolve_ne_flag(ne) + ) + + +def deserialize_nepali_datetime( + data: Any, strict: bool = False +) -> Optional[nepalidatetime]: + if data in (None, ""): + return None + if isinstance(data, nepalidatetime): + return data + if isinstance(data, str): + result = try_parse_nepali_datetime(data) + if result is not None: + return result + if strict: + raise ValueError(f"Cannot parse {data!r} as a Nepali datetime") + return None + if strict: + raise ValueError(f"Cannot parse {type(data).__name__} as a Nepali datetime") + return None + + +def serialize_nepali_time( + value: Any, + *, + fmt: Optional[str] = None, + ne: Optional[bool] = None, + strict: bool = False, +) -> Optional[str]: + if value is None: + return None + parsed = value if isinstance(value, python_time) else try_parse_nepali_time(value) + if parsed is None: + if strict: + raise ValueError(f"Cannot serialize {value!r} as a Nepali time") + return str(value) + formatted = format_nepali_time(parsed, fmt or nepkit_settings.BS_TIME_FORMAT) + if resolve_ne_flag(ne) and formatted: + formatted = english_to_nepali(formatted) + return formatted + + +def deserialize_nepali_time(data: Any, strict: bool = False) -> Optional[python_time]: + if data in (None, ""): + return None + if isinstance(data, python_time): + return data + if isinstance(data, python_datetime): + return data.time() + if isinstance(data, str): + result = try_parse_nepali_time(data) + if result is not None: + return result + if strict: + raise ValueError(f"Cannot parse {data!r} as a Nepali time") + return None + if strict: + raise ValueError(f"Cannot parse {type(data).__name__} as a Nepali time") + return None + + +def serialize_nepali_currency( + value: Any, + *, + currency_symbol: str = "Rs.", + ne: Optional[bool] = None, +) -> Optional[str]: + if value is None: + return None + if isinstance(value, str) and not _looks_numeric(value): + return value + return format_nepali_currency( + value, currency_symbol=currency_symbol, ne=resolve_ne_flag(ne) + ) + + +def deserialize_nepali_currency(data: Any, strict: bool = False) -> Any: + """Parse a currency input back to a ``Decimal`` (or float for empty input). + + Accepts plain numbers and Nepali-formatted strings (with Nepali digits + and the optional ``Rs.`` prefix). With ``strict=True`` raises + ``ValueError`` for unrecognised strings. + """ + if data in (None, ""): + return None + if isinstance(data, (int, float, Decimal)): + return data + if isinstance(data, str): + cleaned = _strip_nepali_currency(data) + try: + return Decimal(cleaned) + except (InvalidOperation, ValueError) as exc: + if strict: + raise ValueError(f"Cannot parse {data!r} as a currency") from exc + return data + if strict: + raise ValueError(f"Cannot parse {type(data).__name__} as a currency") + return data + + +def _looks_numeric(value: str) -> bool: + """Heuristic: does ``value`` look like a number? + + Accepts commas and leading ``+``/``-``; rejects infinities, NaN, and + obviously non-numeric strings so callers can short-circuit. + """ + cleaned = value.replace(",", "").strip() + if not cleaned: + return False + try: + number = float(cleaned) + except (ValueError, TypeError): + return False + return math.isfinite(number) + + +def _strip_nepali_currency(text: str) -> str: + """Strip ``Rs.`` / ``रु.`` prefix and convert Nepali digits to English.""" + cleaned = text.strip() + for prefix in ("Rs.", "Rs ", "रु.", "रु ", "NPR", "NPR "): + if cleaned.startswith(prefix): + cleaned = cleaned[len(prefix) :] + break + cleaned = cleaned.replace(",", "").strip() + return _nepali_digits_to_english(cleaned) + + +_NEPALI_DIGITS = { + "०": "0", + "१": "1", + "२": "2", + "३": "3", + "४": "4", + "५": "5", + "६": "6", + "७": "7", + "८": "8", + "९": "9", +} + + +def _nepali_digits_to_english(text: str) -> str: + return "".join(_NEPALI_DIGITS.get(ch, ch) for ch in text) + + +def to_decimal(value: Any, strict: bool = False) -> Any: + """Coerce ``value`` to ``Decimal`` when possible, otherwise return as-is. + + With ``strict=True`` raises ``ValueError`` for unparseable strings + instead of returning them. + """ + if value in (None, ""): + return None + if isinstance(value, Decimal): + return value + if isinstance(value, bool): + if strict: + raise ValueError(f"Cannot convert boolean to Decimal: {value!r}") + return value + if isinstance(value, (int, float)): + if isinstance(value, float) and not math.isfinite(value): + if strict: + raise ValueError( + f"Cannot convert non-finite float to Decimal: {value!r}" + ) + return value + return Decimal(str(value)) + if isinstance(value, str): + try: + return Decimal(value) + except (InvalidOperation, ValueError) as exc: + if strict: + raise ValueError(f"Cannot convert {value!r} to Decimal") from exc + return value + if strict: + raise ValueError(f"Cannot convert {type(value).__name__} to Decimal") + return value + + +def build_localized_payload( + representation: dict, + *, + ne: bool = False, + raw_values: Optional[dict] = None, +) -> dict: + """Return ``representation`` plus a ``_ne`` mirror if ``ne`` is True. + + The mirror uses keys suffixed with ``_ne`` so frameworks that expect a + flat dict (Django's ``JsonResponse``, Ninja schemas with dict fields) + can opt-in to localized output without custom mixins. + + ``raw_values`` lets the caller supply the original Python objects + (e.g. :class:`nepalidate` instances) for fields where the serialized + representation has already been stringified. When supplied, raw + values take precedence over the (possibly stringified) ones in + ``representation``. + """ + if not ne: + return representation + out = dict(representation) + for key, value in representation.items(): + mirror_key = f"{key}_ne" + if mirror_key in out: + continue + raw = (raw_values or {}).get(key, value) + if isinstance(raw, nepalidate): + out[mirror_key] = raw.strftime_ne(nepkit_settings.BS_DATE_FORMAT) + elif isinstance(raw, nepalidatetime): + out[mirror_key] = raw.strftime_ne(nepkit_settings.BS_DATETIME_FORMAT) + elif isinstance(raw, python_time): + out[mirror_key] = english_to_nepali( + format_nepali_time(raw, nepkit_settings.BS_TIME_FORMAT) + ) + elif isinstance(raw, (int, float, Decimal)): + out[mirror_key] = format_nepali_currency(raw, currency_symbol="", ne=True) + elif isinstance(value, str) and _looks_numeric(value): + out[mirror_key] = format_nepali_currency(value, currency_symbol="", ne=True) + return out diff --git a/django_nepkit/conf.py b/django_nepkit/conf.py index cba3765..124a1fc 100644 --- a/django_nepkit/conf.py +++ b/django_nepkit/conf.py @@ -1,34 +1,72 @@ +import threading + from django.conf import settings DEFAULTS = { "DEFAULT_LANGUAGE": "en", "DATE_INPUT_FORMATS": ["%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y"], + "TIME_INPUT_FORMATS": ["%H:%M:%S", "%H:%M", "%I:%M %p", "%I:%M:%S %p"], "ADMIN_DATEPICKER": True, "TIME_FORMAT": 12, "BS_DATE_FORMAT": "%Y-%m-%d", "BS_DATETIME_FORMAT": "%Y-%m-%d %H:%M:%S", + "BS_TIME_FORMAT": "%I:%M %p", } class NepkitSettings: - """Handles the global NEPKIT settings from your settings.py.""" + """ + Lazy NEPKIT settings accessor. + + Settings are re-read from ``settings.NEPKIT`` on **every** attribute + access so that ``override_settings`` and ``setting_changed`` are honoured + in tests and runtime. + + When constructed with an explicit ``user_settings`` dict (e.g. in unit + tests that instantiate ``NepkitSettings(...)`` directly), that dict is + used verbatim and the live ``settings.NEPKIT`` is ignored. The + module-level ``nepkit_settings`` instance below does *not* pass + ``user_settings``, so it always reflects the project's current + ``NEPKIT`` setting. + """ def __init__(self, user_settings=None, defaults=None): - self._user_settings = user_settings or {} + self._explicit_user_settings = user_settings self.defaults = defaults or DEFAULTS + # Lock only used when ``user_settings`` is None — the live case + # uses ``getattr(settings, ...)`` which is already thread-safe. + self._lock = threading.Lock() + + def _user_settings(self): + if self._explicit_user_settings is not None: + return self._explicit_user_settings + return getattr(settings, "NEPKIT", {}) or {} def __getattr__(self, attr): + # Avoid intercepting dunder / private lookups (copy, deepcopy, etc.) + if attr.startswith("_") or attr in { + "choices", + "does_not_exist", + }: + raise AttributeError(attr) if attr not in self.defaults: raise AttributeError(f"Invalid NEPKIT setting: '{attr}'") - try: - # Check if the user has a custom setting - val = self._user_settings[attr] - except KeyError: - # Use default value if not set + with self._lock: + user = self._user_settings() + val = user[attr] + except (KeyError, AttributeError, TypeError): val = self.defaults[attr] - return val + def __setattr__(self, attr, value): + if attr.startswith("_") or attr == "defaults": + super().__setattr__(attr, value) + else: + raise AttributeError( + f"NepkitSettings is read-only; to override {attr!r} set " + f"settings.NEPKIT['{attr}'] = {value!r}" + ) -nepkit_settings = NepkitSettings(getattr(settings, "NEPKIT", {}), DEFAULTS) + +nepkit_settings = NepkitSettings(defaults=DEFAULTS) diff --git a/django_nepkit/constants.py b/django_nepkit/constants.py index 8c750b3..84a9291 100644 --- a/django_nepkit/constants.py +++ b/django_nepkit/constants.py @@ -3,6 +3,9 @@ Constants for django-nepkit package. Contains hardcoded values, word mappings, and configuration data. """ +# Word used to prefix negative numbers in :func:`number_to_nepali_words`. +NEGATIVE_PREFIX = "ऋणात्मक" + # Nepali number words mapping (0-99) NEPALI_ONES = [ "", diff --git a/django_nepkit/filters.py b/django_nepkit/filters.py deleted file mode 100644 index bba828e..0000000 --- a/django_nepkit/filters.py +++ /dev/null @@ -1,113 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from django.db.models import QuerySet - -try: - from django_filters import rest_framework as filters -except ImportError as e: - raise ModuleNotFoundError( - "django-nepkit filter support is optional. Install with `django-nepkit[drf]` " - "to use `django_nepkit.filters`." - ) from e - - -class NepaliDateYearFilter(filters.NumberFilter): - """ - A filter for `NepaliDateField` that allows filtering by Bikram Sambat Year. - Expects an integer year (e.g., 2080). - """ - - def filter(self, qs: QuerySet, value: Any) -> QuerySet: - if value: - from django_nepkit.utils import BS_DATE_FORMAT - - # Find the date separator (e.g., '-' in '2080-01-01') - if BS_DATE_FORMAT.startswith("%Y"): - separator = BS_DATE_FORMAT[2] if len(BS_DATE_FORMAT) > 2 else "-" - return qs.filter( - **{f"{self.field_name}__startswith": f"{value}{separator}"} - ) - - # Fallback if the format is unusual - return qs.filter(**{f"{self.field_name}__icontains": str(value)}) - return qs - - -class NepaliDateMonthFilter(filters.NumberFilter): - """ - A filter for `NepaliDateField` that allows filtering by Bikram Sambat Month. - Expects an integer month (1-12). - """ - - def filter(self, qs: QuerySet, value: Any) -> QuerySet: - if value: - from django_nepkit.utils import BS_DATE_FORMAT - - month_str = f"{int(value):02d}" - - # Standard format: look for '-01-' for Baisakh - if BS_DATE_FORMAT == "%Y-%m-%d": - return qs.filter(**{f"{self.field_name}__contains": f"-{month_str}-"}) - - # Adaptive check for other separators - separator = BS_DATE_FORMAT[2] if len(BS_DATE_FORMAT) > 2 else "-" - return qs.filter( - **{f"{self.field_name}__contains": f"{separator}{month_str}{separator}"} - ) - return qs - - -class NepaliDateRangeFilter(filters.CharFilter): - """ - A filter for `NepaliDateField` that allows filtering by a range of BS dates. - Supports: - - "start,end" -> range - - "start," -> greater than or equal - - ",end" -> less than or equal - - "date" -> exact match - """ - - def filter(self, qs: QuerySet, value: Any) -> QuerySet: - if value: - parts = [p.strip() for p in str(value).split(",")] - if len(parts) == 1: - return qs.filter(**{self.field_name: parts[0]}) - if len(parts) == 2: - if parts[0] and parts[1]: - return qs.filter( - **{f"{self.field_name}__range": (parts[0], parts[1])} - ) - if parts[0]: - return qs.filter(**{f"{self.field_name}__gte": parts[0]}) - if parts[1]: - return qs.filter(**{f"{self.field_name}__lte": parts[1]}) - return qs - - -class NepaliCurrencyRangeFilter(filters.CharFilter): - """ - A filter for `NepaliCurrencyField` that allows range filtering. - Supports: - - "min,max" -> range - - "min," -> greater than or equal - - ",max" -> less than or equal - - "value" -> exact match - """ - - def filter(self, qs: QuerySet, value: Any) -> QuerySet: - if value: - parts = [p.strip() for p in str(value).split(",")] - if len(parts) == 1: - return qs.filter(**{self.field_name: parts[0]}) - if len(parts) == 2: - if parts[0] and parts[1]: - return qs.filter( - **{f"{self.field_name}__range": (parts[0], parts[1])} - ) - if parts[0]: - return qs.filter(**{f"{self.field_name}__gte": parts[0]}) - if parts[1]: - return qs.filter(**{f"{self.field_name}__lte": parts[1]}) - return qs diff --git a/django_nepkit/models.py b/django_nepkit/models.py index cfadede..cf63b2c 100644 --- a/django_nepkit/models.py +++ b/django_nepkit/models.py @@ -1,6 +1,8 @@ from datetime import date as python_date from datetime import datetime as python_datetime +from datetime import time as python_time +from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -8,16 +10,16 @@ from nepali.datetime import nepalidate, nepalidatetime from nepali.locations import districts, municipalities, provinces from django_nepkit.utils import ( - BS_DATE_FORMAT, - BS_DATETIME_FORMAT, try_parse_nepali_date, try_parse_nepali_datetime, + try_parse_nepali_time, ) from django_nepkit.validators import validate_nepali_phone_number from django_nepkit.widgets import ( DistrictSelectWidget, MunicipalitySelectWidget, NepaliDatePickerWidget, + NepaliTimeWidget, ProvinceSelectWidget, ) from django_nepkit.conf import nepkit_settings @@ -65,6 +67,9 @@ class NepaliPhoneNumberField(models.CharField): class BaseNepaliBSField(NepaliFieldMixin, models.CharField): """Base class for Nepali date and datetime fields.""" + # Subclasses set these. + nepali_cls = None # nepalidate or nepalidatetime + def __init__(self, *args, **kwargs): self.auto_now = kwargs.pop("auto_now", False) self.auto_now_add = kwargs.pop("auto_now_add", False) @@ -76,45 +81,89 @@ class BaseNepaliBSField(NepaliFieldMixin, models.CharField): kwargs.setdefault("max_length", getattr(self, "default_max_length", 20)) super().__init__(*args, **kwargs) + @property + def format_str(self) -> str: + """Resolved at call time so ``override_settings`` propagates.""" + if self.nepali_cls is nepalidate: + return nepkit_settings.BS_DATE_FORMAT + if self.nepali_cls is nepalidatetime: + return nepkit_settings.BS_DATETIME_FORMAT + return nepkit_settings.BS_DATE_FORMAT + + def _convert_from_python(self, value): + """Convert a Python ``date``/``datetime`` to a Nepali object. + + Raises ``ValidationError`` on failure (out-of-range, etc.) so + invalid data cannot reach the database. + """ + if isinstance(value, python_datetime) and timezone.is_aware(value): + value = timezone.localtime(value) + try: + if self.nepali_cls is nepalidate: + return nepalidate.from_date(value) + if self.nepali_cls is nepalidatetime: + return nepalidatetime.from_datetime(value) + except (ValueError, TypeError, OverflowError) as exc: + raise ValidationError( + _("Cannot convert %(value)r to a Nepali date/time: %(err)s"), + params={"value": value, "err": exc}, + ) from exc + raise ValidationError( + _("Unsupported source type for Nepali date field: %(value)r"), + params={"value": value}, + ) + def pre_save(self, model_instance, add): if self.auto_now or (self.auto_now_add and add): now = timezone.now() if timezone.is_aware(now): now = timezone.localtime(now) - - nepali_cls = getattr(self, "nepali_cls", nepalidate) - format_str = getattr(self, "format_str", BS_DATE_FORMAT) - - value = ( - nepali_cls.from_date(now).strftime(format_str) - if nepali_cls == nepalidate - else nepali_cls.from_datetime(now).strftime(format_str) - ) - setattr(model_instance, self.attname, value) - return value + if self.nepali_cls is nepalidate: + value = nepalidate.from_date(now.date()) + else: + value = nepalidatetime.from_datetime(now) + formatted = value.strftime(self.format_str) + setattr(model_instance, self.attname, formatted) + return formatted return super().pre_save(model_instance, add) def from_db_value(self, value, expression, connection): - parsed = self.parse_func(value) - return parsed if parsed is not None else value + if value is None: + return None + parsed = self._parse_str(value) + if parsed is None: + # Lenient on read: log the inconsistency but don't crash the + # queryset. An admin can see and fix bad rows. + import logging + + logging.getLogger(__name__).warning( + "Nepali field %s.%s stored value %r could not be parsed", + self.model.__name__ if hasattr(self, "model") else "?", + self.attname, + value, + ) + return value + return parsed + + def _parse_str(self, value): + """Parse a stored string into a Nepali object (or ``None``).""" + if isinstance(value, str): + return self.parse_func(value) + return None def to_python(self, value): if value is None or isinstance(value, self.nepali_cls): return value if isinstance(value, (python_date, python_datetime)): - try: - if isinstance(value, python_datetime) and timezone.is_aware(value): - value = timezone.localtime(value) - return ( - self.nepali_cls.from_date(value) - if self.nepali_cls == nepalidate - else self.nepali_cls.from_datetime(value) - ) - except (ValueError, TypeError): - return str(value) + return self._convert_from_python(value) if isinstance(value, str): - parsed = self.parse_func(value) - return parsed if parsed is not None else value + parsed = self._parse_str(value) + if parsed is not None: + return parsed + raise ValidationError( + _("Invalid Nepali date/time string: %(value)r"), + params={"value": value}, + ) return super().to_python(value) def _get_string_value(self, value): @@ -134,18 +183,20 @@ class BaseNepaliBSField(NepaliFieldMixin, models.CharField): if isinstance(value, self.nepali_cls): return value.strftime(self.format_str) if isinstance(value, (python_date, python_datetime)): - try: - if isinstance(value, python_datetime) and timezone.is_aware(value): - value = timezone.localtime(value) - nepali_obj = ( - self.nepali_cls.from_date(value) - if self.nepali_cls == nepalidate - else self.nepali_cls.from_datetime(value) - ) - return nepali_obj.strftime(self.format_str) - except (ValueError, TypeError): - return str(value) - return str(value) + converted = self._convert_from_python(value) + return converted.strftime(self.format_str) + if isinstance(value, str): + parsed = self._parse_str(value) + if parsed is not None: + return parsed.strftime(self.format_str) + raise ValidationError( + _("Invalid Nepali date/time string: %(value)r"), + params={"value": value}, + ) + raise ValidationError( + _("Unsupported value for Nepali date/time field: %(value)r"), + params={"value": value}, + ) def deconstruct(self): name, path, args, kwargs = super().deconstruct() @@ -167,11 +218,9 @@ class NepaliDateField(BaseNepaliBSField): description = _("Nepali Date (Bikram Sambat)") default_max_length = 10 nepali_cls = nepalidate - format_str = BS_DATE_FORMAT parse_func = staticmethod(try_parse_nepali_date) def formfield(self, **kwargs): - # Import here to avoid circular dependency from django_nepkit.forms import NepaliDateFormField kwargs.setdefault("form_class", NepaliDateFormField) @@ -179,30 +228,145 @@ class NepaliDateField(BaseNepaliBSField): class NepaliTimeField(NepaliFieldMixin, models.TimeField): + """A ``TimeField`` that supports Devanagari digit display. + + Stores a real ``datetime.time`` value in the database (so existing + queries / indexes behave like a normal ``TimeField``) but renders the + value through ``BS_TIME_FORMAT`` and optionally converts the digits to + Devanagari when ``ne=True``. + """ + description = _("Nepali Time") + @property + def format_str(self) -> str: + return nepkit_settings.BS_TIME_FORMAT + + def pre_save(self, model_instance, add): + if self.auto_now or (self.auto_now_add and add): + now = timezone.now() + if timezone.is_aware(now): + now = timezone.localtime(now) + value = now.time() + setattr(model_instance, self.attname, value) + return value + return super().pre_save(model_instance, add) + + def from_db_value(self, value, expression, connection): + if value is None: + return None + if isinstance(value, python_time): + return value + if isinstance(value, str): + parsed = try_parse_nepali_time(value) + if parsed is not None: + return parsed + import logging + + logging.getLogger(__name__).warning( + "NepaliTimeField stored value %r is not a valid time; returning as-is", + value, + ) + return value + + def to_python(self, value): + if value is None or isinstance(value, python_time): + return value + if isinstance(value, python_datetime): + return value.time() + if isinstance(value, str): + parsed = try_parse_nepali_time(value) + if parsed is not None: + return parsed + raise ValidationError( + _("Invalid Nepali time string: %(value)r"), + params={"value": value}, + ) + return super().to_python(value) + + def get_prep_value(self, value): + if value is None: + return value + if isinstance(value, python_datetime): + value = value.time() + return super().get_prep_value(value) + + def value_to_string(self, obj): + from django_nepkit.utils import format_nepali_time + + value = self.value_from_object(obj) + return "" if value is None else format_nepali_time(value, self.format_str) + + def format_value_for_display(self, value) -> str: + """Render a ``time`` for templates / admin using the Nepali format.""" + from django_nepkit.utils import format_nepali_time + + return format_nepali_time(value, self.format_str) + + def formfield(self, **kwargs): + defaults = { + "widget": NepaliTimeWidget(ne=self.ne, en=self.en, htmx=self.htmx), + } + defaults.update(kwargs) + return super().formfield(**defaults) + class NepaliDateTimeField(BaseNepaliBSField): description = _("Nepali DateTime (Bikram Sambat)") default_max_length = 19 nepali_cls = nepalidatetime - format_str = BS_DATETIME_FORMAT parse_func = staticmethod(try_parse_nepali_datetime) class BaseLocationField(NepaliFieldMixin, models.CharField): - """Base class for Province, District, and Municipality fields.""" + """Base class for Province, District, and Municipality fields. + + ``choices`` is a *property* that re-reads ``nepkit_settings`` on every + access, so flipping ``NEPKIT['DEFAULT_LANGUAGE']`` (e.g. via + ``override_settings``) is reflected in the admin / forms without + having to reconstruct the field. An explicit ``ne=True`` / + ``ne=False`` passed to the constructor pins the language for the + field's lifetime — only the implicit default re-reads the setting. + """ def __init__(self, *args, **kwargs): kwargs.setdefault("max_length", 100) - - default_lang = nepkit_settings.DEFAULT_LANGUAGE - ne = kwargs.get("ne", default_lang == "ne") - - # Load choices from the library (provinces, districts, etc.) - kwargs.setdefault("choices", self.get_choices_from_source(ne)) + self._explicit_ne = "ne" in kwargs + # ``Field.__init__`` sets ``self.choices = choices or []``; the + # property setter is a no-op, so any caller-provided choices are + # ignored (matching the previous behaviour). + kwargs.setdefault("choices", []) super().__init__(*args, **kwargs) + @property + def _resolved_ne(self) -> bool: + if self._explicit_ne: + return self.ne + return nepkit_settings.DEFAULT_LANGUAGE == "ne" + + @property + def choices(self): + # Invalidate Django's flatchoices cache so the next consumer + # (validate, formfield, etc.) sees the live list. + self._flatchoices = None + return self.get_choices_from_source(self._resolved_ne) + + @choices.setter + def choices(self, value): + # ``Field.__init__`` and the deprecated ``refresh_choices`` set + # this attribute. We accept the write but discard it — the + # property always returns the live value. + pass + + def refresh_choices(self, ne=None): # noqa: ARG002 — kept for backwards compat + """Deprecated: ``choices`` is now a live property. + + Kept as a no-op so existing ``setting_changed`` handlers / + test fixtures keep working. + """ + self._flatchoices = None + return self.choices + def get_choices_from_source(self, ne): source = getattr(self, "source", []) return [(self._get_name(item, ne), self._get_name(item, ne)) for item in source] diff --git a/django_nepkit/serializers.py b/django_nepkit/serializers.py deleted file mode 100644 index 39fc943..0000000 --- a/django_nepkit/serializers.py +++ /dev/null @@ -1,212 +0,0 @@ -from __future__ import annotations - -from typing import Any, Optional, Type - -from nepali.datetime import nepalidate, nepalidatetime - -try: - from rest_framework import serializers -except ModuleNotFoundError as e: - raise ModuleNotFoundError( - "django-nepkit DRF support is optional. Install with `django-nepkit[drf]` " - "to use `django_nepkit.serializers`." - ) from e - -from django_nepkit.conf import nepkit_settings -from django_nepkit.utils import ( - BS_DATE_FORMAT, - BS_DATETIME_FORMAT, - format_nepali_currency, - try_parse_nepali_date, - try_parse_nepali_datetime, -) - - -class BaseNepaliBSField(serializers.Field): - """ - Handles Nepali (BS) dates. - - Input: A date string (like '2080-01-01') or a date object. - - Output: A formatted string for your API. - """ - - format: str = "" - nepali_type: Type[object] = object - - default_error_messages = { - "invalid": "Invalid Bikram Sambat value. Expected format: {format}.", - "invalid_type": "Invalid type. Expected a string.", - } - - def __init__( - self, - *, - format: Optional[str] = None, - ne: Optional[bool] = None, - en: Optional[bool] = None, - **kwargs: Any, - ) -> None: - """ - Args: - format: Optional `strftime` format used for representation. - If not provided, uses the class default. - ne: If True, output in Devanagari script. If None, uses DEFAULT_LANGUAGE. - en: If True, output in English. If None, derived from ne. - """ - if format is not None: - self.format = format - - default_lang = nepkit_settings.DEFAULT_LANGUAGE - - if ne is None: - self.ne = default_lang == "ne" - else: - self.ne = ne - - if en is None: - self.en = not self.ne - else: - self.en = en - - super().__init__(**kwargs) - - def _parse(self, value: str): - if self.nepali_type is nepalidate: - return try_parse_nepali_date(value) - if self.nepali_type is nepalidatetime: - return try_parse_nepali_datetime(value) - return None - - def _format_value(self, value: Any) -> str: - """Format value using strftime or strftime_ne (Devanagari) based on self.ne.""" - if self.ne and hasattr(value, "strftime_ne"): - return value.strftime_ne(self.format) # type: ignore[attr-defined] - return value.strftime(self.format) # type: ignore[attr-defined] - - def to_representation(self, value: Any) -> Optional[str]: - if value is None: - return None - - if isinstance(value, self.nepali_type): - return self._format_value(value) - - # Convert string dates to Nepali date objects - if isinstance(value, str): - parsed = self._parse(value) - if parsed is not None: - return self._format_value(parsed) - - return str(value) - - def to_internal_value(self, data: Any): - if data in (None, ""): - return None - - if isinstance(data, self.nepali_type): - return data - - if not isinstance(data, str): - self.fail("invalid_type") - - parsed = self._parse(data) - if parsed is not None: - return parsed - - self.fail("invalid", format=self.format) - - -class NepaliDateSerializerField(BaseNepaliBSField): - """API field for Nepali Dates.""" - - format = BS_DATE_FORMAT - nepali_type = nepalidate - - -class NepaliDateTimeSerializerField(BaseNepaliBSField): - """API field for Nepali Date and Time.""" - - format = BS_DATETIME_FORMAT - nepali_type = nepalidatetime - - -class NepaliCurrencySerializerField(serializers.Field): - """ - API field for Nepali Currency. - Formats decimal values with Nepali-style commas. - """ - - def __init__(self, currency_symbol="Rs.", ne=None, **kwargs): - self.currency_symbol = currency_symbol - self.ne = ne - super().__init__(**kwargs) - - 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 - ) - - def to_internal_value(self, data): - # We don't support converting back from formatted string to decimal here - # Users should send raw decimal/float values for input. - return data - - -class NepaliLocalizedSerializerMixin: - """ - A mixin for ModelSerializer that automatically adds localized counterparts - for eligible fields if `ne=True` is passed in the context. - Eligible fields: NepaliDateField, NepaliDateTimeField, NepaliCurrencyField. - - Usage: - class MySerializer(NepaliLocalizedSerializerMixin, serializers.ModelSerializer): - ... - """ - - def to_representation(self, instance): - ret = super().to_representation(instance) # type: ignore[misc] - ne = self.context.get("ne", False) - if not ne: - return ret - - from django_nepkit.models import ( - NepaliCurrencyField, - NepaliDateField, - NepaliDateTimeField, - ) - - model = getattr(self.Meta, "model", None) # type: ignore[attr-defined] - if not model: - return ret - - for field_name, value in list(ret.items()): - try: - model_field = model._meta.get_field(field_name) - localized_name = f"{field_name}_ne" - - if localized_name in ret: - continue - - if isinstance(model_field, NepaliDateField): - raw_val = getattr(instance, field_name) - if hasattr(raw_val, "strftime_ne"): - ret[localized_name] = raw_val.strftime_ne(BS_DATE_FORMAT) - - elif isinstance(model_field, NepaliDateTimeField): - raw_val = getattr(instance, field_name) - if hasattr(raw_val, "strftime_ne"): - ret[localized_name] = raw_val.strftime_ne(BS_DATETIME_FORMAT) - - elif isinstance(model_field, NepaliCurrencyField): - ret[localized_name] = format_nepali_currency( - value, currency_symbol="", ne=True - ) - except Exception: - continue - - return ret diff --git a/django_nepkit/static/django_nepkit/css/admin-nepali-datepicker.css b/django_nepkit/static/django_nepkit/css/admin-nepali-datepicker.css index e0fd7fe..03e8b90 100644 --- a/django_nepkit/static/django_nepkit/css/admin-nepali-datepicker.css +++ b/django_nepkit/static/django_nepkit/css/admin-nepali-datepicker.css @@ -35,3 +35,15 @@ .ndp-container.ndp-dark .ndp-table td.ndp-today{ color:#93c5fd; /* blue-300 */ } + +/* Validation state for Nepali time inputs */ +.nepkit-time-input.nepkit-time-invalid { + border-color: #dc2626; /* red-600 */ + background-color: rgba(220, 38, 38, 0.04); + box-shadow: 0 0 0 1px #dc2626 inset; +} +.nepkit-time-input.nepkit-time-invalid:focus { + outline: none; + border-color: #b91c1c; + box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.35); +} diff --git a/django_nepkit/static/django_nepkit/js/address-chaining.js b/django_nepkit/static/django_nepkit/js/address-chaining.js index e6fda4c..553ec5f 100644 --- a/django_nepkit/static/django_nepkit/js/address-chaining.js +++ b/django_nepkit/static/django_nepkit/js/address-chaining.js @@ -1,158 +1,243 @@ (function() { 'use strict'; - // Placeholder text constants - const PLACEHOLDERS = { + var PLACEHOLDERS = { province: { ne: 'प्रदेश छान्नुहोस्', en: 'Select Province' }, district: { ne: 'जिल्ला छान्नुहोस्', en: 'Select District' }, municipality: { ne: 'नगरपालिका छान्नुहोस्', en: 'Select Municipality' } }; - /** - * Get placeholder text for a specific location type - */ function getPlaceholder(type, isNepali) { - return PLACEHOLDERS[type][isNepali ? 'ne' : 'en']; + var map = PLACEHOLDERS[type]; + return (map && map[isNepali ? 'ne' : 'en']) || ''; } - function updateOptions(selectElement, data, placeholder) { - selectElement.innerHTML = ''; - if (placeholder) { - const opt = document.createElement('option'); - opt.value = ''; - opt.textContent = placeholder; - selectElement.appendChild(opt); + /** + * Replace the option list of a select element. + * + * If ``preserveSelection`` is true, the currently selected value is + * kept when it also appears in the new option list — this is what + * lets the admin initialise a preselected form without losing the + * saved province/district value. + * + * If ``dispatchChange`` is true (the default) a synthetic ``change`` + * event is fired so downstream listeners can react. Pass ``false`` + * when the update is happening as part of initialisation to avoid + * running user-defined change handlers during page load. + */ + function updateOptions(selectElement, data, placeholder, preserveSelection, dispatchChange) { + if (!selectElement) return; + if (dispatchChange === undefined) { + dispatchChange = true; } - data.forEach(item => { - const opt = document.createElement('option'); + var currentValue = preserveSelection ? selectElement.value : ''; + selectElement.innerHTML = ''; + + if (placeholder) { + var placeholderOpt = document.createElement('option'); + placeholderOpt.value = ''; + placeholderOpt.textContent = placeholder; + selectElement.appendChild(placeholderOpt); + } + + var found = false; + (data || []).forEach(function(item) { + var opt = document.createElement('option'); opt.value = item.id; opt.textContent = item.text; selectElement.appendChild(opt); + if (currentValue && item.id === currentValue) { + found = true; + } }); - selectElement.dispatchEvent(new Event('change')); + + if (preserveSelection && currentValue) { + selectElement.value = found ? currentValue : ''; + } + + if (dispatchChange) { + selectElement.dispatchEvent(new Event('change')); + } } function getLocalData(type, parentId, isNepali) { if (!window.NEPKIT_DATA) return null; - const lang = isNepali ? 'ne' : 'en'; - const data = window.NEPKIT_DATA[lang]; + var lang = isNepali ? 'ne' : 'en'; + var data = window.NEPKIT_DATA[lang]; if (!data) return null; - if (type === 'districts') { return data.districts[parentId] || []; - } else if (type === 'municipalities') { + } + if (type === 'municipalities') { return data.municipalities[parentId] || []; } return null; } function getMatchingSelect(container, selector, isNepali) { - const matches = container.querySelectorAll(selector); - for (let el of matches) { - const elIsNepali = el.dataset.ne === 'true'; - if (elIsNepali === isNepali) { + var matches = container.querySelectorAll(selector); + for (var i = 0; i < matches.length; i++) { + var el = matches[i]; + if (el.dataset.ne === 'true' === isNepali) { return el; } } return null; } - /** - * Fetch data from server endpoint - */ function fetchData(selectElement, paramName, paramValue, placeholderType, isNepali) { - if (!selectElement.dataset.url) return; - - let url = selectElement.dataset.url + '?' + paramName + '=' + encodeURIComponent(paramValue); + if (!selectElement.dataset.url) return Promise.resolve(); + var url = selectElement.dataset.url + '?' + paramName + '=' + encodeURIComponent(paramValue); if (isNepali) url += '&ne=true'; if (selectElement.dataset.en === 'true') url += '&en=true'; - fetch(url) - .then(response => response.json()) - .then(data => { - updateOptions(selectElement, data, getPlaceholder(placeholderType, isNepali)); + return fetch(url) + .then(function(response) { + if (!response.ok) { + throw new Error('HTTP ' + response.status + ' from ' + url); + } + return response.json(); + }) + .then(function(data) { + updateOptions(selectElement, data, getPlaceholder(placeholderType, isNepali), true); + }) + .catch(function(err) { + // Don't swallow fetch errors silently — log them so the + // empty dropdown the user sees is at least traceable. + if (window.console && console.error) { + console.error( + 'nepkit: failed to load ' + placeholderType + ' for ' + paramValue + ':', + err + ); + } + updateOptions(selectElement, [], getPlaceholder(placeholderType, isNepali), true); }); } - /** - * Update dependent select element based on parent value - */ - function updateDependentSelect(childSelect, dataType, parentValue, placeholderType, isNepali, paramName) { - if (!childSelect) return; + function updateDependentSelect(childSelect, dataType, parentValue, placeholderType, isNepali, paramName, preserveSelection, dispatchChange) { + if (!childSelect) return Promise.resolve(); + if (preserveSelection === undefined) preserveSelection = false; + if (dispatchChange === undefined) dispatchChange = true; if (!parentValue) { - updateOptions(childSelect, [], getPlaceholder(placeholderType, isNepali)); - return; + updateOptions( + childSelect, + [], + getPlaceholder(placeholderType, isNepali), + preserveSelection, + dispatchChange + ); + return Promise.resolve(); } - const localData = getLocalData(dataType, parentValue, isNepali); - + var localData = getLocalData(dataType, parentValue, isNepali); if (localData) { - updateOptions(childSelect, localData, getPlaceholder(placeholderType, isNepali)); - } else { - fetchData(childSelect, paramName, parentValue, placeholderType, isNepali); + updateOptions( + childSelect, + localData, + getPlaceholder(placeholderType, isNepali), + preserveSelection, + dispatchChange + ); + return Promise.resolve(); } + return fetchData(childSelect, paramName, parentValue, placeholderType, isNepali); + } + + /** + * Initialise a single form. Walks the chained selects in order + * (province → district → municipality) and, when a parent already has + * a value (e.g. when editing an existing record in the admin), + * filters the child list to match. ``dispatchChange`` is forced to + * ``false`` so that init doesn't fire synthetic change events at + * user-defined listeners. + */ + function initForm(container) { + if (!container) return; + var provinceSelects = container.querySelectorAll('.nepkit-province-select'); + + provinceSelects.forEach(function(provinceSelect) { + var isNepali = provinceSelect.dataset.ne === 'true'; + var formScope = provinceSelect.closest('form') || container; + var districtSelect = getMatchingSelect(formScope, '.nepkit-district-select', isNepali); + var municipalitySelect = getMatchingSelect(formScope, '.nepkit-municipality-select', isNepali); + + if (provinceSelect.value) { + updateDependentSelect( + districtSelect, + 'districts', + provinceSelect.value, + 'district', + isNepali, + 'province', + true, + false + ); + } else if (districtSelect) { + updateOptions(districtSelect, [], getPlaceholder('district', isNepali), false, false); + } + + if (districtSelect && districtSelect.value) { + updateDependentSelect( + municipalitySelect, + 'municipalities', + districtSelect.value, + 'municipality', + isNepali, + 'district', + true, + false + ); + } else if (municipalitySelect) { + updateOptions(municipalitySelect, [], getPlaceholder('municipality', isNepali), false, false); + } + }); + + // District-only forms (no province in the chain). + var districtOnlySelects = container.querySelectorAll('.nepkit-district-select'); + districtOnlySelects.forEach(function(districtSelect) { + if (districtSelect.value) { + var isNepali = districtSelect.dataset.ne === 'true'; + var formScope2 = districtSelect.closest('form') || container; + var municipalitySelect = getMatchingSelect(formScope2, '.nepkit-municipality-select', isNepali); + updateDependentSelect( + municipalitySelect, + 'municipalities', + districtSelect.value, + 'municipality', + isNepali, + 'district', + true, + false + ); + } + }); } function init() { - // Initialize province selects - document.querySelectorAll('.nepkit-province-select').forEach(provinceSelect => { - const isNepali = provinceSelect.dataset.ne === 'true'; - const container = provinceSelect.closest('form') || document; - const districtSelect = getMatchingSelect(container, '.nepkit-district-select', isNepali); - const municipalitySelect = getMatchingSelect(container, '.nepkit-municipality-select', isNepali); - - if (!provinceSelect.value) { - if (districtSelect && !districtSelect.value) { - updateOptions(districtSelect, [], getPlaceholder('district', isNepali)); - } - if (municipalitySelect && !municipalitySelect.value) { - updateOptions(municipalitySelect, [], getPlaceholder('municipality', isNepali)); - } - } - }); - - // Initialize district selects - document.querySelectorAll('.nepkit-district-select').forEach(districtSelect => { - const isNepali = districtSelect.dataset.ne === 'true'; - const container = districtSelect.closest('form') || document; - const municipalitySelect = getMatchingSelect(container, '.nepkit-municipality-select', isNepali); - - if (!districtSelect.value) { - if (municipalitySelect && !municipalitySelect.value) { - updateOptions(municipalitySelect, [], getPlaceholder('municipality', isNepali)); - } - } - }); + initForm(document); } document.addEventListener('change', function(e) { - // Handle province select change if (e.target.matches('.nepkit-province-select')) { - const province = e.target.value; - const isNepali = e.target.dataset.ne === 'true'; - const container = e.target.closest('form') || document; - const districtSelect = getMatchingSelect(container, '.nepkit-district-select', isNepali); - const municipalitySelect = getMatchingSelect(container, '.nepkit-municipality-select', isNepali); + var province = e.target.value; + var isNepali = e.target.dataset.ne === 'true'; + var container = e.target.closest('form') || document; + var districtSelect = getMatchingSelect(container, '.nepkit-district-select', isNepali); + var municipalitySelect = getMatchingSelect(container, '.nepkit-municipality-select', isNepali); - // Always clear municipality if province changes if (municipalitySelect) { - updateOptions(municipalitySelect, [], getPlaceholder('municipality', isNepali)); + updateOptions(municipalitySelect, [], getPlaceholder('municipality', isNepali), false, true); } - - // Update district options - updateDependentSelect(districtSelect, 'districts', province, 'district', isNepali, 'province'); + updateDependentSelect(districtSelect, 'districts', province, 'district', isNepali, 'province', false, true); } - // Handle district select change if (e.target.matches('.nepkit-district-select')) { - const district = e.target.value; - const isNepali = e.target.dataset.ne === 'true'; - const container = e.target.closest('form') || document; - const municipalitySelect = getMatchingSelect(container, '.nepkit-municipality-select', isNepali); - - // Update municipality options - updateDependentSelect(municipalitySelect, 'municipalities', district, 'municipality', isNepali, 'district'); + var district = e.target.value; + var isNepali2 = e.target.dataset.ne === 'true'; + var container2 = e.target.closest('form') || document; + var municipalitySelect2 = getMatchingSelect(container2, '.nepkit-municipality-select', isNepali2); + updateDependentSelect(municipalitySelect2, 'municipalities', district, 'municipality', isNepali2, 'district', false, true); } }); @@ -161,4 +246,41 @@ } else { init(); } + + // Re-initialise on DOM mutations so dynamically-injected forms + // (admin inlines, htmx swaps, etc.) get the same treatment. + // We only watch for new