Files
MathApp/accounts/jalali.py
Zareian 63cd1af6f7 public user IDs, plans priced by tutor cost, age-based tutor tone, self-service account deletion
- profile publicId ("User482917", accounts/public_id.py); admin shows and searches it
- plans API reports aiDailyQuota per plan and freeAiDailyQuota; migration 0007 sets the
  temporary prices (monthly 99,000 / yearly 990,000 toman, 5 tutor questions a day) unless
  an admin already edited the yearly plan
- tutor tone follows the learner's age (18+ gets an adult tone at any grade); notation
  still follows the grade's book; answer cache keyed by tone (prompt tutor-v12-age-tone)
- POST /api/user/delete: phone users confirm with a fresh SMS code (same checks as login,
  now shared in verify_phone_otp), guests type "حذف"; payment records are kept, demo
  accounts refused
- delete_users --skip-paid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:10:17 +03:30

107 lines
4.0 KiB
Python

"""
تبدیلِ تاریخِ شمسی ↔ میلادی برای تاریخِ تولد.
الگوریتمِ jalaali (همان jalaali-js، بورکوفسکی): کبیسه‌ها از جدولِ نقطه‌های شکستِ
چرخه حساب می‌شوند، نه از قاعده‌ی سی‌وسه‌ساله‌ی ساده، و برای سال‌های ۱۱۷۸ تا ۱۶۳۳
با تقویمِ رسمی یکی است. تست‌ها (`accounts/tests_birth_date.py`) چند تاریخِ
شناخته‌شده‌ی دو طرفِ اسفندِ کبیسه را می‌سنجند.
چرا در بک‌اند: فرانت فقط روز/ماه/سالِ شمسی می‌فرستد. اگر هر دو طرف تبدیل
می‌کردند، دو پیاده‌سازی می‌توانست یک روز با هم اختلاف داشته باشد.
"""
from datetime import date
_BREAKS = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210,
1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178]
def _cal(jy):
"""(leap, gy, march) — leap: سالِ مانده در چرخه؛ march: روزِ مارسِ نوروز."""
if jy < _BREAKS[0] or jy >= _BREAKS[-1]:
raise ValueError(f'سالِ شمسیِ خارج از بازه: {jy}')
gy = jy + 621
leap_j = -14
jp = _BREAKS[0]
jump = 0
for jm in _BREAKS[1:]:
jump = jm - jp
if jy < jm:
break
leap_j += (jump // 33) * 8 + (jump % 33) // 4
jp = jm
n = jy - jp
leap_j += (n // 33) * 8 + ((n % 33) + 3) // 4
if jump % 33 == 4 and jump - n == 4:
leap_j += 1
leap_g = gy // 4 - ((gy // 100 + 1) * 3) // 4 - 150
march = 20 + leap_j - leap_g
if jump - n < 6:
n = n - jump + ((jump + 4) // 33) * 33
leap = (((n + 1) % 33) - 1) % 4
if leap == -1:
leap = 4
return leap, gy, march
def is_leap(jy: int) -> bool:
return _cal(jy)[0] == 0
def month_length(jy: int, jm: int) -> int:
if jm <= 6:
return 31
if jm <= 11:
return 30
return 30 if is_leap(jy) else 29
def to_gregorian(jy: int, jm: int, jd: int) -> date:
"""ValueError برای تاریخِ ناموجود (مثلاً ۳۰ اسفندِ سالِ غیرکبیسه)."""
if not 1 <= jm <= 12 or not 1 <= jd <= month_length(jy, jm):
raise ValueError('چنین روزی در تقویمِ شمسی وجود ندارد.')
_, gy, march = _cal(jy)
nowruz = date(gy, 3, march)
day_of_year = (jm - 1) * 31 - (jm - 7) * (jm >= 7) + jd - 1
return date.fromordinal(nowruz.toordinal() + day_of_year)
def from_gregorian(d: date) -> tuple[int, int, int]:
jy = d.year - 621
_, gy, march = _cal(jy)
k = d.toordinal() - date(gy, 3, march).toordinal()
if k < 0:
jy -= 1
_, gy, march = _cal(jy)
k = d.toordinal() - date(gy, 3, march).toordinal()
if k < 186:
return jy, k // 31 + 1, k % 31 + 1
k -= 186
return jy, k // 30 + 7, k % 30 + 1
def parse(value: str) -> date:
"""«1385-04-12» (ارقامِ فارسی هم) → تاریخِ میلادی."""
from accounts.phone import _DIGITS
parts = str(value).translate(_DIGITS).strip().replace('/', '-').split('-')
if len(parts) != 3 or not all(p.isdigit() for p in parts):
raise ValueError('تاریخ باید به شکلِ سال-ماه-روز باشد، مثلاً 1385-04-12.')
return to_gregorian(int(parts[0]), int(parts[1]), int(parts[2]))
def to_string(d: date) -> str:
jy, jm, jd = from_gregorian(d)
return f'{jy:04d}-{jm:02d}-{jd:02d}'
def age_of(birth_date: date | None, birth_year: int | None, today: date | None = None) -> int | None:
"""سنِ کامل از تاریخِ تولد؛ برای کاربری که فقط سالِ شمسی داده، تقریبی (سال منهای سال)."""
from django.utils import timezone
today = today or timezone.localdate()
if birth_date:
return today.year - birth_date.year - ((today.month, today.day) < (birth_date.month, birth_date.day))
if birth_year:
return from_gregorian(today)[0] - birth_year
return None