Files
django-nepkit/django_nepkit/models.py
T
sankalp 589fe8c104 feat(django_nepkit): cache parsed Nepali dates/times in admin list views and improve HTML escaping security
Cache parsed values with LRU and use per‑option format_html to prevent XSS attacks.
2026-06-06 21:51:51 +05:45

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)