mirror of
				https://github.com/django/django.git
				synced 2025-10-31 09:41:08 +00:00 
			
		
		
		
	Fixed #33879 -- Improved timesince handling of long intervals.
This commit is contained in:
		| @@ -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): | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user