From 5ee2e8f32bc3663b8aba855925ec2af4852edf9b Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Thu, 29 Jan 2026 00:19:27 +0545 Subject: [PATCH] feat(model): added nepalidatefield with calendar picker --- django_nepkit/admin.py | 155 ++++++++++++++++++ django_nepkit/forms.py | 28 ++++ django_nepkit/models.py | 91 ++++++++++ .../static/js/nepali-datepicker-init.js | 45 +++++ django_nepkit/templatetag/nepali.py | 67 ++++++++ django_nepkit/widgets.py | 28 ++++ 6 files changed, 414 insertions(+) create mode 100644 django_nepkit/static/js/nepali-datepicker-init.js create mode 100644 django_nepkit/templatetag/nepali.py diff --git a/django_nepkit/admin.py b/django_nepkit/admin.py index e69de29..0036a18 100644 --- a/django_nepkit/admin.py +++ b/django_nepkit/admin.py @@ -0,0 +1,155 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from nepali.datetime import nepalidate, nepalidatetime + +from django_nepkit.models import NepaliDateField + + +def format_nepali_date(date_value, format_string="%B %d, %Y"): + """ + Format a nepalidate object with Nepali month names. + + Args: + date_value: A nepalidate object or string in YYYY-MM-DD format + format_string: strftime format string (default: '%B %d, %Y') + %B = Full month name (Baishak, Jestha, etc.) + %b = Short month name + %d = Day of month + %Y = Year + + Returns: + Formatted date string with Nepali month names, or empty string if invalid + """ + + if date_value is None: + return "" + + try: + if isinstance(date_value, str): + date_value = nepalidate.strptime(date_value, "%Y-%m-%d") + + if isinstance(date_value, nepalidate): + return date_value.strftime(format_string) + except (ValueError, TypeError, AttributeError): + pass + + return str(date_value) if date_value else "" + + +def format_nepali_datetime(datetime_value, format_string="%B %d, %Y %I:%M %p"): + """ + Format a nepalidatetime object with Nepali month names. + + Default output uses **12-hour time with AM/PM**. + """ + if datetime_value is None: + return "" + + try: + if isinstance(datetime_value, str): + datetime_value = nepalidatetime.strptime( + datetime_value, "%Y-%m-%d %H:%M:%S" + ) + + if isinstance(datetime_value, nepalidatetime): + return datetime_value.strftime(format_string) + except (ValueError, TypeError, AttributeError): + pass + + return str(datetime_value) if datetime_value else "" + + +class NepaliDateFilter(admin.FieldListFilter): + """ + A list filter for NepaliDateField to filter by BS Year. + """ + + def __init__(self, field, request, params, model, model_admin, field_path): + self.parameter_name = f"{field_path}_bs_year" + super().__init__(field, request, params, model, model_admin, field_path) + self.title = _("Nepali Date (Year)") + + def expected_parameters(self): + return [self.parameter_name] + + def choices(self, changelist): + yield { + "selected": self.used_parameters.get(self.parameter_name) is None, + "query_string": changelist.get_query_string(remove=[self.parameter_name]), + "display": _("All"), + } + current_year = nepalidate.today().year + for year in range(current_year - 10, current_year + 2): + yield { + "selected": self.used_parameters.get(self.parameter_name) == str(year), + "query_string": changelist.get_query_string( + {self.parameter_name: str(year)} + ), + "display": str(year), + } + + def queryset(self, request, queryset): + value = self.used_parameters.get(self.parameter_name) + if value: + year = int(value) + # Convert BS year range to AD date range + start_date_bs = nepalidate(year, 1, 1) + # Find last day of the year. Some BS years end on the 30th, + # so we try 31 first and then fall back to 30 if that date + # is not valid. + try: + end_date_bs = nepalidate(year, 12, 31) + except ValueError: + end_date_bs = nepalidate(year, 12, 30) + + start_date_ad = start_date_bs.to_date() + end_date_ad = end_date_bs.to_date() + + return queryset.filter( + **{f"{self.field_path}__range": (start_date_ad, end_date_ad)} + ) + return queryset + + +admin.FieldListFilter.register( + lambda f: isinstance(f, NepaliDateField), NepaliDateFilter, take_priority=True +) + + +class NepaliAdminMixin: + """ + Mixin for Django admin classes that provides Nepali date utilities. + Makes format_nepali_date and NepaliDateFilter available without explicit imports. + """ + + def format_nepali_date(self, date_value, format_string="%B %d, %Y"): + """ + Format a nepalidate object with Nepali month names. + Available as a method on admin classes using this mixin. + """ + return format_nepali_date(date_value, format_string) + + def format_nepali_datetime( + self, datetime_value, format_string="%B %d, %Y %I:%M %p" + ): + return format_nepali_datetime(datetime_value, format_string) + + +class NepaliModelAdmin(NepaliAdminMixin, admin.ModelAdmin): + """ + Base ModelAdmin class with Nepali date utilities built-in. + Use this instead of admin.ModelAdmin to have format_nepali_date available. + + Example: + from django_nepkit import NepaliModelAdmin, NepaliDateFilter + + @admin.register(MyModel) + class MyModelAdmin(NepaliModelAdmin): + list_filter = (('date_field', NepaliDateFilter),) + + def display_date(self, obj): + return self.format_nepali_date(obj.date_field) + """ + + # Make NepaliDateFilter available as a class attribute + NepaliDateFilter = NepaliDateFilter diff --git a/django_nepkit/forms.py b/django_nepkit/forms.py index d503e44..5cf206b 100644 --- a/django_nepkit/forms.py +++ b/django_nepkit/forms.py @@ -1,9 +1,37 @@ +from datetime import date as python_date + from django import forms +from django.utils.translation import gettext_lazy as _ +from nepali.datetime import nepalidate from django_nepkit.validators import validate_nepali_phone_number +from django_nepkit.widgets import NepaliDatePickerWidget class NepaliPhoneNumberField(forms.CharField): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.validators.append(validate_nepali_phone_number) + + +class NepaliDateFormField(forms.DateField): + widget = NepaliDatePickerWidget + + def __init__(self, *args, **kwargs): + kwargs.pop("max_length", None) + super().__init__(*args, **kwargs) + + def to_python(self, value): + if value in self.empty_values: + return None + if isinstance(value, nepalidate): + return value + if isinstance(value, python_date): + return nepalidate.from_date(value) + try: + return nepalidate.strptime(str(value).strip(), "%Y-%m-%d") + except Exception: + raise forms.ValidationError( + _("Enter a valid Nepali date in YYYY-MM-DD format."), + code="invalid", + ) diff --git a/django_nepkit/models.py b/django_nepkit/models.py index 2385f6d..a37c075 100644 --- a/django_nepkit/models.py +++ b/django_nepkit/models.py @@ -1,11 +1,16 @@ +from datetime import date as python_date + from django.db import models from django.utils.translation import gettext_lazy as _ +from nepali.datetime import nepalidate from nepali.locations import districts, municipalities, provices +from django_nepkit.forms import NepaliDateFormField from django_nepkit.validators import validate_nepali_phone_number from django_nepkit.widgets import ( DistrictSelectWidget, MunicipalitySelectWidget, + NepaliDatePickerWidget, ProvinceSelectWidget, ) @@ -63,3 +68,89 @@ class MunicipalityField(models.CharField): class NepaliTimeField(models.TimeField): description = _("Nepali Time") + + +class NepaliDateField(models.CharField): + 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) + + 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): + value = nepalidate.today().strftime("%Y-%m-%d") + 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 value + try: + return nepalidate.strptime(value, "%Y-%m-%d") + except (ValueError, TypeError): + return value + + def to_python(self, value): + if value is None or isinstance(value, nepalidate): + return value + if isinstance(value, python_date): + try: + return nepalidate.from_date(value) + except (ValueError, TypeError): + return str(value) + if isinstance(value, str): + try: + return nepalidate.strptime(value.strip(), "%Y-%m-%d") + except (ValueError, TypeError): + return value + return super().to_python(value) + + def validate(self, value, model_instance): + if isinstance(value, nepalidate): + value = value.strftime("%Y-%m-%d") + super().validate(value, model_instance) + + def run_validators(self, value): + if isinstance(value, nepalidate): + value = value.strftime("%Y-%m-%d") + super().run_validators(value) + + def get_prep_value(self, value): + if value is None: + return value + if isinstance(value, nepalidate): + return value.strftime("%Y-%m-%d") + if isinstance(value, python_date): + try: + return nepalidate.from_date(value).strftime("%Y-%m-%d") + except (ValueError, TypeError): + return str(value) + if isinstance(value, str): + return value + # Fallback: convert to string + 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, + } + defaults.update(kwargs) + return super().formfield(**defaults) diff --git a/django_nepkit/static/js/nepali-datepicker-init.js b/django_nepkit/static/js/nepali-datepicker-init.js new file mode 100644 index 0000000..6028e62 --- /dev/null +++ b/django_nepkit/static/js/nepali-datepicker-init.js @@ -0,0 +1,45 @@ +(function($) { + "use strict"; + + function initNepaliDatePickers() { + if ($.fn.nepaliDatePicker) { + // Initialize any date pickers that aren't already initialized + $(".nepkit-datepicker") + .not(".nepali-datepicker-initialized") + .each(function() { + $(this) + .nepaliDatePicker({ + dateFormat: "%y-%m-%d", + closeOnDateSelect: true, + minDate: "१९७५-१-१", + maxDate: "२१००-१२-३०", + }) + .addClass("nepali-datepicker-initialized"); + }); + } + } + + // Initialize on page load + $(document).ready(function() { + initNepaliDatePickers(); + }); + + // Re-initialize when Django admin adds forms dynamically (e.g., inlines, popups) + // This handles Django admin's dynamic form loading + if (typeof django !== "undefined" && django.jQuery) { + django.jQuery(document).on("formset:added", function() { + initNepaliDatePickers(); + }); + } + + // Also listen for DOM changes (for admin popups and other dynamic content) + if (typeof MutationObserver !== "undefined") { + var observer = new MutationObserver(function(mutations) { + initNepaliDatePickers(); + }); + observer.observe(document.body, { + childList: true, + subtree: true, + }); + } +})(window.jQuery || window.django.jQuery); diff --git a/django_nepkit/templatetag/nepali.py b/django_nepkit/templatetag/nepali.py new file mode 100644 index 0000000..10f804f --- /dev/null +++ b/django_nepkit/templatetag/nepali.py @@ -0,0 +1,67 @@ +import datetime + +from django import template +from nepali.datetime import nepalidate, nepalihumanize +from nepali.number import nepalinumber + +register = template.Library() + + +@register.filter +def nepali_date(value, format_str="%Y-%m-%d"): + """ + Formats a date or datetime object into a Nepali date string. + """ + if value is None: + return "" + + if isinstance(value, (datetime.datetime, datetime.date)): + value = nepalidate.from_date(value) + + if hasattr(value, "strftime"): + return value.strftime(format_str) + return value + + +@register.filter +def nepali_date_ne(value, format_str="%Y-%m-%d"): + """ + Formats a date or datetime object into a Nepali date string (Devanagari). + """ + if value is None: + return "" + + if isinstance(value, (datetime.datetime, datetime.date)): + value = nepalidate.from_date(value) + + if hasattr(value, "strftime_ne"): + return value.strftime_ne(format_str) + return value + + +@register.filter +def nepali_number(value): + """ + Converts a number to Devanagari. + """ + if value is None: + return "" + return nepalinumber(value).str_ne() + + +@register.filter +def nepali_humanize(value, threshold=None, format_str=None): + """ + Returns a human-readable "time ago" string in Nepali. + """ + if value is None: + return "" + + # Threshold and format_str are optional + kwargs = {} + if threshold: + kwargs["threshold"] = threshold + if format_str: + kwargs["format"] = format_str + + return nepalihumanize(value, **kwargs) diff --git a/django_nepkit/widgets.py b/django_nepkit/widgets.py index 86bd07e..477c5da 100644 --- a/django_nepkit/widgets.py +++ b/django_nepkit/widgets.py @@ -31,3 +31,31 @@ class MunicipalitySelectWidget(ChainedSelectWidget): attrs["data-url"] = reverse_lazy("django_nepkit:municipality-list") kwargs["attrs"] = attrs super().__init__(*args, **kwargs) + + +class NepaliDatePickerWidget(forms.TextInput): + input_type = "text" + + class Media: + css = { + "all": ( + "https://unpkg.com/nepali-date-picker@2.0.2/dist/nepaliDatePicker.min.css", + ) + } + js = ( + "https://code.jquery.com/jquery-3.5.1.slim.min.js", + "https://unpkg.com/nepali-date-picker@2.0.2/dist/nepaliDatePicker.min.js", + "django_nepkit/js/nepali-datepicker-init.js", + ) + + def __init__(self, *args, **kwargs): + attrs = kwargs.get("attrs", {}) + classes = attrs.get("class", "") + if "vDateField" in classes: + classes = classes.replace("vDateField", "") + + attrs["class"] = (classes + " nepkit-datepicker").strip() + attrs["autocomplete"] = "off" + attrs["placeholder"] = "YYYY-MM-DD" + kwargs["attrs"] = attrs + super().__init__(*args, **kwargs)