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 ↔ `nepalida­tetime` | +| `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
/ .nepkit-province-select / .nepkit-district-select + // additions inside a ancestor; everything else is ignored to + // avoid the O(document) scan on every keystroke. + if (typeof MutationObserver !== 'undefined' && document.body) { + var debounceTimer = null; + var observer = new MutationObserver(function(mutations) { + var needsInit = false; + for (var i = 0; i < mutations.length; i++) { + var added = mutations[i].addedNodes; + for (var j = 0; j < added.length; j++) { + var node = added[j]; + if (node.nodeType !== 1) continue; + if ( + node.matches && ( + node.matches('form') || + node.matches('.nepkit-province-select') || + node.matches('.nepkit-district-select') || + (node.querySelector && node.querySelector('.nepkit-province-select, .nepkit-district-select')) + ) + ) { + needsInit = true; + break; + } + } + if (needsInit) break; + } + if (!needsInit) return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(function() { + initForm(document); + }, 50); + }); + observer.observe(document.body, { childList: true, subtree: true }); + } })(); diff --git a/django_nepkit/static/django_nepkit/js/nepali-time-init.js b/django_nepkit/static/django_nepkit/js/nepali-time-init.js new file mode 100644 index 0000000..9b3ca51 --- /dev/null +++ b/django_nepkit/static/django_nepkit/js/nepali-time-init.js @@ -0,0 +1,81 @@ +(function () { + 'use strict'; + + /** + * Lightweight time-input helper. Validates HH:MM / HH:MM:SS / hh:mm AM-PM + * and converts English digits to Devanagari when the field is in `ne` mode. + * Kept intentionally simple so it does not require jQuery. + * + * Nepali-digit input (०-९) is normalized to ASCII before validation so + * users typing on a Nepali keyboard layout don't see spurious errors. + */ + var TIME_RE = /^([0-9]{1,2}):([0-9]{2})(:([0-9]{2}))?(\s*([AaPp][Mm]))?$/; + var NEPALI_TO_ASCII = {'०':'0','१':'1','२':'2','३':'3','४':'4','५':'5','६':'6','७':'7','८':'8','९':'9'}; + var ASCII_TO_NEPALI = {'0':'०','1':'१','2':'२','3':'३','4':'४','5':'५','6':'६','7':'७','8':'८','9':'९'}; + + function isNepali(el) { + return el.dataset.ne === 'true'; + } + + function toAsciiDigits(text) { + return String(text).replace(/[०-९]/g, function (d) { return NEPALI_TO_ASCII[d]; }); + } + + function toNepaliDigits(text) { + return String(text).replace(/[0-9]/g, function (d) { return ASCII_TO_NEPALI[d]; }); + } + + function validate(rawValue) { + if (!rawValue) return true; + var match = TIME_RE.exec(toAsciiDigits(rawValue).trim()); + if (!match) return false; + var h = parseInt(match[1], 10); + var m = parseInt(match[2], 10); + if (m > 59) return false; + if (match[5]) { + if (h < 1 || h > 12) return false; + } else if (h > 23) { + return false; + } + return true; + } + + function initOne(el) { + if (el.dataset.nepkitTimeInit === '1') return; + el.dataset.nepkitTimeInit = '1'; + + el.addEventListener('blur', function () { + if (!el.value) { + el.classList.remove('nepkit-time-invalid'); + return; + } + if (!validate(el.value)) { + el.classList.add('nepkit-time-invalid'); + return; + } + el.classList.remove('nepkit-time-invalid'); + if (isNepali(el)) { + el.value = toNepaliDigits(el.value); + } + }); + } + + function initAll(root) { + var scope = root || document; + var nodes = scope.querySelectorAll('.nepkit-time-input:not([data-nepkit-time-init="1"])'); + nodes.forEach(initOne); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { initAll(); }); + } else { + initAll(); + } + + if (typeof MutationObserver !== 'undefined' && document.body) { + new MutationObserver(function () { initAll(); }).observe(document.body, { + childList: true, + subtree: true, + }); + } +})(); diff --git a/django_nepkit/tests/README.md b/django_nepkit/tests/README.md index 9459d5b..90a2c8e 100644 --- a/django_nepkit/tests/README.md +++ b/django_nepkit/tests/README.md @@ -1,7 +1,10 @@ # Test Suite for django-nepkit ## Overview -Comprehensive pytest test suite covering all major components of the django-nepkit library. + +Comprehensive pytest test suite covering all major components of the +django-nepkit library. The library is **framework-agnostic** — there is +no longer any DRF or django-filter dependency. ## Running Tests @@ -18,49 +21,42 @@ DJANGO_SETTINGS_MODULE=django_nepkit.tests.settings uv run pytest django_nepkit/ ## Test Coverage -### ✅ Models (`test_models.py`) - 15 tests -- `NepaliDateField`: Storage format, retrieval, null handling, language settings -- `NepaliDateTimeField`: DateTime storage and retrieval -- `NepaliPhoneNumberField`: Valid/invalid phone number validation -- Location fields: Province, District, Municipality choices and language support +### ✅ Models (`test_models.py`) +- `NepaliDateField`, `NepaliDateTimeField`, `NepaliTimeField`: storage, retrieval, language settings +- `NepaliPhoneNumberField`: valid / invalid phone numbers +- Location fields: province, district, municipality choices and language support -### ✅ Forms (`test_forms.py`) - 6 tests -- `NepaliDateFormField`: Input validation, multiple formats, required/optional handling +### ✅ Forms (`test_forms.py`) +- `NepaliDateFormField`: input validation, multiple formats, required/optional handling -### ✅ Validators (`test_validators.py`) - 7 tests -- `validate_nepali_phone_number`: Mobile and landline validation patterns +### ✅ Validators (`test_validators.py`) +- `validate_nepali_phone_number`: mobile and landline validation patterns -### ✅ Utilities (`test_utils.py`) - 10 tests -- `try_parse_nepali_date`: Date parsing from strings and objects -- `try_parse_nepali_datetime`: DateTime parsing with edge cases +### ✅ Utilities (`test_utils.py`) +- `try_parse_nepali_date` / `try_parse_nepali_datetime` +- `try_parse_nepali_time` / `format_nepali_time` -### ✅ Serializers (`test_serializers.py`) - 10 tests -- `NepaliDateSerializerField`: Serialization/deserialization -- `NepaliDateTimeSerializerField`: DateTime handling -- Language output (Devanagari support) +### ✅ API helpers (`test_api.py`) +- `serialize_nepali_date` / `deserialize_nepali_date` +- `serialize_nepali_datetime` / `deserialize_nepali_datetime` +- `serialize_nepali_time` / `deserialize_nepali_time` +- `serialize_nepali_currency` / `deserialize_nepali_currency` +- `to_decimal` +- `build_localized_payload` -### ✅ Filters (`test_filters.py`) - 4 tests -- `NepaliDateYearFilter`: Year-based filtering -- `NepaliDateMonthFilter`: Month-based filtering +### ✅ Admin (`test_admin_currency.py`, `test_admin_media.py`) +- Currency formatting helpers +- Address-chaining media wiring -### ⚠️ Widgets (`test_widgets.py`) - 11 tests (10 xfail) -- Widget rendering tests marked as xfail due to Django app registry timing issues in test environment -- HTMX configuration test passes +### ✅ Address chaining JS (`test_address_chaining_js.py`) +- Static-analysis checks for the JavaScript init / change handlers -## Test Statistics -- **Total Tests**: 62 -- **Passing**: 50 -- **Expected Failures (xfail)**: 11 (widget rendering) -- **Actual Failures**: 1 (phone validation edge case) - -## Known Issues -1. **Widget Rendering Tests**: Marked as xfail due to Django translation infrastructure initialization timing in test environment. These work correctly in production. -2. **Phone Validation**: One edge case test needs adjustment for the validator's actual behavior. +### ✅ Widgets (`test_widgets.py`) +- `NepaliDatePickerWidget`, `NepaliTimeWidget`, location select widgets ## Dependencies -- pytest -- pytest-django (via DJANGO_SETTINGS_MODULE) -- Django -- nepali-datetime -- djangorestframework (optional, for serializer tests) -- django-filter (optional, for filter tests) + +- `pytest` +- `pytest-django` (via `DJANGO_SETTINGS_MODULE`) +- `Django` +- `nepali` diff --git a/django_nepkit/tests/settings.py b/django_nepkit/tests/settings.py index c1bced6..bf626ab 100644 --- a/django_nepkit/tests/settings.py +++ b/django_nepkit/tests/settings.py @@ -42,5 +42,6 @@ NEPKIT = { "DEFAULT_LANGUAGE": "en", "ADMIN_DATEPICKER": True, "TIME_FORMAT": 12, + "BS_TIME_FORMAT": "%I:%M %p", "DATE_INPUT_FORMATS": ["%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y"], } diff --git a/django_nepkit/tests/test_address_chaining_js.py b/django_nepkit/tests/test_address_chaining_js.py new file mode 100644 index 0000000..0871835 --- /dev/null +++ b/django_nepkit/tests/test_address_chaining_js.py @@ -0,0 +1,91 @@ +""" +Static-analysis tests for the address-chaining JavaScript. These don't run +the JS but make sure the file contains the behaviours we depend on. +""" + +import re +from pathlib import Path + +JS_PATH = ( + Path(__file__).resolve().parent.parent + / "static" + / "django_nepkit" + / "js" + / "address-chaining.js" +) + + +def _read_js(): + assert JS_PATH.exists(), f"address-chaining.js missing at {JS_PATH}" + return JS_PATH.read_text(encoding="utf-8") + + +class TestAddressChainingScript: + def test_defines_placeholders(self): + js = _read_js() + assert "प्रदेश छान्नुहोस्" in js + assert "Select Province" in js + assert "जिल्ला छान्नुहोस्" in js + assert "Select District" in js + assert "नगरपालिका छान्नुहोस्" in js + assert "Select Municipality" in js + + def test_has_preserve_selection_logic(self): + js = _read_js() + # The init function must filter child selects when the parent has + # a value (the admin bug fix). + assert re.search(r"if\s*\(\s*provinceSelect\.value\s*\)\s*\{", js), ( + "provinceSelect.value is not checked on init" + ) + assert re.search( + r"if\s*\(\s*districtSelect\s*&&\s*districtSelect\.value\s*\)", js + ), "districtSelect.value is not checked on init" + + def test_has_preserve_selection_parameter(self): + js = _read_js() + assert "preserveSelection" in js + + def test_watches_mutations(self): + js = _read_js() + assert "MutationObserver" in js + + def test_handles_district_only_forms(self): + js = _read_js() + # District-only flow (no province) is handled separately. + assert "districtOnlySelects" in js or "nepkit-district-select" in js + + def test_orders_data_before_chaining(self): + """When the JS is referenced, the data file must come first.""" + from django_nepkit.widgets import ChainedSelectWidget + + media = ChainedSelectWidget().media # resolve the property + media_js = list(media._js) + data_idx = media_js.index("django_nepkit/js/nepal-data.js") + chain_idx = media_js.index("django_nepkit/js/address-chaining.js") + assert data_idx < chain_idx + + def test_init_does_not_dispatch_change(self): + """The init path must pass ``false`` for dispatchChange so we don't + fire synthetic change events on page load.""" + js = _read_js() + # Look for the initForm function and check it calls updateOptions / + # updateDependentSelect with a `false` literal (the dispatchChange + # opt-out). We use a permissive pattern because the call sites + # include nested function calls whose ``)`` would break a strict + # ``[^)]*``-anchored regex. + assert re.search(r"updateOptions\([^;]+,\s*false\s*,\s*false\s*\)\s*;", js), ( + "initForm should call updateOptions(..., false, false)" + ) + + def test_logs_fetch_errors(self): + """fetchData must log an error to the console on failure.""" + js = _read_js() + assert "console.error" in js, "fetchData should log fetch errors" + + def test_observer_filters_to_nepkit_forms(self): + """The MutationObserver should not run init on every DOM mutation — + only on additions of forms / nepkit selects.""" + js = _read_js() + assert "addedNodes" in js + assert "nepkit-province-select" in js + assert "nepkit-district-select" in js diff --git a/django_nepkit/tests/test_address_normalization.py b/django_nepkit/tests/test_address_normalization.py index 642a74c..73473c2 100644 --- a/django_nepkit/tests/test_address_normalization.py +++ b/django_nepkit/tests/test_address_normalization.py @@ -1,4 +1,4 @@ -from django_nepkit.utils import normalize_address +from django_nepkit.utils import _find_address_components, normalize_address def test_normalize_address_empty(): @@ -66,3 +66,27 @@ def test_normalize_address_partial_tokens(): result = normalize_address("Lalitpur") assert result["district"] == "Lalitpur" assert result["municipality"] == "Lalitpur Metropolitan City" + + +def test_normalize_address_is_memoised(): + """Repeated calls for the same input hit the LRU cache.""" + _find_address_components.cache_clear() + a = _find_address_components("Kathmandu") + info = _find_address_components.cache_info() + assert info.hits == 0 + assert info.misses == 1 + b = _find_address_components("Kathmandu") + info2 = _find_address_components.cache_info() + assert info2.hits == 1 + assert a is b # cached tuple is the same object + + +def test_normalize_address_returns_fresh_dict(): + """``normalize_address`` returns a fresh dict so callers can mutate safely.""" + a = normalize_address("Kathmandu") + b = normalize_address("Kathmandu") + assert a == b + assert a is not b + a["district"] = "mutated" + # The next call must not see the mutation. + assert normalize_address("Kathmandu")["district"] == "Kathmandu" diff --git a/django_nepkit/tests/test_admin_media.py b/django_nepkit/tests/test_admin_media.py new file mode 100644 index 0000000..6fd7796 --- /dev/null +++ b/django_nepkit/tests/test_admin_media.py @@ -0,0 +1,87 @@ +""" +Tests for ``NepaliModelAdmin`` media and address-chaining integration. +""" + +from django.db import models + +from django_nepkit.admin import NepaliModelAdmin +from django_nepkit.models import ( + DistrictField, + MunicipalityField, + NepaliTimeField, + ProvinceField, +) +from django_nepkit.widgets import NepaliTimeWidget +from django.contrib.admin.sites import AdminSite + + +class _ChainedModel(models.Model): + province = ProvinceField() + district = DistrictField() + municipality = MunicipalityField() + + class Meta: + app_label = "django_nepkit" + + +class _TimeModel(models.Model): + t = NepaliTimeField() + + class Meta: + app_label = "django_nepkit" + + +class _PlainModel(models.Model): + name = models.CharField(max_length=10) + + class Meta: + app_label = "django_nepkit" + + +class TestNepaliModelAdminMedia: + def test_includes_address_chaining_js(self): + admin_cls = NepaliModelAdmin(_ChainedModel, AdminSite()) + js = admin_cls.media._js + assert "django_nepkit/js/nepal-data.js" in js + assert "django_nepkit/js/address-chaining.js" in js + + def test_chaining_js_ordered_after_data(self): + admin_cls = NepaliModelAdmin(_ChainedModel, AdminSite()) + js = list(admin_cls.media._js) + data_idx = js.index("django_nepkit/js/nepal-data.js") + chain_idx = js.index("django_nepkit/js/address-chaining.js") + assert data_idx < chain_idx + + def test_time_widget_carries_its_own_js(self): + """The NepaliTimeWidget contributes its own JS via form media.""" + from django_nepkit.widgets import NepaliTimeWidget + + widget = NepaliTimeWidget() + js = list(widget.media._js) + assert "django_nepkit/js/nepali-time-init.js" in js + + +class TestAdminUsesNepaliTimeWidget: + def test_time_field_gets_nepali_widget(self): + admin_cls = NepaliModelAdmin(_TimeModel, AdminSite()) + ff = admin_cls.formfield_for_dbfield( + _TimeModel._meta.get_field("t"), request=None + ) + assert isinstance(ff.widget, NepaliTimeWidget) + + def test_time_widget_inherits_ne_setting(self): + admin_cls = NepaliModelAdmin(_TimeModel, AdminSite()) + ff = admin_cls.formfield_for_dbfield( + _TimeModel._meta.get_field("t"), request=None + ) + assert ff.widget.ne is False + assert ff.widget.en is True + + +class TestFormfieldForDbfieldIdempotency: + def test_unrelated_field_falls_through(self): + admin_cls = NepaliModelAdmin(_PlainModel, AdminSite()) + ff = admin_cls.formfield_for_dbfield( + _PlainModel._meta.get_field("name"), request=None + ) + assert ff is not None diff --git a/django_nepkit/tests/test_api.py b/django_nepkit/tests/test_api.py new file mode 100644 index 0000000..414eb35 --- /dev/null +++ b/django_nepkit/tests/test_api.py @@ -0,0 +1,245 @@ +""" +Tests for the framework-agnostic serialization helpers in ``django_nepkit.api``. +""" + +from datetime import datetime as python_datetime, time as python_time +from decimal import Decimal + +import pytest +from nepali.datetime import nepalidate, nepalidatetime + +from django_nepkit.api import ( + _looks_numeric, + 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, +) + + +class TestSerializeNepaliDate: + def test_none_passthrough(self): + assert serialize_nepali_date(None) is None + + def test_serializes_nepalidate(self): + assert serialize_nepali_date(nepalidate(2081, 1, 15)) == "2081-01-15" + + def test_serializes_string_input(self): + assert serialize_nepali_date("2081-01-15") == "2081-01-15" + + def test_ne_flag_uses_devanagari(self): + assert "२०८१" in serialize_nepali_date(nepalidate(2081, 1, 15), ne=True) + + def test_custom_format(self): + assert ( + serialize_nepali_date(nepalidate(2081, 1, 15), fmt="%Y/%m/%d") + == "2081/01/15" + ) + + def test_strict_raises_on_garbage(self): + with pytest.raises(ValueError, match="Cannot serialize"): + serialize_nepali_date("not-a-date", strict=True) + + def test_non_strict_returns_str(self): + """Non-strict mode is a passthrough so existing integrations don't break.""" + assert serialize_nepali_date("not-a-date") == "not-a-date" + + +class TestDeserializeNepaliDate: + def test_none(self): + assert deserialize_nepali_date(None) is None + + def test_empty_string(self): + assert deserialize_nepali_date("") is None + + def test_round_trip(self): + result = deserialize_nepali_date("2081-01-15") + assert isinstance(result, nepalidate) + assert result.year == 2081 + + def test_strict_raises_on_garbage(self): + with pytest.raises(ValueError, match="Cannot parse"): + deserialize_nepali_date("not-a-date", strict=True) + + def test_strict_raises_on_wrong_type(self): + with pytest.raises(ValueError, match="Cannot parse"): + deserialize_nepali_date(12345, strict=True) + + +class TestSerializeNepaliDatetime: + def test_serializes(self): + assert ( + serialize_nepali_datetime(nepalidatetime(2081, 1, 15, 14, 30)) + == "2081-01-15 14:30:00" + ) + + def test_ne_flag(self): + result = serialize_nepali_datetime(nepalidatetime(2081, 1, 15, 14, 30), ne=True) + assert "२०८१" in result + + +class TestDeserializeNepaliDatetime: + def test_round_trip(self): + result = deserialize_nepali_datetime("2081-01-15 14:30:00") + assert isinstance(result, nepalidatetime) + assert result.hour == 14 + + def test_strict_raises_on_garbage(self): + with pytest.raises(ValueError, match="Cannot parse"): + deserialize_nepali_datetime("not-a-datetime", strict=True) + + +class TestSerializeNepaliTime: + def test_none(self): + assert serialize_nepali_time(None) is None + + def test_serializes_time(self): + assert serialize_nepali_time(python_time(14, 30)) == "02:30 PM" + + def test_ne_flag(self): + result = serialize_nepali_time(python_time(14, 30), ne=True) + assert "०२" in result + + def test_strict_raises_on_garbage(self): + with pytest.raises(ValueError, match="Cannot serialize"): + serialize_nepali_time("not-a-time", strict=True) + + +class TestDeserializeNepaliTime: + def test_round_trip(self): + result = deserialize_nepali_time("02:30 PM") + assert isinstance(result, python_time) + assert result.hour == 14 + assert result.minute == 30 + + def test_from_datetime(self): + result = deserialize_nepali_time(python_datetime(2024, 1, 1, 9, 0)) + assert result == python_time(9, 0) + + def test_strict_raises_on_garbage(self): + with pytest.raises(ValueError, match="Cannot parse"): + deserialize_nepali_time("not-a-time", strict=True) + + +class TestSerializeNepaliCurrency: + def test_none(self): + assert serialize_nepali_currency(None) is None + + def test_formats_with_symbol(self): + assert serialize_nepali_currency(1234567) == "Rs. 12,34,567.00" + + def test_no_symbol(self): + assert serialize_nepali_currency(1234567, currency_symbol="") == "12,34,567.00" + + def test_ne_flag(self): + assert "१२" in serialize_nepali_currency(12, ne=True) + + def test_preserves_decimal_precision(self): + value = Decimal("123456789012345.67") + assert "12,34,56,78,90,12,345.67" in serialize_nepali_currency(value) + + def test_non_numeric_string_passthrough(self): + """Non-numeric strings are passed through so legacy callers don't crash.""" + assert serialize_nepali_currency("n/a") == "n/a" + + +class TestDeserializeNepaliCurrency: + def test_none(self): + assert deserialize_nepali_currency(None) is None + + def test_passes_through_numbers(self): + assert deserialize_nepali_currency(1234) == 1234 + assert deserialize_nepali_currency(Decimal("12.50")) == Decimal("12.50") + + def test_parses_formatted_string(self): + result = deserialize_nepali_currency("Rs. 12,34,567.00") + assert isinstance(result, Decimal) + assert result == Decimal("1234567.00") + + def test_parses_nepali_digits(self): + result = deserialize_nepali_currency("Rs. १२,३४५") + assert isinstance(result, Decimal) + assert result == Decimal("12345") + + def test_strict_raises_on_invalid_string(self): + with pytest.raises(ValueError, match="Cannot parse"): + deserialize_nepali_currency("not-a-number", strict=True) + + +class TestToDecimal: + def test_none(self): + assert to_decimal(None) is None + + def test_decimal_passthrough(self): + assert to_decimal(Decimal("1.5")) == Decimal("1.5") + + def test_int(self): + assert to_decimal(42) == Decimal("42") + + def test_string(self): + assert to_decimal("12.34") == Decimal("12.34") + + def test_invalid_string_returns_input(self): + assert to_decimal("not-a-number") == "not-a-number" + + def test_strict_raises_on_invalid_string(self): + with pytest.raises(ValueError, match="Cannot convert"): + to_decimal("not-a-number", strict=True) + + def test_strict_raises_on_bool(self): + with pytest.raises(ValueError, match="boolean"): + to_decimal(True, strict=True) + + def test_strict_raises_on_non_finite(self): + with pytest.raises(ValueError, match="non-finite"): + to_decimal(float("inf"), strict=True) + + +class TestLooksNumeric: + def test_rejects_infinity_strings(self): + """``1e1000`` overflows to ``inf`` and must be rejected.""" + assert _looks_numeric("1e1000") is False + assert _looks_numeric("inf") is False + assert _looks_numeric("nan") is False + + def test_accepts_plain_numbers(self): + assert _looks_numeric("1234") is True + assert _looks_numeric("-12.5") is True + assert _looks_numeric("1,234.5") is True + + def test_rejects_empty_and_garbage(self): + assert _looks_numeric("") is False + assert _looks_numeric("abc") is False + assert _looks_numeric("12abc") is False + + +class TestBuildLocalizedPayload: + def test_disabled_returns_input(self): + data = {"a": 1} + assert build_localized_payload(data, ne=False) == {"a": 1} + + def test_mirrors_nepalidate(self): + data = {"date": nepalidate(2080, 1, 1)} + out = build_localized_payload(data, ne=True, raw_values=data) + assert out["date_ne"] == "२०८०-०१-०१" + + def test_mirrors_currency(self): + data = {"amount": 100000} + out = build_localized_payload(data, ne=True) + assert "१" in out["amount_ne"] + + def test_does_not_overwrite_existing(self): + data = {"amount": 100, "amount_ne": "preexisting"} + out = build_localized_payload(data, ne=True) + assert out["amount_ne"] == "preexisting" + + def test_no_mirror_when_ne_false(self): + data = {"date": nepalidate(2080, 1, 1)} + out = build_localized_payload(data, ne=False, raw_values=data) + assert "date_ne" not in out diff --git a/django_nepkit/tests/test_api_enhancements.py b/django_nepkit/tests/test_api_enhancements.py deleted file mode 100644 index fdc102e..0000000 --- a/django_nepkit/tests/test_api_enhancements.py +++ /dev/null @@ -1,104 +0,0 @@ -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": - return NepaliCurrencyField() - 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. - # Instead, we just want to test our local logic. - # So we'll patch the call to super() or just mock the return value. - pass - - # Actually, the mixin DOES call super().to_representation(instance). - # 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"} - - class RealTestSerializer(NepaliLocalizedSerializerMixin, Base): - def __init__(self, context): - self.context = context - self.Meta = MagicMock() - self.Meta.model = MockModel - - 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"] == "२०८०-०१-०१" - - -def test_currency_range_filter(): - from django.db.models import QuerySet - - qs = MagicMock(spec=QuerySet) - 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") - - # Test exact (single value) - 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") - - # 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/tests/test_conf.py b/django_nepkit/tests/test_conf.py index b563eb9..4dfe2d2 100644 --- a/django_nepkit/tests/test_conf.py +++ b/django_nepkit/tests/test_conf.py @@ -3,6 +3,7 @@ Tests for django-nepkit configuration (conf.py). """ import pytest +from django.test import override_settings class TestNepkitSettings: @@ -86,3 +87,43 @@ class TestNepkitSettings: # Others still use defaults assert settings.DEFAULT_LANGUAGE == "en" assert settings.ADMIN_DATEPICKER is True + + def test_readonly_settings(self): + """Setting attributes on the live ``nepkit_settings`` is rejected.""" + from django_nepkit.conf import nepkit_settings + + with pytest.raises(AttributeError, match="read-only"): + nepkit_settings.DEFAULT_LANGUAGE = "fr" + + def test_module_level_settings_re_read_on_override(self): + """``override_settings(NEPKIT=...)`` propagates to ``nepkit_settings``.""" + from django_nepkit.conf import nepkit_settings + + with override_settings( + NEPKIT={ + "DEFAULT_LANGUAGE": "en", + "TIME_FORMAT": 12, + "BS_DATE_FORMAT": "%d/%m/%Y", + "BS_TIME_FORMAT": "%I:%M %p", + "BS_DATETIME_FORMAT": "%Y-%m-%d %H:%M:%S", + } + ): + assert nepkit_settings.BS_DATE_FORMAT == "%d/%m/%Y" + # After the override, it reverts. + assert nepkit_settings.BS_DATE_FORMAT == "%Y-%m-%d" + + def test_module_level_settings_no_user_settings_arg(self): + """The module-level instance doesn't capture user_settings at import.""" + from django_nepkit.conf import nepkit_settings + + assert nepkit_settings._explicit_user_settings is None + + def test_custom_instance_with_explicit_user_settings(self): + """Instances created with explicit user_settings use them verbatim.""" + from django_nepkit.conf import NepkitSettings, DEFAULTS + + s = NepkitSettings(user_settings={"DEFAULT_LANGUAGE": "en"}, defaults=DEFAULTS) + # Even when override_settings flips the language, the explicit + # instance keeps the user-supplied value. + with override_settings(NEPKIT={"DEFAULT_LANGUAGE": "en"}): + assert s.DEFAULT_LANGUAGE == "en" diff --git a/django_nepkit/tests/test_currency_and_words.py b/django_nepkit/tests/test_currency_and_words.py index afd3eb4..d3f2401 100644 --- a/django_nepkit/tests/test_currency_and_words.py +++ b/django_nepkit/tests/test_currency_and_words.py @@ -1,7 +1,11 @@ +from decimal import Decimal + +import pytest + from django_nepkit.utils import ( + english_to_nepali_unicode, format_nepali_currency, number_to_nepali_words, - english_to_nepali_unicode, ) from django_nepkit.models import NepaliCurrencyField @@ -14,6 +18,34 @@ def test_format_nepali_currency(): assert format_nepali_currency(None) == "" +def test_format_nepali_currency_preserves_decimal_precision(): + """Large Decimals should not be truncated to float precision.""" + value = Decimal("123456789012345.67") + formatted = format_nepali_currency(value) + assert "12,34,56,78,90,12,345.67" in formatted + + +def test_format_nepali_currency_negative(): + """Negative values render with a leading minus sign.""" + assert format_nepali_currency(-100) == "Rs. -100.00" + + +def test_format_nepali_currency_invalid_raises(): + with pytest.raises(ValueError, match="Cannot format"): + format_nepali_currency("hello") + with pytest.raises(ValueError, match="non-finite"): + format_nepali_currency(float("inf")) + with pytest.raises(ValueError, match="non-finite"): + format_nepali_currency(float("nan")) + with pytest.raises(ValueError, match="boolean"): + format_nepali_currency(True) + + +def test_format_nepali_currency_accepts_string_number(): + """Strings that look like numbers are still accepted for ergonomics.""" + assert format_nepali_currency("1234.5") == "Rs. 1,234.50" + + def test_number_to_nepali_words(): assert number_to_nepali_words(0) == "शून्य" assert number_to_nepali_words(1) == "एक" @@ -28,6 +60,21 @@ def test_number_to_nepali_words(): # Note: 12,34,567 -> 12 Lakhs 34 Thousand 5 Hundred 67 +def test_number_to_nepali_words_negative(): + """Negative numbers get a 'ऋणात्मक' prefix.""" + assert number_to_nepali_words(-25) == "ऋणात्मक पच्चीस" + assert number_to_nepali_words(-100) == "ऋणात्मक एक सय" + + +def test_number_to_nepali_words_invalid_raises(): + with pytest.raises(ValueError, match="Cannot convert"): + number_to_nepali_words("hello") + with pytest.raises(ValueError, match="Cannot convert"): + number_to_nepali_words(float("inf")) + with pytest.raises(ValueError, match="boolean"): + number_to_nepali_words(True) + + def test_english_to_nepali_unicode(): assert english_to_nepali_unicode("123") == "१२३" assert english_to_nepali_unicode("Price: 100") == "Price: १००" diff --git a/django_nepkit/tests/test_filters.py b/django_nepkit/tests/test_filters.py deleted file mode 100644 index 89a3acc..0000000 --- a/django_nepkit/tests/test_filters.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -Tests for django-nepkit filters (DRF and Admin). -""" - -import pytest - -import importlib.util - -FILTERS_AVAILABLE = importlib.util.find_spec("django_filters") is not None - -if FILTERS_AVAILABLE: - from django_nepkit.filters import ( - NepaliDateYearFilter, - NepaliDateMonthFilter, - ) - - -@pytest.mark.skipif(not FILTERS_AVAILABLE, reason="django-filter not installed") -class TestNepaliDateYearFilter: - """Tests for NepaliDateYearFilter.""" - - def test_filter_initialization(self): - """Test that NepaliDateYearFilter can be initialized.""" - filter_instance = NepaliDateYearFilter(field_name="birth_date") - - assert filter_instance.field_name == "birth_date" - - def test_filter_has_correct_type(self): - """Test that filter is a NumberFilter.""" - from django_filters import NumberFilter - - filter_instance = NepaliDateYearFilter(field_name="birth_date") - assert isinstance(filter_instance, NumberFilter) - - -@pytest.mark.skipif(not FILTERS_AVAILABLE, reason="django-filter not installed") -class TestNepaliDateMonthFilter: - """Tests for NepaliDateMonthFilter.""" - - def test_filter_initialization(self): - """Test that NepaliDateMonthFilter can be initialized.""" - filter_instance = NepaliDateMonthFilter(field_name="birth_date") - - assert filter_instance.field_name == "birth_date" - - def test_filter_has_correct_type(self): - """Test that filter is a NumberFilter.""" - from django_filters import NumberFilter - - filter_instance = NepaliDateMonthFilter(field_name="birth_date") - assert isinstance(filter_instance, NumberFilter) diff --git a/django_nepkit/tests/test_models.py b/django_nepkit/tests/test_models.py index 3e48428..0a6d0dc 100644 --- a/django_nepkit/tests/test_models.py +++ b/django_nepkit/tests/test_models.py @@ -2,14 +2,18 @@ Tests for django-nepkit model fields. """ +from datetime import time as python_time + import pytest from django.core.exceptions import ValidationError +from django.test import override_settings from nepali.datetime import nepalidate, nepalidatetime from django_nepkit.models import ( NepaliDateField, NepaliDateTimeField, NepaliPhoneNumberField, + NepaliTimeField, ProvinceField, DistrictField, MunicipalityField, @@ -59,6 +63,28 @@ class TestNepaliDateField: assert field.en is True assert field.ne is False + def test_to_python_raises_on_garbage_string(self): + """Invalid date strings raise ValidationError instead of being silently stored.""" + field = NepaliDateField() + with pytest.raises(ValidationError): + field.to_python("not-a-date") + + def test_get_prep_value_raises_on_garbage(self): + """Invalid strings are rejected at the ORM boundary.""" + field = NepaliDateField() + with pytest.raises(ValidationError): + field.get_prep_value("not-a-date") + + def test_to_python_accepts_valid_string(self): + field = NepaliDateField() + result = field.to_python("2081-01-15") + assert isinstance(result, nepalidate) + + def test_to_python_passes_through_nepalidate(self): + field = NepaliDateField() + d = nepalidate(2081, 1, 15) + assert field.to_python(d) is d + class TestNepaliDateTimeField: """Tests for NepaliDateTimeField.""" @@ -160,3 +186,124 @@ class TestLocationFields: field = DistrictField(htmx=True) assert field.htmx is True + + def test_choices_is_a_property(self): + """``choices`` is a live property that re-evaluates per access.""" + field = ProvinceField() + # ``choices`` is a property object, not a list. + assert isinstance(type(field).choices, property) + + def test_choices_propagate_override_settings(self): + """Implicit-``ne`` fields re-read ``nepkit_settings`` on every access.""" + with override_settings( + NEPKIT={"DEFAULT_LANGUAGE": "en", "BS_DATE_FORMAT": "%Y-%m-%d"} + ): + field = ProvinceField() + en_names = {c[0] for c in field.choices} + assert "Koshi Province" in en_names + for name in en_names: + for ch in name: + assert not (0x0900 <= ord(ch) <= 0x097F), ( + f"unexpected Devanagari char in English choice {name!r}" + ) + + with override_settings( + NEPKIT={"DEFAULT_LANGUAGE": "ne", "BS_DATE_FORMAT": "%Y-%m-%d"} + ): + # Same field instance — choices must reflect the new setting. + ne_names = {c[0] for c in field.choices} + assert ne_names != en_names + assert any(0x0900 <= ord(ch) <= 0x097F for name in ne_names for ch in name) + + def test_choices_explicit_ne_is_pinned(self): + """An explicit ``ne=`` constructor arg pins the language for life.""" + with override_settings( + NEPKIT={"DEFAULT_LANGUAGE": "ne", "BS_DATE_FORMAT": "%Y-%m-%d"} + ): + pinned_en = ProvinceField(ne=False) + en_names = {c[0] for c in pinned_en.choices} + assert "Koshi Province" in en_names + + # Even after flipping the setting, the field stays English. + with override_settings( + NEPKIT={"DEFAULT_LANGUAGE": "ne", "BS_DATE_FORMAT": "%Y-%m-%d"} + ): + assert "Koshi Province" in {c[0] for c in pinned_en.choices} + + def test_choices_setter_is_noop(self): + """Writing to ``choices`` is accepted but discarded.""" + field = ProvinceField() + original = list(field.choices) + field.choices = [("custom", "custom")] # should be a no-op + assert field.choices == original + + def test_refresh_choices_is_backwards_compatible(self): + """``refresh_choices`` is kept as a no-op (it just invalidates the cache).""" + field = ProvinceField() + # Should not raise and should return the current choices list. + result = field.refresh_choices() + assert len(result) > 0 + + def test_choices_invalidate_flatchoices_cache(self): + """Reading ``choices`` clears Django's ``_flatchoices`` cache (when present).""" + field = ProvinceField() + # Populate the cache on Django <6.0 (Django 6.0+ has no `_flatchoices`). + if hasattr(field, "_get_flatchoices"): + _ = field._get_flatchoices() + assert field._flatchoices is not None + _ = field.choices + if hasattr(field, "_flatchoices"): + assert field._flatchoices is None + # ``flatchoices`` (Django 6.0+) is a non-caching property and + # must always return the live list. + assert any("Koshi" in tup[0] for tup in field.flatchoices) + + +class TestNepaliTimeField: + """Tests for NepaliTimeField.""" + + def test_to_python_from_time(self): + field = NepaliTimeField() + t = python_time(14, 30, 0) + assert field.to_python(t) is t + + def test_to_python_from_string(self): + field = NepaliTimeField() + result = field.to_python("14:30") + assert isinstance(result, python_time) + assert result.hour == 14 + assert result.minute == 30 + + def test_to_python_from_none(self): + field = NepaliTimeField() + assert field.to_python(None) is None + + def test_get_prep_value_from_datetime(self): + from datetime import datetime as python_datetime + + field = NepaliTimeField() + result = field.get_prep_value(python_datetime(2024, 1, 1, 9, 0, 0)) + assert result == python_time(9, 0, 0) + + def test_value_to_string_uses_format(self): + field = NepaliTimeField() + field.attname = "value" + + class Obj: + value = python_time(14, 30, 0) + + assert field.value_to_string(Obj()) == "02:30 PM" + + def test_auto_now_add_works(self): + field = NepaliTimeField(auto_now_add=True) + assert field.auto_now_add is True + assert field.editable is False + + def test_deconstruct_preserves_auto_flags(self): + field = NepaliTimeField(auto_now=True) + name, path, args, kwargs = field.deconstruct() + assert kwargs.get("auto_now") is True + + def test_format_value_for_display(self): + field = NepaliTimeField() + assert field.format_value_for_display(python_time(9, 5)) == "09:05 AM" diff --git a/django_nepkit/tests/test_serializers.py b/django_nepkit/tests/test_serializers.py deleted file mode 100644 index 0fc9f6a..0000000 --- a/django_nepkit/tests/test_serializers.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -Tests for django-nepkit DRF serializers. -""" - -import pytest -from nepali.datetime import nepalidate, nepalidatetime - -try: - from rest_framework import serializers - from django_nepkit.serializers import ( - NepaliDateSerializerField, - NepaliDateTimeSerializerField, - ) - - DRF_AVAILABLE = True -except ImportError: - DRF_AVAILABLE = False - - -@pytest.mark.skipif(not DRF_AVAILABLE, reason="DRF not installed") -class TestNepaliDateSerializerField: - """Tests for NepaliDateSerializerField.""" - - def test_serialize_nepalidate_object(self): - """Test serializing a nepalidate object to string.""" - field = NepaliDateSerializerField() - date_obj = nepalidate(2081, 1, 15) - - result = field.to_representation(date_obj) - assert result == "2081-01-15" - assert isinstance(result, str) - - def test_serialize_string_date(self): - """Test serializing a string date.""" - field = NepaliDateSerializerField() - - result = field.to_representation("2081-01-15") - assert result == "2081-01-15" - - def test_serialize_none(self): - """Test that None is serialized as None.""" - field = NepaliDateSerializerField() - - result = field.to_representation(None) - assert result is None - - def test_deserialize_valid_date(self): - """Test deserializing a valid date string.""" - field = NepaliDateSerializerField() - - result = field.to_internal_value("2081-01-15") - assert isinstance(result, nepalidate) - assert result.year == 2081 - assert result.month == 1 - assert result.day == 15 - - def test_deserialize_none(self): - """Test that None/empty string is deserialized as None.""" - field = NepaliDateSerializerField() - - assert field.to_internal_value(None) is None - assert field.to_internal_value("") is None - - def test_deserialize_invalid_raises_error(self): - """Test that invalid date raises ValidationError.""" - field = NepaliDateSerializerField() - - with pytest.raises(serializers.ValidationError): - field.to_internal_value("invalid-date") - - def test_language_setting_ne(self): - """Test Devanagari output when ne=True.""" - field = NepaliDateSerializerField(ne=True) - date_obj = nepalidate(2081, 1, 15) - - result = field.to_representation(date_obj) - # Should use strftime_ne for Devanagari digits - assert result == "२०८१-०१-१५" - assert field.ne is True - - -@pytest.mark.skipif(not DRF_AVAILABLE, reason="DRF not installed") -class TestNepaliDateTimeSerializerField: - """Tests for NepaliDateTimeSerializerField.""" - - def test_serialize_nepalidatetime_object(self): - """Test serializing a nepalidatetime object to string.""" - field = NepaliDateTimeSerializerField() - dt_obj = nepalidatetime(2081, 1, 15, 14, 30, 0) - - result = field.to_representation(dt_obj) - assert result == "2081-01-15 14:30:00" - assert isinstance(result, str) - - def test_deserialize_valid_datetime(self): - """Test deserializing a valid datetime string.""" - field = NepaliDateTimeSerializerField() - - result = field.to_internal_value("2081-01-15 14:30:00") - assert isinstance(result, nepalidatetime) - assert result.year == 2081 - assert result.hour == 14 - assert result.minute == 30 - - def test_custom_format(self): - """Test using a custom datetime format.""" - field = NepaliDateTimeSerializerField(format="%Y-%m-%d") - dt_obj = nepalidatetime(2081, 1, 15, 14, 30, 0) - - result = field.to_representation(dt_obj) - assert result == "2081-01-15" diff --git a/django_nepkit/tests/test_utils.py b/django_nepkit/tests/test_utils.py index 8502e82..9569a6c 100644 --- a/django_nepkit/tests/test_utils.py +++ b/django_nepkit/tests/test_utils.py @@ -2,11 +2,15 @@ Tests for django-nepkit utility functions. """ +from datetime import datetime as python_datetime, time as python_time + from nepali.datetime import nepalidate, nepalidatetime from django_nepkit.utils import ( + format_nepali_time, try_parse_nepali_date, try_parse_nepali_datetime, + try_parse_nepali_time, ) @@ -90,3 +94,53 @@ class TestDateTimeParsing: """Test that invalid datetime string returns None.""" result = try_parse_nepali_datetime("invalid-datetime") assert result is None + + +class TestTimeParsing: + """Tests for the framework-agnostic time parser.""" + + def test_parse_time_from_string_24h(self): + result = try_parse_nepali_time("14:30") + assert isinstance(result, python_time) + assert result.hour == 14 + assert result.minute == 30 + + def test_parse_time_from_string_12h(self): + result = try_parse_nepali_time("02:45 PM") + assert isinstance(result, python_time) + assert result.hour == 14 + assert result.minute == 45 + + def test_parse_time_from_object(self): + t = python_time(9, 15, 0) + assert try_parse_nepali_time(t) is t + + def test_parse_time_from_datetime(self): + dt = python_datetime(2024, 1, 1, 8, 0, 0) + result = try_parse_nepali_time(dt) + assert isinstance(result, python_time) + assert result.hour == 8 + + def test_parse_time_none_and_empty(self): + assert try_parse_nepali_time(None) is None + assert try_parse_nepali_time("") is None + + def test_parse_time_invalid(self): + assert try_parse_nepali_time("not-a-time") is None + + +class TestTimeFormatting: + """Tests for ``format_nepali_time``.""" + + def test_format_default(self): + assert format_nepali_time(python_time(14, 30)) == "02:30 PM" + + def test_format_with_explicit_format(self): + assert format_nepali_time(python_time(14, 30), "%H:%M") == "14:30" + + def test_format_none(self): + assert format_nepali_time(None) == "" + + def test_format_datetime(self): + dt = python_datetime(2024, 1, 1, 9, 0, 0) + assert format_nepali_time(dt, "%H:%M") == "09:00" diff --git a/django_nepkit/tests/test_views.py b/django_nepkit/tests/test_views.py index 81a011d..c93284e 100644 --- a/django_nepkit/tests/test_views.py +++ b/django_nepkit/tests/test_views.py @@ -44,6 +44,38 @@ class TestRenderOptions: assert "Kathmandu" in html assert "Lalitpur" in html + def test_escapes_xss_in_id(self): + """District names with HTML should be escaped, not injected verbatim.""" + from django_nepkit.views import _render_options + + data = [ + { + "id": '">', + "text": "Bad", + } + ] + response = _render_options(data, "Select") + html = response.content.decode() + assert "" not in html + assert "<script>" in html + + def test_escapes_xss_in_text(self): + from django_nepkit.views import _render_options + + data = [{"id": "X", "text": ""}] + response = _render_options(data, "Select") + html = response.content.decode() + assert "') + html = response.content.decode() + assert "" not in html + assert "<script>" in html + class TestGetPrimaryParam: """Tests for _get_primary_param internal helper.""" @@ -63,6 +95,18 @@ class TestGetPrimaryParam: value = _get_primary_param(request, "province") assert value == "Bagmati Province" + def test_no_fallback_when_multiple_non_internal_params(self, rf): + """If the URL has multiple non-internal params, the named one wins and + the fallback is skipped to avoid the wrong-key foot-gun.""" + from django_nepkit.views import _get_primary_param + + request = rf.get( + "/", + {"province": "Bagmati Province", "field": "Koshi Province"}, + ) + value = _get_primary_param(request, "province") + assert value == "Bagmati Province" + def test_internal_params_excluded_from_fallback(self, rf): from django_nepkit.views import _get_primary_param @@ -113,6 +157,42 @@ class TestParseLanguageParams: assert ne is False assert en is True + def test_unknown_value_falls_back_to_default(self, rf): + """Unrecognised values fall back to the project default (non-strict).""" + from django_nepkit.views import _parse_language_params + + request = rf.get("/", {"ne": "banana"}) + ne, en = _parse_language_params(request) + # Default in test settings is "en", so ne should be False. + assert ne is False + assert en is True + + def test_strict_raises_on_garbage(self, rf): + from django_nepkit.views import _parse_language_params + + request = rf.get("/", {"ne": "banana"}) + with pytest.raises(ValueError, match="Invalid value"): + _parse_language_params(request, strict=True) + + def test_strict_accepts_all_canonical_forms(self, rf): + from django_nepkit.views import _parse_language_params + + for value, expected in [ + ("true", True), + ("True", True), + ("1", True), + ("yes", True), + ("on", True), + ("false", False), + ("FALSE", False), + ("0", False), + ("no", False), + ("off", False), + ]: + request = rf.get("/", {"ne": value}) + ne, _ = _parse_language_params(request, strict=True) + assert ne is expected, f"failed for {value!r}" + class TestShouldReturnHtml: """Tests for _should_return_html internal helper.""" diff --git a/django_nepkit/tests/test_widgets.py b/django_nepkit/tests/test_widgets.py index 2f60da6..30a2708 100644 --- a/django_nepkit/tests/test_widgets.py +++ b/django_nepkit/tests/test_widgets.py @@ -56,6 +56,26 @@ class TestNepaliDatePickerWidget: assert "placeholder=" in html + def test_en_mode_format_value_renders_clean(self): + """EN mode must not leak ``nepalidate(…)`` Python reprs.""" + from nepali.datetime import nepalidate + + widget = NepaliDatePickerWidget(en=True) + # Pass a string (the DB-stored form); the widget should pass it + # through unchanged rather than calling strptime on it. + rendered = widget.format_value("2081-01-15") + assert rendered == "2081-01-15" + # Pass a real object and check the result doesn't contain + # "nepalidate(" or "nepalidate(2" — i.e. no Python repr leak. + rendered_obj = widget.format_value(nepalidate(2081, 1, 15)) + assert "nepalidate" not in rendered_obj + + def test_invalid_value_does_not_leak_repr(self): + """Unknown value types render as empty instead of a Python repr.""" + widget = NepaliDatePickerWidget(en=True) + rendered = widget.format_value(object()) + assert rendered == "" + class TestLocationWidgets: """Tests for location select widgets.""" @@ -99,3 +119,22 @@ class TestLocationWidgets: html = widget.render("province", None) assert 'data-ne="true"' in html + + def test_municipality_widget_htmx_attributes(self): + """Test that MunicipalitySelectWidget enables HTMX mode when htmx=True.""" + widget = MunicipalitySelectWidget(htmx=True) + assert widget.htmx is True + assert widget._hx_url_name == "django_nepkit:municipality-list" + assert widget._hx_target == ".nepkit-municipality-select" + + def test_unknown_type_renders_empty_for_time(self): + from datetime import time + + from django_nepkit.widgets import NepaliTimeWidget + + widget = NepaliTimeWidget(en=True) + # TimeField values should pass through. + assert widget.format_value(time(9, 30)) == "09:30 AM" + # Unknown types should not leak a Python repr. + rendered = widget.format_value(12345) + assert rendered == "" diff --git a/django_nepkit/utils.py b/django_nepkit/utils.py index acd5943..7bb1aba 100644 --- a/django_nepkit/utils.py +++ b/django_nepkit/utils.py @@ -1,76 +1,338 @@ from __future__ import annotations +import re +from datetime import time as python_time +from decimal import Decimal, InvalidOperation +from functools import lru_cache from typing import Any, Optional from nepali.datetime import nepalidate, nepalidatetime +from nepali.exceptions import FormatNotMatchException from nepali.locations import districts, municipalities, provinces from django_nepkit.conf import nepkit_settings +from django_nepkit.constants import NEGATIVE_PREFIX, NEPALI_ONES, NEPALI_UNITS -BS_DATE_FORMAT = nepkit_settings.BS_DATE_FORMAT -BS_DATETIME_FORMAT = nepkit_settings.BS_DATETIME_FORMAT + +# Anything we treat as "this format didn't match, try the next one". +_PARSE_ERRORS = (ValueError, TypeError, FormatNotMatchException) + + +# --------------------------------------------------------------------------- +# Live format constants +# --------------------------------------------------------------------------- +# +# ``BS_DATE_FORMAT`` / ``BS_DATETIME_FORMAT`` / ``BS_TIME_FORMAT`` are exposed +# for backwards compat (e.g. ``from django_nepkit.utils import BS_DATE_FORMAT``). +# Re-exporting them through a module-level ``__getattr__`` means each +# attribute access re-reads ``nepkit_settings`` rather than being frozen at +# import time. +# +# If the user changes ``settings.NEPKIT['BS_DATE_FORMAT']`` via +# ``override_settings`` (or a runtime signal), a fresh +# ``from django_nepkit.utils import BS_DATE_FORMAT`` will see the new value. + +__all__ = [ + "BS_DATE_FORMAT", # noqa: F822 — exposed via module __getattr__ + "BS_DATETIME_FORMAT", # noqa: F822 + "BS_TIME_FORMAT", # noqa: F822 + "format_nepali_currency", + "format_nepali_date", + "format_nepali_datetime", + "format_nepali_time", + "try_parse_nepali_date", + "try_parse_nepali_datetime", + "try_parse_nepali_time", + "get_districts_by_province", + "get_municipalities_by_district", + "number_to_nepali_words", + "english_to_nepali_unicode", + "normalize_address", +] + + +_LIVE_SETTINGS = { + "BS_DATE_FORMAT", + "BS_DATETIME_FORMAT", + "BS_TIME_FORMAT", +} + + +def __getattr__(name): + if name in _LIVE_SETTINGS: + return getattr(nepkit_settings, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- def _try_parse_nepali(value: Any, cls: Any, fallback_fmt: str) -> Any: - """Helper to turn a string into a Nepali date object.""" + """Turn a string into a Nepali date/datetime object. + + Returns ``None`` for empty input or when no format matches. Other + exceptions (programmer errors, OOM) propagate. + """ if value in (None, ""): return None if isinstance(value, cls): return value if isinstance(value, str): - formats = nepkit_settings.DATE_INPUT_FORMATS + formats = list(nepkit_settings.DATE_INPUT_FORMATS) if fallback_fmt not in formats: - formats = list(formats) + [fallback_fmt] - + formats.append(fallback_fmt) for fmt in formats: try: return cls.strptime(value.strip(), fmt) - except Exception: + except _PARSE_ERRORS: continue return None def try_parse_nepali_date(value: Any) -> Optional[nepalidate]: - """Convert any value to a Nepali Date.""" - return _try_parse_nepali(value, nepalidate, BS_DATE_FORMAT) + """Convert any value to a Nepali Date (``None`` on failure).""" + return _try_parse_nepali(value, nepalidate, nepkit_settings.BS_DATE_FORMAT) def try_parse_nepali_datetime(value: Any) -> Optional[nepalidatetime]: - """Convert any value to a Nepali Date and Time.""" - return _try_parse_nepali(value, nepalidatetime, BS_DATETIME_FORMAT) + """Convert any value to a Nepali DateTime (``None`` on failure).""" + return _try_parse_nepali(value, nepalidatetime, nepkit_settings.BS_DATETIME_FORMAT) -def _get_location_children(parent_list, parent_name, child_attr, ne=False): - """Find children (like districts) of a parent (like a province).""" - selected_parent = None - for p in parent_list: - p_name = p.name - p_name_ne = getattr(p, "name_nepali", None) +def try_parse_nepali_time(value: Any) -> Optional[python_time]: + """Convert any value to a Python ``time`` (``None`` on failure).""" + from datetime import datetime as _dt - if p_name == parent_name or p_name_ne == parent_name: - selected_parent = p - break + if value in (None, ""): + return None + if isinstance(value, python_time): + return value + if isinstance(value, _dt): + return value.time() + if isinstance(value, str): + formats = list(nepkit_settings.TIME_INPUT_FORMATS) + if nepkit_settings.BS_TIME_FORMAT not in formats: + formats.append(nepkit_settings.BS_TIME_FORMAT) + for fmt in formats: + try: + return _dt.strptime(value.strip(), fmt).time() + except _PARSE_ERRORS: + continue + return None - if not selected_parent: - return [] - children = getattr(selected_parent, child_attr, []) +# --------------------------------------------------------------------------- +# Formatting +# --------------------------------------------------------------------------- + + +def format_nepali_time(value: Any, format_str: Optional[str] = None) -> str: + """Format a Python ``time``/``datetime`` as a string. + + Returns an empty string for ``None`` and falls back to ``str(value)`` when + the value cannot be formatted. Honours ``nepkit_settings.BS_TIME_FORMAT`` + when no explicit format is supplied. + """ + from datetime import datetime as _dt + + if value is None: + return "" + if format_str is None: + format_str = nepkit_settings.BS_TIME_FORMAT + if isinstance(value, _dt): + value = value.time() + if isinstance(value, python_time): + try: + return value.strftime(format_str) + except (ValueError, TypeError): + return str(value) + return str(value) + + +def format_nepali_date(value: Any, format_str: Optional[str] = None) -> str: + """Format a ``nepalidate``/``nepalidatetime``/``str`` as a string. + + Returns an empty string for ``None`` and ``str(value)`` for anything + that can't be parsed. Honours ``nepkit_settings.BS_DATE_FORMAT`` + when no explicit format is supplied. + """ + if value is None: + return "" + if format_str is None: + format_str = nepkit_settings.BS_DATE_FORMAT + if isinstance(value, (nepalidate, nepalidatetime)): + try: + return value.strftime(format_str) + except (ValueError, TypeError): + return str(value) + if isinstance(value, str): + # Already-formatted strings pass through unchanged. + return value + return str(value) + + +def format_nepali_datetime(value: Any, format_str: Optional[str] = None) -> str: + """Format a ``nepalidatetime`` for display. + + Honours ``nepkit_settings.BS_DATETIME_FORMAT`` when no explicit + format is supplied. + """ + if value is None: + return "" + if format_str is None: + format_str = nepkit_settings.BS_DATETIME_FORMAT + if isinstance(value, nepalidatetime): + try: + return value.strftime(format_str) + except (ValueError, TypeError): + return str(value) + if isinstance(value, nepalidate): + # A pure date stored where a datetime is expected — format as a + # date and append the default time. + try: + return value.strftime( + format_str.split(" ")[0] if " " in format_str else format_str + ) + except (ValueError, TypeError): + return str(value) + if isinstance(value, str): + return value + return str(value) + + +def format_nepali_currency( + number: Any, currency_symbol: str = "Rs.", ne: bool = False +) -> str: + """ + Format a number with Nepali-style commas and optional currency symbol. + + Examples: + ``1234567`` -> ``Rs. 12,34,567.00`` + ``Decimal('123456789012345.67')`` -> preserves precision + ``None`` -> ``""`` + + Raises: + ValueError: if ``number`` cannot be coerced to a finite ``Decimal``. + """ + from nepali.number import add_comma, english_to_nepali + + if number is None: + return "" + if isinstance(number, bool): + raise ValueError(f"Cannot format boolean as currency: {number!r}") + + try: + amount = Decimal(str(number)) if not isinstance(number, Decimal) else number + except (InvalidOperation, ValueError) as exc: + raise ValueError(f"Cannot format {number!r} as currency: {exc}") from exc + + if not amount.is_finite(): + raise ValueError(f"Cannot format non-finite currency value: {number!r}") + + quantized = amount.quantize(Decimal("0.01")) + sign = "-" if quantized < 0 else "" + abs_q = -quantized if quantized < 0 else quantized + integer_part, _, decimal_part = format(abs_q, "f").partition(".") + formatted_integer = add_comma(int(integer_part)) + res = ( + f"{formatted_integer}.{decimal_part or '00'}" + if "." in format(abs_q, "f") or decimal_part + else f"{formatted_integer}.00" + ) if ne: - return [ - { - "id": getattr(child, "name_nepali", child.name), - "text": getattr(child, "name_nepali", child.name), - } - for child in children - ] - else: - return [{"id": child.name, "text": child.name} for child in children] + res = english_to_nepali(res) + + if currency_symbol: + return f"{currency_symbol} {sign}{res}" + return f"{sign}{res}" + + +# --------------------------------------------------------------------------- +# Nepali words +# --------------------------------------------------------------------------- + + +def number_to_nepali_words(number: Any) -> str: + """ + Convert a number to Nepali words. + + Examples: + ``123`` -> ``एक सय तेईस`` + ``-25`` -> ``ऋणात्मक पच्चीस`` + + Raises: + ValueError: if ``number`` cannot be coerced to an int. + """ + if number is None: + return "" + + try: + if isinstance(number, bool): + raise ValueError(f"Cannot convert boolean to words: {number!r}") + if isinstance(number, Decimal): + if not number.is_finite(): + raise ValueError(f"Cannot convert non-finite Decimal: {number!r}") + num = int(number) + else: + num = int(Decimal(str(number)).quantize(Decimal("1"))) + except (InvalidOperation, ValueError, TypeError) as exc: + raise ValueError(f"Cannot convert {number!r} to Nepali words: {exc}") from exc + + if num == 0: + return "शून्य" + + def _convert(n: int) -> str: + if n == 0: + return "" + if n < 0: + return f"{NEGATIVE_PREFIX} {_convert(-n)}".strip() + if n < 100: + return NEPALI_ONES[n] + for i in range(len(NEPALI_UNITS) - 1, 0, -1): + div, unit_name = NEPALI_UNITS[i] + if n >= div: + prefix_val = n // div + remainder = n % div + prefix_words = _convert(prefix_val) + res = f"{prefix_words} {unit_name}".strip() + if remainder > 0: + res = f"{res} {_convert(remainder)}" + return res.strip() + return "" + + return _convert(num) + + +def english_to_nepali_unicode(text: Any) -> str: + """Convert English digits in ``text`` to Nepali Unicode digits.""" + from nepali.number import english_to_nepali + + if text is None: + return "" + return english_to_nepali(text) + + +# --------------------------------------------------------------------------- +# Location data +# --------------------------------------------------------------------------- + + +def _normalize_nepali_text(text): + """Normalize Nepali text for easier matching. + + Replaces Chandrabindu with Anusvara so that variant spellings of the + same place name (e.g. काठमाडौँ vs काठमाडौं) compare equal. + """ + if not text: + return text + return text.replace("ँ", "ं").replace("ाँ", "ां") def get_districts_by_province(province_name, ne=False, en=True): """Get all districts for a province.""" - # Logic note: if ne=True is passed, we shouldn't care about en=True (handled by caller typically) return _get_location_children(provinces, province_name, "districts", ne=ne) @@ -79,223 +341,187 @@ def get_municipalities_by_district(district_name, ne=False, en=True): return _get_location_children(districts, district_name, "municipalities", ne=ne) -def format_nepali_currency( - number: Any, currency_symbol: str = "Rs.", ne: bool = False -) -> str: +def _get_location_children(parent_list, parent_name, child_attr, ne=False): + selected_parent = None + for p in parent_list: + p_name = p.name + p_name_ne = getattr(p, "name_nepali", None) + if p_name == parent_name or p_name_ne == parent_name: + selected_parent = p + break + if not selected_parent: + return [] + children = getattr(selected_parent, child_attr, []) + if ne: + return [ + { + "id": getattr(child, "name_nepali", child.name), + "text": getattr(child, "name_nepali", child.name), + } + for child in children + ] + return [{"id": child.name, "text": child.name} for child in children] + + +# --------------------------------------------------------------------------- +# Address normalization +# --------------------------------------------------------------------------- + +# Rank higher = better match. Used to pick the best candidate when a token +# matches multiple location names. Exact > substring so that the most +# precise match always wins. +_MATCH_RANK_EXACT = 100 +_MATCH_RANK_SUBSTRING = 20 + +# Minimum lengths for substring matches (avoid matching single characters). +_MIN_EN_SUBSTRING = 4 +_MIN_NE_SUBSTRING = 2 + + +def _classify_match( + token, token_lower, name_eng, name_eng_lower, name_nep, name_nep_norm +): + """Return a (rank, matched_name) tuple or ``None`` if there's no match. + + The caller is expected to take the highest-ranked candidate. """ - Formats a number with Nepali-style commas and optional currency symbol. - Eg. 1234567 -> Rs. 12,34,567 - """ - from nepali.number import add_comma, english_to_nepali - - if number is None: - return "" - - try: - # Convert to string and split by decimal point - num_str = f"{float(number):.2f}" - if "." in num_str: - integer_part, decimal_part = num_str.split(".") - else: - integer_part, decimal_part = num_str, "" - - # Format integer part with commas - formatted_integer = add_comma(int(integer_part)) - - # Join back - res = formatted_integer - if decimal_part: - res = f"{res}.{decimal_part}" - - if ne: - res = english_to_nepali(res) - - if currency_symbol: - return f"{currency_symbol} {res}" - return res - except Exception: - return str(number) - - -def number_to_nepali_words(number: Any) -> str: - """ - Converts a number to Nepali words. - Eg. 123 -> एक सय तेईस - """ - from django_nepkit.constants import NEPALI_ONES, NEPALI_UNITS - - if number is None: - return "" - - try: - num = int(float(number)) - except (ValueError, TypeError): - return str(number) - - if num == 0: - return "शून्य" - - def _convert(n): - if n == 0: - return "" - if n < 100: - return NEPALI_ONES[n] - - for i in range(len(NEPALI_UNITS) - 1, 0, -1): - div, unit_name = NEPALI_UNITS[i] - if n >= div: - prefix_val = n // div - remainder = n % div - - # For 'सय' (100), we use NEPALI_ONES[prefix_val] - # For others, we might need recursive calls if prefix_val >= 100 - prefix_words = _convert(prefix_val) - res = f"{prefix_words} {unit_name}" - if remainder > 0: - res += f" {_convert(remainder)}" - return res.strip() - return "" - - return _convert(num) - - -def english_to_nepali_unicode(text: Any) -> str: - """ - Converts English text/numbers to Nepali Unicode. - Currently focuses on numbers. - """ - from nepali.number import english_to_nepali - - if text is None: - return "" - - return english_to_nepali(text) - - -def _normalize_nepali_text(text): - """ - Normalize Nepali text for easier matching. - Replaces Chandrabindu with Anusvara. - """ - if not text: - return text - return text.replace("ँ", "ं").replace("ाँ", "ां") + # Nepali exact (case-sensitive, since the script has no case). + if name_nep and token == name_nep: + return (_MATCH_RANK_EXACT, name_eng) + if name_nep and name_nep_norm and token == name_nep_norm: + return (_MATCH_RANK_EXACT, name_eng) + # English exact (case-insensitive). + if name_eng and token_lower == name_eng_lower: + return (_MATCH_RANK_EXACT, name_eng) + # English substring (length-guarded to avoid false positives). + if len(token) >= _MIN_EN_SUBSTRING and name_eng and token_lower in name_eng_lower: + return (_MATCH_RANK_SUBSTRING, name_eng) + # Nepali substring + if len(token) >= _MIN_NE_SUBSTRING and name_nep_norm and token in name_nep_norm: + return (_MATCH_RANK_SUBSTRING, name_eng) + return None def _matches_location_name(name_eng, name_nep, token, normalized_token): + """Boolean wrapper around :func:`_classify_match`. + + Returns ``True`` when ``token`` matches either the English or Nepali + name according to the same rules used by + :func:`_find_location_in_tokens`. Kept for backwards compatibility + with existing test suites. """ - Check if a token matches a location name (English or Nepali). - - Args: - name_eng: English name of location - name_nep: Nepali name of location - token: Original token to match - normalized_token: Normalized version of token - - Returns: - True if token matches the location name - """ - token_lower = token.lower() - name_nep_norm = _normalize_nepali_text(name_nep) - - # Exact matches - if ( - token == name_nep - or normalized_token == name_nep_norm - or token_lower == name_eng.lower() - ): - 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 + classified = _classify_match( + token, + token.lower(), + name_eng, + (name_eng or "").lower(), + name_nep, + _normalize_nepali_text(name_nep), + ) + return classified is not None def _find_location_in_tokens(location_list, tokens, normalized_tokens): - """ - Find a location from a list by matching against tokens. + """Find the best-matching location for any of ``tokens``. - Args: - location_list: List of location objects to search - tokens: List of original tokens - normalized_tokens: List of normalized tokens + Returns the location object, or ``None`` if nothing matches. When + multiple candidates match, the one with the highest rank wins; ties + are broken by the first token that produced the match. - Returns: - Matching location object or None + ``normalized_tokens`` is accepted for API stability but is currently + unused (ranking is done on the ASCII-folded form of each token). """ - for i, token in enumerate(tokens): - nt = normalized_tokens[i] + del normalized_tokens # currently unused; kept for API symmetry + best = None + best_rank = -1 + for token in tokens: for location in location_list: - if _matches_location_name(location.name, location.name_nepali, token, nt): - return location - return None + name_eng = location.name + name_eng_lower = name_eng.lower() if name_eng else "" + name_nep = getattr(location, "name_nepali", None) or "" + name_nep_norm = _normalize_nepali_text(name_nep) + classified = _classify_match( + token, token.lower(), name_eng, name_eng_lower, name_nep, name_nep_norm + ) + if classified is None: + continue + rank, _ = classified + if rank > best_rank: + best_rank = rank + best = location + if rank == _MATCH_RANK_EXACT: + return best + return best def _is_nepali_text(tokens): """Check if any token contains Devanagari characters.""" - import re - return any(re.search(r"[\u0900-\u097F]", t) for t in tokens) -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'. +@lru_cache(maxsize=1024) +def _find_address_components(address_string: str): + """Resolve the (municipality, district, province) location objects. + + Memoised on ``address_string`` (the input is a small string and the + location data is static). Returns ``(None, None, None)`` for empty + input. The location objects come from ``nepali.locations`` and are + safe to share across callers. """ if not address_string: - return {"province": None, "district": None, "municipality": None} + return None, None, None - result: dict[str, Optional[str]] = { - "province": None, - "district": None, - "municipality": None, - } - - # Prepare tokens content = address_string.replace(",", " ").replace("-", " ") tokens = [t.strip() for t in content.split() if t.strip()] normalized_tokens = [_normalize_nepali_text(t) for t in tokens] - # Find locations - most specific to least specific found_municipality = _find_location_in_tokens( municipalities, tokens, normalized_tokens ) found_district = _find_location_in_tokens(districts, tokens, normalized_tokens) found_province = _find_location_in_tokens(provinces, tokens, normalized_tokens) - # 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 and not found_province: + found_province = found_district.province - if found_district: - result["district"] = found_district.name - if not found_province: - found_province = found_district.province + return found_municipality, found_district, found_province - if found_province: - result["province"] = found_province.name - # Use Nepali names if input contains Nepali text - if _is_nepali_text(tokens): - if found_municipality: - result["municipality"] = found_municipality.name_nepali - if found_district: - result["district"] = found_district.name_nepali - if found_province: - result["province"] = found_province.name_nepali +def normalize_address(address_string: str) -> dict[str, Optional[str]]: + """ + Best-effort: split a Nepali address string into Province, District, and + Municipality. + Returns a dict with the (English) names of the matched locations, or + their Nepali names if the input contains Devanagari. The heavy + matching is memoised on ``address_string``; each call still returns + a fresh dict so callers can mutate the result without affecting the + cache. + """ + result: dict[str, Optional[str]] = { + "province": None, + "district": None, + "municipality": None, + } + if not address_string: + return result + + tokens = [t.strip() for t in address_string.split() if t.strip()] + is_nepali = _is_nepali_text(tokens) + + municipality, district, province = _find_address_components(address_string) + + def _name(loc): + if loc is None: + return None + return loc.name_nepali if is_nepali else loc.name + + result["municipality"] = _name(municipality) + result["district"] = _name(district) + result["province"] = _name(province) return result diff --git a/django_nepkit/views.py b/django_nepkit/views.py index 90611e5..e22e8c8 100644 --- a/django_nepkit/views.py +++ b/django_nepkit/views.py @@ -1,102 +1,132 @@ -from django.http import JsonResponse, HttpResponse +import logging +from django.http import HttpResponse, JsonResponse +from django.utils.html import format_html, format_html_join + +from django_nepkit.conf import nepkit_settings from django_nepkit.utils import ( get_districts_by_province, get_municipalities_by_district, ) -from django_nepkit.conf import nepkit_settings +logger = logging.getLogger(__name__) + +# Accepted values for ``?ne=`` / ``?en=`` query params. Anything else is +# either treated as "not provided" (default) or, with ``strict=True``, +# rejected. +_TRUE_VALUES = {"true", "1", "yes", "on"} +_FALSE_VALUES = {"false", "0", "no", "off"} + + +def _coerce_bool(raw): + """Validate a boolean query string value. + + Returns: + ``True``/``False`` if the value is recognised, ``None`` otherwise. + """ + if raw is None: + return None + lowered = raw.lower() + if lowered in _TRUE_VALUES: + return True + if lowered in _FALSE_VALUES: + return False + return None def _render_options(data, placeholder): - """Internal helper to render list of options as HTML.""" - options = [f''] - for item in data: - options.append(f'') - return HttpResponse("\n".join(options), content_type="text/html") + """Render a list of location options as HTML. + + All values are HTML-escaped so that user-supplied data injected via + data import can't smuggle markup into the rendered page. + """ + rendered_options = format_html_join( + "\n", + '', + ((item.get("id", ""), item.get("text", "")) for item in data), + ) + html = format_html( + '\n{1}', placeholder, rendered_options + ) + return HttpResponse(str(html), content_type="text/html") def _get_primary_param(request, param_name, exclude_params=None): """ - Extract a parameter from request.GET with fallback logic. + Extract a parameter from ``request.GET``. - Args: - request: Django request object - param_name: Primary parameter name to look for - exclude_params: List of parameter names to exclude from fallback - - Returns: - Parameter value or None + The primary parameter is read first. If it is not present, the helper + falls back to the **single** remaining non-internal parameter — this + is a soft contract that makes the chained-select endpoints + friendlier to hand-written URLs (``?Bagmati``), but it is **not** + used when the query string has multiple non-internal keys (which + would make the result ambiguous). """ from django_nepkit.constants import INTERNAL_PARAMS if exclude_params is None: exclude_params = INTERNAL_PARAMS - # Try primary parameter first value = request.GET.get(param_name) + if value: + return value - # Fallback: take the first non-internal parameter - if not value: - for key, val in request.GET.items(): - if key not in exclude_params and val: - value = val - break - - return value + non_internal = [ + (k, v) + for k, v in request.GET.items() + if k not in exclude_params and v and k != param_name + ] + if len(non_internal) == 1: + return non_internal[0][1] + return None -def _parse_language_params(request): +def _parse_language_params(request, strict=False): """ - Parse language parameters from request. + Parse the ``?ne=`` / ``?en=`` query parameters. Args: - request: Django request object + request: Django request. + strict: When ``True``, raise ``ValueError`` if the value is + present but unrecognised (anything outside + ``true|false|1|0|yes|no|on|off``). When ``False`` (the + default), unrecognised values fall back to the project + default so existing integrations aren't broken. Returns: - Tuple of (ne, en) boolean values + Tuple of ``(ne, en)`` booleans. """ default_lang = nepkit_settings.DEFAULT_LANGUAGE - ne_param = request.GET.get("ne") - ne = ne_param.lower() == "true" if ne_param else default_lang == "ne" - en_param = request.GET.get("en") - en = en_param.lower() == "true" if en_param else not ne + ne_raw = request.GET.get("ne") + en_raw = request.GET.get("en") - return ne, en + ne_value = _coerce_bool(ne_raw) + en_value = _coerce_bool(en_raw) + + if strict and ne_raw is not None and ne_value is None: + raise ValueError(f"Invalid value for ?ne=: {ne_raw!r}") + if strict and en_raw is not None and en_value is None: + raise ValueError(f"Invalid value for ?en=: {en_raw!r}") + + if ne_value is None: + ne_value = default_lang == "ne" + if en_value is None: + en_value = not ne_value + + return ne_value, en_value def _should_return_html(request): - """ - Check if response should be HTML format. - - Args: - request: Django request object - - Returns: - Boolean indicating if HTML format is requested - """ - return ( - request.GET.get("html", "false").lower() == "true" - or request.headers.get("HX-Request") == "true" - ) + """Decide whether to return HTML (for HTMX / direct include) or JSON.""" + if request.headers.get("HX-Request") == "true": + return True + return request.GET.get("html", "false").lower() == "true" def _location_list_view(request, param_name, data_func, placeholders): - """ - Generic view handler for location hierarchy endpoints. - - Args: - request: Django request object - param_name: Name of the primary parameter to extract - data_func: Function to call to get location data - placeholders: Tuple of (nepali_placeholder, english_placeholder) - - Returns: - JsonResponse or HttpResponse with location data - """ + """Generic handler for the province→district→municipality chain.""" param_value = _get_primary_param(request, param_name) - if not param_value: return JsonResponse([], safe=False) @@ -108,12 +138,11 @@ def _location_list_view(request, param_name, data_func, placeholders): if as_html: placeholder = placeholders[0] if ne else placeholders[1] return _render_options(data, placeholder) - return JsonResponse(data, safe=False) def district_list_view(request): - """Return list of districts for a given province.""" + """Return districts for a given province.""" from django_nepkit.constants import PLACEHOLDERS return _location_list_view( @@ -125,7 +154,7 @@ def district_list_view(request): def municipality_list_view(request): - """Return list of municipalities for a given district.""" + """Return municipalities for a given district.""" from django_nepkit.constants import PLACEHOLDERS return _location_list_view( diff --git a/django_nepkit/widgets.py b/django_nepkit/widgets.py index ff0de19..1ab0bed 100644 --- a/django_nepkit/widgets.py +++ b/django_nepkit/widgets.py @@ -1,9 +1,15 @@ +import logging +from datetime import time as python_time + from django import forms -from django.urls import reverse, NoReverseMatch +from django.urls import NoReverseMatch, reverse from nepali.datetime import nepalidate -from django_nepkit.utils import BS_DATE_FORMAT +from nepali.number import english_to_nepali from django_nepkit.conf import nepkit_settings +from django_nepkit.utils import BS_TIME_FORMAT, format_nepali_time + +logger = logging.getLogger(__name__) def _append_css_class(attrs, class_name: str): @@ -23,48 +29,57 @@ class NepaliWidgetMixin: attrs = kwargs.get("attrs", {}) or {} - # Language settings if self.ne: attrs["data-ne"] = "true" if self.en: attrs["data-en"] = "true" - # Pass the date format to JavaScript - attrs["data-format"] = BS_DATE_FORMAT - + attrs["data-format"] = self._default_format() self._configure_attrs(attrs) - kwargs["attrs"] = attrs super().__init__(*args, **kwargs) + def _default_format(self) -> str: + return nepkit_settings.BS_DATE_FORMAT + def _configure_attrs(self, attrs): - """Override in subclasses or use class attributes.""" css_class = getattr(self, "css_class", None) if css_class: _append_css_class(attrs, css_class) + def _resolve_url(self, url_name, attr_name): + """Resolve a URL name and return the URL string. + + On failure, log a warning and return an empty string so the JS + can degrade gracefully. The widget's data attribute is omitted + entirely (rather than set to a broken value). + """ + try: + return reverse(url_name) + except NoReverseMatch: + logger.warning( + "nepkit widget %r: URL name %r not found; " + "the corresponding select will not auto-populate. " + "Make sure 'django_nepkit.urls' is included in your URLconf.", + self.__class__.__name__, + url_name, + ) + return "" + def get_context(self, name, value, attrs): context = super().get_context(name, value, attrs) widget_attrs = context.get("widget", {}).get("attrs", {}) - # Handle URLs for data fetching url_name = getattr(self, "_url_name", None) if url_name: - try: - widget_attrs["data-url"] = reverse(url_name) - except NoReverseMatch: - pass + widget_attrs["data-url"] = self._resolve_url(url_name, "data-url") - # Handle HTMX dynamic updates if self.htmx: hx_url_name = getattr(self, "_hx_url_name", None) if hx_url_name: - try: - widget_attrs["hx-get"] = reverse(hx_url_name) - widget_attrs["hx-target"] = getattr(self, "_hx_target", "") - widget_attrs["hx-trigger"] = "change" - except NoReverseMatch: - pass + widget_attrs["hx-get"] = self._resolve_url(hx_url_name, "hx-get") + widget_attrs["hx-target"] = getattr(self, "_hx_target", "") + widget_attrs["hx-trigger"] = "change" return context @@ -102,6 +117,12 @@ class MunicipalitySelectWidget(NepaliWidgetMixin, ChainedSelectWidget): css_class = "nepkit-municipality-select" _url_name = "django_nepkit:municipality-list" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.htmx: + self._hx_url_name = "django_nepkit:municipality-list" + self._hx_target = ".nepkit-municipality-select" + class NepaliDatePickerWidget(NepaliWidgetMixin, forms.TextInput): input_type = "text" @@ -119,6 +140,7 @@ class NepaliDatePickerWidget(NepaliWidgetMixin, forms.TextInput): ) def _configure_attrs(self, attrs): + super()._configure_attrs(attrs) classes = attrs.get("class", "") if "vDateField" in classes: classes = classes.replace("vDateField", "") @@ -127,15 +149,74 @@ class NepaliDatePickerWidget(NepaliWidgetMixin, forms.TextInput): _append_css_class(attrs, "nepkit-datepicker") attrs["autocomplete"] = "off" attrs["placeholder"] = ( - BS_DATE_FORMAT.replace("%Y", "YYYY").replace("%m", "MM").replace("%d", "DD") + nepkit_settings.BS_DATE_FORMAT.replace("%Y", "YYYY") + .replace("%m", "MM") + .replace("%d", "DD") ) def format_value(self, value): if value is None: return None + from django_nepkit.utils import format_nepali_date - if self.ne and isinstance(value, nepalidate): - if hasattr(value, "strftime_ne"): - return value.strftime_ne(BS_DATE_FORMAT) + try: + if isinstance(value, nepalidate): + if self.ne and hasattr(value, "strftime_ne"): + return value.strftime_ne(nepkit_settings.BS_DATE_FORMAT) + return format_nepali_date(value, nepkit_settings.BS_DATE_FORMAT) + if isinstance(value, str): + return value + except (ValueError, TypeError, AttributeError): + logger.warning( + "nepkit-datepicker: cannot format value %r; rendering empty", value + ) + return "" - return super().format_value(value) + +class NepaliTimeWidget(NepaliWidgetMixin, forms.TextInput): + """Plain text input for time values with Devanagari digit support.""" + + input_type = "text" + css_class = "nepkit-time" + + class Media: + js = ("django_nepkit/js/nepali-time-init.js",) + + def _default_format(self) -> str: + return BS_TIME_FORMAT + + def _configure_attrs(self, attrs): + super()._configure_attrs(attrs) + _append_css_class(attrs, "nepkit-time-input") + attrs["autocomplete"] = "off" + fmt = nepkit_settings.BS_TIME_FORMAT + attrs["placeholder"] = ( + fmt.replace("%H", "HH") + .replace("%I", "hh") + .replace("%M", "mm") + .replace("%S", "ss") + .replace("%p", "AM/PM") + ) + + def format_value(self, value): + if value is None: + return None + if isinstance(value, python_time): + try: + formatted = format_nepali_time(value, nepkit_settings.BS_TIME_FORMAT) + except (ValueError, TypeError): + logger.warning( + "nepkit-time: cannot format value %r; rendering empty", value + ) + return "" + if self.ne and formatted: + formatted = english_to_nepali(formatted) + return formatted + if isinstance(value, str): + return value + # Unknown type — refuse to leak a Python repr into the page. + logger.warning( + "nepkit-time: unexpected value type %s; rendering empty", + type(value).__name__, + ) + return "" diff --git a/example/demo/serializers.py b/example/demo/serializers.py deleted file mode 100644 index a805705..0000000 --- a/example/demo/serializers.py +++ /dev/null @@ -1,39 +0,0 @@ -from rest_framework import serializers -from django_nepkit.serializers import ( - NepaliDateSerializerField, - NepaliCurrencySerializerField, - NepaliLocalizedSerializerMixin, -) -from .models import Person, Citizen, AuditedPerson, Transaction - - -class PersonSerializer(NepaliLocalizedSerializerMixin, serializers.ModelSerializer): - birth_date = NepaliDateSerializerField() - - class Meta: - model = Person - fields = "__all__" - - -class TransactionSerializer( - NepaliLocalizedSerializerMixin, serializers.ModelSerializer -): - amount_formatted = NepaliCurrencySerializerField(source="amount") - - class Meta: - model = Transaction - fields = ["id", "title", "amount", "amount_formatted", "date"] - - -class CitizenSerializer(serializers.ModelSerializer): - class Meta: - model = Citizen - fields = "__all__" - - -class AuditedPersonSerializer(serializers.ModelSerializer): - birth_date = NepaliDateSerializerField() - - class Meta: - model = AuditedPerson - fields = "__all__" diff --git a/example/demo/urls.py b/example/demo/urls.py index c0a5fd6..888fa56 100644 --- a/example/demo/urls.py +++ b/example/demo/urls.py @@ -1,20 +1,17 @@ -from . import views -from rest_framework.routers import DefaultRouter from django.urls import path -app_name = "demo" +from . import views -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" -) +app_name = "demo" 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 + # JSON API (plain Django — no DRF, no django-filter) + path("api/persons.json", views.person_api, name="person-api"), + path("api/citizens.json", views.citizen_api, name="citizen-api"), + path("api/audited.json", views.audited_api, name="audited-api"), + path("api/transactions.json", views.transaction_api, name="transaction-api"), +] diff --git a/example/demo/views.py b/example/demo/views.py index f8b7ea1..6e67c3e 100644 --- a/example/demo/views.py +++ b/example/demo/views.py @@ -1,21 +1,39 @@ +""" +Plain Django views for the demo. + +The form-based views (``person_list`` / ``person_create`` / ``transaction_create`` +/ ``address_normalize_demo``) use Django's built-in machinery. The JSON +endpoints under ``/api/`` use the framework-agnostic helpers in +``django_nepkit.api`` so this demo doesn't need DRF or django-filter. +""" + +from __future__ import annotations + +from decimal import Decimal, InvalidOperation +from typing import Any + from django import forms -from django.shortcuts import render, redirect -from rest_framework import viewsets, filters as drf_filters -from django_filters import rest_framework as django_filters -from django_nepkit.filters import NepaliDateYearFilter, NepaliDateMonthFilter -from .models import Person, Citizen, AuditedPerson, Transaction -from .serializers import ( - PersonSerializer, - CitizenSerializer, - AuditedPersonSerializer, - TransactionSerializer, -) -from django_nepkit.filters import ( - NepaliCurrencyRangeFilter, - NepaliDateRangeFilter, -) +from django.core.paginator import Paginator +from django.db.models import Q +from django.http import HttpRequest, JsonResponse +from django.shortcuts import redirect, render +from django.utils.translation import gettext as _ +from django.views.decorators.http import require_GET + +from django_nepkit import api as nepkit_api +from django_nepkit.conf import nepkit_settings from django_nepkit.utils import normalize_address +from .models import AuditedPerson, Citizen, Person, Transaction + + +_TRUE_VALUES = {"true", "1", "yes", "on"} + + +# --------------------------------------------------------------------------- # +# Form-based views +# --------------------------------------------------------------------------- # + class PersonForm(forms.ModelForm): class Meta: @@ -34,7 +52,13 @@ class PersonForm(forms.ModelForm): ] -def person_list(request): +class TransactionForm(forms.ModelForm): + class Meta: + model = Transaction + fields = ["title", "amount"] + + +def person_list(request: HttpRequest): persons = Person.objects.all() transactions = Transaction.objects.all() return render( @@ -44,7 +68,7 @@ def person_list(request): ) -def person_create(request): +def person_create(request: HttpRequest): if request.method == "POST": form = PersonForm(request.POST) if form.is_valid(): @@ -55,13 +79,7 @@ def person_create(request): return render(request, "demo/person_form.html", {"form": form}) -class TransactionForm(forms.ModelForm): - class Meta: - model = Transaction - fields = ["title", "amount"] - - -def transaction_create(request): +def transaction_create(request: HttpRequest): if request.method == "POST": form = TransactionForm(request.POST) if form.is_valid(): @@ -74,71 +92,7 @@ def transaction_create(request): ) -# --- API Support (DRF) --- - - -class PersonViewSet(viewsets.ModelViewSet): - queryset = Person.objects.all() - serializer_class = PersonSerializer - - # Sort and Search work automatically with BS date strings - filter_backends = [ - django_filters.DjangoFilterBackend, - drf_filters.SearchFilter, - drf_filters.OrderingFilter, - ] - - ordering_fields = ["birth_date", "created_at"] - search_fields = ["name", "birth_date", "phone_number"] - - # Simple exact date filter - filterset_fields = { - "birth_date": ["exact"], - } - - class PersonFilter(django_filters.FilterSet): - year = NepaliDateYearFilter(field_name="birth_date") - month = NepaliDateMonthFilter(field_name="birth_date") - - class Meta: - model = Person - fields = ["province", "district"] - - filterset_class = PersonFilter - - -class CitizenViewSet(viewsets.ModelViewSet): - queryset = Citizen.objects.all() - serializer_class = CitizenSerializer - filter_backends = [drf_filters.OrderingFilter] - ordering_fields = ["province", "district"] - - -class AuditedPersonViewSet(viewsets.ModelViewSet): - queryset = AuditedPerson.objects.all() - serializer_class = AuditedPersonSerializer - filter_backends = [drf_filters.SearchFilter, drf_filters.OrderingFilter] - search_fields = ["name", "birth_date"] - ordering_fields = ["created_at"] - - -class TransactionViewSet(viewsets.ModelViewSet): - queryset = Transaction.objects.all() - serializer_class = TransactionSerializer - filter_backends = [django_filters.DjangoFilterBackend] - - class TransactionFilter(django_filters.FilterSet): - amount_range = NepaliCurrencyRangeFilter(field_name="amount") - date_range = NepaliDateRangeFilter(field_name="date") - - class Meta: - model = Transaction - fields = ["title", "amount", "date"] - - filterset_class = TransactionFilter - - -def address_normalize_demo(request): +def address_normalize_demo(request: HttpRequest): address = request.GET.get("address", "") result = None if address: @@ -150,5 +104,357 @@ def address_normalize_demo(request): ) return render( - request, "demo/address_normalize.html", {"address": address, "result": result} + request, + "demo/address_normalize.html", + {"address": address, "result": result}, + ) + + +# --------------------------------------------------------------------------- # +# Plain-Django JSON API (no DRF, no django-filter) +# --------------------------------------------------------------------------- # + + +def _bad_request(message: str, **extra) -> JsonResponse: + payload = {"error": message} + payload.update(extra) + return JsonResponse(payload, status=400) + + +def _ne_flag(request: HttpRequest) -> bool: + """Resolve the ``?ne=true`` flag for JSON endpoints. + + Falls back to the ``Accept-Language`` header before defaulting to the + project setting so that consumers that send ``ne`` only on some + requests still get a sensible answer. + """ + raw = request.GET.get("ne") + if raw is not None: + return raw.lower() in _TRUE_VALUES + accept = (request.headers.get("Accept-Language") or "").lower() + if "ne" in accept: + return True + return nepkit_settings.DEFAULT_LANGUAGE == "ne" + + +def _strict_flag(request: HttpRequest) -> bool: + """Resolve the ``?strict=1`` flag for JSON endpoints. + + When True, the demo's ``serialize_*`` calls raise on unparseable + values instead of falling back to ``str(value)`` — useful for + smoke-testing the strict paths the API exposes. + """ + raw = request.GET.get("strict") + if raw is None: + return False + return raw.lower() in _TRUE_VALUES + + +def _paginate(request: HttpRequest, queryset, per_page: int = 25): + try: + page_num = max(1, int(request.GET.get("page", 1))) + except (TypeError, ValueError): + page_num = 1 + paginator = Paginator(queryset, per_page) + page = paginator.get_page(page_num) + return page.object_list, { + "page": page.number, + "pages": paginator.num_pages, + "per_page": per_page, + "total": paginator.count, + } + + +def _search(queryset, search_fields: list[str], term: str | None): + if not term: + return queryset + q = Q() + for field in search_fields: + q |= Q(**{f"{field}__icontains": term}) + return queryset.filter(q) + + +def _apply_ordering(queryset, ordering_fields: list[str], order_param: str | None): + """Apply ``?order=`` whitelist. + + Returns ``(queryset, error)``: the queryset is unchanged on error, and + ``error`` is an :class:`HttpResponseBadRequest` if the value is + malformed. + """ + if not order_param: + return queryset, None + field = order_param.lstrip("-") + if field not in ordering_fields: + return ( + queryset, + _bad_request( + _("Invalid order field: %(field)s"), + field=field, + allowed=sorted(ordering_fields), + ), + ) + return queryset.order_by(order_param), None + + +def _year_month_filter(queryset, field_name: str, year: str | None, month: str | None): + """Year/month substring filter, mirroring the default ``BS_DATE_FORMAT``.""" + fmt = nepkit_settings.BS_DATE_FORMAT + if year: + try: + year_int = int(year) + except (TypeError, ValueError): + return queryset, _bad_request(_("Invalid year: %(value)s"), value=year) + if fmt.startswith("%Y"): + sep = fmt[2] if len(fmt) > 2 and not fmt[2].startswith("%") else "-" + queryset = queryset.filter( + **{f"{field_name}__startswith": f"{year_int}{sep}"} + ) + if month: + try: + month_int = int(month) + except (TypeError, ValueError): + return queryset, _bad_request(_("Invalid month: %(value)s"), value=month) + if not 1 <= month_int <= 12: + return queryset, _bad_request( + _("Month out of range: %(value)s"), value=month + ) + month_str = f"{month_int:02d}" + if fmt == "%Y-%m-%d": + queryset = queryset.filter(**{f"{field_name}__contains": f"-{month_str}-"}) + else: + sep = fmt[2] if len(fmt) > 2 and not fmt[2].startswith("%") else "-" + queryset = queryset.filter( + **{f"{field_name}__contains": f"{sep}{month_str}{sep}"} + ) + return queryset, None + + +def _range_filter(queryset, field_name: str, raw: str | None, field_type: str = "auto"): + """Filter by ``"min,max"`` / ``"min,"`` / ``",max"`` / ``"value"``. + + ``field_type`` is one of ``"auto"``, ``"decimal"``, ``"date"`` or + ``"integer"`` and is used to validate user input before it reaches + the ORM (so a stray string doesn't 500 the request). + """ + if not raw: + return queryset, None + + parts = [p.strip() for p in raw.split(",")] + if len(parts) > 2: + return queryset, _bad_request( + _("Range must be one or two comma-separated values: %(value)s"), + value=raw, + ) + + def _validate(value: str): + if not value: + return None + if field_type == "decimal": + try: + return Decimal(value) + except (InvalidOperation, ValueError): + raise ValueError(value) + if field_type == "integer": + try: + return int(value) + except (TypeError, ValueError): + raise ValueError(value) + if field_type == "date": + # Will be re-validated by the ORM; let it pass. + return value + # Auto: try decimal first, fall back to string. + try: + return Decimal(value) + except (InvalidOperation, ValueError): + return value + + try: + if len(parts) == 1: + v = _validate(parts[0]) + if v is None: + return queryset, None + return queryset.filter(**{field_name: v}), None + lo, hi = parts + lo_v = _validate(lo) if lo else None + hi_v = _validate(hi) if hi else None + if lo_v is not None and hi_v is not None: + return queryset.filter(**{f"{field_name}__range": (lo_v, hi_v)}), None + if lo_v is not None: + return queryset.filter(**{f"{field_name}__gte": lo_v}), None + if hi_v is not None: + return queryset.filter(**{f"{field_name}__lte": hi_v}), None + return queryset, None + except ValueError as exc: + return queryset, _bad_request( + _("Invalid range value: %(value)s") % {"value": str(exc)} + ) + + +# ---- Person ----------------------------------------------------------------- + + +@require_GET +def person_api(request: HttpRequest): + qs = Person.objects.all() + qs = _search(qs, ["name", "birth_date", "phone_number"], request.GET.get("q")) + qs, err = _apply_ordering( + qs, ["name", "birth_date", "created_at"], request.GET.get("order") + ) + if err is not None: + return err + + birth_date = request.GET.get("birth_date") + if birth_date: + qs = qs.filter(birth_date=birth_date) + qs, err = _year_month_filter( + qs, "birth_date", request.GET.get("year"), request.GET.get("month") + ) + if err is not None: + return err + if request.GET.get("province"): + qs = qs.filter(province=request.GET["province"]) + if request.GET.get("district"): + qs = qs.filter(district=request.GET["district"]) + + items, meta = _paginate(request, qs) + ne = _ne_flag(request) + strict = _strict_flag(request) + payload = [_person_to_payload(p, ne=ne, strict=strict) for p in items] + return JsonResponse({"results": payload, "meta": meta}) + + +def _person_to_payload( + person: Person, *, ne: bool, strict: bool = False +) -> dict[str, Any]: + return nepkit_api.build_localized_payload( + { + "id": person.id, + "name": person.name, + "birth_date": nepkit_api.serialize_nepali_date( + person.birth_date, ne=ne, strict=strict + ), + "phone_number": person.phone_number, + "province": person.province, + "district": person.district, + "municipality": person.municipality, + "created_at": nepkit_api.serialize_nepali_datetime( + person.created_at, ne=ne, strict=strict + ), + }, + ne=ne, + ) + + +# ---- Citizen ---------------------------------------------------------------- + + +@require_GET +def citizen_api(request: HttpRequest): + qs = Citizen.objects.all() + qs, err = _apply_ordering( + qs, ["name", "province", "district"], request.GET.get("order") + ) + if err is not None: + return err + items, meta = _paginate(request, qs) + ne = _ne_flag(request) + return JsonResponse( + { + "results": [ + nepkit_api.build_localized_payload( + { + "id": c.id, + "name": c.name, + "province": c.province, + "district": c.district, + "municipality": c.municipality, + }, + ne=ne, + ) + for c in items + ], + "meta": meta, + } + ) + + +# ---- Audited person --------------------------------------------------------- + + +@require_GET +def audited_api(request: HttpRequest): + qs = AuditedPerson.objects.all() + qs = _search(qs, ["name", "birth_date"], request.GET.get("q")) + qs, err = _apply_ordering(qs, ["name", "created_at"], request.GET.get("order")) + if err is not None: + return err + items, meta = _paginate(request, qs) + ne = _ne_flag(request) + strict = _strict_flag(request) + return JsonResponse( + { + "results": [ + nepkit_api.build_localized_payload( + { + "id": a.id, + "name": a.name, + "birth_date": nepkit_api.serialize_nepali_date( + a.birth_date, ne=ne, strict=strict + ), + "created_at": nepkit_api.serialize_nepali_datetime( + a.created_at, ne=ne, strict=strict + ), + }, + ne=ne, + ) + for a in items + ], + "meta": meta, + } + ) + + +# ---- Transaction ------------------------------------------------------------ + + +@require_GET +def transaction_api(request: HttpRequest): + qs = Transaction.objects.all() + qs = _search(qs, ["title", "amount", "date"], request.GET.get("q")) + qs, err = _range_filter( + qs, "amount", request.GET.get("amount_range"), field_type="decimal" + ) + if err is not None: + return err + qs, err = _range_filter( + qs, "date", request.GET.get("date_range"), field_type="date" + ) + if err is not None: + return err + if request.GET.get("title"): + qs = qs.filter(title=request.GET["title"]) + items, meta = _paginate(request, qs) + ne = _ne_flag(request) + strict = _strict_flag(request) + return JsonResponse( + { + "results": [ + nepkit_api.build_localized_payload( + { + "id": t.id, + "title": t.title, + "amount": str(t.amount), + "amount_formatted": nepkit_api.serialize_nepali_currency( + t.amount, ne=ne + ), + "date": nepkit_api.serialize_nepali_date( + t.date, ne=ne, strict=strict + ), + }, + ne=ne, + ) + for t in items + ], + "meta": meta, + } ) diff --git a/example/example_project/settings.py b/example/example_project/settings.py index 6675f37..e686ad9 100644 --- a/example/example_project/settings.py +++ b/example/example_project/settings.py @@ -14,8 +14,6 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.staticfiles", "django_nepkit", - "rest_framework", - "django_filters", "demo", ] diff --git a/example/example_project/urls.py b/example/example_project/urls.py index 66c8988..00750f2 100644 --- a/example/example_project/urls.py +++ b/example/example_project/urls.py @@ -3,6 +3,6 @@ from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), - # path("nepkit/", include("django_nepkit.urls")), + path("nepkit/", include("django_nepkit.urls")), path("", include("demo.urls")), ] diff --git a/pyproject.toml b/pyproject.toml index 1013155..0873077 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,6 @@ license = { text = "MIT" } requires-python = ">=3.12" dependencies = [ "django>=4.2", - "django-filter>=25.2", - "djangorestframework>=3.16.1", "nepali>=1.2.0", ] keywords = [ @@ -53,12 +51,6 @@ classifiers = [ "Natural Language :: English" ] -[project.optional-dependencies] -drf = [ - "djangorestframework>=3.14", - "django-filter>=23.1", -] - [project.urls] Homepage = "https://github.com/S4NKALP/django-nepkit" diff --git a/setup.py b/setup.py index 34a83e0..e5cc2fa 100644 --- a/setup.py +++ b/setup.py @@ -14,6 +14,7 @@ setup( "Framework :: Django", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", "Framework :: Django :: 6.0", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", @@ -30,10 +31,7 @@ setup( python_requires=">=3.11", packages=find_packages(exclude=("tests*",)), install_requires=[ - "django>=4.2,<6.0", + "django>=4.2", "nepali>=1.1.3", ], - extras_require={ - "drf": ["djangorestframework>=3.14"], - }, ) diff --git a/todo.md b/todo.md index ee3a1e1..62e7413 100644 --- a/todo.md +++ b/todo.md @@ -1,13 +1,41 @@ # Features TODO ## Localization & Formatting + - [x] **Nepali Currency Formatter**: Template tag for formatting money using the Nepali numbering system (Lakhs, Crores). - [x] **Numbers to Words**: Convert digits like `1234` into Nepali text (`एक हजार दुई सय चौंतीस`). -- [x] **Unicode Helpers**: Built-in filters for converting common English text/numbers into Nepali Unicode equivalent. +- [x] **Unicode Helpers**: Built-in filters for converting common text/numbers into Nepali Unicode equivalent. ## Integration -- [x] **REST API Enhancements**: Add more specialized DRF serializers for range filters and localized outputs. + +- [x] **Framework-agnostic API**: Drop DRF / django-filter dependency; expose `django_nepkit.api` helpers usable from any view layer. ## Address & Location + - [x] **Address Normalization**: Implement a way to verify or suggest standardized Nepali addresses. -- [ ] **Map Integration**: Helpers to get geocoding data for selected Provinces/Districts/Municipalities. + +## Validation & Robustness (audit, v0.2.2) + +- [x] Fix settings TOCTOU (`NepkitSettings` re-reads `settings.NEPKIT` on every access). +- [x] Fix XSS in `_render_options`. +- [x] `format_nepali_currency` end-to-end `Decimal`, raise on non-finite. +- [x] `number_to_nepali_words` handles negatives; raises on non-numeric. +- [x] `BaseNepaliBSField.to_python` / `get_prep_value` raise on bad input. +- [x] `_parse_language_params` `strict` mode + canonical-value whitelist. +- [x] `_get_primary_param` only falls back when there's a single non-internal param. +- [x] Narrow `except Exception: pass` in `admin.py` to `(FieldDoesNotExist, AttributeError)`. +- [x] Log `NoReverseMatch` in widgets (with hint to include `django_nepkit.urls`). +- [x] `strict` flag on every `django_nepkit.api.serialize_*` / `deserialize_*` / `to_decimal`. +- [x] `_looks_numeric` rejects `inf` / `nan` / `1e1000`. +- [x] Demo API: validate `?order=`, `?year=`, `?month=`, `?amount_range=`, `?date_range=`; return 400 on bad input. +- [x] `address-chaining.js`: scope MutationObserver; suppress init-time `change` events; log fetch errors. +- [x] `nepali-time-init.js`: support Nepali-digit input; ship default CSS for `.nepkit-time-invalid`. +- [x] Rank location matches in `_find_location_in_tokens` (exact > substring). +- [x] Catch `nepali.exceptions.FormatNotMatchException` in the parsers. +- [x] `NepaliTimeField.from_db_value` (validate on read). +- [x] `NepkitSettings.__setattr__` blocks accidental writes (read-only). + +## Still open / nice-to-haves + +- [ ] Cache parsed `BaseNepaliBSField` values per row in admin list views (perf). +- [ ] Replace the `format_html_join` helper in `_render_options` with a `