Files
django-nepkit/django_nepkit/models.py
T
sankalp 09f302ceaa feat: implement HTMX support, DRF filters, and professionalized documentation
- Implement server-side location chaining with HTMX support via `htmx=True` in fields and widgets.
- Update location views to handle HTMX requests and return HTML <option> fragments.
- Add NepaliDateYearFilter and NepaliDateRangeFilter for robust DRF API filtering.
- Expand default `DATE_INPUT_FORMATS` in configuration to support multiple common Nepali date styles.
- Update DRF serializers to automatically respect global `NEPKIT` configuration settings.
- Add `django-filter` as an optional dependency under the `drf` extra in pyproject.toml.
- Correct underlying dependency references from `nepali-datetime` to nepali library.
- Complete overhaul of README.md for premium DX, including new sections for storage behavior, timezone awareness, and API usage.
2026-01-30 15:22:13 +05:45

348 lines
12 KiB
Python

from datetime import date as python_date
from datetime import datetime as python_datetime
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.forms import NepaliDateFormField
from django_nepkit.utils import (
BS_DATE_FORMAT,
BS_DATETIME_FORMAT,
try_parse_nepali_date,
try_parse_nepali_datetime,
)
from django_nepkit.validators import validate_nepali_phone_number
from django_nepkit.widgets import (
DistrictSelectWidget,
MunicipalitySelectWidget,
NepaliDatePickerWidget,
ProvinceSelectWidget,
)
from django_nepkit.conf import nepkit_settings
class NepaliFieldMixin:
"""
Mixin for Nepali model fields to handle 'ne' and 'en' arguments.
"""
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 NepaliDateField(NepaliFieldMixin, models.CharField):
"""
A Django Model Field that stores Nepali (Bikram Sambat) Date.
Internally it stores the date as BS string (YYYY-MM-DD) in the database.
"""
description = _("Nepali Date (Bikram Sambat)")
def __init__(self, *args, **kwargs):
self.auto_now = kwargs.pop("auto_now", False)
self.auto_now_add = kwargs.pop("auto_now_add", False)
# Match Django's DateField behavior
if self.auto_now or self.auto_now_add:
kwargs.setdefault("editable", False)
kwargs.setdefault("blank", True)
kwargs.setdefault("max_length", 10)
super().__init__(*args, **kwargs)
def pre_save(self, model_instance, add):
if self.auto_now or (self.auto_now_add and add):
# Using timezone.now() to get current date, then converting to BS.
now = timezone.now()
if timezone.is_aware(now):
now = timezone.localtime(now)
value = nepalidate.from_date(now).strftime(BS_DATE_FORMAT)
setattr(model_instance, self.attname, value)
return value
return super().pre_save(model_instance, add)
def from_db_value(self, value, expression, connection):
parsed = try_parse_nepali_date(value)
return parsed if parsed is not None else value
def to_python(self, value):
if value is None or isinstance(value, nepalidate):
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 nepalidate.from_date(value)
except (ValueError, TypeError):
return str(value)
if isinstance(value, str):
parsed = try_parse_nepali_date(value)
return parsed if parsed is not None else value
return super().to_python(value)
def validate(self, value, model_instance):
if isinstance(value, nepalidate):
value = value.strftime(BS_DATE_FORMAT)
super().validate(value, model_instance)
def run_validators(self, value):
if isinstance(value, nepalidate):
value = value.strftime(BS_DATE_FORMAT)
super().run_validators(value)
def get_prep_value(self, value):
if value is None:
return value
if isinstance(value, nepalidate):
return value.strftime(BS_DATE_FORMAT)
if isinstance(value, (python_date, python_datetime)):
try:
if isinstance(value, python_datetime) and timezone.is_aware(value):
value = timezone.localtime(value)
return nepalidate.from_date(value).strftime(BS_DATE_FORMAT)
except (ValueError, TypeError):
return str(value)
if isinstance(value, str):
return value
return str(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 = {
"form_class": NepaliDateFormField,
"widget": NepaliDatePickerWidget(ne=self.ne, en=self.en),
}
defaults.update(kwargs)
return super().formfield(**defaults)
class NepaliTimeField(NepaliFieldMixin, models.TimeField):
"""
A Django Model Field for Time with Nepali localization support.
"""
description = _("Nepali Time")
class NepaliDateTimeField(NepaliFieldMixin, models.CharField):
"""
A Django Model Field that stores Nepali (Bikram Sambat) DateTime.
Internally it stores the datetime as BS string (YYYY-MM-DD HH:MM:SS) in the database.
"""
description = _("Nepali DateTime (Bikram Sambat)")
def __init__(self, *args, **kwargs):
self.auto_now = kwargs.pop("auto_now", False)
self.auto_now_add = kwargs.pop("auto_now_add", False)
# Match Django's DateTimeField behavior
if self.auto_now or self.auto_now_add:
kwargs.setdefault("editable", False)
kwargs.setdefault("blank", True)
kwargs.setdefault("max_length", 19) # YYYY-MM-DD HH:MM:SS
super().__init__(*args, **kwargs)
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 = nepalidatetime.from_datetime(now).strftime(BS_DATETIME_FORMAT)
setattr(model_instance, self.attname, value)
return value
return super().pre_save(model_instance, add)
def from_db_value(self, value, expression, connection):
parsed = try_parse_nepali_datetime(value)
return parsed if parsed is not None else value
def to_python(self, value):
if value is None or isinstance(value, nepalidatetime):
return value
if isinstance(value, python_datetime):
try:
if timezone.is_aware(value):
value = timezone.localtime(value)
return nepalidatetime.from_datetime(value)
except (ValueError, TypeError):
return str(value)
if isinstance(value, str):
parsed = try_parse_nepali_datetime(value)
return parsed if parsed is not None else value
return super().to_python(value)
def validate(self, value, model_instance):
if isinstance(value, nepalidatetime):
value = value.strftime(BS_DATETIME_FORMAT)
super().validate(value, model_instance)
def run_validators(self, value):
if isinstance(value, nepalidatetime):
value = value.strftime(BS_DATETIME_FORMAT)
super().run_validators(value)
def get_prep_value(self, value):
if value is None:
return value
if isinstance(value, nepalidatetime):
return value.strftime(BS_DATETIME_FORMAT)
if isinstance(value, python_datetime):
try:
if timezone.is_aware(value):
value = timezone.localtime(value)
return nepalidatetime.from_datetime(value).strftime(BS_DATETIME_FORMAT)
except (ValueError, TypeError):
return str(value)
if isinstance(value, str):
return value
return str(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 ProvinceField(NepaliFieldMixin, models.CharField):
description = _("Nepali Province")
def __init__(self, *args, **kwargs):
kwargs.setdefault("max_length", 100)
# We need self.ne set by Mixin, but Mixin init runs last in normal MRO if we just use super().__init__.
# So we manually handle pop before calling super, OR we rely on kwargs.
# Actually, if we use NepaliFieldMixin as first parent, its __init__ will run.
# But we need to define choices based on ne.
# So we should pop 'ne' here, set it, then pass to super?
# But Mixin expects to pop it.
# Better approach: Extract ne here without popping (peek), or pop and pass back?
# Let's just duplicate popping here to set choices, then re-inject for Mixin?
# Or just handle it manually here and pass remaining to super.
# Let's modify behavior: pop ne here, calculate choices, then put it back?
# Or just manually set self.ne/self.en here and don't rely on Mixin's init for choices, but rely on it for other things?
# To avoid complexity, I'll peek at kwargs['ne'] if it exists.
default_lang = nepkit_settings.DEFAULT_LANGUAGE
ne = kwargs.get("ne", default_lang == "ne")
def get_p_name(p, ne):
name = getattr(p, "name_nepali", p.name) if ne else p.name
if name == "Province 1":
return "Koshi Province"
if name == "प्रदेश नं. १":
return "कोशी प्रदेश"
return name
choices = [(get_p_name(p, ne), get_p_name(p, ne)) for p in provinces]
kwargs.setdefault("choices", choices)
super().__init__(*args, **kwargs)
def formfield(self, **kwargs):
defaults = {"widget": ProvinceSelectWidget(ne=self.ne, en=self.en, htmx=self.htmx)}
defaults.update(kwargs)
return super(models.CharField, self).formfield(**defaults)
# Wait, super().formfield() calls Django's CharField.formfield.
# We want that.
class DistrictField(NepaliFieldMixin, models.CharField):
description = _("Nepali District")
def __init__(self, *args, **kwargs):
default_lang = nepkit_settings.DEFAULT_LANGUAGE
ne = kwargs.get("ne", default_lang == "ne")
kwargs.setdefault("max_length", 100)
def get_d_name(d, ne):
return getattr(d, "name_nepali", d.name) if ne else d.name
choices = [(get_d_name(d, ne), get_d_name(d, ne)) for d in districts]
kwargs.setdefault("choices", choices)
super().__init__(*args, **kwargs)
def formfield(self, **kwargs):
defaults = {"widget": DistrictSelectWidget(ne=self.ne, en=self.en, htmx=self.htmx)}
defaults.update(kwargs)
return super(models.CharField, self).formfield(**defaults)
class MunicipalityField(NepaliFieldMixin, models.CharField):
description = _("Nepali Municipality")
def __init__(self, *args, **kwargs):
default_lang = nepkit_settings.DEFAULT_LANGUAGE
ne = kwargs.get("ne", default_lang == "ne")
kwargs.setdefault("max_length", 100)
def get_m_name(m, ne):
return getattr(m, "name_nepali", m.name) if ne else m.name
choices = [(get_m_name(m, ne), get_m_name(m, ne)) for m in municipalities]
kwargs.setdefault("choices", choices)
super().__init__(*args, **kwargs)
def formfield(self, **kwargs):
defaults = {"widget": MunicipalitySelectWidget(ne=self.ne, en=self.en, htmx=self.htmx)}
defaults.update(kwargs)
return super(models.CharField, self).formfield(**defaults)