Cache parsed values with LRU and use per‑option format_html to prevent XSS attacks.
426 lines
15 KiB
Python
426 lines
15 KiB
Python
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 _
|
|
from nepali.datetime import nepalidate, nepalidatetime
|
|
from nepali.locations import districts, municipalities, provinces
|
|
|
|
from django_nepkit.utils import (
|
|
_cached_parse_nepali_date,
|
|
_cached_parse_nepali_datetime,
|
|
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
|
|
|
|
|
|
class NepaliFieldMixin:
|
|
"""Adds Nepali 'ne' and 'en' support to any field."""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
default_lang = nepkit_settings.DEFAULT_LANGUAGE
|
|
self.ne = kwargs.pop("ne", default_lang == "ne")
|
|
|
|
explicit_en = "en" in kwargs
|
|
en_value = kwargs.pop("en", not self.ne)
|
|
|
|
if self.ne and not explicit_en:
|
|
self.en = False
|
|
else:
|
|
self.en = en_value
|
|
|
|
self.htmx = kwargs.pop("htmx", False)
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def deconstruct(self):
|
|
name, path, args, kwargs = super().deconstruct()
|
|
if self.ne:
|
|
kwargs["ne"] = True
|
|
if not self.en:
|
|
kwargs["en"] = False
|
|
if self.htmx:
|
|
kwargs["htmx"] = True
|
|
return name, path, args, kwargs
|
|
|
|
|
|
class NepaliPhoneNumberField(models.CharField):
|
|
description = _("Nepali Phone Number")
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
kwargs.setdefault("max_length", 10)
|
|
super().__init__(*args, **kwargs)
|
|
self.validators.append(validate_nepali_phone_number)
|
|
|
|
|
|
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)
|
|
|
|
if self.auto_now or self.auto_now_add:
|
|
kwargs.setdefault("editable", False)
|
|
kwargs.setdefault("blank", True)
|
|
|
|
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)
|
|
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):
|
|
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``).
|
|
|
|
Routes through a per-string ``lru_cache`` so admin list views
|
|
that load thousands of rows don't re-parse the same stored date
|
|
every time. The cache is shared across all field instances
|
|
(the parse result for a given string is the same regardless of
|
|
which model it's bound to).
|
|
"""
|
|
if not isinstance(value, str):
|
|
return None
|
|
if self.nepali_cls is nepalidate:
|
|
return _cached_parse_nepali_date(value)
|
|
if self.nepali_cls is nepalidatetime:
|
|
return _cached_parse_nepali_datetime(value)
|
|
# Subclasses with a different ``nepali_cls`` fall back to the
|
|
# uncached ``parse_func``.
|
|
return self.parse_func(value)
|
|
|
|
def to_python(self, value):
|
|
if value is None or isinstance(value, self.nepali_cls):
|
|
return value
|
|
if isinstance(value, (python_date, python_datetime)):
|
|
return self._convert_from_python(value)
|
|
if isinstance(value, str):
|
|
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):
|
|
if isinstance(value, (nepalidate, nepalidatetime)):
|
|
return value.strftime(self.format_str)
|
|
return value
|
|
|
|
def validate(self, value, model_instance):
|
|
super().validate(self._get_string_value(value), model_instance)
|
|
|
|
def run_validators(self, value):
|
|
super().run_validators(self._get_string_value(value))
|
|
|
|
def get_prep_value(self, value):
|
|
if value is None:
|
|
return value
|
|
if isinstance(value, self.nepali_cls):
|
|
return value.strftime(self.format_str)
|
|
if isinstance(value, (python_date, python_datetime)):
|
|
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()
|
|
if self.auto_now:
|
|
kwargs["auto_now"] = True
|
|
if self.auto_now_add:
|
|
kwargs["auto_now_add"] = True
|
|
return name, path, args, kwargs
|
|
|
|
def formfield(self, **kwargs):
|
|
defaults = {
|
|
"widget": NepaliDatePickerWidget(ne=self.ne, en=self.en),
|
|
}
|
|
defaults.update(kwargs)
|
|
return super().formfield(**defaults)
|
|
|
|
|
|
class NepaliDateField(BaseNepaliBSField):
|
|
description = _("Nepali Date (Bikram Sambat)")
|
|
default_max_length = 10
|
|
nepali_cls = nepalidate
|
|
parse_func = staticmethod(try_parse_nepali_date)
|
|
|
|
def formfield(self, **kwargs):
|
|
from django_nepkit.forms import NepaliDateFormField
|
|
|
|
kwargs.setdefault("form_class", NepaliDateFormField)
|
|
return super().formfield(**kwargs)
|
|
|
|
|
|
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
|
|
parse_func = staticmethod(try_parse_nepali_datetime)
|
|
|
|
|
|
class BaseLocationField(NepaliFieldMixin, models.CharField):
|
|
"""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)
|
|
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]
|
|
|
|
def _get_name(self, item, ne):
|
|
return getattr(item, "name_nepali", item.name) if ne else item.name
|
|
|
|
def formfield(self, **kwargs):
|
|
widget_cls = getattr(self, "widget_class", None)
|
|
if widget_cls:
|
|
defaults = {"widget": widget_cls(ne=self.ne, en=self.en, htmx=self.htmx)}
|
|
defaults.update(kwargs)
|
|
return super(models.CharField, self).formfield(**defaults)
|
|
return super().formfield(**kwargs)
|
|
|
|
|
|
class ProvinceField(BaseLocationField):
|
|
description = _("Nepali Province")
|
|
source = provinces
|
|
widget_class = ProvinceSelectWidget
|
|
|
|
|
|
class DistrictField(BaseLocationField):
|
|
description = _("Nepali District")
|
|
source = districts
|
|
widget_class = DistrictSelectWidget
|
|
|
|
|
|
class MunicipalityField(BaseLocationField):
|
|
description = _("Nepali Municipality")
|
|
source = municipalities
|
|
widget_class = MunicipalitySelectWidget
|
|
|
|
|
|
class NepaliCurrencyField(models.DecimalField):
|
|
description = _("Nepali Currency")
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
kwargs.setdefault("max_digits", 19)
|
|
kwargs.setdefault("decimal_places", 2)
|
|
super().__init__(*args, **kwargs)
|