Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed #33879 -- Fixed wrong results for long intervals. #16027

Merged
merged 1 commit into from
Jan 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
100 changes: 68 additions & 32 deletions django/utils/timesince.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import calendar
import datetime

from django.utils.html import avoid_wrapping
Expand All @@ -14,14 +13,16 @@
"minute": ngettext_lazy("%(num)d minute", "%(num)d minutes", "num"),
}

TIMESINCE_CHUNKS = (
(60 * 60 * 24 * 365, "year"),
(60 * 60 * 24 * 30, "month"),
(60 * 60 * 24 * 7, "week"),
(60 * 60 * 24, "day"),
(60 * 60, "hour"),
(60, "minute"),
)
TIME_STRINGS_KEYS = list(TIME_STRINGS.keys())

TIME_CHUNKS = [
60 * 60 * 24 * 7, # week
60 * 60 * 24, # day
60 * 60, # hour
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):
Expand All @@ -31,18 +32,26 @@ def timesince(d, now=None, reversed=False, time_strings=None, depth=2):
"0 minutes".

Units used are years, months, weeks, days, hours, and minutes.
Seconds and microseconds are ignored. 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.
Seconds and microseconds are ignored.

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 dict.

`depth` is an optional integer to control the number of adjacent time
units returned.

Adapted from
Originally adapted from
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:
time_strings = TIME_STRINGS
Expand All @@ -60,37 +69,64 @@ def timesince(d, now=None, reversed=False, time_strings=None, depth=2):
d, now = now, d
delta = now - d

# Deal with leapyears by subtracing the number of leapdays
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
# Ignore microseconds.
since = delta.days * 24 * 60 * 60 + delta.seconds
if since <= 0:
# d is in the future compared to now, stop processing.
return avoid_wrapping(time_strings["minute"] % {"num": 0})
for i, (seconds, name) in enumerate(TIMESINCE_CHUNKS):
count = since // seconds
if count != 0:

# Get years and months.
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
else:
return avoid_wrapping(time_strings["minute"] % {"num": 0})

result = []
current_depth = 0
while i < len(TIMESINCE_CHUNKS) and current_depth < depth:
seconds, name = TIMESINCE_CHUNKS[i]
count = since // seconds
if count == 0:
while i < len(TIME_STRINGS_KEYS) and current_depth < depth:
value = partials[i]
if value == 0:
break
result.append(avoid_wrapping(time_strings[name] % {"num": count}))
since -= seconds * count
name = TIME_STRINGS_KEYS[i]
result.append(avoid_wrapping(time_strings[name] % {"num": value}))
current_depth += 1
i += 1

return gettext(", ").join(result)


Expand Down
4 changes: 2 additions & 2 deletions tests/humanize_tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,8 +506,8 @@ def test_inflection_for_timedelta(self):
# "%(delta)s from now" translations
now + datetime.timedelta(days=1),
now + datetime.timedelta(days=2),
now + datetime.timedelta(days=30),
now + datetime.timedelta(days=60),
now + datetime.timedelta(days=31),
now + datetime.timedelta(days=61),
now + datetime.timedelta(days=500),
now + datetime.timedelta(days=865),
]
Expand Down
17 changes: 17 additions & 0 deletions tests/template_tests/filter_tests/test_timesince.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,23 @@ def test_timesince18(self):
)
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):
def test_since_now(self):
Expand Down
35 changes: 33 additions & 2 deletions tests/utils_tests/test_timesince.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ def setUp(self):
self.onehour = datetime.timedelta(hours=1)
self.oneday = datetime.timedelta(days=1)
self.oneweek = datetime.timedelta(days=7)
self.onemonth = datetime.timedelta(days=30)
self.oneyear = datetime.timedelta(days=365)
self.onemonth = datetime.timedelta(days=31)
self.oneyear = datetime.timedelta(days=366)

def test_equal_datetimes(self):
"""equal datetimes."""
Expand Down Expand Up @@ -205,6 +205,37 @@ def test_depth(self):
self.assertEqual(timesince(self.t, value, 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):
msg = "depth must be greater than 0."
with self.assertRaisesMessage(ValueError, msg):
Expand Down