Commit 399e5209 by Ned Batchelder

Merge pull request #2608 from edx/ned/date-l10n

strftime_localized
parents c246f7da de5f3028
...@@ -16,13 +16,13 @@ from xmodule.contentstore.django import contentstore ...@@ -16,13 +16,13 @@ from xmodule.contentstore.django import contentstore
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xmodule.util.date_utils import get_default_time_display
from xmodule.modulestore import InvalidLocationError from xmodule.modulestore import InvalidLocationError
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from xmodule.modulestore.django import loc_mapper from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.locator import BlockUsageLocator from xmodule.modulestore.locator import BlockUsageLocator
from util.date_utils import get_default_time_display
from util.json_request import JsonResponse from util.json_request import JsonResponse
from django.http import HttpResponseNotFound from django.http import HttpResponseNotFound
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
......
...@@ -10,8 +10,8 @@ from django.conf import settings ...@@ -10,8 +10,8 @@ from django.conf import settings
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from util.date_utils import get_default_time_display
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.util.date_utils import get_default_time_display
from xmodule.modulestore.django import loc_mapper from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.locator import BlockUsageLocator from xmodule.modulestore.locator import BlockUsageLocator
......
<%inherit file="base.html" /> <%inherit file="base.html" />
<%! <%!
import logging import logging
from xmodule.util.date_utils import get_default_time_display, almost_same_datetime from util.date_utils import get_default_time_display, almost_same_datetime
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
%> %>
......
<%inherit file="base.html" /> <%inherit file="base.html" />
<%! <%!
import logging import logging
from xmodule.util import date_utils from util.date_utils import get_default_time_display
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from xmodule.modulestore.django import loc_mapper from xmodule.modulestore.django import loc_mapper
...@@ -188,7 +188,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v ...@@ -188,7 +188,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
<a href="#" class="edit-release-date action" data-date="" data-time="" data-locator="${section_locator}"><i class="icon-time"></i> <span class="sr">${_("Schedule")}</span></a> <a href="#" class="edit-release-date action" data-date="" data-time="" data-locator="${section_locator}"><i class="icon-time"></i> <span class="sr">${_("Schedule")}</span></a>
%else: %else:
<span class="published-status"><strong>${_("Release date:")}</strong> <span class="published-status"><strong>${_("Release date:")}</strong>
${date_utils.get_default_time_display(section.start)}</span> ${get_default_time_display(section.start)}</span>
<a href="#" class="edit-release-date action" data-date="${start_date_str}" data-time="${start_time_str}" data-locator="${section_locator}"><i class="icon-time"></i> <span class="sr">${_("Edit section release date")}</span></a> <a href="#" class="edit-release-date action" data-date="${start_date_str}" data-time="${start_time_str}" data-locator="${section_locator}"><i class="icon-time"></i> <span class="sr">${_("Edit section release date")}</span></a>
%endif %endif
</div> </div>
......
"""Tests for xmodule.util.date_utils""" # -*- coding: utf-8 -*-
"""
Tests for util.date_utils
"""
from nose.tools import assert_equals, assert_false # pylint: disable=E0611
from xmodule.util.date_utils import get_default_time_display, get_time_display, almost_same_datetime
from datetime import datetime, timedelta, tzinfo from datetime import datetime, timedelta, tzinfo
from pytz import UTC, timezone 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
from util.date_utils import (
get_default_time_display, get_time_display, almost_same_datetime,
strftime_localized,
)
def test_get_default_time_display(): def test_get_default_time_display():
...@@ -104,3 +116,106 @@ def test_almost_same_datetime(): ...@@ -104,3 +116,106 @@ def test_almost_same_datetime():
timedelta(minutes=10) 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)
...@@ -828,13 +828,17 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): ...@@ -828,13 +828,17 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
Returns the desired text corresponding the course's start date. Prefers .advertised_start, Returns the desired text corresponding the course's start date. Prefers .advertised_start,
then falls back to .start then falls back to .start
""" """
i18n = self.runtime.service(self, "i18n")
_ = i18n.ugettext
strftime = i18n.strftime
def try_parse_iso_8601(text): def try_parse_iso_8601(text):
try: try:
result = Date().from_json(text) result = Date().from_json(text)
if result is None: if result is None:
result = text.title() result = text.title()
else: else:
result = result.strftime("%b %d, %Y") result = strftime(result, "SHORT_DATE")
except ValueError: except ValueError:
result = text.title() result = text.title()
...@@ -843,12 +847,12 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): ...@@ -843,12 +847,12 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
if isinstance(self.advertised_start, basestring): if isinstance(self.advertised_start, basestring):
return try_parse_iso_8601(self.advertised_start) return try_parse_iso_8601(self.advertised_start)
elif self.start_date_is_still_default: elif self.start_date_is_still_default:
_ = self.runtime.service(self, "i18n").ugettext
# Translators: TBD stands for 'To Be Determined' and is used when a course # Translators: TBD stands for 'To Be Determined' and is used when a course
# does not yet have an announced start date. # does not yet have an announced start date.
return _('TBD') return _('TBD')
else: else:
return (self.advertised_start or self.start).strftime("%b %d, %Y") when = self.advertised_start or self.start
return strftime(when, "SHORT_DATE")
@property @property
def start_date_is_still_default(self): def start_date_is_still_default(self):
...@@ -865,7 +869,11 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): ...@@ -865,7 +869,11 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
If the course does not have an end date set (course.end is None), an empty string will be returned. If the course does not have an end date set (course.end is None), an empty string will be returned.
""" """
return '' if self.end is None else self.end.strftime("%b %d, %Y") if self.end is None:
return ''
else:
strftime = self.runtime.service(self, "i18n").strftime
return strftime(self.end, "SHORT_DATE")
@property @property
def forum_posts_allowed(self): def forum_posts_allowed(self):
......
"""
Convenience methods for working with datetime objects
"""
from datetime import timedelta
from pytz import timezone, UTC, UnknownTimeZoneError
def get_default_time_display(dtime):
"""
Converts a datetime to a string representation. This is the default
representation used in Studio and LMS.
It is of the form "Apr 09, 2013 at 16:00 UTC".
If None is passed in for dt, an empty string will be returned.
"""
if dtime is None:
return u""
if dtime.tzinfo is not None:
try:
timezone = u" " + dtime.tzinfo.tzname(dtime)
except NotImplementedError:
timezone = dtime.strftime('%z')
else:
timezone = u" UTC"
return unicode(dtime.strftime(u"%b %d, %Y at %H:%M{tz}")).format(
tz=timezone).strip()
def get_time_display(dtime, format_string=None, coerce_tz=None):
"""
Converts a datetime to a string representation.
If None is passed in for dt, an empty string will be returned.
If the format_string is None, or if format_string is improperly
formatted, this method will return the value from `get_default_time_display`.
Coerces aware datetime to tz=coerce_tz if set. coerce_tz should be a pytz timezone string
like "US/Pacific", or None
format_string should be a unicode string that is a valid argument for datetime's strftime method.
"""
if dtime is not None and dtime.tzinfo is not None and coerce_tz:
try:
to_tz = timezone(coerce_tz)
except UnknownTimeZoneError:
to_tz = UTC
dtime = to_tz.normalize(dtime.astimezone(to_tz))
if dtime is None or format_string is None:
return get_default_time_display(dtime)
try:
return unicode(dtime.strftime(format_string))
except ValueError:
return get_default_time_display(dtime)
def almost_same_datetime(dt1, dt2, allowed_delta=timedelta(minutes=1)):
"""
Returns true if these are w/in a minute of each other. (in case secs saved to db
or timezone aren't same)
:param dt1:
:param dt2:
"""
return abs(dt1 - dt2) < allowed_delta
...@@ -7,8 +7,8 @@ msgid "" ...@@ -7,8 +7,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: 0.1a\n" "Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2014-02-18 13:34-0500\n" "POT-Creation-Date: 2014-02-18 16:36-0500\n"
"PO-Revision-Date: 2014-02-18 18:34:51.762964\n" "PO-Revision-Date: 2014-02-18 21:36:33.541469\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: openedx-translation <openedx-translation@googlegroups.com>\n" "Language-Team: openedx-translation <openedx-translation@googlegroups.com>\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
......
...@@ -23,7 +23,9 @@ generates output conf/locale/$DUMMY_LOCALE/LC_MESSAGES, ...@@ -23,7 +23,9 @@ generates output conf/locale/$DUMMY_LOCALE/LC_MESSAGES,
where $DUMMY_LOCALE is the dummy_locale value set in the i18n config where $DUMMY_LOCALE is the dummy_locale value set in the i18n config
""" """
import re
import sys import sys
import polib import polib
from path import path from path import path
...@@ -173,6 +175,10 @@ def make_dummy(filename, locale, converter): ...@@ -173,6 +175,10 @@ def make_dummy(filename, locale, converter):
raise IOError('File does not exist: %r' % filename) raise IOError('File does not exist: %r' % filename)
pofile = polib.pofile(filename) pofile = polib.pofile(filename)
for msg in pofile: for msg in pofile:
# Some strings are actually formatting strings, don't dummy-ify them,
# or dates will look like "DÀTÉ_TÌMÉ_FÖRMÀT Ⱡ'σ# EST"
if re.match(r"^[A-Z_]+_FORMAT$", msg.msgid):
continue
converter.convert_msg(msg) converter.convert_msg(msg)
# Apply declaration for English pluralization rules so that ngettext will # Apply declaration for English pluralization rules so that ngettext will
......
...@@ -25,8 +25,6 @@ from lms.lib.xblock.runtime import LmsModuleSystem, unquote_slashes ...@@ -25,8 +25,6 @@ from lms.lib.xblock.runtime import LmsModuleSystem, unquote_slashes
from edxmako.shortcuts import render_to_string from edxmako.shortcuts import render_to_string
from psychometrics.psychoanalyze import make_psychometrics_data_update_handler from psychometrics.psychoanalyze import make_psychometrics_data_update_handler
from student.models import anonymous_id_for_user, user_by_anonymous_id from student.models import anonymous_id_for_user, user_by_anonymous_id
from util.json_request import JsonResponse
from util.sandboxing import can_execute_unsafe_code
from xblock.core import XBlock from xblock.core import XBlock
from xblock.fields import Scope from xblock.fields import Scope
from xblock.runtime import KvsFieldData, KeyValueStore from xblock.runtime import KvsFieldData, KeyValueStore
...@@ -42,6 +40,10 @@ from xmodule_modifiers import replace_course_urls, replace_jump_to_id_urls, repl ...@@ -42,6 +40,10 @@ from xmodule_modifiers import replace_course_urls, replace_jump_to_id_urls, repl
from xmodule.lti_module import LTIModule from xmodule.lti_module import LTIModule
from xmodule.x_module import XModuleDescriptor from xmodule.x_module import XModuleDescriptor
from util.date_utils import strftime_localized
from util.json_request import JsonResponse
from util.sandboxing import can_execute_unsafe_code
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -428,10 +430,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours ...@@ -428,10 +430,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
wrappers=block_wrappers, wrappers=block_wrappers,
get_real_user=user_by_anonymous_id, get_real_user=user_by_anonymous_id,
services={ services={
# django.utils.translation implements the gettext.Translations 'i18n': ModuleI18nService(),
# interface (it has ugettext, ungettext, etc), so we can use it
# directly as the runtime i18n service.
'i18n': django.utils.translation,
}, },
get_user_role=lambda: get_user_role(user, course_id), get_user_role=lambda: get_user_role(user, course_id),
) )
...@@ -652,3 +651,20 @@ def _check_files_limits(files): ...@@ -652,3 +651,20 @@ def _check_files_limits(files):
return msg return msg
return None return None
class ModuleI18nService(object):
"""
Implement the XBlock runtime "i18n" service.
Mostly a pass-through to Django's translation module.
django.utils.translation implements the gettext.Translations interface (it
has ugettext, ungettext, etc), so we can use it directly as the runtime
i18n service.
"""
def __getattr__(self, name):
return getattr(django.utils.translation, name)
def strftime(self, *args, **kwargs):
return strftime_localized(*args, **kwargs)
<%! <%!
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from xmodule.util.date_utils import get_time_display from util.date_utils import get_time_display
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.conf import settings from django.conf import settings
%> %>
...@@ -31,7 +31,7 @@ ...@@ -31,7 +31,7 @@
due_date = '' due_date = ''
else: else:
formatted_string = get_time_display(section['due'], due_date_display_format, coerce_tz=settings.TIME_ZONE) formatted_string = get_time_display(section['due'], due_date_display_format, coerce_tz=settings.TIME_ZONE)
due_date = '' if len(formatted_string)==0 else _('due {date}'.format(date=formatted_string)) due_date = '' if len(formatted_string)==0 else _('due {date}').format(date=formatted_string)
%> %>
<p class="subtitle">${section['format']} ${due_date}</p> <p class="subtitle">${section['format']} ${due_date}</p>
</a> </a>
......
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
%> %>
<%! <%!
from xmodule.util.date_utils import get_time_display from util.date_utils import get_time_display
from django.conf import settings from django.conf import settings
%> %>
......
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<%! <%!
from xmodule.util.date_utils import get_default_time_display from util.date_utils import get_default_time_display
%> %>
<div class="folditbasic"> <div class="folditbasic">
<p><strong>${_("Due:")}</strong> ${get_default_time_display(due)} <p><strong>${_("Due:")}</strong> ${get_default_time_display(due)}
......
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<%! from django.core.urlresolvers import reverse %> <%! from django.core.urlresolvers import reverse %>
<%! from time import strftime %>
<%inherit file="main.html" /> <%inherit file="main.html" />
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
......
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
-e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk -e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk
# Our libraries: # Our libraries:
-e git+https://github.com/edx/XBlock.git@6d431d786587bd8f3a19a893364914d6e2d6c28f#egg=XBlock -e git+https://github.com/edx/XBlock.git@893cd83dfb24405ce81b07f49c1c2e3053cdc865#egg=XBlock
-e git+https://github.com/edx/codejail.git@e3d98f9455#egg=codejail -e git+https://github.com/edx/codejail.git@e3d98f9455#egg=codejail
-e git+https://github.com/edx/diff-cover.git@v0.2.9#egg=diff_cover -e git+https://github.com/edx/diff-cover.git@v0.2.9#egg=diff_cover
-e git+https://github.com/edx/js-test-tool.git@v0.1.5#egg=js_test_tool -e git+https://github.com/edx/js-test-tool.git@v0.1.5#egg=js_test_tool
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment