mirror of https://github.com/django/django.git
Fixed #33879 -- Improved timesince handling of long intervals.
This commit is contained in:
parent
99bd5fb4c2
commit
8d67e16493
|
@ -1,4 +1,3 @@
|
||||||
import calendar
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from django.utils.html import avoid_wrapping
|
from django.utils.html import avoid_wrapping
|
||||||
|
@ -14,14 +13,16 @@ TIME_STRINGS = {
|
||||||
"minute": ngettext_lazy("%(num)d minute", "%(num)d minutes", "num"),
|
"minute": ngettext_lazy("%(num)d minute", "%(num)d minutes", "num"),
|
||||||
}
|
}
|
||||||
|
|
||||||
TIMESINCE_CHUNKS = (
|
TIME_STRINGS_KEYS = list(TIME_STRINGS.keys())
|
||||||
(60 * 60 * 24 * 365, "year"),
|
|
||||||
(60 * 60 * 24 * 30, "month"),
|
TIME_CHUNKS = [
|
||||||
(60 * 60 * 24 * 7, "week"),
|
60 * 60 * 24 * 7, # week
|
||||||
(60 * 60 * 24, "day"),
|
60 * 60 * 24, # day
|
||||||
(60 * 60, "hour"),
|
60 * 60, # hour
|
||||||
(60, "minute"),
|
60, # minute
|
||||||
)
|
]
|
||||||
|
|
||||||
|
MONTHS_DAYS = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||||
|
|
||||||
|
|
||||||
def timesince(d, now=None, reversed=False, time_strings=None, depth=2):
|
def timesince(d, now=None, reversed=False, time_strings=None, depth=2):
|
||||||
|
@ -31,9 +32,16 @@ def timesince(d, now=None, reversed=False, time_strings=None, depth=2):
|
||||||
"0 minutes".
|
"0 minutes".
|
||||||
|
|
||||||
Units used are years, months, weeks, days, hours, and minutes.
|
Units used are years, months, weeks, days, hours, and minutes.
|
||||||
Seconds and microseconds are ignored. Up to `depth` adjacent units will be
|
Seconds and microseconds are ignored.
|
||||||
displayed. For example, "2 weeks, 3 days" and "1 year, 3 months" are
|
|
||||||
possible outputs, but "2 weeks, 3 hours" and "1 year, 5 days" are not.
|
The algorithm takes into account the varying duration of years and months.
|
||||||
|
There is exactly "1 year, 1 month" between 2013/02/10 and 2014/03/10,
|
||||||
|
but also between 2007/08/10 and 2008/09/10 despite the delta being 393 days
|
||||||
|
in the former case and 397 in the latter.
|
||||||
|
|
||||||
|
Up to `depth` adjacent units will be displayed. For example,
|
||||||
|
"2 weeks, 3 days" and "1 year, 3 months" are possible outputs, but
|
||||||
|
"2 weeks, 3 hours" and "1 year, 5 days" are not.
|
||||||
|
|
||||||
`time_strings` is an optional dict of strings to replace the default
|
`time_strings` is an optional dict of strings to replace the default
|
||||||
TIME_STRINGS dict.
|
TIME_STRINGS dict.
|
||||||
|
@ -41,8 +49,9 @@ def timesince(d, now=None, reversed=False, time_strings=None, depth=2):
|
||||||
`depth` is an optional integer to control the number of adjacent time
|
`depth` is an optional integer to control the number of adjacent time
|
||||||
units returned.
|
units returned.
|
||||||
|
|
||||||
Adapted from
|
Originally adapted from
|
||||||
https://web.archive.org/web/20060617175230/http://blog.natbat.co.uk/archive/2003/Jun/14/time_since
|
https://web.archive.org/web/20060617175230/http://blog.natbat.co.uk/archive/2003/Jun/14/time_since
|
||||||
|
Modified to improve results for years and months.
|
||||||
"""
|
"""
|
||||||
if time_strings is None:
|
if time_strings is None:
|
||||||
time_strings = TIME_STRINGS
|
time_strings = TIME_STRINGS
|
||||||
|
@ -60,37 +69,64 @@ def timesince(d, now=None, reversed=False, time_strings=None, depth=2):
|
||||||
d, now = now, d
|
d, now = now, d
|
||||||
delta = now - d
|
delta = now - d
|
||||||
|
|
||||||
# Deal with leapyears by subtracing the number of leapdays
|
# Ignore microseconds.
|
||||||
leapdays = calendar.leapdays(d.year, now.year)
|
|
||||||
if leapdays != 0:
|
|
||||||
if calendar.isleap(d.year):
|
|
||||||
leapdays -= 1
|
|
||||||
elif calendar.isleap(now.year):
|
|
||||||
leapdays += 1
|
|
||||||
delta -= datetime.timedelta(leapdays)
|
|
||||||
|
|
||||||
# ignore microseconds
|
|
||||||
since = delta.days * 24 * 60 * 60 + delta.seconds
|
since = delta.days * 24 * 60 * 60 + delta.seconds
|
||||||
if since <= 0:
|
if since <= 0:
|
||||||
# d is in the future compared to now, stop processing.
|
# d is in the future compared to now, stop processing.
|
||||||
return avoid_wrapping(time_strings["minute"] % {"num": 0})
|
return avoid_wrapping(time_strings["minute"] % {"num": 0})
|
||||||
for i, (seconds, name) in enumerate(TIMESINCE_CHUNKS):
|
|
||||||
count = since // seconds
|
# Get years and months.
|
||||||
if count != 0:
|
total_months = (now.year - d.year) * 12 + (now.month - d.month)
|
||||||
|
if d.day > now.day or (d.day == now.day and d.time() > now.time()):
|
||||||
|
total_months -= 1
|
||||||
|
years, months = divmod(total_months, 12)
|
||||||
|
|
||||||
|
# Calculate the remaining time.
|
||||||
|
# Create a "pivot" datetime shifted from d by years and months, then use
|
||||||
|
# that to determine the other parts.
|
||||||
|
if years or months:
|
||||||
|
pivot_year = d.year + years
|
||||||
|
pivot_month = d.month + months
|
||||||
|
if pivot_month > 12:
|
||||||
|
pivot_month -= 12
|
||||||
|
pivot_year += 1
|
||||||
|
pivot = datetime.datetime(
|
||||||
|
pivot_year,
|
||||||
|
pivot_month,
|
||||||
|
min(MONTHS_DAYS[pivot_month - 1], d.day),
|
||||||
|
d.hour,
|
||||||
|
d.minute,
|
||||||
|
d.second,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pivot = d
|
||||||
|
remaining_time = (now - pivot).total_seconds()
|
||||||
|
partials = [years, months]
|
||||||
|
for chunk in TIME_CHUNKS:
|
||||||
|
count = remaining_time // chunk
|
||||||
|
partials.append(count)
|
||||||
|
remaining_time -= chunk * count
|
||||||
|
|
||||||
|
# Find the first non-zero part (if any) and then build the result, until
|
||||||
|
# depth.
|
||||||
|
i = 0
|
||||||
|
for i, value in enumerate(partials):
|
||||||
|
if value != 0:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
return avoid_wrapping(time_strings["minute"] % {"num": 0})
|
return avoid_wrapping(time_strings["minute"] % {"num": 0})
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
current_depth = 0
|
current_depth = 0
|
||||||
while i < len(TIMESINCE_CHUNKS) and current_depth < depth:
|
while i < len(TIME_STRINGS_KEYS) and current_depth < depth:
|
||||||
seconds, name = TIMESINCE_CHUNKS[i]
|
value = partials[i]
|
||||||
count = since // seconds
|
if value == 0:
|
||||||
if count == 0:
|
|
||||||
break
|
break
|
||||||
result.append(avoid_wrapping(time_strings[name] % {"num": count}))
|
name = TIME_STRINGS_KEYS[i]
|
||||||
since -= seconds * count
|
result.append(avoid_wrapping(time_strings[name] % {"num": value}))
|
||||||
current_depth += 1
|
current_depth += 1
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
return gettext(", ").join(result)
|
return gettext(", ").join(result)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -506,8 +506,8 @@ class HumanizeTests(SimpleTestCase):
|
||||||
# "%(delta)s from now" translations
|
# "%(delta)s from now" translations
|
||||||
now + datetime.timedelta(days=1),
|
now + datetime.timedelta(days=1),
|
||||||
now + datetime.timedelta(days=2),
|
now + datetime.timedelta(days=2),
|
||||||
now + datetime.timedelta(days=30),
|
now + datetime.timedelta(days=31),
|
||||||
now + datetime.timedelta(days=60),
|
now + datetime.timedelta(days=61),
|
||||||
now + datetime.timedelta(days=500),
|
now + datetime.timedelta(days=500),
|
||||||
now + datetime.timedelta(days=865),
|
now + datetime.timedelta(days=865),
|
||||||
]
|
]
|
||||||
|
|
|
@ -147,6 +147,23 @@ class TimesinceTests(TimezoneTestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(output, "1\xa0day")
|
self.assertEqual(output, "1\xa0day")
|
||||||
|
|
||||||
|
# Tests for #33879 (wrong results for 11 months + several weeks).
|
||||||
|
@setup({"timesince19": "{{ earlier|timesince }}"})
|
||||||
|
def test_timesince19(self):
|
||||||
|
output = self.engine.render_to_string(
|
||||||
|
"timesince19", {"earlier": self.today - timedelta(days=358)}
|
||||||
|
)
|
||||||
|
self.assertEqual(output, "11\xa0months, 3\xa0weeks")
|
||||||
|
|
||||||
|
@setup({"timesince20": "{{ a|timesince:b }}"})
|
||||||
|
def test_timesince20(self):
|
||||||
|
now = datetime(2018, 5, 9)
|
||||||
|
output = self.engine.render_to_string(
|
||||||
|
"timesince20",
|
||||||
|
{"a": now, "b": now + timedelta(days=365) + timedelta(days=364)},
|
||||||
|
)
|
||||||
|
self.assertEqual(output, "1\xa0year, 11\xa0months")
|
||||||
|
|
||||||
|
|
||||||
class FunctionTests(SimpleTestCase):
|
class FunctionTests(SimpleTestCase):
|
||||||
def test_since_now(self):
|
def test_since_now(self):
|
||||||
|
|
|
@ -16,8 +16,8 @@ class TimesinceTests(TestCase):
|
||||||
self.onehour = datetime.timedelta(hours=1)
|
self.onehour = datetime.timedelta(hours=1)
|
||||||
self.oneday = datetime.timedelta(days=1)
|
self.oneday = datetime.timedelta(days=1)
|
||||||
self.oneweek = datetime.timedelta(days=7)
|
self.oneweek = datetime.timedelta(days=7)
|
||||||
self.onemonth = datetime.timedelta(days=30)
|
self.onemonth = datetime.timedelta(days=31)
|
||||||
self.oneyear = datetime.timedelta(days=365)
|
self.oneyear = datetime.timedelta(days=366)
|
||||||
|
|
||||||
def test_equal_datetimes(self):
|
def test_equal_datetimes(self):
|
||||||
"""equal datetimes."""
|
"""equal datetimes."""
|
||||||
|
@ -205,6 +205,37 @@ class TimesinceTests(TestCase):
|
||||||
self.assertEqual(timesince(self.t, value, depth=depth), expected)
|
self.assertEqual(timesince(self.t, value, depth=depth), expected)
|
||||||
self.assertEqual(timeuntil(value, self.t, depth=depth), expected)
|
self.assertEqual(timeuntil(value, self.t, depth=depth), expected)
|
||||||
|
|
||||||
|
def test_months_edge(self):
|
||||||
|
t = datetime.datetime(2022, 1, 1)
|
||||||
|
tests = [
|
||||||
|
(datetime.datetime(2022, 1, 31), "4\xa0weeks, 2\xa0days"),
|
||||||
|
(datetime.datetime(2022, 2, 1), "1\xa0month"),
|
||||||
|
(datetime.datetime(2022, 2, 28), "1\xa0month, 3\xa0weeks"),
|
||||||
|
(datetime.datetime(2022, 3, 1), "2\xa0months"),
|
||||||
|
(datetime.datetime(2022, 3, 31), "2\xa0months, 4\xa0weeks"),
|
||||||
|
(datetime.datetime(2022, 4, 1), "3\xa0months"),
|
||||||
|
(datetime.datetime(2022, 4, 30), "3\xa0months, 4\xa0weeks"),
|
||||||
|
(datetime.datetime(2022, 5, 1), "4\xa0months"),
|
||||||
|
(datetime.datetime(2022, 5, 31), "4\xa0months, 4\xa0weeks"),
|
||||||
|
(datetime.datetime(2022, 6, 1), "5\xa0months"),
|
||||||
|
(datetime.datetime(2022, 6, 30), "5\xa0months, 4\xa0weeks"),
|
||||||
|
(datetime.datetime(2022, 7, 1), "6\xa0months"),
|
||||||
|
(datetime.datetime(2022, 7, 31), "6\xa0months, 4\xa0weeks"),
|
||||||
|
(datetime.datetime(2022, 8, 1), "7\xa0months"),
|
||||||
|
(datetime.datetime(2022, 8, 31), "7\xa0months, 4\xa0weeks"),
|
||||||
|
(datetime.datetime(2022, 9, 1), "8\xa0months"),
|
||||||
|
(datetime.datetime(2022, 9, 30), "8\xa0months, 4\xa0weeks"),
|
||||||
|
(datetime.datetime(2022, 10, 1), "9\xa0months"),
|
||||||
|
(datetime.datetime(2022, 10, 31), "9\xa0months, 4\xa0weeks"),
|
||||||
|
(datetime.datetime(2022, 11, 1), "10\xa0months"),
|
||||||
|
(datetime.datetime(2022, 11, 30), "10\xa0months, 4\xa0weeks"),
|
||||||
|
(datetime.datetime(2022, 12, 1), "11\xa0months"),
|
||||||
|
(datetime.datetime(2022, 12, 31), "11\xa0months, 4\xa0weeks"),
|
||||||
|
]
|
||||||
|
for value, expected in tests:
|
||||||
|
with self.subTest():
|
||||||
|
self.assertEqual(timesince(t, value), expected)
|
||||||
|
|
||||||
def test_depth_invalid(self):
|
def test_depth_invalid(self):
|
||||||
msg = "depth must be greater than 0."
|
msg = "depth must be greater than 0."
|
||||||
with self.assertRaisesMessage(ValueError, msg):
|
with self.assertRaisesMessage(ValueError, msg):
|
||||||
|
|
Loading…
Reference in New Issue