اگر با جنگو (Django) برای کاربر ایرانی سایت ساخته باشید، این صحنه را دیده‌اید: همه چیز آماده است، اما مدیر سایت وارد پنل ادمین می‌شود و تاریخ‌ها را میلادی می‌بیند. تاریخ شمسی در جنگو همیشه یک کار جانبی بوده: یک کتابخانه برای تبدیل، یک ویجت تقویم از جای دیگر، چند فیلتر قالب دست‌ساز و کلی وصله‌کاری. دوستم مهدی، که در گیت‌هاب با نام MeyT می‌شناسیدش، برای همین دردسر یک ماژول تازه ساخته به اسم Djalaali که همین دیروز نسخه 1.1.0 آن روی PyPI آمد.

خلاصه حرف مهدی این بود: «یک خط در INSTALLED_APPS اضافه کن، کل تاریخ‌های پنل ادمین شمسی می‌شود.» من هم نصبش کردم، یک پروژه کوچک ساختم و همه چیز را خودم امتحان کردم. در این نوشته می‌گویم Djalaali چیست، چطور نصبش کنید، چه چیزی را خودکار شمسی می‌کند و چه نکته‌هایی را باید بدانید.

تاریخ شمسی در جنگو با Djalaali؛ کل پنل ادمین Django شمسی با یک خط پنل ادمین جنگو با تاریخ شمسی پس از نصب Djalaali؛ ستون تاریخ و زمان شروع شمسی و ستون ثبت میلادی
پنل ادمین جنگو در پروژه آزمایشی من؛ ستون‌های «تاریخ» و «زمان شروع» شمسی‌اند و ستون آخر عمدا میلادی مانده است.

Djalaali چیست؟

Djalaali یک کتابخانه پایتون با مجوز MIT است که پشتیبانی تقویم جلالی را به پروژه‌های Django اضافه می‌کند. اسمش هم از ترکیب Django و Jalaali آمده. چیزهایی که در یک بسته به شما می‌دهد:

  • دو کلاس jdate و jdatetime که دقیقا مثل datetime.date و datetime.datetime خود پایتون کار می‌کنند.
  • تبدیل میلادی به شمسی و برعکس، تشخیص سال کبیسه و تعداد روزهای هر ماه.
  • تبدیل خودکار همه DateField و DateTimeFieldها در فرم‌های مدل (ModelForm) و پنل ادمین به ورودی شمسی با تقویم انتخاب تاریخ.
  • نمایش شمسی تاریخ‌ها در لیست ادمین و فیلدهای فقط‌خواندنی.
  • فیلتر قالب jdate برای نمایش شمسی در قالب‌های HTML.
  • یک ماژول C برای سرعت بیشتر، با بازگشت خودکار به پایتون خالص.

مهم‌ترین تصمیم طراحی Djalaali به نظر من این است که دیتابیس دست نمی‌خورد. تاریخ‌ها همچنان به شکل استاندارد میلادی ذخیره می‌شوند و فقط ورود و نمایش برای کاربر شمسی است. پس ORM، مرتب‌سازی، فیلتر کردن، API و هر سرویسی که تاریخ میلادی می‌خواهد مثل قبل کار می‌کند و اگر روزی خواستید Djalaali را بردارید، داده‌ها هیچ تغییری لازم ندارند.

تاریخ شمسی در جنگو با Djalaali؛ کل پنل ادمین Django شمسی با یک خط نمودار کار Djalaali؛ ورود تاریخ شمسی در فرم جنگو، تبدیل خودکار و ذخیره میلادی در پایگاه داده
کاربر شمسی می‌بیند، دیتابیس میلادی نگه می‌دارد.

نصب Djalaali و شمسی کردن پنل ادمین جنگو

نصبش واقعا همان‌قدر ساده است که مهدی گفت. اول بسته را در محیط مجازی پروژه نصب کنید:

pip install djalaali

بعد نامش را به INSTALLED_APPS در settings.py اضافه کنید:

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    # ...
    "djalaali",
    "blog",
]

تمام. نه مایگریشن لازم است، نه عوض کردن فیلدهای مدل، نه تنظیم ادمین. سرور را دوباره اجرا کنید و وارد ادمین شوید؛ تاریخ‌های لیست‌ها شمسی شده‌اند و با کلیک روی هر فیلد تاریخ، یک تقویم شمسی باز می‌شود. برای فیلدهای DateTimeField همان تقویم بخش ساعت، دقیقه و ثانیه هم دارد.

تاریخ شمسی در جنگو با Djalaali؛ کل پنل ادمین Django شمسی با یک خط تقویم شمسی انتخاب تاریخ و ساعت در فرم ادمین جنگو با Djalaali
تقویم شمسی Djalaali در فرم ویرایش ادمین، برای یک فیلد DateTimeField.

