mirror of
https://github.com/zulip/zulip.git
synced 2025-10-22 20:42:14 +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
|
||||
"zstd",
|
||||
|
||||
# Internationalization
|
||||
"pyicu",
|
||||
]
|
||||
docs = [
|
||||
# Needed to build RTD docs
|
||||
@@ -406,6 +409,7 @@ module = [
|
||||
"fakeldap.*",
|
||||
"firebase_admin.*",
|
||||
"gitlint.*",
|
||||
"icu.*", # https://gitlab.pyicu.org/main/pyicu/-/issues/156
|
||||
"integrations.*",
|
||||
"jsonref.*",
|
||||
"ldap.*", # https://github.com/python-ldap/python-ldap/issues/368
|
||||
|
@@ -24,6 +24,7 @@ VENV_DEPENDENCIES = [
|
||||
"libsasl2-dev", # For building python-ldap from source
|
||||
"libvips", # For thumbnailing
|
||||
"libvips-tools",
|
||||
"libicu-dev", # For building pyicu
|
||||
]
|
||||
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pyinotify"
|
||||
version = "0.9.6"
|
||||
@@ -5830,6 +5836,7 @@ dev = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-partials" },
|
||||
{ name = "pygments" },
|
||||
{ name = "pyicu" },
|
||||
{ name = "pyinotify" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pymongo" },
|
||||
@@ -5954,6 +5961,7 @@ prod = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-partials" },
|
||||
{ name = "pygments" },
|
||||
{ name = "pyicu" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pymongo" },
|
||||
{ name = "pynacl" },
|
||||
@@ -6057,6 +6065,7 @@ dev = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-partials" },
|
||||
{ name = "pygments" },
|
||||
{ name = "pyicu" },
|
||||
{ name = "pyinotify" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pymongo" },
|
||||
@@ -6182,6 +6191,7 @@ prod = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-partials" },
|
||||
{ name = "pygments" },
|
||||
{ name = "pyicu" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pymongo" },
|
||||
{ name = "pynacl" },
|
||||
|
@@ -49,4 +49,4 @@ API_FEATURE_LEVEL = 427
|
||||
# historical commits sharing the same major version, in which case a
|
||||
# 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 functools import cache
|
||||
|
||||
import icu
|
||||
from django.utils.translation import get_language
|
||||
|
||||
|
||||
class TimeZoneNotUTCError(Exception):
|
||||
@@ -49,9 +53,35 @@ def datetime_to_timestamp(dt: datetime) -> int:
|
||||
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:
|
||||
if use_twenty_four_hour_time:
|
||||
hhmm_string = dt.strftime("%H:%M")
|
||||
else:
|
||||
hhmm_string = dt.strftime("%I:%M %p")
|
||||
return dt.strftime(f"%A, %B %d, %Y at {hhmm_string} %Z")
|
||||
assert dt.tzinfo is not None
|
||||
time_zone = getattr(dt.tzinfo, "key", None)
|
||||
if time_zone is None:
|
||||
offset = dt.tzinfo.utcoffset(dt)
|
||||
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(
|
||||
"> Meeting at <time:2025-09-30T09:30:00-07:00>\n\n--\n\nReply", mail.outbox[0].body[:56]
|
||||
)
|
||||
self.assertIn(
|
||||
'<p>Meeting at <time datetime="2025-09-30T16:30:00Z">Tuesday, September 30, 2025 at 04:30 PM UTC</time></p>',
|
||||
self.assertRegex(
|
||||
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.
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import zoneinfo
|
||||
from collections.abc import Sequence
|
||||
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.lib.initial_password import initial_password
|
||||
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.realms import get_realm
|
||||
from zerver.models.recipients import get_direct_message_group_user_ids
|
||||
@@ -53,9 +51,7 @@ class SendLoginEmailTest(ZulipTestCase):
|
||||
firefox_windows = (
|
||||
"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)
|
||||
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):
|
||||
self.client_post(
|
||||
"/accounts/login/", info=login_info, HTTP_USER_AGENT=firefox_windows
|
||||
@@ -66,7 +62,7 @@ class SendLoginEmailTest(ZulipTestCase):
|
||||
subject = "New login from Firefox on Windows"
|
||||
self.assertEqual(mail.outbox[0].subject, subject)
|
||||
# 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
|
||||
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
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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 django.utils.translation import override as override_language
|
||||
|
||||
from zerver.lib.test_classes import ZulipTestCase
|
||||
from zerver.lib.timestamp import (
|
||||
@@ -10,6 +12,7 @@ from zerver.lib.timestamp import (
|
||||
datetime_to_timestamp,
|
||||
floor_to_day,
|
||||
floor_to_hour,
|
||||
format_datetime_to_string,
|
||||
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]:
|
||||
with self.assertRaises(TimeZoneNotUTCError):
|
||||
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