This commit is contained in:
Your Name
2026-10-19 23:16:52 +03:30
7 changed files with 157 additions and 5 deletions
+3
View File
@@ -2,6 +2,9 @@
SECRET_KEY=change-me-in-production SECRET_KEY=change-me-in-production
DEBUG=1 DEBUG=1
ALLOWED_HOSTS=localhost,127.0.0.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 (PostgreSQL URL; empty => local SQLite for dev/tests) ----
DATABASE_URL= DATABASE_URL=
+87
View File
@@ -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 ""
+56 -1
View File
@@ -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 from apps.core.settings_service import SettingsService
@@ -19,3 +23,54 @@ class SettingsServiceTests(TestCase):
service.set("odoo.api_key", "super-secret") service.set("odoo.api_key", "super-secret")
values = service.all_values() values = service.all_values()
self.assertEqual(values["odoo.api_key"], "••••••") 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(""), "")
+3 -1
View File
@@ -13,6 +13,8 @@ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from apps.core.jalali import jalali_datetime
from apps.reports.models import FinalReport, ReportDefinition from apps.reports.models import FinalReport, ReportDefinition
GREEN = colors.HexColor("#166534") GREEN = colors.HexColor("#166534")
@@ -128,7 +130,7 @@ class ReportService:
for item in timeline: for item in timeline:
rows.append([ rows.append([
item.get("step", ""), item.get("state", ""), str(item.get("attempt", "")), 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(self._table(rows, [45 * mm, 30 * mm, 18 * mm, 35 * mm, 42 * mm], header=True))
story.append(Spacer(1, 3 * mm)) story.append(Spacer(1, 3 * mm))
+3 -2
View File
@@ -2,6 +2,7 @@ from django.db import transaction
from django.utils import timezone from django.utils import timezone
from apps.core.errors import ApiError from apps.core.errors import ApiError
from apps.core.jalali import jalali_date, jalali_time
from apps.reservations.models import Reservation, Slot 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, "customer_name": customer.company_name or customer.user.get_full_name() or customer.user.username,
"reservation_title": reservation.title, "reservation_title": reservation.title,
"slot_date": str(reservation.slot.date), "slot_date": jalali_date(reservation.slot.date),
"slot_time": str(reservation.slot.start_time), "slot_time": jalali_time(reservation.slot.start_time),
}, },
recipient=customer.user.phone or customer.user.username, recipient=customer.user.phone or customer.user.username,
) )
+4 -1
View File
@@ -133,7 +133,10 @@ AUTH_PASSWORD_VALIDATORS = [
] ]
LANGUAGE_CODE = "en-us" 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_I18N = True
USE_TZ = True USE_TZ = True
+1
View File
@@ -10,3 +10,4 @@ redis>=5.0,<6.0
python-dotenv>=1.0,<2.0 python-dotenv>=1.0,<2.0
gunicorn>=21.2,<23.0 gunicorn>=21.2,<23.0
reportlab>=4.2,<5.0 reportlab>=4.2,<5.0
jdatetime>=5.0,<7.0