پشت صحنه، Djalaali موقع بالا آمدن پروژه متد ساخت فیلد فرم در DateField را جایگزین می‌کند، پس هر فرمی که جنگو از روی مدل بسازد فیلد شمسی می‌گیرد. اگر USE_TZ روشن باشد، زمان‌ها هم قبل از نمایش به منطقه زمانی فعال (مثلا Asia/Tehran) برده می‌شوند.

کار با jdate و jdatetime در پایتون

اگر با ماژول datetime پایتون کار کرده باشید، چیز تازه‌ای یاد نمی‌گیرید. همه این خروجی‌ها را خودم اجرا کردم:

import datetime
from djalaali.jdate import jdate
from djalaali.jdatetime import jdatetime

d = jdate(1403, 1, 1)
jdate.fromgregorian(datetime.date(2024, 3, 20))  # jdate(1403, 1, 1)
d.togregorian()                    # datetime.date(2024, 3, 20)
d.isleap()                         # True
d.isoformat()                      # '1403-01-01'
d.strftime("%A %d %B %Y")          # 'چهارشنبه 01 فروردین 1403'

jdate.today()
jdate.fromisoformat("1403-01-01")
jdate.strptime("1403/01/15", "%Y/%m/%d")

d + datetime.timedelta(days=30)    # jdate(1403, 1, 31)
(d - jdate(1402, 12, 1)).days      # 29
d < jdate(1403, 2, 1)              # True

t = jdatetime(1403, 1, 1, 13, 5)
t.togregorian()                    # datetime.datetime(2024, 3, 20, 13, 5)
jdatetime.now()

جمع و تفریق با timedelta و مقایسه تاریخ‌ها برای کارهایی مثل مهلت ثبت‌نام، تاریخ انقضای اشتراک یا شمارش روزهای مانده تا یک رویداد خیلی به کار می‌آید.

تبدیل سطح پایین با ماژول algo

اگر فقط تبدیل عدد به عدد می‌خواهید و به شیء تاریخ نیازی ندارید، تابع‌های ماژول algo سریع‌ترین راه‌اند:

from djalaali.algo import to_jalaali, to_gregorian, is_leap_year, month_length

to_jalaali(2024, 3, 20)    # (1403, 1, 1)
to_gregorian(1403, 1, 1)   # (2024, 3, 20)
is_leap_year(1403)         # True
month_length(1403, 12)     # 30

یک نکته که خودم هم موقع تست به آن خوردم: 1403 سال کبیسه است و اسفندش ۳۰ روز دارد، اما 1404 کبیسه نیست. برای همین jdate(1404, 12, 30) و jdate(1403, 12, 31) هر دو خطای ValueError می‌دهند. اگر تاریخ را از کاربر می‌گیرید، این خطا را مدیریت کنید. محدوده سال‌های پشتیبانی‌شده هم از 61- تا 3177 است و بیرون از آن، بسته به تابع، خطای ErrorInvalidYear یا ValueError می‌گیرید.

الگوریتم تاریخ شمسی تست‌شده با ICU

تبدیل تاریخ جلالی ظاهرا ساده است، اما مرز سال‌ها و سال‌های کبیسه جایی است که خیلی از پیاده‌سازی‌ها اشتباه می‌کنند؛ یک خطای کوچک در الگوریتم یعنی نوروز یک روز جابه‌جا. الگوریتم Djalaali از پیاده‌سازی شناخته‌شده go-jalaali گرفته شده و مهدی آن را با ICU سنجیده است.

ICU (International Components for Unicode) همان کتابخانه‌ای است که بخش بزرگی از نرم‌افزارهای دنیا برای کار با زبان‌ها، تقویم‌ها و تاریخ از آن استفاده می‌کنند؛ از مرورگرها تا سیستم‌عامل‌ها. در مخزن پروژه یک فایل تست هست که تاریخ شمسی تک‌تک روزهای سال 1876 تا 2124 میلادی را از ICU دارد؛ یعنی ۹۰٬۶۵۹ روز. تست‌ها خروجی Djalaali را برای همه این روزها با ICU مقایسه می‌کنند و رفت و برگشت تبدیل را هم برای همه سال‌های پشتیبانی‌شده چک می‌کنند. پوشش تست کد هم طبق گزارش پروژه ۱۰۰ درصد است.

این یعنی اگر جایی خروجی Djalaali غلط باشد، خروجی ICU هم در همان روز غلط است. برای من که بارها با مبدل‌های تاریخ نیمه‌کاره سر و کله زده‌ام، این مهم‌ترین دلیل اعتماد به یک کتابخانه تقویم است.

