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:
40 files changed
+3286
-1406
No files matched your search
+141
@@ -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
|
||||
|
||||
@@ -19,13 +19,13 @@ Building software for local requirements comes with unique challenges, from hand
|
||||
|
||||
## 🎯 Features
|
||||
|
||||
- **📅 BS Date Support**: Model fields for `nepalidate` and `nepalidatetime` objects.
|
||||
- **📅 BS Date & Time Support**: Model fields for `nepalidate`, `nepalidatetime` and `time` objects, with optional Devanagari digit output.
|
||||
- **🗺️ Regional Locations**: Pre-defined Provinces, Districts, and Municipalities.
|
||||
- **📱 Phone Validation**: Patterns for local mobile and landline numbers.
|
||||
- **💰 Currency Formatting**: `NepaliCurrencyField` with automatic Lakhs/Crores comma placement.
|
||||
- **🔤 Numbers to Words**: Convert digits into Nepali text representation.
|
||||
- **🔌 Admin Integration**: Automatic setup for datepickers and localized list displays.
|
||||
- **🚀 API Support**: DRF Serializers and Filtering backends for BS searching and ordering.
|
||||
- **🚀 Framework-agnostic API helpers**: Pure-Python serialize/deserialize functions that work with any framework (DRF, Ninja, plain Django, …) — **no third-party API dependency**.
|
||||
- **⚡ Location Chaining**: Address linking via client side JS or server driven HTMX.
|
||||
- **🔍 Address Normalization**: Utility to extract structured locations from raw strings.
|
||||
|
||||
@@ -59,10 +59,20 @@ NEPKIT = {
|
||||
"DEFAULT_LANGUAGE": "en", # "en" or "ne"
|
||||
"ADMIN_DATEPICKER": True, # Toggle the datepicker
|
||||
"TIME_FORMAT": 12, # 12 or 24 hour display
|
||||
"DATE_INPUT_FORMATS": ["%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y"], # Input formats
|
||||
"BS_DATE_FORMAT": "%Y-%m-%d", # Default storage / display format
|
||||
"BS_DATETIME_FORMAT": "%Y-%m-%d %H:%M:%S",
|
||||
"BS_TIME_FORMAT": "%I:%M %p", # 12-hour by default
|
||||
"DATE_INPUT_FORMATS": ["%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y"],
|
||||
"TIME_INPUT_FORMATS": ["%H:%M:%S", "%H:%M", "%I:%M %p", "%I:%M:%S %p"],
|
||||
}
|
||||
```
|
||||
|
||||
> Settings are read from `settings.NEPKIT` on every access, so
|
||||
> `override_settings(NEPKIT={…})` (e.g. in tests) works as expected. If
|
||||
> you need to flip `DEFAULT_LANGUAGE` at runtime, call
|
||||
> `field.refresh_choices()` on any `ProvinceField` / `DistrictField` /
|
||||
> `MunicipalityField` that has already been instantiated.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Core Usage
|
||||
@@ -110,6 +120,7 @@ class Address(models.Model):
|
||||
province = ProvinceField()
|
||||
district = DistrictField()
|
||||
municipality = MunicipalityField()
|
||||
```
|
||||
|
||||
### Address Normalization
|
||||
|
||||
@@ -125,7 +136,6 @@ result = normalize_address("House 123, Bharatpur, Chitwan")
|
||||
result_ne = normalize_address("विराटनगर, कोशी")
|
||||
# Returns: {'province': 'कोशी प्रदेश', 'district': 'मोरङ', 'municipality': 'विराटनगर महानगरपालिका'}
|
||||
```
|
||||
```
|
||||
|
||||
### Server Side Chaining (HTMX)
|
||||
|
||||
@@ -141,18 +151,57 @@ Enable `htmx=True` for a server driven experience.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API & DRF Support
|
||||
## 🔌 API Integration (framework-agnostic)
|
||||
|
||||
Search and ordering work natively. BS year/month filtering is supported.
|
||||
`django-nepkit` ships pure-Python helpers in `django_nepkit.api` so you can
|
||||
emit JSON from **any** view layer — plain Django, DRF, Django Ninja, or
|
||||
something custom. There is no dependency on a third-party API package.
|
||||
|
||||
```python
|
||||
from django_nepkit.filters import NepaliDateYearFilter
|
||||
from django.http import JsonResponse
|
||||
from django_nepkit import api as nepkit_api
|
||||
|
||||
class ProfileFilter(filters.FilterSet):
|
||||
# Filter by BS Year (e.g., /api/profiles/?year=2081)
|
||||
year = NepaliDateYearFilter(field_name="birth_date")
|
||||
def person_payload(person, *, ne: bool = False) -> dict:
|
||||
return nepkit_api.build_localized_payload(
|
||||
{
|
||||
"id": person.id,
|
||||
"name": person.name,
|
||||
"birth_date": nepkit_api.serialize_nepali_date(person.birth_date, ne=ne),
|
||||
"amount": nepkit_api.serialize_nepali_currency(person.amount, ne=ne),
|
||||
},
|
||||
ne=ne, # also emits *_ne mirrors when ne=True
|
||||
)
|
||||
```
|
||||
|
||||
Available helpers:
|
||||
|
||||
| helper | purpose |
|
||||
| ------ | ------- |
|
||||
| `serialize_nepali_date` / `deserialize_nepali_date` | BS date strings ↔ `nepalidate` |
|
||||
| `serialize_nepali_datetime` / `deserialize_nepali_datetime` | BS datetime ↔ `nepalidatetime` |
|
||||
| `serialize_nepali_time` / `deserialize_nepali_time` | HH:MM / hh:mm AM/PM ↔ `time` |
|
||||
| `serialize_nepali_currency` / `deserialize_nepali_currency` | `"Rs. 12,34,567.00"` / Devanagari digits ↔ `Decimal` |
|
||||
| `to_decimal` | coerce strings / numbers to `Decimal` |
|
||||
| `build_localized_payload` | add `*_ne` mirrors to a dict representation |
|
||||
|
||||
All `serialize_*` / `deserialize_*` helpers (and `to_decimal`) accept a
|
||||
`strict: bool = False` argument. When `strict=True`, unparseable input
|
||||
raises `ValueError` instead of being silently returned as `str(value)`,
|
||||
which makes it easy to layer strict validation into API endpoints that
|
||||
need to surface 400s on bad input:
|
||||
|
||||
```python
|
||||
try:
|
||||
cleaned = nepkit_api.deserialize_nepali_date(request.data["dob"], strict=True)
|
||||
except ValueError as exc:
|
||||
return JsonResponse({"error": str(exc)}, status=400)
|
||||
```
|
||||
|
||||
The previous `django_nepkit.serializers` (DRF) and `django_nepkit.ninja`
|
||||
modules were removed in v0.2.2 because they made the package drag in
|
||||
extra dependencies. If you need a thin DRF/Ninja adapter it is now a
|
||||
ten-line file that delegates to `django_nepkit.api`.
|
||||
|
||||
---
|
||||
|
||||
## 💰 Formatting & Helpers
|
||||
@@ -219,7 +268,8 @@ No. The `YYYY-MM-DD` format is hardcoded to ensure database level sorting and in
|
||||
|
||||
**Q: Can I use Devanagari output?**
|
||||
|
||||
Yes. Pass `ne=True` to fields, forms, or serializers.
|
||||
Yes. Pass `ne=True` to fields, widgets, or use the `serialize_*` helpers
|
||||
in `django_nepkit.api` to emit Devanagari digits from any view.
|
||||
|
||||
**Q: Can I display the datepicker in English?**
|
||||
|
||||
|
||||
+75
-20
@@ -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
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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,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 = [
|
||||
"",
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -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`
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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: १००"
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 "<script>" 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 "<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 "<script>" in html
|
||||
|
||||
|
||||
class TestGetPrimaryParam:
|
||||
"""Tests for _get_primary_param internal helper."""
|
||||
@@ -63,6 +95,18 @@ class TestGetPrimaryParam:
|
||||
value = _get_primary_param(request, "province")
|
||||
assert value == "Bagmati Province"
|
||||
|
||||
def test_no_fallback_when_multiple_non_internal_params(self, rf):
|
||||
"""If the URL has multiple non-internal params, the named one wins and
|
||||
the fallback is skipped to avoid the wrong-key foot-gun."""
|
||||
from django_nepkit.views import _get_primary_param
|
||||
|
||||
request = rf.get(
|
||||
"/",
|
||||
{"province": "Bagmati Province", "field": "Koshi Province"},
|
||||
)
|
||||
value = _get_primary_param(request, "province")
|
||||
assert value == "Bagmati Province"
|
||||
|
||||
def test_internal_params_excluded_from_fallback(self, rf):
|
||||
from django_nepkit.views import _get_primary_param
|
||||
|
||||
@@ -113,6 +157,42 @@ class TestParseLanguageParams:
|
||||
assert ne is False
|
||||
assert en is True
|
||||
|
||||
def test_unknown_value_falls_back_to_default(self, rf):
|
||||
"""Unrecognised values fall back to the project default (non-strict)."""
|
||||
from django_nepkit.views import _parse_language_params
|
||||
|
||||
request = rf.get("/", {"ne": "banana"})
|
||||
ne, en = _parse_language_params(request)
|
||||
# Default in test settings is "en", so ne should be False.
|
||||
assert ne is False
|
||||
assert en is True
|
||||
|
||||
def test_strict_raises_on_garbage(self, rf):
|
||||
from django_nepkit.views import _parse_language_params
|
||||
|
||||
request = rf.get("/", {"ne": "banana"})
|
||||
with pytest.raises(ValueError, match="Invalid value"):
|
||||
_parse_language_params(request, strict=True)
|
||||
|
||||
def test_strict_accepts_all_canonical_forms(self, rf):
|
||||
from django_nepkit.views import _parse_language_params
|
||||
|
||||
for value, expected in [
|
||||
("true", True),
|
||||
("True", True),
|
||||
("1", True),
|
||||
("yes", True),
|
||||
("on", True),
|
||||
("false", False),
|
||||
("FALSE", False),
|
||||
("0", False),
|
||||
("no", False),
|
||||
("off", False),
|
||||
]:
|
||||
request = rf.get("/", {"ne": value})
|
||||
ne, _ = _parse_language_params(request, strict=True)
|
||||
assert ne is expected, f"failed for {value!r}"
|
||||
|
||||
|
||||
class TestShouldReturnHtml:
|
||||
"""Tests for _should_return_html internal helper."""
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 ""
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -14,8 +14,6 @@ INSTALLED_APPS = [
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django_nepkit",
|
||||
"rest_framework",
|
||||
"django_filters",
|
||||
"demo",
|
||||
]
|
||||
|
||||
|
||||
@@ -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")),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user