asdasdas
This commit is contained in:
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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(""), "")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user