ماژول C؛ حدود ۶۰ برابر سریع‌تر از پایتون خالص

هسته تبدیل تاریخ دو پیاده‌سازی دارد: یکی به زبان C و یکی پایتون خالص (Pure Python) . هر دو نتیجه یکسان می‌دهند، اما طبق اندازه‌گیری مهدی نسخه C حدود ۶۰ برابر سریع‌تر است. من روی لپ‌تاپ خودم با یک تست ساده روی to_jalaali حدود ۱۰۰ نانوثانیه در برابر ۳۷۰۰ نانوثانیه گرفتم؛ عدد دقیق به سیستم بستگی دارد، اما فاصله واقعی است و در صفحه‌ای که هزاران ردیف تاریخ دارد حس می‌شود.

بهترین بخش ماجرا این است که لازم نیست کامپایلر نصب کنید. روی PyPI فایل wheel آماده برای ویندوز، مک و لینوکس، هم برای پردازنده‌های x86 و هم ARM منتشر شده (حتی ویندوز ARM و لینوکس Alpine) . یک wheel برای هر سیستم کافی است و همه نسخه‌های پایتون از 3.9 به بعد را پوشش می‌دهد. اگر هم جایی wheel آماده نبود و ساخت ماژول C شکست خورد، نصب خراب نمی‌شود و Djalaali خودکار سراغ نسخه پایتون خالص می‌رود.

from djalaali import algo
print(algo.HAS_C_EXTENSION)   # True یعنی نسخه C فعال است

# برای تست یا عیب‌یابی، اجبار به پایتون خالص:
# DJALAALI_PURE_PYTHON=1 python manage.py runserver

فیلدهای مدل و فرم شمسی

در مدل‌ها چیزی عوض نمی‌شود. همان DateField و DateTimeField معمولی کافی است. اگر فیلدی دارید که باید حتما میلادی بماند، مثلا تاریخ ثبت در یک سیستم خارجی، از GregorianDateField استفاده کنید:

from django.db import models
from djalaali.models import GregorianDateField


class Event(models.Model):
    title = models.CharField(max_length=100)
    date = models.DateField()                       # شمسی در فرم و ادمین
    starts_at = models.DateTimeField(null=True)     # شمسی در فرم و ادمین
    registered_on = GregorianDateField(null=True)   # میلادی می‌ماند

این دقیقا همان مدلی است که در تصویر اول دیدید. یک نکته ظریف: اگر خودتان برای فیلدی ویجت یا کلاس فرم تعیین کرده باشید (مثلا در Meta.widgets)، Djalaali به آن دست نمی‌زند.

در فرم‌های معمولی (forms.Form) هم می‌توانید فیلدهای شمسی را مستقیم استفاده کنید:

from django import forms
from djalaali.forms import JalaliDateField, JalaliDateTimeField, JalaliDateInput


class EventForm(forms.Form):
    date = JalaliDateField()
    starts_at = JalaliDateTimeField(required=False)
    birthday = JalaliDateField(widget=JalaliDateInput(today_label="امروز"))

فقط یادتان نرود در قالب فرم {{ form.media }} را بگذارید تا فایل‌های CSS و جاوااسکریپت تقویم بارگذاری شوند؛ وگرنه فیلد کار می‌کند ولی تقویم باز نمی‌شود.

نمایش تاریخ شمسی در جنگو با فیلتر jdate

برای صفحه‌های خود سایت، فیلتر jdate هر تاریخ میلادی را موقع نمایش شمسی می‌کند:

{% load djalaali_tags %}

