timesince.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. import datetime
  2. from django.utils.html import avoid_wrapping
  3. from django.utils.timezone import is_aware
  4. from django.utils.translation import gettext, ngettext_lazy
  5. TIME_STRINGS = {
  6. "year": ngettext_lazy("%(num)d year", "%(num)d years", "num"),
  7. "month": ngettext_lazy("%(num)d month", "%(num)d months", "num"),
  8. "week": ngettext_lazy("%(num)d week", "%(num)d weeks", "num"),
  9. "day": ngettext_lazy("%(num)d day", "%(num)d days", "num"),
  10. "hour": ngettext_lazy("%(num)d hour", "%(num)d hours", "num"),
  11. "minute": ngettext_lazy("%(num)d minute", "%(num)d minutes", "num"),
  12. }
  13. TIME_STRINGS_KEYS = list(TIME_STRINGS.keys())
  14. TIME_CHUNKS = [
  15. 60 * 60 * 24 * 7, # week
  16. 60 * 60 * 24, # day
  17. 60 * 60, # hour
  18. 60, # minute
  19. ]
  20. MONTHS_DAYS = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
  21. def timesince(d, now=None, reversed=False, time_strings=None, depth=2):
  22. """
  23. Take two datetime objects and return the time between d and now as a nicely
  24. formatted string, e.g. "10 minutes". If d occurs after now, return
  25. "0 minutes".
  26. Units used are years, months, weeks, days, hours, and minutes.
  27. Seconds and microseconds are ignored.
  28. The algorithm takes into account the varying duration of years and months.
  29. There is exactly "1 year, 1 month" between 2013/02/10 and 2014/03/10,
  30. but also between 2007/08/10 and 2008/09/10 despite the delta being 393 days
  31. in the former case and 397 in the latter.
  32. Up to `depth` adjacent units will be displayed. For example,
  33. "2 weeks, 3 days" and "1 year, 3 months" are possible outputs, but
  34. "2 weeks, 3 hours" and "1 year, 5 days" are not.
  35. `time_strings` is an optional dict of strings to replace the default
  36. TIME_STRINGS dict.
  37. `depth` is an optional integer to control the number of adjacent time
  38. units returned.
  39. Originally adapted from
  40. https://web.archive.org/web/20060617175230/http://blog.natbat.co.uk/archive/2003/Jun/14/time_since
  41. Modified to improve results for years and months.
  42. """
  43. if time_strings is None:
  44. time_strings = TIME_STRINGS
  45. if depth <= 0:
  46. raise ValueError("depth must be greater than 0.")
  47. # Convert datetime.date to datetime.datetime for comparison.
  48. if not isinstance(d, datetime.datetime):
  49. d = datetime.datetime(d.year, d.month, d.day)
  50. if now and not isinstance(now, datetime.datetime):
  51. now = datetime.datetime(now.year, now.month, now.day)
  52. # Compared datetimes must be in the same time zone.
  53. if not now:
  54. now = datetime.datetime.now(d.tzinfo if is_aware(d) else None)
  55. elif is_aware(now) and is_aware(d):
  56. now = now.astimezone(d.tzinfo)
  57. if reversed:
  58. d, now = now, d
  59. delta = now - d
  60. # Ignore microseconds.
  61. since = delta.days * 24 * 60 * 60 + delta.seconds
  62. if since <= 0:
  63. # d is in the future compared to now, stop processing.
  64. return avoid_wrapping(time_strings["minute"] % {"num": 0})
  65. # Get years and months.
  66. total_months = (now.year - d.year) * 12 + (now.month - d.month)
  67. if d.day > now.day or (d.day == now.day and d.time() > now.time()):
  68. total_months -= 1
  69. years, months = divmod(total_months, 12)
  70. # Calculate the remaining time.
  71. # Create a "pivot" datetime shifted from d by years and months, then use
  72. # that to determine the other parts.
  73. if years or months:
  74. pivot_year = d.year + years
  75. pivot_month = d.month + months
  76. if pivot_month > 12:
  77. pivot_month -= 12
  78. pivot_year += 1
  79. pivot = datetime.datetime(
  80. pivot_year,
  81. pivot_month,
  82. min(MONTHS_DAYS[pivot_month - 1], d.day),
  83. d.hour,
  84. d.minute,
  85. d.second,
  86. tzinfo=d.tzinfo,
  87. )
  88. else:
  89. pivot = d
  90. remaining_time = (now - pivot).total_seconds()
  91. partials = [years, months]
  92. for chunk in TIME_CHUNKS:
  93. count = int(remaining_time // chunk)
  94. partials.append(count)
  95. remaining_time -= chunk * count
  96. # Find the first non-zero part (if any) and then build the result, until
  97. # depth.
  98. i = 0
  99. for i, value in enumerate(partials):
  100. if value != 0:
  101. break
  102. else:
  103. return avoid_wrapping(time_strings["minute"] % {"num": 0})
  104. result = []
  105. current_depth = 0
  106. while i < len(TIME_STRINGS_KEYS) and current_depth < depth:
  107. value = partials[i]
  108. if value == 0:
  109. break
  110. name = TIME_STRINGS_KEYS[i]
  111. result.append(avoid_wrapping(time_strings[name] % {"num": value}))
  112. current_depth += 1
  113. i += 1
  114. return gettext(", ").join(result)
  115. def timeuntil(d, now=None, time_strings=None, depth=2):
  116. """
  117. Like timesince, but return a string measuring the time until the given time.
  118. """
  119. return timesince(d, now, reversed=True, time_strings=time_strings, depth=depth)