mirror of
https://github.com/zulip/zulip.git
synced 2025-10-23 04:52:12 +00:00
timestamp: Use localized formatting in format_datetime_to_string.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
This commit is contained in:
committed by
Tim Abbott
parent
9ca788cbbc
commit
890ccec8d4
@@ -210,6 +210,9 @@ prod = [
|
|||||||
|
|
||||||
# Better compression than zlib
|
# Better compression than zlib
|
||||||
"zstd",
|
"zstd",
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
"pyicu",
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
# Needed to build RTD docs
|
# Needed to build RTD docs
|
||||||
@@ -406,6 +409,7 @@ module = [
|
|||||||
"fakeldap.*",
|
"fakeldap.*",
|
||||||
"firebase_admin.*",
|
"firebase_admin.*",
|
||||||
"gitlint.*",
|
"gitlint.*",
|
||||||
|
"icu.*", # https://gitlab.pyicu.org/main/pyicu/-/issues/156
|
||||||
"integrations.*",
|
"integrations.*",
|
||||||
"jsonref.*",
|
"jsonref.*",
|
||||||
"ldap.*", # https://github.com/python-ldap/python-ldap/issues/368
|
"ldap.*", # https://github.com/python-ldap/python-ldap/issues/368
|
||||||
|
@@ -24,6 +24,7 @@ VENV_DEPENDENCIES = [
|
|||||||
"libsasl2-dev", # For building python-ldap from source
|
"libsasl2-dev", # For building python-ldap from source
|
||||||
"libvips", # For thumbnailing
|
"libvips", # For thumbnailing
|
||||||
"libvips-tools",
|
"libvips-tools",
|
||||||
|
"libicu-dev", # For building pyicu
|
||||||
]
|
]
|
||||||
|
|
||||||
COMMON_YUM_VENV_DEPENDENCIES = [
|
COMMON_YUM_VENV_DEPENDENCIES = [
|
||||||
|
10
uv.lock
generated
10
uv.lock
generated
@@ -3505,6 +3505,12 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyicu"
|
||||||
|
version = "2.15.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/88/b0/c8b61bac55424e2ff80e20d7251c3f002baff3c07c34cee3849e3505d8f5/pyicu-2.15.3.tar.gz", hash = "sha256:f32e78e1cb64d0aeb14f027e037a8944861d3114548818a6adf0081ef51aefc3", size = 267569, upload-time = "2025-09-15T20:58:50.936Z" }
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyinotify"
|
name = "pyinotify"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@@ -5830,6 +5836,7 @@ dev = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-partials" },
|
{ name = "pydantic-partials" },
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
|
{ name = "pyicu" },
|
||||||
{ name = "pyinotify" },
|
{ name = "pyinotify" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "pymongo" },
|
{ name = "pymongo" },
|
||||||
@@ -5954,6 +5961,7 @@ prod = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-partials" },
|
{ name = "pydantic-partials" },
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
|
{ name = "pyicu" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "pymongo" },
|
{ name = "pymongo" },
|
||||||
{ name = "pynacl" },
|
{ name = "pynacl" },
|
||||||
@@ -6057,6 +6065,7 @@ dev = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-partials" },
|
{ name = "pydantic-partials" },
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
|
{ name = "pyicu" },
|
||||||
{ name = "pyinotify" },
|
{ name = "pyinotify" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "pymongo" },
|
{ name = "pymongo" },
|
||||||
@@ -6182,6 +6191,7 @@ prod = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-partials" },
|
{ name = "pydantic-partials" },
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
|
{ name = "pyicu" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "pymongo" },
|
{ name = "pymongo" },
|
||||||
{ name = "pynacl" },
|
{ name = "pynacl" },
|
||||||
|
@@ -49,4 +49,4 @@ API_FEATURE_LEVEL = 427
|
|||||||
# historical commits sharing the same major version, in which case a
|
# historical commits sharing the same major version, in which case a
|
||||||
# minor version bump suffices.
|
# minor version bump suffices.
|
||||||
|
|
||||||
PROVISION_VERSION = (351, 0) # bumped 2025-10-01 to remove emoji-datasource-google-blob
|
PROVISION_VERSION = (351, 1) # bumped 2025-10-02 to add PyICU
|
||||||
|
@@ -1,4 +1,8 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from functools import cache
|
||||||
|
|
||||||
|
import icu
|
||||||
|
from django.utils.translation import get_language
|
||||||
|
|
||||||
|
|
||||||
class TimeZoneNotUTCError(Exception):
|
class TimeZoneNotUTCError(Exception):
|
||||||
@@ -49,9 +53,35 @@ def datetime_to_timestamp(dt: datetime) -> int:
|
|||||||
return int(dt.timestamp())
|
return int(dt.timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def get_date_time_pattern_generator(language: str) -> icu.DateTimePatternGenerator:
|
||||||
|
return icu.DateTimePatternGenerator.createInstance(icu.Locale(language))
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def get_icu_time_zone(time_zone: str) -> icu.TimeZone:
|
||||||
|
return icu.TimeZone.createTimeZone(time_zone)
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def get_date_time_format(language: str, use_twenty_four_hour_time: bool) -> icu.SimpleDateFormat:
|
||||||
|
skeleton = f"yMMMEd{'H' if use_twenty_four_hour_time else 'h'}mz"
|
||||||
|
pattern = get_date_time_pattern_generator(language).getBestPattern(skeleton)
|
||||||
|
return icu.SimpleDateFormat(pattern, icu.Locale(language))
|
||||||
|
|
||||||
|
|
||||||
def format_datetime_to_string(dt: datetime, use_twenty_four_hour_time: bool) -> str:
|
def format_datetime_to_string(dt: datetime, use_twenty_four_hour_time: bool) -> str:
|
||||||
if use_twenty_four_hour_time:
|
assert dt.tzinfo is not None
|
||||||
hhmm_string = dt.strftime("%H:%M")
|
time_zone = getattr(dt.tzinfo, "key", None)
|
||||||
else:
|
if time_zone is None:
|
||||||
hhmm_string = dt.strftime("%I:%M %p")
|
offset = dt.tzinfo.utcoffset(dt)
|
||||||
return dt.strftime(f"%A, %B %d, %Y at {hhmm_string} %Z")
|
assert offset is not None
|
||||||
|
sign = "-" if offset < timedelta(0) else "+"
|
||||||
|
hours, rest = divmod(abs(offset), timedelta(hours=1))
|
||||||
|
minutes, rest = divmod(rest, timedelta(minutes=1))
|
||||||
|
assert rest == timedelta(0)
|
||||||
|
time_zone = f"GMT{sign}{hours:02}:{minutes:02}"
|
||||||
|
language = get_language()
|
||||||
|
calendar = icu.Calendar.createInstance(get_icu_time_zone(time_zone), icu.Locale(language))
|
||||||
|
calendar.setTime(dt)
|
||||||
|
return get_date_time_format(language, use_twenty_four_hour_time).format(calendar)
|
||||||
|
@@ -1426,9 +1426,9 @@ class TestMessageNotificationEmails(ZulipTestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"> Meeting at <time:2025-09-30T09:30:00-07:00>\n\n--\n\nReply", mail.outbox[0].body[:56]
|
"> Meeting at <time:2025-09-30T09:30:00-07:00>\n\n--\n\nReply", mail.outbox[0].body[:56]
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertRegex(
|
||||||
'<p>Meeting at <time datetime="2025-09-30T16:30:00Z">Tuesday, September 30, 2025 at 04:30 PM UTC</time></p>',
|
|
||||||
mail.outbox[0].alternatives[0][0],
|
mail.outbox[0].alternatives[0][0],
|
||||||
|
r'<p>Meeting at <time datetime="2025-09-30T16:30:00Z">Tue, Sep 30, 2025, 4:30[ \u202f]PM UTC</time></p>',
|
||||||
)
|
)
|
||||||
|
|
||||||
# The timestamp is not formatted correctly.
|
# The timestamp is not formatted correctly.
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import zoneinfo
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
@@ -14,7 +13,6 @@ from zerver.actions.streams import do_set_stream_property
|
|||||||
from zerver.actions.user_settings import do_change_user_setting
|
from zerver.actions.user_settings import do_change_user_setting
|
||||||
from zerver.lib.initial_password import initial_password
|
from zerver.lib.initial_password import initial_password
|
||||||
from zerver.lib.test_classes import ZulipTestCase
|
from zerver.lib.test_classes import ZulipTestCase
|
||||||
from zerver.lib.timezone import canonicalize_timezone
|
|
||||||
from zerver.models import Message, Recipient, Stream, UserProfile
|
from zerver.models import Message, Recipient, Stream, UserProfile
|
||||||
from zerver.models.realms import get_realm
|
from zerver.models.realms import get_realm
|
||||||
from zerver.models.recipients import get_direct_message_group_user_ids
|
from zerver.models.recipients import get_direct_message_group_user_ids
|
||||||
@@ -53,9 +51,7 @@ class SendLoginEmailTest(ZulipTestCase):
|
|||||||
firefox_windows = (
|
firefox_windows = (
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0"
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0"
|
||||||
)
|
)
|
||||||
user_tz = zoneinfo.ZoneInfo(canonicalize_timezone(user.timezone))
|
|
||||||
mock_time = datetime(year=2018, month=1, day=1, tzinfo=timezone.utc)
|
mock_time = datetime(year=2018, month=1, day=1, tzinfo=timezone.utc)
|
||||||
reference_time = mock_time.astimezone(user_tz).strftime("%A, %B %d, %Y at %I:%M %p %Z")
|
|
||||||
with time_machine.travel(mock_time, tick=False):
|
with time_machine.travel(mock_time, tick=False):
|
||||||
self.client_post(
|
self.client_post(
|
||||||
"/accounts/login/", info=login_info, HTTP_USER_AGENT=firefox_windows
|
"/accounts/login/", info=login_info, HTTP_USER_AGENT=firefox_windows
|
||||||
@@ -66,7 +62,7 @@ class SendLoginEmailTest(ZulipTestCase):
|
|||||||
subject = "New login from Firefox on Windows"
|
subject = "New login from Firefox on Windows"
|
||||||
self.assertEqual(mail.outbox[0].subject, subject)
|
self.assertEqual(mail.outbox[0].subject, subject)
|
||||||
# local time is correct and in email's body
|
# local time is correct and in email's body
|
||||||
self.assertIn(reference_time, mail.outbox[0].body)
|
self.assertRegex(str(mail.outbox[0].body), r"Sun, Dec 31, 2017, 4:00[ \u202f]PM PST")
|
||||||
|
|
||||||
# Try again with the 24h time format setting enabled for this user
|
# Try again with the 24h time format setting enabled for this user
|
||||||
self.logout() # We just logged in, we'd be redirected without this
|
self.logout() # We just logged in, we'd be redirected without this
|
||||||
@@ -77,7 +73,7 @@ class SendLoginEmailTest(ZulipTestCase):
|
|||||||
"/accounts/login/", info=login_info, HTTP_USER_AGENT=firefox_windows
|
"/accounts/login/", info=login_info, HTTP_USER_AGENT=firefox_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
reference_time = mock_time.astimezone(user_tz).strftime("%A, %B %d, %Y at %H:%M %Z")
|
reference_time = "Sun, Dec 31, 2017, 16:00 PST"
|
||||||
self.assertIn(reference_time, mail.outbox[1].body)
|
self.assertIn(reference_time, mail.outbox[1].body)
|
||||||
|
|
||||||
def test_dont_send_login_emails_if_send_login_emails_is_false(self) -> None:
|
def test_dont_send_login_emails_if_send_login_emails_is_false(self) -> None:
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
from datetime import timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from dateutil import parser
|
from dateutil import parser
|
||||||
|
from django.utils.translation import override as override_language
|
||||||
|
|
||||||
from zerver.lib.test_classes import ZulipTestCase
|
from zerver.lib.test_classes import ZulipTestCase
|
||||||
from zerver.lib.timestamp import (
|
from zerver.lib.timestamp import (
|
||||||
@@ -10,6 +12,7 @@ from zerver.lib.timestamp import (
|
|||||||
datetime_to_timestamp,
|
datetime_to_timestamp,
|
||||||
floor_to_day,
|
floor_to_day,
|
||||||
floor_to_hour,
|
floor_to_hour,
|
||||||
|
format_datetime_to_string,
|
||||||
timestamp_to_datetime,
|
timestamp_to_datetime,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,3 +48,21 @@ class TestTimestamp(ZulipTestCase):
|
|||||||
for function in [floor_to_hour, floor_to_day, ceiling_to_hour, ceiling_to_hour]:
|
for function in [floor_to_hour, floor_to_day, ceiling_to_hour, ceiling_to_hour]:
|
||||||
with self.assertRaises(TimeZoneNotUTCError):
|
with self.assertRaises(TimeZoneNotUTCError):
|
||||||
function(non_utc_datetime)
|
function(non_utc_datetime)
|
||||||
|
|
||||||
|
def test_format_datetime_to_string(self) -> None:
|
||||||
|
dt = datetime(2001, 2, 3, 4, 5, 6, tzinfo=timezone.utc)
|
||||||
|
self.assertEqual(format_datetime_to_string(dt, True), "Sat, Feb 3, 2001, 04:05 GMT")
|
||||||
|
dt = datetime(2001, 2, 3, 4, 5, 6, tzinfo=timezone(timedelta(hours=7, minutes=8)))
|
||||||
|
self.assertEqual(format_datetime_to_string(dt, True), "Sat, Feb 3, 2001, 04:05 GMT+7:08")
|
||||||
|
dt = datetime(2001, 2, 3, 4, 5, 6, tzinfo=timezone(-timedelta(hours=7, minutes=8)))
|
||||||
|
self.assertEqual(format_datetime_to_string(dt, True), "Sat, Feb 3, 2001, 04:05 GMT-7:08")
|
||||||
|
dt = datetime(2001, 2, 3, 4, 5, 6, tzinfo=ZoneInfo("America/Los_Angeles"))
|
||||||
|
self.assertEqual(format_datetime_to_string(dt, True), "Sat, Feb 3, 2001, 04:05 PST")
|
||||||
|
self.assertRegex(
|
||||||
|
format_datetime_to_string(dt, False), r"^Sat, Feb 3, 2001, 4:05[ \u202f]AM PST$"
|
||||||
|
)
|
||||||
|
with override_language("ja-JP"):
|
||||||
|
self.assertEqual(format_datetime_to_string(dt, True), "2001年2月3日(土) 4:05 GMT-8")
|
||||||
|
self.assertEqual(
|
||||||
|
format_datetime_to_string(dt, False), "2001年2月3日(土) 午前4:05 GMT-8"
|
||||||
|
)
|
||||||
|
Reference in New Issue
Block a user