{{ event.date|jdate }}                    {# 1403/01/01 #}
{{ event.date|jdate:"d F Y" }}            {# 01 فروردین 1403 #}
{{ event.date|jdate:"l j F Y" }}          {# چهارشنبه 1 فروردین 1403 #}
{{ event.starts_at|jdate:"Y/m/d H:i" }}   {# 1403/01/01 13:05 #}

حروف قالب همان حروف فیلتر date جنگو است؛ مثلا Y سال چهاررقمی، m ماه دورقمی، j روز بدون صفر، F نام ماه، l نام روز هفته و H:i ساعت و دقیقه.

تنظیم فرمت نمایش و ورود تاریخ

تنظیم‌های Djalaali از الگوی خود جنگو پیروی می‌کنند. پیش‌فرض‌ها این‌ها هستند و فقط اگر چیز دیگری می‌خواهید لازم است در settings.py بنویسیدشان:

# نمایش (مثل DATE_FORMAT جنگو): لیست ادمین، فیلدهای فقط‌خواندنی، فیلتر jdate
DJALAALI_DATE_FORMAT = "Y/m/d"
DJALAALI_DATETIME_FORMAT = "Y/m/d H:i"

# ورودی فرم (مثل DATE_INPUT_FORMATS، با قالب strptime)، به ترتیب امتحان می‌شوند
DJALAALI_DATE_INPUT_FORMATS = ["%Y/%m/%d", "%Y-%m-%d", "%Y.%m.%d"]

دقت کنید که دو زبان متفاوت در کار است: فرمت نمایش حروف جنگو را می‌گیرد (Y) و فرمت ورودی قالب strptime پایتون را (%Y). اولین فرمت ورودی تعیین می‌کند ویجت تاریخ را چطور نشان دهد و placeholder و تقویم هم از آن پیروی می‌کنند، پس فقط می‌تواند %Y، %m، %d و جداکننده داشته باشد. برای یک فیلد خاص هم می‌توانید با پارامتر input_formats تنظیم سراسری را کنار بگذارید.

اسم ماه‌ها و روزها هم قابل تغییر است؛ مثلا برای یک رابط انگلیسی:

from djalaali.jdate import JalaliConfig

JalaliConfig().months[1] = ("Farvardin", "Far")

تجربه من و چند نکته قبل از استفاده

برای این نوشته یک پروژه جنگوی خالی ساختم، فقط Djalaali را اضافه کردم و یک مدل رویداد با چند تاریخ نوشتم؛ تصویرهای بالا از همان پروژه است. روی پایتون 3.14 و جنگو 6.1 بدون هیچ مشکلی کار کرد، با اینکه صفحه PyPI رسما جنگو 4.2 تا 5.2 را فهرست کرده. چند نکته که بهتر است بدانید:

  • پروژه تازه منتشر شده و وضعیتش روی PyPI «Alpha» است. برای پروژه جدی، نسخه را در requirements.txt قفل کنید و قبل از به‌روزرسانی تغییرات را ببینید.
  • داده‌ها میلادی ذخیره می‌شوند؛ وقتی در ORM فیلتر می‌کنید (مثلا date__year)، سال میلادی است، نه شمسی. برای فیلتر شمسی، بازه را با jdate(...).togregorian() بسازید.
  • برچسب‌های بخش ساعت در تقویم (Hour، Minute، Second) هنوز ترجمه فارسی ندارند، که البته در حد جزئیات است.
  • اگر قبلا کتابخانه شمسی دیگری در پروژه دارید، قبل از جایگزینی روی یک نسخه آزمایشی امتحان کنید که دو کتابخانه با هم سر فرم‌ها دعوا نکنند.

کد پروژه آزمایشی من خیلی کوتاه است: یک settings.py معمولی با "djalaali" در INSTALLED_APPS، مدل Event بالا و یک ModelAdmin با list_display. در مخزن Djalaali هم یک پروژه نمونه کامل در پوشه demo/ هست که با make demo اجرا می‌شود.

جمع‌بندی

اگر دنبال یک راه تمیز برای تاریخ شمسی در جنگو هستید، Djalaali همان چیزی است که سال‌ها دلم می‌خواست وجود داشته باشد: یک خط تنظیم، ادمین شمسی، دیتابیس دست‌نخورده، الگوریتمی که با ICU روی ۹۰ هزار روز تست شده و سرعتی که ماژول C می‌دهد. به مهدی بابت این کار تبریک می‌گویم؛ اگر به کارتان آمد، در مخزن گیت‌هاب Djalaali به آن ستاره بدهید، باگ‌ها را گزارش کنید و بسته را از صفحه PyPI نصب کنید.

اگر تازه پایتون را شروع کرده‌اید، نوشته ویژگی‌های جدید پایتون 3.15 را بخوانید، و اگر بیشتر سمت API هستید، نگاهی به کلاس FastAPI در رشت بیندازید. من هم قبلا با تقویم‌ها بازی کرده‌ام؛ از ربات اینستاگرامی تقویم دیلمی تا سایتی که تالار اسرارش فقط با پایتون باز می‌شود. شما برای تاریخ شمسی در پروژه‌های جنگو تا حالا از چه چیزی استفاده می‌کردید؟ در بخش نظرات بنویسید.


آرما

علیرضا مؤید احمدی هستم و اغلب منو به اسم آرما می‌شناسن. در حال حاضر مدیر شرکت جنگل هستم و بیشتر زمانم رو اونجا صرف می‌کنم. خوشحال می‌شم از اینکه خاطرات و تجربیاتم رو با دوستانم به اشتراک بذارم.

0 دیدگاه

دیدگاهتان را بنویسید

جای‌بان آواتار

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *