diff --git a/common/djangoapps/util/date_utils.py b/common/djangoapps/util/date_utils.py index a862bc7d31..e147735bcd 100644 --- a/common/djangoapps/util/date_utils.py +++ b/common/djangoapps/util/date_utils.py @@ -1,8 +1,13 @@ """ Convenience methods for working with datetime objects """ + from datetime import timedelta +import re + from pytz import timezone, UTC, UnknownTimeZoneError +from django.utils.translation import pgettext, ugettext + def get_default_time_display(dtime): """ @@ -62,3 +67,299 @@ def almost_same_datetime(dt1, dt2, allowed_delta=timedelta(minutes=1)): :param dt2: """ return abs(dt1 - dt2) < allowed_delta + + +DEFAULT_SHORT_DATE_FORMAT = "%b %d, %Y" +DEFAULT_LONG_DATE_FORMAT = "%A, %B %d, %Y" +DEFAULT_TIME_FORMAT = "%I:%M:%S %p" +DEFAULT_DATE_TIME_FORMAT = "%b %d, %Y at %H:%M" + + +def strftime_localized(dtime, format): # pylint: disable=redefined-builtin + """ + Format a datetime, just like the built-in strftime, but with localized words. + + The format string can also be one of: + + * "SHORT_DATE" for a date in brief form, localized. + + * "LONG_DATE" for a longer form of date, localized. + + * "DATE_TIME" for a date and time together, localized. + + * "TIME" for just the time, localized. + + The localization is based on the current language Django is using for the + request. The exact format strings used for each of the names above is + determined by the translator for each language. + + Args: + dtime (datetime): The datetime value to format. + + format (str): The format string to use, as specified by + :ref:`datetime.strftime`. + + Returns: + A unicode string with the formatted datetime. + + """ + + if format == "SHORT_DATE": + format = "%x" + elif format == "LONG_DATE": + # Translators: the translation for "LONG_DATE_FORMAT" must be a format + # string for formatting dates in a long form. For example, the + # American English form is "%A, %B %d %Y". + # See http://strftime.org for details. + format = ugettext("LONG_DATE_FORMAT") + if format == "LONG_DATE_FORMAT": + format = DEFAULT_LONG_DATE_FORMAT + elif format == "DATE_TIME": + # Translators: the translation for "DATE_TIME_FORMAT" must be a format + # string for formatting dates with times. For example, the American + # English form is "%b %d, %Y at %H:%M". + # See http://strftime.org for details. + format = ugettext("DATE_TIME_FORMAT") + if format == "DATE_TIME_FORMAT": + format = DEFAULT_DATE_TIME_FORMAT + elif format == "TIME": + format = "%X" + + def process_percent_code(match): + """ + Convert one percent-prefixed code in the format string. + + Called by re.sub just below. + + """ + code = match.group() + if code == "%": + # This only happens if the string ends with a %, which is not legal. + raise ValueError("strftime format ends with raw %") + + if code == "%a": + part = pgettext('abbreviated weekday name', WEEKDAYS_ABBREVIATED[dtime.weekday()]) + elif code == "%A": + part = pgettext('weekday name', WEEKDAYS[dtime.weekday()]) + elif code == "%b": + part = pgettext('abbreviated month name', MONTHS_ABBREVIATED[dtime.month]) + elif code == "%B": + part = pgettext('month name', MONTHS[dtime.month]) + elif code == "%p": + part = pgettext('am/pm indicator', AM_PM[dtime.hour // 12]) + elif code == "%x": + # Get the localized short date format, and recurse. + # Translators: the translation for "SHORT_DATE_FORMAT" must be a + # format string for formatting dates in a brief form. For example, + # the American English form is "%b %d %Y". + # See http://strftime.org for details. + actual_format = ugettext("SHORT_DATE_FORMAT") + if actual_format == "SHORT_DATE_FORMAT": + actual_format = DEFAULT_SHORT_DATE_FORMAT + if "%x" in actual_format: + # Prevent infinite accidental recursion. + actual_format = DEFAULT_SHORT_DATE_FORMAT + part = strftime_localized(dtime, actual_format) + elif code == "%X": + # Get the localized time format, and recurse. + # Translators: the translation for "TIME_FORMAT" must be a format + # string for formatting times. For example, the American English + # form is "%H:%M:%S". See http://strftime.org for details. + actual_format = ugettext("TIME_FORMAT") + if actual_format == "TIME_FORMAT": + actual_format = DEFAULT_TIME_FORMAT + if "%X" in actual_format: + # Prevent infinite accidental recursion. + actual_format = DEFAULT_TIME_FORMAT + part = strftime_localized(dtime, actual_format) + else: + # All the other format codes: just let built-in strftime take + # care of them. + part = dtime.strftime(code) + + return part + + formatted_date = re.sub(r"%.|%", process_percent_code, format) + return formatted_date + + +# In order to extract the strings below, we have to mark them with pgettext. +# But we'll do the actual pgettext later, so use a no-op for now, and save the +# real pgettext so we can assign it back to the global name later. +real_pgettext = pgettext +pgettext = lambda context, text: text # pylint: disable=invalid-name + +AM_PM = { + # Translators: This is an AM/PM indicator for displaying times. It is + # used for the %p directive in date-time formats. See http://strftime.org + # for details. + 0: pgettext('am/pm indicator', 'AM'), + # Translators: This is an AM/PM indicator for displaying times. It is + # used for the %p directive in date-time formats. See http://strftime.org + # for details. + 1: pgettext('am/pm indicator', 'PM'), +} + +WEEKDAYS = { + # Translators: this is a weekday name that will be used when displaying + # dates, as in "Monday Februrary 10, 2014". It is used for the %A + # directive in date-time formats. See http://strftime.org for details. + 0: pgettext('weekday name', 'Monday'), + # Translators: this is a weekday name that will be used when displaying + # dates, as in "Tuesday Februrary 11, 2014". It is used for the %A + # directive in date-time formats. See http://strftime.org for details. + 1: pgettext('weekday name', 'Tuesday'), + # Translators: this is a weekday name that will be used when displaying + # dates, as in "Wednesday Februrary 12, 2014". It is used for the %A + # directive in date-time formats. See http://strftime.org for details. + 2: pgettext('weekday name', 'Wednesday'), + # Translators: this is a weekday name that will be used when displaying + # dates, as in "Thursday Februrary 13, 2014". It is used for the %A + # directive in date-time formats. See http://strftime.org for details. + 3: pgettext('weekday name', 'Thursday'), + # Translators: this is a weekday name that will be used when displaying + # dates, as in "Friday Februrary 14, 2014". It is used for the %A + # directive in date-time formats. See http://strftime.org for details. + 4: pgettext('weekday name', 'Friday'), + # Translators: this is a weekday name that will be used when displaying + # dates, as in "Saturday Februrary 15, 2014". It is used for the %A + # directive in date-time formats. See http://strftime.org for details. + 5: pgettext('weekday name', 'Saturday'), + # Translators: this is a weekday name that will be used when displaying + # dates, as in "Sunday Februrary 16, 2014". It is used for the %A + # directive in date-time formats. See http://strftime.org for details. + 6: pgettext('weekday name', 'Sunday'), +} + +WEEKDAYS_ABBREVIATED = { + # Translators: this is an abbreviated weekday name that will be used when + # displaying dates, as in "Mon Feb 10, 2014". It is used for the %a + # directive in date-time formats. See http://strftime.org for details. + 0: pgettext('abbreviated weekday name', 'Mon'), + # Translators: this is an abbreviated weekday name that will be used when + # displaying dates, as in "Tue Feb 11, 2014". It is used for the %a + # directive in date-time formats. See http://strftime.org for details. + 1: pgettext('abbreviated weekday name', 'Tue'), + # Translators: this is an abbreviated weekday name that will be used when + # displaying dates, as in "Wed Feb 12, 2014". It is used for the %a + # directive in date-time formats. See http://strftime.org for details. + 2: pgettext('abbreviated weekday name', 'Wed'), + # Translators: this is an abbreviated weekday name that will be used when + # displaying dates, as in "Thu Feb 13, 2014". It is used for the %a + # directive in date-time formats. See http://strftime.org for details. + 3: pgettext('abbreviated weekday name', 'Thu'), + # Translators: this is an abbreviated weekday name that will be used when + # displaying dates, as in "Fri Feb 14, 2014". It is used for the %a + # directive in date-time formats. See http://strftime.org for details. + 4: pgettext('abbreviated weekday name', 'Fri'), + # Translators: this is an abbreviated weekday name that will be used when + # displaying dates, as in "Sat Feb 15, 2014". It is used for the %a + # directive in date-time formats. See http://strftime.org for details. + 5: pgettext('abbreviated weekday name', 'Sat'), + # Translators: this is an abbreviated weekday name that will be used when + # displaying dates, as in "Sun Feb 16, 2014". It is used for the %a + # directive in date-time formats. See http://strftime.org for details. + 6: pgettext('abbreviated weekday name', 'Sun'), +} + +MONTHS_ABBREVIATED = { + # Translators: this is an abbreviated month name that will be used when + # displaying dates, as in "Jan 10, 2014". It is used for the %b + # directive in date-time formats. See http://strftime.org for details. + 1: pgettext('abbreviated month name', 'Jan'), + # Translators: this is an abbreviated month name that will be used when + # displaying dates, as in "Feb 10, 2014". It is used for the %b + # directive in date-time formats. See http://strftime.org for details. + 2: pgettext('abbreviated month name', 'Feb'), + # Translators: this is an abbreviated month name that will be used when + # displaying dates, as in "Mar 10, 2014". It is used for the %b + # directive in date-time formats. See http://strftime.org for details. + 3: pgettext('abbreviated month name', 'Mar'), + # Translators: this is an abbreviated month name that will be used when + # displaying dates, as in "Apr 10, 2014". It is used for the %b + # directive in date-time formats. See http://strftime.org for details. + 4: pgettext('abbreviated month name', 'Apr'), + # Translators: this is an abbreviated month name that will be used when + # displaying dates, as in "May 10, 2014". It is used for the %b + # directive in date-time formats. See http://strftime.org for details. + 5: pgettext('abbreviated month name', 'May'), + # Translators: this is an abbreviated month name that will be used when + # displaying dates, as in "Jun 10, 2014". It is used for the %b + # directive in date-time formats. See http://strftime.org for details. + 6: pgettext('abbreviated month name', 'Jun'), + # Translators: this is an abbreviated month name that will be used when + # displaying dates, as in "Jul 10, 2014". It is used for the %b + # directive in date-time formats. See http://strftime.org for details. + 7: pgettext('abbreviated month name', 'Jul'), + # Translators: this is an abbreviated month name that will be used when + # displaying dates, as in "Aug 10, 2014". It is used for the %b + # directive in date-time formats. See http://strftime.org for details. + 8: pgettext('abbreviated month name', 'Aug'), + # Translators: this is an abbreviated month name that will be used when + # displaying dates, as in "Sep 10, 2014". It is used for the %b + # directive in date-time formats. See http://strftime.org for details. + 9: pgettext('abbreviated month name', 'Sep'), + # Translators: this is an abbreviated month name that will be used when + # displaying dates, as in "Oct 10, 2014". It is used for the %b + # directive in date-time formats. See http://strftime.org for details. + 10: pgettext('abbreviated month name', 'Oct'), + # Translators: this is an abbreviated month name that will be used when + # displaying dates, as in "Nov 10, 2014". It is used for the %b + # directive in date-time formats. See http://strftime.org for details. + 11: pgettext('abbreviated month name', 'Nov'), + # Translators: this is an abbreviated month name that will be used when + # displaying dates, as in "Dec 10, 2014". It is used for the %b + # directive in date-time formats. See http://strftime.org for details. + 12: pgettext('abbreviated month name', 'Dec'), +} + +MONTHS = { + # Translators: this is a month name that will be used when displaying + # dates, as in "January 10, 2014". It is used for the %B directive in + # date-time formats. See http://strftime.org for details. + 1: pgettext('month name', 'January'), + # Translators: this is a month name that will be used when displaying + # dates, as in "February 10, 2014". It is used for the %B directive in + # date-time formats. See http://strftime.org for details. + 2: pgettext('month name', 'February'), + # Translators: this is a month name that will be used when displaying + # dates, as in "March 10, 2014". It is used for the %B directive in + # date-time formats. See http://strftime.org for details. + 3: pgettext('month name', 'March'), + # Translators: this is a month name that will be used when displaying + # dates, as in "April 10, 2014". It is used for the %B directive in + # date-time formats. See http://strftime.org for details. + 4: pgettext('month name', 'April'), + # Translators: this is a month name that will be used when displaying + # dates, as in "May 10, 2014". It is used for the %B directive in + # date-time formats. See http://strftime.org for details. + 5: pgettext('month name', 'May'), + # Translators: this is a month name that will be used when displaying + # dates, as in "June 10, 2014". It is used for the %B directive in + # date-time formats. See http://strftime.org for details. + 6: pgettext('month name', 'June'), + # Translators: this is a month name that will be used when displaying + # dates, as in "July 10, 2014". It is used for the %B directive in + # date-time formats. See http://strftime.org for details. + 7: pgettext('month name', 'July'), + # Translators: this is a month name that will be used when displaying + # dates, as in "August 10, 2014". It is used for the %B directive in + # date-time formats. See http://strftime.org for details. + 8: pgettext('month name', 'August'), + # Translators: this is a month name that will be used when displaying + # dates, as in "September 10, 2014". It is used for the %B directive in + # date-time formats. See http://strftime.org for details. + 9: pgettext('month name', 'September'), + # Translators: this is a month name that will be used when displaying + # dates, as in "October 10, 2014". It is used for the %B directive in + # date-time formats. See http://strftime.org for details. + 10: pgettext('month name', 'October'), + # Translators: this is a month name that will be used when displaying + # dates, as in "November 10, 2014". It is used for the %B directive in + # date-time formats. See http://strftime.org for details. + 11: pgettext('month name', 'November'), + # Translators: this is a month name that will be used when displaying + # dates, as in "December 10, 2014". It is used for the %B directive in + # date-time formats. See http://strftime.org for details. + 12: pgettext('month name', 'December'), +} diff --git a/common/djangoapps/util/tests/test_date_utils.py b/common/djangoapps/util/tests/test_date_utils.py index 22776d9569..9dddd46456 100644 --- a/common/djangoapps/util/tests/test_date_utils.py +++ b/common/djangoapps/util/tests/test_date_utils.py @@ -1,11 +1,21 @@ -"""Tests for util.date_utils""" +# -*- coding: utf-8 -*- +""" +Tests for util.date_utils +""" from datetime import datetime, timedelta, tzinfo +from functools import partial +import unittest +import ddt +from mock import patch from nose.tools import assert_equals, assert_false # pylint: disable=E0611 -from pytz import UTC, timezone +from pytz import UTC -from util.date_utils import get_default_time_display, get_time_display, almost_same_datetime +from util.date_utils import ( + get_default_time_display, get_time_display, almost_same_datetime, + strftime_localized, +) def test_get_default_time_display(): @@ -106,3 +116,106 @@ def test_almost_same_datetime(): timedelta(minutes=10) ) ) + + +def fake_ugettext(text, translations): + """ + A fake implementation of ugettext, for testing. + """ + return translations.get(text, text) + + +def fake_pgettext(context, text, translations): + """ + A fake implementation of pgettext, for testing. + """ + return translations.get((context, text), text) + + +@ddt.ddt +class StrftimeLocalizedTest(unittest.TestCase): + """ + Tests for strftime_localized. + """ + @ddt.data( + ("%Y", "2013"), + ("%m/%d/%y", "02/14/13"), + ("hello", "hello"), + (u'%Y년 %m월 %d일', u"2013년 02월 14일"), + ("%a, %b %d, %Y", "Thu, Feb 14, 2013"), + ("%I:%M:%S %p", "04:41:17 PM"), + ) + def test_usual_strftime_behavior(self, (fmt, expected)): + dtime = datetime(2013, 02, 14, 16, 41, 17) + self.assertEqual(expected, strftime_localized(dtime, fmt)) + # strftime doesn't like Unicode, so do the work in UTF8. + self.assertEqual(expected, dtime.strftime(fmt.encode('utf8')).decode('utf8')) + + @ddt.data( + ("SHORT_DATE", "Feb 14, 2013"), + ("LONG_DATE", "Thursday, February 14, 2013"), + ("TIME", "04:41:17 PM"), + ("%x %X!", "Feb 14, 2013 04:41:17 PM!"), + ) + def test_shortcuts(self, (fmt, expected)): + dtime = datetime(2013, 02, 14, 16, 41, 17) + self.assertEqual(expected, strftime_localized(dtime, fmt)) + + @patch('util.date_utils.pgettext', partial(fake_pgettext, translations={ + ("abbreviated month name", "Feb"): "XXfebXX", + ("month name", "February"): "XXfebruaryXX", + ("abbreviated weekday name", "Thu"): "XXthuXX", + ("weekday name", "Thursday"): "XXthursdayXX", + ("am/pm indicator", "PM"): "XXpmXX", + })) + @ddt.data( + ("SHORT_DATE", "XXfebXX 14, 2013"), + ("LONG_DATE", "XXthursdayXX, XXfebruaryXX 14, 2013"), + ("DATE_TIME", "XXfebXX 14, 2013 at 16:41"), + ("TIME", "04:41:17 XXpmXX"), + ("%x %X!", "XXfebXX 14, 2013 04:41:17 XXpmXX!"), + ) + def test_translated_words(self, (fmt, expected)): + dtime = datetime(2013, 02, 14, 16, 41, 17) + self.assertEqual(expected, strftime_localized(dtime, fmt)) + + @patch('util.date_utils.ugettext', partial(fake_ugettext, translations={ + "SHORT_DATE_FORMAT": "date(%Y.%m.%d)", + "LONG_DATE_FORMAT": "date(%A.%Y.%B.%d)", + "DATE_TIME_FORMAT": "date(%Y.%m.%d@%H.%M)", + "TIME_FORMAT": "%Hh.%Mm.%Ss", + })) + @ddt.data( + ("SHORT_DATE", "date(2013.02.14)"), + ("Look: %x", "Look: date(2013.02.14)"), + ("LONG_DATE", "date(Thursday.2013.February.14)"), + ("DATE_TIME", "date(2013.02.14@16.41)"), + ("TIME", "16h.41m.17s"), + ("The time is: %X", "The time is: 16h.41m.17s"), + ("%x %X", "date(2013.02.14) 16h.41m.17s"), + ) + def test_translated_formats(self, (fmt, expected)): + dtime = datetime(2013, 02, 14, 16, 41, 17) + self.assertEqual(expected, strftime_localized(dtime, fmt)) + + @patch('util.date_utils.ugettext', partial(fake_ugettext, translations={ + "SHORT_DATE_FORMAT": "oops date(%Y.%x.%d)", + "TIME_FORMAT": "oops %Hh.%Xm.%Ss", + })) + @ddt.data( + ("SHORT_DATE", "Feb 14, 2013"), + ("TIME", "04:41:17 PM"), + ) + def test_recursion_protection(self, (fmt, expected)): + dtime = datetime(2013, 02, 14, 16, 41, 17) + self.assertEqual(expected, strftime_localized(dtime, fmt)) + + @ddt.data( + "%", + "Hello%" + "%Y/%m/%d%", + ) + def test_invalid_format_strings(self, fmt): + dtime = datetime(2013, 02, 14, 16, 41, 17) + with self.assertRaises(ValueError): + strftime_localized(dtime, fmt)