From fd89086d22837950abf9eda1d72006378eb0cfb1 Mon Sep 17 00:00:00 2001 From: Emad modaresi Date: Wed, 14 Oct 2026 09:03:54 +0330 Subject: [PATCH] change date and timezone to iran format --- .env.example | 3 ++ apps/core/jalali.py | 87 +++++++++++++++++++++++++++++++++++ apps/core/tests.py | 57 ++++++++++++++++++++++- apps/reports/services.py | 4 +- apps/reservations/services.py | 5 +- config/settings.py | 5 +- requirements.txt | 1 + 7 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 apps/core/jalali.py diff --git a/.env.example b/.env.example index e392de2..24479e3 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,9 @@ SECRET_KEY=change-me-in-production DEBUG=1 ALLOWED_HOSTS=localhost,127.0.0.1 +# Dates are shown to users in Jalali; keep this aligned with APP_TIME_ZONE in +# the frontend (src/utils/jalali.js) or timestamps will drift by the offset. +TIME_ZONE=Asia/Tehran # ---- Database (PostgreSQL URL; empty => local SQLite for dev/tests) ---- DATABASE_URL= diff --git a/apps/core/jalali.py b/apps/core/jalali.py new file mode 100644 index 0000000..af80d8c --- /dev/null +++ b/apps/core/jalali.py @@ -0,0 +1,87 @@ +"""Jalali (Shamsi) date rendering for output that never passes through the API. + +The REST layer keeps speaking ISO-8601 Gregorian on purpose — django-filter +lookups, the Odoo integration and the stored report payloads all depend on it. +Conversion belongs where a human actually reads a date: the generated PDF and +the customer-facing SMS. + +Numerals stay ASCII. ReportLab's built-in fonts have no Persian glyphs, so +emitting Persian digits would render as black boxes in the PDF. +""" + +from __future__ import annotations + +from datetime import date, datetime, time +from typing import Any, Optional + +import jdatetime +from django.utils import timezone + +__all__ = ["jalali_date", "jalali_datetime", "jalali_time"] + + +def _parse_iso(value: str) -> Optional[datetime]: + """Read back what ``datetime.isoformat()`` wrote, tolerating a bare date.""" + try: + return datetime.fromisoformat(value) + except ValueError: + try: + return datetime.combine(date.fromisoformat(value), time()) + except ValueError: + return None + + +def _localize(value: datetime) -> datetime: + """Move an aware datetime into the project's ``TIME_ZONE``. + + Naive values are returned untouched — they are already wall-clock local, and + ``timezone.localtime`` refuses them outright under ``USE_TZ = True``. + """ + if not timezone.is_aware(value): + return value + try: + return timezone.localtime(value) + except (ValueError, OverflowError): + # Timestamps at the epoch boundary overflow when shifted west of UTC. + return value + + +def _coerce(value: Any) -> Any: + """Accept a date, a datetime, or an ISO string; return date/datetime/None.""" + if isinstance(value, str): + return _parse_iso(value) + return value + + +def jalali_date(value: Any) -> str: + """Render a ``date``/``datetime``/ISO string as ``YYYY/MM/DD`` (Jalali).""" + value = _coerce(value) + if isinstance(value, datetime): + value = _localize(value).date() + if not isinstance(value, date): + return "" + j = jdatetime.date.fromgregorian(date=value) + return f"{j.year:04d}/{j.month:02d}/{j.day:02d}" + + +def jalali_datetime(value: Any) -> str: + """Render a datetime/ISO string as ``YYYY/MM/DD HH:MM`` (Jalali, local time).""" + value = _coerce(value) + if not isinstance(value, datetime): + return jalali_date(value) + j = jdatetime.datetime.fromgregorian(datetime=_localize(value)) + return f"{j.year:04d}/{j.month:02d}/{j.day:02d} {j.hour:02d}:{j.minute:02d}" + + +def jalali_time(value: Any) -> str: + """Render a ``time``/``datetime``/``HH:MM[:SS]`` string as ``HH:MM``.""" + if isinstance(value, str): + parts = value.strip().split(":") + if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit(): + return f"{int(parts[0]):02d}:{parts[1]}" + return "" + if isinstance(value, datetime): + value = _localize(value).time() + if isinstance(value, time): + return f"{value.hour:02d}:{value.minute:02d}" + return "" \ No newline at end of file diff --git a/apps/core/tests.py b/apps/core/tests.py index 373a2b7..b2cb11b 100644 --- a/apps/core/tests.py +++ b/apps/core/tests.py @@ -1,5 +1,9 @@ -from django.test import TestCase +from datetime import date, datetime, time, timedelta, timezone as dt_timezone +import jdatetime +from django.test import TestCase, override_settings + +from apps.core.jalali import jalali_date, jalali_datetime, jalali_time from apps.core.settings_service import SettingsService @@ -19,3 +23,54 @@ class SettingsServiceTests(TestCase): service.set("odoo.api_key", "super-secret") values = service.all_values() self.assertEqual(values["odoo.api_key"], "••••••") + + +class JalaliTests(TestCase): + """The PDF and the customer SMS are the only places dates are rendered for + humans; the API itself stays on ISO-8601.""" + + def test_known_reference_dates(self): + self.assertEqual(jalali_date(date(2026, 3, 21)), "1405/01/01") # Nowruz 1405 + self.assertEqual(jalali_date(date(2026, 3, 14)), "1404/12/23") + self.assertEqual(jalali_date(date(1979, 2, 11)), "1357/11/22") + + def test_round_trips_against_jdatetime_over_two_decades(self): + """A conversion error here silently misdates a quality report.""" + day = date(2020, 1, 1) + end = date(2040, 1, 1) + while day < end: + expected = jdatetime.date.fromgregorian(date=day) + self.assertEqual( + jalali_date(day), + f"{expected.year:04d}/{expected.month:02d}/{expected.day:02d}", + ) + day += timedelta(days=1) + + def test_accepts_iso_strings(self): + self.assertEqual(jalali_date("2026-03-14"), "1404/12/23") + self.assertEqual(jalali_datetime("2026-03-14T05:00:00+00:00")[:10], "1404/12/23") + + def test_blank_and_malformed_input_is_empty_not_an_error(self): + for value in (None, "", "not-a-date", 0): + self.assertEqual(jalali_date(value), "") + self.assertEqual(jalali_datetime(value), "") + + @override_settings(TIME_ZONE="Asia/Tehran") + def test_aware_datetimes_render_in_the_project_time_zone(self): + # 20:30Z is already the next calendar day in Tehran (+03:30). + moment = datetime(2026, 3, 14, 20, 30, tzinfo=dt_timezone.utc) + self.assertEqual(jalali_datetime(moment), "1404/12/24 00:00") + + @override_settings(TIME_ZONE="UTC") + def test_aware_datetimes_follow_the_configured_zone(self): + moment = datetime(2026, 3, 14, 20, 30, tzinfo=dt_timezone.utc) + self.assertEqual(jalali_datetime(moment), "1404/12/23 20:30") + + def test_naive_datetimes_are_left_alone(self): + self.assertEqual(jalali_datetime(datetime(2026, 3, 14, 8, 5)), "1404/12/23 08:05") + + def test_times(self): + self.assertEqual(jalali_time(time(8, 0)), "08:00") + self.assertEqual(jalali_time("08:30:15"), "08:30") + self.assertEqual(jalali_time("09:05"), "09:05") + self.assertEqual(jalali_time(""), "") diff --git a/apps/reports/services.py b/apps/reports/services.py index de0a6fc..9924b19 100644 --- a/apps/reports/services.py +++ b/apps/reports/services.py @@ -13,6 +13,8 @@ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.units import mm from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle +from apps.core.jalali import jalali_datetime + from apps.reports.models import FinalReport, ReportDefinition GREEN = colors.HexColor("#166534") @@ -128,7 +130,7 @@ class ReportService: for item in timeline: rows.append([ item.get("step", ""), item.get("state", ""), str(item.get("attempt", "")), - item.get("operator", ""), item.get("completed_at", ""), + item.get("operator", ""), jalali_datetime(item.get("completed_at", "")), ]) story.append(self._table(rows, [45 * mm, 30 * mm, 18 * mm, 35 * mm, 42 * mm], header=True)) story.append(Spacer(1, 3 * mm)) diff --git a/apps/reservations/services.py b/apps/reservations/services.py index 9605bb0..6960e84 100644 --- a/apps/reservations/services.py +++ b/apps/reservations/services.py @@ -2,6 +2,7 @@ from django.db import transaction from django.utils import timezone from apps.core.errors import ApiError +from apps.core.jalali import jalali_date, jalali_time from apps.reservations.models import Reservation, Slot @@ -46,8 +47,8 @@ class ReservationService: { "customer_name": customer.company_name or customer.user.get_full_name() or customer.user.username, "reservation_title": reservation.title, - "slot_date": str(reservation.slot.date), - "slot_time": str(reservation.slot.start_time), + "slot_date": jalali_date(reservation.slot.date), + "slot_time": jalali_time(reservation.slot.start_time), }, recipient=customer.user.phone or customer.user.username, ) diff --git a/config/settings.py b/config/settings.py index 83e9b6b..0703712 100644 --- a/config/settings.py +++ b/config/settings.py @@ -133,7 +133,10 @@ AUTH_PASSWORD_VALIDATORS = [ ] LANGUAGE_CODE = "en-us" -TIME_ZONE = "UTC" +# Iran Standard Time. Dates are rendered to users as Jalali, so the backend must +# agree with the frontend on what "today" and a timestamp's wall clock mean — +# otherwise a slot booked at 09:00 Tehran reads as 04:30 on the day it shifts. +TIME_ZONE = env("TIME_ZONE", "Asia/Tehran") USE_I18N = True USE_TZ = True diff --git a/requirements.txt b/requirements.txt index 07be307..c64c0f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ redis>=5.0,<6.0 python-dotenv>=1.0,<2.0 gunicorn>=21.2,<23.0 reportlab>=4.2,<5.0 +jdatetime>=5.0,<7.0