chore(root): remove DRF and django-filter dependencies

drop DRF and django-filter to make django_nepkit framework‑agnostic, simplify dependencies, and align with v0.2.2 breaking change.
This commit is contained in:
sankalp committed 2026-06-06 21:06:03 +05:45
1 parent 38c426c5be
commit 0268bcfe98
40 files changed
+3286 -1406

No files matched your search

+141
View File
@@ -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
+61 -11
View File
@@ -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?**
+75 -20
View File
@@ -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",
]
+115 -99
View File
@@ -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",
)
+358
View File
@@ -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
+47 -9
View File
@@ -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)
+3
View File
@@ -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 = [
"",
-113
View File
@@ -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
+213 -49
View File
@@ -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]
-212
View File
@@ -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
@@ -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);
}
@@ -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 <form> / .nepkit-province-select / .nepkit-district-select
// additions inside a <form> 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 });
}
})();
@@ -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,
});
}
})();
+34 -38
View File
@@ -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`
+1
View File
@@ -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"],
}
@@ -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
@@ -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"
+87
View File
@@ -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
+245
View File
@@ -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
@@ -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")
+41
View File
@@ -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"
+48 -1
View File
@@ -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: १००"
-51
View File
@@ -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)
+147
View File
@@ -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"
-111
View File
@@ -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"
+54
View File
@@ -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"
+80
View File
@@ -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": '"></option><script>alert(1)</script>',
"text": "Bad",
}
]
response = _render_options(data, "Select")
html = response.content.decode()
assert "<script>alert(1)</script>" not in html
assert "&lt;script&gt;" in html
def test_escapes_xss_in_text(self):
from django_nepkit.views import _render_options
data = [{"id": "X", "text": "<img src=x onerror=alert(1)>"}]
response = _render_options(data, "Select")
html = response.content.decode()
assert "<img" not in html
assert "&lt;img" in html
def test_escapes_placeholder(self):
from django_nepkit.views import _render_options
response = _render_options([], '"><script>x</script>')
html = response.content.decode()
assert "<script>x</script>" not in html
assert "&lt;script&gt;" 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."""
+39
View File
@@ -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 == ""
+437 -211
View File
@@ -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
+91 -62
View File
@@ -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'<option value="">{placeholder}</option>']
for item in data:
options.append(f'<option value="{item["id"]}">{item["text"]}</option>')
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",
'<option value="{}">{}</option>',
((item.get("id", ""), item.get("text", "")) for item in data),
)
html = format_html(
'<option value="">{0}</option>\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(
+106 -25
View File
@@ -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 ""
-39
View File
@@ -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__"
+8 -11
View File
@@ -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"),
]
+396 -90
View File
@@ -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,
}
)
-2
View File
@@ -14,8 +14,6 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django_nepkit",
"rest_framework",
"django_filters",
"demo",
]
+1 -1
View File
@@ -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")),
]
-8
View File
@@ -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"
+2 -4
View File
@@ -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"],
},
)
+31 -3
View File
@@ -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 `<template>`-based renderer for better escaping control.
Generated
-37
View File
@@ -273,35 +273,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/ba/a6e2992bc5b8c688249c00ea48cb1b7a9bc09839328c81dc603671460928/django-6.0.2-py3-none-any.whl", hash = "sha256:610dd3b13d15ec3f1e1d257caedd751db8033c5ad8ea0e2d1219a8acf446ecc6", size = 8339381, upload-time = "2026-02-03T13:50:15.501Z" },
]
[[package]]
name = "django-filter"
version = "25.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818, upload-time = "2025-10-05T09:51:31.521Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" },
]
[[package]]
name = "django-nepkit"
version = "0.2.1"
source = { editable = "." }
dependencies = [
{ name = "django" },
{ name = "django-filter" },
{ name = "djangorestframework" },
{ name = "nepali" },
]
[package.optional-dependencies]
drf = [
{ name = "django-filter" },
{ name = "djangorestframework" },
]
[package.dev-dependencies]
dev = [
{ name = "build" },
@@ -316,13 +296,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "django", specifier = ">=4.2" },
{ name = "django-filter", specifier = ">=25.2" },
{ name = "django-filter", marker = "extra == 'drf'", specifier = ">=23.1" },
{ name = "djangorestframework", specifier = ">=3.16.1" },
{ name = "djangorestframework", marker = "extra == 'drf'", specifier = ">=3.14" },
{ name = "nepali", specifier = ">=1.2.0" },
]
provides-extras = ["drf"]
[package.metadata.requires-dev]
dev = [
@@ -335,18 +310,6 @@ dev = [
{ name = "twine", specifier = ">=6.2.0" },
]
[[package]]
name = "djangorestframework"
version = "3.16.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" },
]
[[package]]
name = "docutils"
version = "0.22.4"