Commit 82c612f1 by Kevin Kim Committed by GitHub

Merge pull request #12888 from edx/kkim/convert_date_tz

Convert Dates for Time Zone
parents 4bc81ac0 0bf8fc4b
...@@ -5,7 +5,6 @@ Convenience methods for working with datetime objects ...@@ -5,7 +5,6 @@ Convenience methods for working with datetime objects
from datetime import datetime, timedelta from datetime import datetime, timedelta
import re import re
from django.utils.timezone import now
from django.utils.translation import pgettext, ugettext from django.utils.translation import pgettext, ugettext
from pytz import timezone, utc, UnknownTimeZoneError from pytz import timezone, utc, UnknownTimeZoneError
...@@ -63,15 +62,6 @@ def get_time_display(dtime, format_string=None, coerce_tz=None): ...@@ -63,15 +62,6 @@ def get_time_display(dtime, format_string=None, coerce_tz=None):
return get_default_time_display(dtime) return get_default_time_display(dtime)
def get_formatted_time_zone(time_zone):
"""
Returns a formatted time zone (e.g. 'Asia/Tokyo (JST +0900)') for user account settings time zone drop down
"""
abbr = get_time_display(now(), '%Z', time_zone)
offset = get_time_display(now(), '%z', time_zone)
return "{name} ({abbr}, UTC{offset})".format(name=time_zone, abbr=abbr, offset=offset).replace("_", " ")
def almost_same_datetime(dt1, dt2, allowed_delta=timedelta(minutes=1)): 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 Returns true if these are w/in a minute of each other. (in case secs saved to db
......
...@@ -9,8 +9,7 @@ import unittest ...@@ -9,8 +9,7 @@ import unittest
import ddt import ddt
from mock import patch from mock import patch
from nose.tools import assert_equals, assert_false # pylint: disable=no-name-in-module from nose.tools import assert_equals, assert_false # pylint: disable=no-name-in-module
from pytz import UTC from pytz import utc
from util.date_utils import ( from util.date_utils import (
get_default_time_display, get_time_display, almost_same_datetime, get_default_time_display, get_time_display, almost_same_datetime,
strftime_localized, strftime_localized,
...@@ -19,7 +18,7 @@ from util.date_utils import ( ...@@ -19,7 +18,7 @@ from util.date_utils import (
def test_get_default_time_display(): def test_get_default_time_display():
assert_equals("", get_default_time_display(None)) assert_equals("", get_default_time_display(None))
test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=UTC) test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=utc)
assert_equals( assert_equals(
"Mar 12, 1992 at 15:03 UTC", "Mar 12, 1992 at 15:03 UTC",
get_default_time_display(test_time)) get_default_time_display(test_time))
...@@ -34,12 +33,12 @@ def test_get_dflt_time_disp_notz(): ...@@ -34,12 +33,12 @@ def test_get_dflt_time_disp_notz():
def test_get_time_disp_ret_empty(): def test_get_time_disp_ret_empty():
assert_equals("", get_time_display(None)) assert_equals("", get_time_display(None))
test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=UTC) test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=utc)
assert_equals("", get_time_display(test_time, "")) assert_equals("", get_time_display(test_time, ""))
def test_get_time_display(): def test_get_time_display():
test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=UTC) test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=utc)
assert_equals("dummy text", get_time_display(test_time, 'dummy text')) assert_equals("dummy text", get_time_display(test_time, 'dummy text'))
assert_equals("Mar 12 1992", get_time_display(test_time, '%b %d %Y')) assert_equals("Mar 12 1992", get_time_display(test_time, '%b %d %Y'))
assert_equals("Mar 12 1992 UTC", get_time_display(test_time, '%b %d %Y %Z')) assert_equals("Mar 12 1992 UTC", get_time_display(test_time, '%b %d %Y %Z'))
...@@ -47,15 +46,15 @@ def test_get_time_display(): ...@@ -47,15 +46,15 @@ def test_get_time_display():
def test_get_time_pass_through(): def test_get_time_pass_through():
test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=UTC) test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=utc)
assert_equals("Mar 12, 1992 at 15:03 UTC", get_time_display(test_time)) assert_equals("Mar 12, 1992 at 15:03 UTC", get_time_display(test_time))
assert_equals("Mar 12, 1992 at 15:03 UTC", get_time_display(test_time, None)) assert_equals("Mar 12, 1992 at 15:03 UTC", get_time_display(test_time, None))
assert_equals("Mar 12, 1992 at 15:03 UTC", get_time_display(test_time, "%")) assert_equals("Mar 12, 1992 at 15:03 UTC", get_time_display(test_time, "%"))
def test_get_time_display_coerce(): def test_get_time_display_coerce():
test_time_standard = datetime(1992, 1, 12, 15, 3, 30, tzinfo=UTC) test_time_standard = datetime(1992, 1, 12, 15, 3, 30, tzinfo=utc)
test_time_daylight = datetime(1992, 7, 12, 15, 3, 30, tzinfo=UTC) test_time_daylight = datetime(1992, 7, 12, 15, 3, 30, tzinfo=utc)
assert_equals("Jan 12, 1992 at 07:03 PST", assert_equals("Jan 12, 1992 at 07:03 PST",
get_time_display(test_time_standard, None, coerce_tz="US/Pacific")) get_time_display(test_time_standard, None, coerce_tz="US/Pacific"))
assert_equals("Jan 12, 1992 at 15:03 UTC", assert_equals("Jan 12, 1992 at 15:03 UTC",
......
...@@ -10,11 +10,12 @@ from datetime import datetime, timedelta ...@@ -10,11 +10,12 @@ from datetime import datetime, timedelta
import dateutil.parser import dateutil.parser
from math import exp from math import exp
from django.utils.timezone import UTC from openedx.core.lib.time_zone_utils import get_time_zone_abbr
from pytz import utc
from .fields import Date from .fields import Date
DEFAULT_START_DATE = datetime(2030, 1, 1, tzinfo=UTC()) DEFAULT_START_DATE = datetime(2030, 1, 1, tzinfo=utc)
def clean_course_key(course_key, padding_char): def clean_course_key(course_key, padding_char):
...@@ -56,7 +57,7 @@ def has_course_started(start_date): ...@@ -56,7 +57,7 @@ def has_course_started(start_date):
start_date (datetime): The start datetime of the course in question. start_date (datetime): The start datetime of the course in question.
""" """
# TODO: This will throw if start_date is None... consider changing this behavior? # TODO: This will throw if start_date is None... consider changing this behavior?
return datetime.now(UTC()) > start_date return datetime.now(utc) > start_date
def has_course_ended(end_date): def has_course_ended(end_date):
...@@ -68,7 +69,7 @@ def has_course_ended(end_date): ...@@ -68,7 +69,7 @@ def has_course_ended(end_date):
Arguments: Arguments:
end_date (datetime): The end datetime of the course in question. end_date (datetime): The end datetime of the course in question.
""" """
return datetime.now(UTC()) > end_date if end_date is not None else False return datetime.now(utc) > end_date if end_date is not None else False
def course_starts_within(start_date, look_ahead_days): def course_starts_within(start_date, look_ahead_days):
...@@ -80,7 +81,7 @@ def course_starts_within(start_date, look_ahead_days): ...@@ -80,7 +81,7 @@ def course_starts_within(start_date, look_ahead_days):
start_date (datetime): The start datetime of the course in question. start_date (datetime): The start datetime of the course in question.
look_ahead_days (int): number of days to see in future for course start date. look_ahead_days (int): number of days to see in future for course start date.
""" """
return datetime.now(UTC()) + timedelta(days=look_ahead_days) > start_date return datetime.now(utc) + timedelta(days=look_ahead_days) > start_date
def course_start_date_is_default(start, advertised_start): def course_start_date_is_default(start, advertised_start):
...@@ -95,30 +96,31 @@ def course_start_date_is_default(start, advertised_start): ...@@ -95,30 +96,31 @@ def course_start_date_is_default(start, advertised_start):
return advertised_start is None and start == DEFAULT_START_DATE return advertised_start is None and start == DEFAULT_START_DATE
def _datetime_to_string(date_time, format_string, strftime_localized): def _datetime_to_string(date_time, format_string, time_zone, strftime_localized):
""" """
Formats the given datetime with the given function and format string. Formats the given datetime with the given function and format string.
Adds UTC to the resulting string if the format is DATE_TIME or TIME. Adds time zone abbreviation to the resulting string if the format is DATE_TIME or TIME.
Arguments: Arguments:
date_time (datetime): the datetime to be formatted date_time (datetime): the datetime to be formatted
format_string (str): the date format type, as passed to strftime format_string (str): the date format type, as passed to strftime
time_zone (pytz time zone): the time zone to convert to
strftime_localized ((datetime, str) -> str): a nm localized string strftime_localized ((datetime, str) -> str): a nm localized string
formatting function formatting function
""" """
# TODO: Is manually appending UTC really the right thing to do here? What if date_time isn't UTC? result = strftime_localized(date_time.astimezone(time_zone), format_string)
result = strftime_localized(date_time, format_string) abbr = get_time_zone_abbr(time_zone, date_time)
return ( return (
result + u" UTC" if format_string in ['DATE_TIME', 'TIME', 'DAY_AND_TIME'] result + ' ' + abbr if format_string in ['DATE_TIME', 'TIME', 'DAY_AND_TIME']
else result else result
) )
def course_start_datetime_text(start_date, advertised_start, format_string, ugettext, strftime_localized): def course_start_datetime_text(start_date, advertised_start, format_string, time_zone, ugettext, strftime_localized):
""" """
Calculates text to be shown to user regarding a course's start Calculates text to be shown to user regarding a course's start
datetime in UTC. datetime in specified time zone.
Prefers .advertised_start, then falls back to .start. Prefers .advertised_start, then falls back to .start.
...@@ -126,6 +128,7 @@ def course_start_datetime_text(start_date, advertised_start, format_string, uget ...@@ -126,6 +128,7 @@ def course_start_datetime_text(start_date, advertised_start, format_string, uget
start_date (datetime): the course's start datetime start_date (datetime): the course's start datetime
advertised_start (str): the course's advertised start date advertised_start (str): the course's advertised start date
format_string (str): the date format type, as passed to strftime format_string (str): the date format type, as passed to strftime
time_zone (pytz time zone): the time zone to convert to
ugettext ((str) -> str): a text localization function ugettext ((str) -> str): a text localization function
strftime_localized ((datetime, str) -> str): a localized string strftime_localized ((datetime, str) -> str): a localized string
formatting function formatting function
...@@ -138,12 +141,12 @@ def course_start_datetime_text(start_date, advertised_start, format_string, uget ...@@ -138,12 +141,12 @@ def course_start_datetime_text(start_date, advertised_start, format_string, uget
if parsed_advertised_start is not None: if parsed_advertised_start is not None:
# In the Django implementation of strftime_localized, if # In the Django implementation of strftime_localized, if
# the year is <1900, _datetime_to_string will raise a ValueError. # the year is <1900, _datetime_to_string will raise a ValueError.
return _datetime_to_string(parsed_advertised_start, format_string, strftime_localized) return _datetime_to_string(parsed_advertised_start, format_string, time_zone, strftime_localized)
except ValueError: except ValueError:
pass pass
return advertised_start.title() return advertised_start.title()
elif start_date != DEFAULT_START_DATE: elif start_date != DEFAULT_START_DATE:
return _datetime_to_string(start_date, format_string, strftime_localized) return _datetime_to_string(start_date, format_string, time_zone, strftime_localized)
else: else:
_ = ugettext _ = 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
...@@ -151,7 +154,7 @@ def course_start_datetime_text(start_date, advertised_start, format_string, uget ...@@ -151,7 +154,7 @@ def course_start_datetime_text(start_date, advertised_start, format_string, uget
return _('TBD') return _('TBD')
def course_end_datetime_text(end_date, format_string, strftime_localized): def course_end_datetime_text(end_date, format_string, time_zone, strftime_localized):
""" """
Returns a formatted string for a course's end date or datetime. Returns a formatted string for a course's end date or datetime.
...@@ -160,11 +163,12 @@ def course_end_datetime_text(end_date, format_string, strftime_localized): ...@@ -160,11 +163,12 @@ def course_end_datetime_text(end_date, format_string, strftime_localized):
Arguments: Arguments:
end_date (datetime): the end datetime of a course end_date (datetime): the end datetime of a course
format_string (str): the date format type, as passed to strftime format_string (str): the date format type, as passed to strftime
time_zone (pytz time zone): the time zone to convert to
strftime_localized ((datetime, str) -> str): a localized string strftime_localized ((datetime, str) -> str): a localized string
formatting function formatting function
""" """
return ( return (
_datetime_to_string(end_date, format_string, strftime_localized) if end_date is not None _datetime_to_string(end_date, format_string, time_zone, strftime_localized) if end_date is not None
else '' else ''
) )
...@@ -220,10 +224,10 @@ def sorting_dates(start, advertised_start, announcement): ...@@ -220,10 +224,10 @@ def sorting_dates(start, advertised_start, announcement):
try: try:
start = dateutil.parser.parse(advertised_start) start = dateutil.parser.parse(advertised_start)
if start.tzinfo is None: if start.tzinfo is None:
start = start.replace(tzinfo=UTC()) start = start.replace(tzinfo=utc)
except (ValueError, AttributeError): except (ValueError, AttributeError):
start = start start = start
now = datetime.now(UTC()) now = datetime.now(utc)
return announcement, start, now return announcement, start, now
...@@ -7,10 +7,10 @@ from cStringIO import StringIO ...@@ -7,10 +7,10 @@ from cStringIO import StringIO
from datetime import datetime from datetime import datetime
import requests import requests
from django.utils.timezone import UTC
from lazy import lazy from lazy import lazy
from lxml import etree from lxml import etree
from path import Path as path from path import Path as path
from pytz import utc
from xblock.fields import Scope, List, String, Dict, Boolean, Integer, Float from xblock.fields import Scope, List, String, Dict, Boolean, Integer, Float
from xmodule import course_metadata_utils from xmodule import course_metadata_utils
...@@ -106,7 +106,7 @@ class Textbook(object): ...@@ -106,7 +106,7 @@ class Textbook(object):
# see if we already fetched this # see if we already fetched this
if toc_url in _cached_toc: if toc_url in _cached_toc:
(table_of_contents, timestamp) = _cached_toc[toc_url] (table_of_contents, timestamp) = _cached_toc[toc_url]
age = datetime.now(UTC) - timestamp age = datetime.now(utc) - timestamp
# expire every 10 minutes # expire every 10 minutes
if age.seconds < 600: if age.seconds < 600:
return table_of_contents return table_of_contents
...@@ -1190,16 +1190,17 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin): ...@@ -1190,16 +1190,17 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
"""Return the course_id for this course""" """Return the course_id for this course"""
return self.location.course_key return self.location.course_key
def start_datetime_text(self, format_string="SHORT_DATE"): def start_datetime_text(self, format_string="SHORT_DATE", time_zone=utc):
""" """
Returns the desired text corresponding the course's start date and time in UTC. Prefers .advertised_start, Returns the desired text corresponding the course's start date and time in specified time zone, defaulted
then falls back to .start to UTC. Prefers .advertised_start, then falls back to .start
""" """
i18n = self.runtime.service(self, "i18n") i18n = self.runtime.service(self, "i18n")
return course_metadata_utils.course_start_datetime_text( return course_metadata_utils.course_start_datetime_text(
self.start, self.start,
self.advertised_start, self.advertised_start,
format_string, format_string,
time_zone,
i18n.ugettext, i18n.ugettext,
i18n.strftime i18n.strftime
) )
...@@ -1215,13 +1216,14 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin): ...@@ -1215,13 +1216,14 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
self.advertised_start self.advertised_start
) )
def end_datetime_text(self, format_string="SHORT_DATE"): def end_datetime_text(self, format_string="SHORT_DATE", time_zone=utc):
""" """
Returns the end date or date_time for the course formatted as a string. Returns the end date or date_time for the course formatted as a string.
""" """
return course_metadata_utils.course_end_datetime_text( return course_metadata_utils.course_end_datetime_text(
self.end, self.end,
format_string, format_string,
time_zone,
self.runtime.service(self, "i18n").strftime self.runtime.service(self, "i18n").strftime
) )
...@@ -1256,7 +1258,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin): ...@@ -1256,7 +1258,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
setting setting
""" """
blackouts = self.get_discussion_blackout_datetimes() blackouts = self.get_discussion_blackout_datetimes()
now = datetime.now(UTC()) now = datetime.now(utc)
for blackout in blackouts: for blackout in blackouts:
if blackout["start"] <= now <= blackout["end"]: if blackout["start"] <= now <= blackout["end"]:
return False return False
...@@ -1384,7 +1386,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin): ...@@ -1384,7 +1386,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
Returns: Returns:
bool: False if the course has already started, True otherwise. bool: False if the course has already started, True otherwise.
""" """
return datetime.now(UTC()) <= self.start return datetime.now(utc) <= self.start
class CourseSummary(object): class CourseSummary(object):
......
...@@ -5,8 +5,7 @@ from collections import namedtuple ...@@ -5,8 +5,7 @@ from collections import namedtuple
from datetime import timedelta, datetime from datetime import timedelta, datetime
from unittest import TestCase from unittest import TestCase
from django.utils.timezone import UTC from pytz import timezone, utc
from xmodule.block_metadata_utils import ( from xmodule.block_metadata_utils import (
url_name_for_block, url_name_for_block,
display_name_with_default, display_name_with_default,
...@@ -31,7 +30,7 @@ from xmodule.modulestore.tests.utils import ( ...@@ -31,7 +30,7 @@ from xmodule.modulestore.tests.utils import (
) )
_TODAY = datetime.now(UTC()) _TODAY = datetime.now(utc)
_LAST_MONTH = _TODAY - timedelta(days=30) _LAST_MONTH = _TODAY - timedelta(days=30)
_LAST_WEEK = _TODAY - timedelta(days=7) _LAST_WEEK = _TODAY - timedelta(days=7)
_NEXT_WEEK = _TODAY + timedelta(days=7) _NEXT_WEEK = _TODAY + timedelta(days=7)
...@@ -107,14 +106,18 @@ class CourseMetadataUtilsTestCase(TestCase): ...@@ -107,14 +106,18 @@ class CourseMetadataUtilsTestCase(TestCase):
else: else:
raise ValueError("Invalid format string :" + format_string) raise ValueError("Invalid format string :" + format_string)
def nop_gettext(text): def noop_gettext(text):
"""Dummy implementation of gettext, so we don't need Django.""" """Dummy implementation of gettext, so we don't need Django."""
return text return text
test_datetime = datetime(1945, 02, 06, 04, 20, 00, tzinfo=UTC()) test_datetime = datetime(1945, 2, 6, 4, 20, 00, tzinfo=utc)
advertised_start_parsable = "2038-01-19 03:14:07" advertised_start_parsable = "2038-01-19 03:14:07"
advertised_start_bad_date = "215-01-01 10:10:10" advertised_start_bad_date = "215-01-01 10:10:10"
advertised_start_unparsable = "This coming fall" advertised_start_unparsable = "This coming fall"
time_zone_normal_parsable = "2016-03-27 00:59:00"
time_zone_normal_datetime = datetime(2016, 3, 27, 00, 59, 00, tzinfo=utc)
time_zone_daylight_parsable = "2016-03-27 01:00:00"
time_zone_daylight_datetime = datetime(2016, 3, 27, 1, 00, 00, tzinfo=utc)
FunctionTest = namedtuple('FunctionTest', 'function scenarios') # pylint: disable=invalid-name FunctionTest = namedtuple('FunctionTest', 'function scenarios') # pylint: disable=invalid-name
TestScenario = namedtuple('TestScenario', 'arguments expected_return') # pylint: disable=invalid-name TestScenario = namedtuple('TestScenario', 'arguments expected_return') # pylint: disable=invalid-name
...@@ -170,48 +173,73 @@ class CourseMetadataUtilsTestCase(TestCase): ...@@ -170,48 +173,73 @@ class CourseMetadataUtilsTestCase(TestCase):
# Test parsable advertised start date. # Test parsable advertised start date.
# Expect start datetime to be parsed and formatted back into a string. # Expect start datetime to be parsed and formatted back into a string.
TestScenario( TestScenario(
(DEFAULT_START_DATE, advertised_start_parsable, 'DATE_TIME', nop_gettext, mock_strftime_localized), (DEFAULT_START_DATE, advertised_start_parsable, 'DATE_TIME',
utc, noop_gettext, mock_strftime_localized),
mock_strftime_localized(Date().from_json(advertised_start_parsable), 'DATE_TIME') + " UTC" mock_strftime_localized(Date().from_json(advertised_start_parsable), 'DATE_TIME') + " UTC"
), ),
# Test un-parsable advertised start date. # Test un-parsable advertised start date.
# Expect date parsing to throw a ValueError, and the advertised # Expect date parsing to throw a ValueError, and the advertised
# start to be returned in Title Case. # start to be returned in Title Case.
TestScenario( TestScenario(
(test_datetime, advertised_start_unparsable, 'DATE_TIME', nop_gettext, mock_strftime_localized), (test_datetime, advertised_start_unparsable, 'DATE_TIME',
utc, noop_gettext, mock_strftime_localized),
advertised_start_unparsable.title() advertised_start_unparsable.title()
), ),
# Test parsable advertised start date from before January 1, 1900. # Test parsable advertised start date from before January 1, 1900.
# Expect mock_strftime_localized to throw a ValueError, and the # Expect mock_strftime_localized to throw a ValueError, and the
# advertised start to be returned in Title Case. # advertised start to be returned in Title Case.
TestScenario( TestScenario(
(test_datetime, advertised_start_bad_date, 'DATE_TIME', nop_gettext, mock_strftime_localized), (test_datetime, advertised_start_bad_date, 'DATE_TIME',
utc, noop_gettext, mock_strftime_localized),
advertised_start_bad_date.title() advertised_start_bad_date.title()
), ),
# Test without advertised start date, but with a set start datetime. # Test without advertised start date, but with a set start datetime.
# Expect formatted datetime to be returned. # Expect formatted datetime to be returned.
TestScenario( TestScenario(
(test_datetime, None, 'SHORT_DATE', nop_gettext, mock_strftime_localized), (test_datetime, None, 'SHORT_DATE', utc, noop_gettext, mock_strftime_localized),
mock_strftime_localized(test_datetime, 'SHORT_DATE') mock_strftime_localized(test_datetime, 'SHORT_DATE')
), ),
# Test without advertised start date and with default start datetime. # Test without advertised start date and with default start datetime.
# Expect TBD to be returned. # Expect TBD to be returned.
TestScenario( TestScenario(
(DEFAULT_START_DATE, None, 'SHORT_DATE', nop_gettext, mock_strftime_localized), (DEFAULT_START_DATE, None, 'SHORT_DATE', utc, noop_gettext, mock_strftime_localized),
'TBD' 'TBD'
),
# Test correctly formatted start datetime is returned during normal daylight hours
TestScenario(
(DEFAULT_START_DATE, time_zone_normal_parsable, 'DATE_TIME',
timezone('Europe/Paris'), noop_gettext, mock_strftime_localized),
"DATE_TIME " + "2016-03-27 01:59:00 CET"
),
# Test correctly formatted start datetime is returned during daylight savings hours
TestScenario(
(DEFAULT_START_DATE, time_zone_daylight_parsable, 'DATE_TIME',
timezone('Europe/Paris'), noop_gettext, mock_strftime_localized),
"DATE_TIME " + "2016-03-27 03:00:00 CEST"
) )
]), ]),
FunctionTest(course_end_datetime_text, [ FunctionTest(course_end_datetime_text, [
# Test with a set end datetime. # Test with a set end datetime.
# Expect formatted datetime to be returned. # Expect formatted datetime to be returned.
TestScenario( TestScenario(
(test_datetime, 'TIME', mock_strftime_localized), (test_datetime, 'TIME', utc, mock_strftime_localized),
mock_strftime_localized(test_datetime, 'TIME') + " UTC" mock_strftime_localized(test_datetime, 'TIME') + " UTC"
), ),
# Test with default end datetime. # Test with default end datetime.
# Expect empty string to be returned. # Expect empty string to be returned.
TestScenario( TestScenario(
(None, 'TIME', mock_strftime_localized), (None, 'TIME', utc, mock_strftime_localized),
"" ""
),
# Test correctly formatted end datetime is returned during normal daylight hours
TestScenario(
(time_zone_normal_datetime, 'TIME', timezone('Europe/Paris'), mock_strftime_localized),
"TIME " + "2016-03-27 01:59:00 CET"
),
# Test correctly formatted end datetime is returned during daylight savings hours
TestScenario(
(time_zone_daylight_datetime, 'TIME', timezone('Europe/Paris'), mock_strftime_localized),
"TIME " + "2016-03-27 03:00:00 CEST"
) )
]), ]),
FunctionTest(may_certify_for_course, [ FunctionTest(may_certify_for_course, [
......
"""Tests the course modules and their functions"""
import ddt
import unittest import unittest
from datetime import datetime, timedelta from datetime import datetime, timedelta
import itertools
from fs.memoryfs import MemoryFS from fs.memoryfs import MemoryFS
from mock import Mock, patch from mock import Mock, patch
import itertools from pytz import timezone, utc
from xblock.runtime import KvsFieldData, DictKeyValueStore from xblock.runtime import KvsFieldData, DictKeyValueStore
import xmodule.course_module import xmodule.course_module
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore from xmodule.modulestore.xml import ImportSystem, XMLModuleStore
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from django.utils.timezone import UTC
ORG = 'test_org' ORG = 'test_org'
COURSE = 'test_course' COURSE = 'test_course'
NOW = datetime.strptime('2013-01-01T01:00:00', '%Y-%m-%dT%H:%M:00').replace(tzinfo=UTC()) NOW = datetime.strptime('2013-01-01T01:00:00', '%Y-%m-%dT%H:%M:00').replace(tzinfo=utc)
_TODAY = datetime.now(UTC()) _TODAY = datetime.now(utc)
_LAST_WEEK = _TODAY - timedelta(days=7) _LAST_WEEK = _TODAY - timedelta(days=7)
_NEXT_WEEK = _TODAY + timedelta(days=7) _NEXT_WEEK = _TODAY + timedelta(days=7)
...@@ -28,7 +28,7 @@ class CourseFieldsTestCase(unittest.TestCase): ...@@ -28,7 +28,7 @@ class CourseFieldsTestCase(unittest.TestCase):
def test_default_start_date(self): def test_default_start_date(self):
self.assertEqual( self.assertEqual(
xmodule.course_module.CourseFields.start.default, xmodule.course_module.CourseFields.start.default,
datetime(2030, 1, 1, tzinfo=UTC()) datetime(2030, 1, 1, tzinfo=utc)
) )
...@@ -142,6 +142,7 @@ class HasEndedMayCertifyTestCase(unittest.TestCase): ...@@ -142,6 +142,7 @@ class HasEndedMayCertifyTestCase(unittest.TestCase):
self.assertFalse(self.future_noshow_certs.may_certify()) self.assertFalse(self.future_noshow_certs.may_certify())
@ddt.ddt
class IsNewCourseTestCase(unittest.TestCase): class IsNewCourseTestCase(unittest.TestCase):
"""Make sure the property is_new works on courses""" """Make sure the property is_new works on courses"""
...@@ -224,6 +225,20 @@ class IsNewCourseTestCase(unittest.TestCase): ...@@ -224,6 +225,20 @@ class IsNewCourseTestCase(unittest.TestCase):
print "Checking start=%s advertised=%s" % (setting[0], setting[1]) print "Checking start=%s advertised=%s" % (setting[0], setting[1])
self.assertEqual(course.start_datetime_text("DATE_TIME"), setting[4]) self.assertEqual(course.start_datetime_text("DATE_TIME"), setting[4])
@ddt.data(("2015-11-01T08:59", 'Nov 01, 2015', u'Nov 01, 2015 at 01:59 PDT'),
("2015-11-01T09:00", 'Nov 01, 2015', u'Nov 01, 2015 at 01:00 PST'))
@ddt.unpack
def test_start_date_time_zone(self, course_date, expected_short_date, expected_date_time):
"""
Test that start datetime text correctly formats datetimes
for normal daylight hours and daylight savings hours
"""
time_zone = timezone('America/Los_Angeles')
course = get_dummy_course(start=course_date, advertised_start=course_date)
self.assertEqual(course.start_datetime_text(time_zone=time_zone), expected_short_date)
self.assertEqual(course.start_datetime_text("DATE_TIME", time_zone), expected_date_time)
def test_start_date_is_default(self): def test_start_date_is_default(self):
for s in self.start_advertised_settings: for s in self.start_advertised_settings:
d = get_dummy_course(start=s[0], advertised_start=s[1]) d = get_dummy_course(start=s[0], advertised_start=s[1])
...@@ -277,6 +292,20 @@ class IsNewCourseTestCase(unittest.TestCase): ...@@ -277,6 +292,20 @@ class IsNewCourseTestCase(unittest.TestCase):
course = get_dummy_course('2012-12-02T12:00', end='2014-9-04T12:00') course = get_dummy_course('2012-12-02T12:00', end='2014-9-04T12:00')
self.assertEqual('Sep 04, 2014 at 12:00 UTC', course.end_datetime_text("DATE_TIME")) self.assertEqual('Sep 04, 2014 at 12:00 UTC', course.end_datetime_text("DATE_TIME"))
@ddt.data(("2015-11-01T08:59", 'Nov 01, 2015', u'Nov 01, 2015 at 01:59 PDT'),
("2015-11-01T09:00", 'Nov 01, 2015', u'Nov 01, 2015 at 01:00 PST'))
@ddt.unpack
def test_end_date_time_zone(self, course_date, expected_short_date, expected_date_time):
"""
Test that end datetime text correctly formats datetimes
for normal daylight hours and daylight savings hours
"""
time_zone = timezone('America/Los_Angeles')
course = get_dummy_course(course_date, end=course_date)
self.assertEqual(course.end_datetime_text(time_zone=time_zone), expected_short_date)
self.assertEqual(course.end_datetime_text("DATE_TIME", time_zone), expected_date_time)
class DiscussionTopicsTestCase(unittest.TestCase): class DiscussionTopicsTestCase(unittest.TestCase):
def test_default_discussion_topics(self): def test_default_discussion_topics(self):
......
...@@ -7,9 +7,10 @@ from datetime import datetime ...@@ -7,9 +7,10 @@ from datetime import datetime
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.utils.timezone import UTC from pytz import utc
from lazy import lazy from lazy import lazy
from openedx.core.lib.time_zone_utils import get_time_zone_abbr
from xmodule_django.models import CourseKeyField, LocationKeyField from xmodule_django.models import CourseKeyField, LocationKeyField
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -72,43 +73,43 @@ class CustomCourseForEdX(models.Model): ...@@ -72,43 +73,43 @@ class CustomCourseForEdX(models.Model):
def has_started(self): def has_started(self):
"""Return True if the CCX start date is in the past""" """Return True if the CCX start date is in the past"""
return datetime.now(UTC()) > self.start return datetime.now(utc) > self.start
def has_ended(self): def has_ended(self):
"""Return True if the CCX due date is set and is in the past""" """Return True if the CCX due date is set and is in the past"""
if self.due is None: if self.due is None:
return False return False
return datetime.now(UTC()) > self.due return datetime.now(utc) > self.due
def start_datetime_text(self, format_string="SHORT_DATE"): def start_datetime_text(self, format_string="SHORT_DATE", time_zone=utc):
"""Returns the desired text representation of the CCX start datetime """Returns the desired text representation of the CCX start datetime
The returned value is always expressed in UTC The returned value is in specified time zone, defaulted to UTC.
""" """
i18n = self.course.runtime.service(self.course, "i18n") i18n = self.course.runtime.service(self.course, "i18n")
strftime = i18n.strftime strftime = i18n.strftime
value = strftime(self.start, format_string) value = strftime(self.start.astimezone(time_zone), format_string)
if format_string == 'DATE_TIME': if format_string == 'DATE_TIME':
value += u' UTC' value += ' ' + get_time_zone_abbr(time_zone, self.start)
return value return value
def end_datetime_text(self, format_string="SHORT_DATE"): def end_datetime_text(self, format_string="SHORT_DATE", time_zone=utc):
"""Returns the desired text representation of the CCX due datetime """Returns the desired text representation of the CCX due datetime
If the due date for the CCX is not set, the value returned is the empty If the due date for the CCX is not set, the value returned is the empty
string. string.
The returned value is always expressed in UTC The returned value is in specified time zone, defaulted to UTC.
""" """
if self.due is None: if self.due is None:
return '' return ''
i18n = self.course.runtime.service(self.course, "i18n") i18n = self.course.runtime.service(self.course, "i18n")
strftime = i18n.strftime strftime = i18n.strftime
value = strftime(self.due, format_string) value = strftime(self.due.astimezone(time_zone), format_string)
if format_string == 'DATE_TIME': if format_string == 'DATE_TIME':
value += u' UTC' value += ' ' + get_time_zone_abbr(time_zone, self.due)
return value return value
@property @property
......
""" """
tests for the models tests for the models
""" """
import ddt
import json import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.utils.timezone import UTC
from mock import patch from mock import patch
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from pytz import timezone, utc
from student.roles import CourseCcxCoachRole from student.roles import CourseCcxCoachRole
from student.tests.factories import ( from student.tests.factories import (
AdminFactory, AdminFactory,
...@@ -23,6 +24,7 @@ from .factories import ( ...@@ -23,6 +24,7 @@ from .factories import (
from ..overrides import override_field_for_ccx from ..overrides import override_field_for_ccx
@ddt.ddt
@attr('shard_1') @attr('shard_1')
class TestCCX(ModuleStoreTestCase): class TestCCX(ModuleStoreTestCase):
"""Unit tests for the CustomCourseForEdX model """Unit tests for the CustomCourseForEdX model
...@@ -65,7 +67,7 @@ class TestCCX(ModuleStoreTestCase): ...@@ -65,7 +67,7 @@ class TestCCX(ModuleStoreTestCase):
For this reason we test the difference between and make sure it is less For this reason we test the difference between and make sure it is less
than one second. than one second.
""" """
expected = datetime.now(UTC()) expected = datetime.now(utc)
self.set_ccx_override('start', expected) self.set_ccx_override('start', expected)
actual = self.ccx.start # pylint: disable=no-member actual = self.ccx.start # pylint: disable=no-member
diff = expected - actual diff = expected - actual
...@@ -73,7 +75,7 @@ class TestCCX(ModuleStoreTestCase): ...@@ -73,7 +75,7 @@ class TestCCX(ModuleStoreTestCase):
def test_ccx_start_caching(self): def test_ccx_start_caching(self):
"""verify that caching the start property works to limit queries""" """verify that caching the start property works to limit queries"""
now = datetime.now(UTC()) now = datetime.now(utc)
self.set_ccx_override('start', now) self.set_ccx_override('start', now)
with check_mongo_calls(1): with check_mongo_calls(1):
# these statements are used entirely to demonstrate the # these statements are used entirely to demonstrate the
...@@ -90,7 +92,7 @@ class TestCCX(ModuleStoreTestCase): ...@@ -90,7 +92,7 @@ class TestCCX(ModuleStoreTestCase):
def test_ccx_due_is_correct(self): def test_ccx_due_is_correct(self):
"""verify that the due datetime for a ccx is correctly retrieved""" """verify that the due datetime for a ccx is correctly retrieved"""
expected = datetime.now(UTC()) expected = datetime.now(utc)
self.set_ccx_override('due', expected) self.set_ccx_override('due', expected)
actual = self.ccx.due # pylint: disable=no-member actual = self.ccx.due # pylint: disable=no-member
diff = expected - actual diff = expected - actual
...@@ -98,7 +100,7 @@ class TestCCX(ModuleStoreTestCase): ...@@ -98,7 +100,7 @@ class TestCCX(ModuleStoreTestCase):
def test_ccx_due_caching(self): def test_ccx_due_caching(self):
"""verify that caching the due property works to limit queries""" """verify that caching the due property works to limit queries"""
expected = datetime.now(UTC()) expected = datetime.now(utc)
self.set_ccx_override('due', expected) self.set_ccx_override('due', expected)
with check_mongo_calls(1): with check_mongo_calls(1):
# these statements are used entirely to demonstrate the # these statements are used entirely to demonstrate the
...@@ -110,7 +112,7 @@ class TestCCX(ModuleStoreTestCase): ...@@ -110,7 +112,7 @@ class TestCCX(ModuleStoreTestCase):
def test_ccx_has_started(self): def test_ccx_has_started(self):
"""verify that a ccx marked as starting yesterday has started""" """verify that a ccx marked as starting yesterday has started"""
now = datetime.now(UTC()) now = datetime.now(utc)
delta = timedelta(1) delta = timedelta(1)
then = now - delta then = now - delta
self.set_ccx_override('start', then) self.set_ccx_override('start', then)
...@@ -118,7 +120,7 @@ class TestCCX(ModuleStoreTestCase): ...@@ -118,7 +120,7 @@ class TestCCX(ModuleStoreTestCase):
def test_ccx_has_not_started(self): def test_ccx_has_not_started(self):
"""verify that a ccx marked as starting tomorrow has not started""" """verify that a ccx marked as starting tomorrow has not started"""
now = datetime.now(UTC()) now = datetime.now(utc)
delta = timedelta(1) delta = timedelta(1)
then = now + delta then = now + delta
self.set_ccx_override('start', then) self.set_ccx_override('start', then)
...@@ -126,7 +128,7 @@ class TestCCX(ModuleStoreTestCase): ...@@ -126,7 +128,7 @@ class TestCCX(ModuleStoreTestCase):
def test_ccx_has_ended(self): def test_ccx_has_ended(self):
"""verify that a ccx that has a due date in the past has ended""" """verify that a ccx that has a due date in the past has ended"""
now = datetime.now(UTC()) now = datetime.now(utc)
delta = timedelta(1) delta = timedelta(1)
then = now - delta then = now - delta
self.set_ccx_override('due', then) self.set_ccx_override('due', then)
...@@ -135,7 +137,7 @@ class TestCCX(ModuleStoreTestCase): ...@@ -135,7 +137,7 @@ class TestCCX(ModuleStoreTestCase):
def test_ccx_has_not_ended(self): def test_ccx_has_not_ended(self):
"""verify that a ccx that has a due date in the future has not eneded """verify that a ccx that has a due date in the future has not eneded
""" """
now = datetime.now(UTC()) now = datetime.now(utc)
delta = timedelta(1) delta = timedelta(1)
then = now + delta then = now + delta
self.set_ccx_override('due', then) self.set_ccx_override('due', then)
...@@ -152,7 +154,7 @@ class TestCCX(ModuleStoreTestCase): ...@@ -152,7 +154,7 @@ class TestCCX(ModuleStoreTestCase):
})) }))
def test_start_datetime_short_date(self): def test_start_datetime_short_date(self):
"""verify that the start date for a ccx formats properly by default""" """verify that the start date for a ccx formats properly by default"""
start = datetime(2015, 1, 1, 12, 0, 0, tzinfo=UTC()) start = datetime(2015, 1, 1, 12, 0, 0, tzinfo=utc)
expected = "Jan 01, 2015" expected = "Jan 01, 2015"
self.set_ccx_override('start', start) self.set_ccx_override('start', start)
actual = self.ccx.start_datetime_text() # pylint: disable=no-member actual = self.ccx.start_datetime_text() # pylint: disable=no-member
...@@ -163,18 +165,34 @@ class TestCCX(ModuleStoreTestCase): ...@@ -163,18 +165,34 @@ class TestCCX(ModuleStoreTestCase):
})) }))
def test_start_datetime_date_time_format(self): def test_start_datetime_date_time_format(self):
"""verify that the DATE_TIME format also works as expected""" """verify that the DATE_TIME format also works as expected"""
start = datetime(2015, 1, 1, 12, 0, 0, tzinfo=UTC()) start = datetime(2015, 1, 1, 12, 0, 0, tzinfo=utc)
expected = "Jan 01, 2015 at 12:00 UTC" expected = "Jan 01, 2015 at 12:00 UTC"
self.set_ccx_override('start', start) self.set_ccx_override('start', start)
actual = self.ccx.start_datetime_text('DATE_TIME') # pylint: disable=no-member actual = self.ccx.start_datetime_text('DATE_TIME') # pylint: disable=no-member
self.assertEqual(expected, actual) self.assertEqual(expected, actual)
@ddt.data((datetime(2015, 11, 1, 8, 59, 00, tzinfo=utc), "Nov 01, 2015", "Nov 01, 2015 at 01:59 PDT"),
(datetime(2015, 11, 1, 9, 00, 00, tzinfo=utc), "Nov 01, 2015", "Nov 01, 2015 at 01:00 PST"))
@ddt.unpack
def test_start_date_time_zone(self, start_date_time, expected_short_date, expected_date_time):
"""
verify that start date is correctly converted when time zone specified
during normal daylight hours and daylight savings hours
"""
time_zone = timezone('America/Los_Angeles')
self.set_ccx_override('start', start_date_time)
actual_short_date = self.ccx.start_datetime_text(time_zone=time_zone) # pylint: disable=no-member
actual_datetime = self.ccx.start_datetime_text('DATE_TIME', time_zone) # pylint: disable=no-member
self.assertEqual(expected_short_date, actual_short_date)
self.assertEqual(expected_date_time, actual_datetime)
@patch('util.date_utils.ugettext', fake_ugettext(translations={ @patch('util.date_utils.ugettext', fake_ugettext(translations={
"SHORT_DATE_FORMAT": "%b %d, %Y", "SHORT_DATE_FORMAT": "%b %d, %Y",
})) }))
def test_end_datetime_short_date(self): def test_end_datetime_short_date(self):
"""verify that the end date for a ccx formats properly by default""" """verify that the end date for a ccx formats properly by default"""
end = datetime(2015, 1, 1, 12, 0, 0, tzinfo=UTC()) end = datetime(2015, 1, 1, 12, 0, 0, tzinfo=utc)
expected = "Jan 01, 2015" expected = "Jan 01, 2015"
self.set_ccx_override('due', end) self.set_ccx_override('due', end)
actual = self.ccx.end_datetime_text() # pylint: disable=no-member actual = self.ccx.end_datetime_text() # pylint: disable=no-member
...@@ -185,12 +203,28 @@ class TestCCX(ModuleStoreTestCase): ...@@ -185,12 +203,28 @@ class TestCCX(ModuleStoreTestCase):
})) }))
def test_end_datetime_date_time_format(self): def test_end_datetime_date_time_format(self):
"""verify that the DATE_TIME format also works as expected""" """verify that the DATE_TIME format also works as expected"""
end = datetime(2015, 1, 1, 12, 0, 0, tzinfo=UTC()) end = datetime(2015, 1, 1, 12, 0, 0, tzinfo=utc)
expected = "Jan 01, 2015 at 12:00 UTC" expected = "Jan 01, 2015 at 12:00 UTC"
self.set_ccx_override('due', end) self.set_ccx_override('due', end)
actual = self.ccx.end_datetime_text('DATE_TIME') # pylint: disable=no-member actual = self.ccx.end_datetime_text('DATE_TIME') # pylint: disable=no-member
self.assertEqual(expected, actual) self.assertEqual(expected, actual)
@ddt.data((datetime(2015, 11, 1, 8, 59, 00, tzinfo=utc), "Nov 01, 2015", "Nov 01, 2015 at 01:59 PDT"),
(datetime(2015, 11, 1, 9, 00, 00, tzinfo=utc), "Nov 01, 2015", "Nov 01, 2015 at 01:00 PST"))
@ddt.unpack
def test_end_datetime_time_zone(self, end_date_time, expected_short_date, expected_date_time):
"""
verify that end date is correctly converted when time zone specified
during normal daylight hours and daylight savings hours
"""
time_zone = timezone('America/Los_Angeles')
self.set_ccx_override('due', end_date_time)
actual_short_date = self.ccx.end_datetime_text(time_zone=time_zone) # pylint: disable=no-member
actual_datetime = self.ccx.end_datetime_text('DATE_TIME', time_zone) # pylint: disable=no-member
self.assertEqual(expected_short_date, actual_short_date)
self.assertEqual(expected_date_time, actual_datetime)
@patch('util.date_utils.ugettext', fake_ugettext(translations={ @patch('util.date_utils.ugettext', fake_ugettext(translations={
"DATE_TIME_FORMAT": "%b %d, %Y at %H:%M", "DATE_TIME_FORMAT": "%b %d, %Y at %H:%M",
})) }))
......
...@@ -12,11 +12,12 @@ from django.utils.translation import ugettext_lazy ...@@ -12,11 +12,12 @@ from django.utils.translation import ugettext_lazy
from django.utils.translation import to_locale, get_language from django.utils.translation import to_locale, get_language
from edxmako.shortcuts import render_to_string from edxmako.shortcuts import render_to_string
from lazy import lazy from lazy import lazy
import pytz from pytz import utc
from course_modes.models import CourseMode from course_modes.models import CourseMode
from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.verify_student.models import VerificationDeadline, SoftwareSecurePhotoVerification from lms.djangoapps.verify_student.models import VerificationDeadline, SoftwareSecurePhotoVerification
from openedx.core.lib.time_zone_utils import get_time_zone_abbr, get_user_time_zone
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -64,6 +65,11 @@ class DateSummary(object): ...@@ -64,6 +65,11 @@ class DateSummary(object):
"""The text of the link.""" """The text of the link."""
return '' return ''
@property
def time_zone(self):
"""The time zone to display in"""
return get_user_time_zone(self.user)
def __init__(self, course, user): def __init__(self, course, user):
self.course = course self.course = course
self.user = user self.user = user
...@@ -93,7 +99,7 @@ class DateSummary(object): ...@@ -93,7 +99,7 @@ class DateSummary(object):
if self.date is None: if self.date is None:
return '' return ''
locale = to_locale(get_language()) locale = to_locale(get_language())
delta = self.date - datetime.now(pytz.UTC) delta = self.date - datetime.now(utc)
try: try:
relative_date = format_timedelta(delta, locale=locale) relative_date = format_timedelta(delta, locale=locale)
# Babel doesn't have translations for Esperanto, so we get # Babel doesn't have translations for Esperanto, so we get
...@@ -111,7 +117,7 @@ class DateSummary(object): ...@@ -111,7 +117,7 @@ class DateSummary(object):
date_format = _(u"{relative} ago - {absolute}") if date_has_passed else _(u"in {relative} - {absolute}") date_format = _(u"{relative} ago - {absolute}") if date_has_passed else _(u"in {relative} - {absolute}")
return date_format.format( return date_format.format(
relative=relative_date, relative=relative_date,
absolute=self.date.strftime(self.date_format.encode('utf-8')).decode('utf-8'), absolute=self.date.astimezone(self.time_zone).strftime(self.date_format.encode('utf-8')).decode('utf-8'),
) )
@property @property
...@@ -123,7 +129,7 @@ class DateSummary(object): ...@@ -123,7 +129,7 @@ class DateSummary(object):
future. future.
""" """
if self.date is not None: if self.date is not None:
return datetime.now(pytz.UTC) <= self.date return datetime.now(utc) <= self.date
return False return False
def __repr__(self): def __repr__(self):
...@@ -143,7 +149,7 @@ class TodaysDate(DateSummary): ...@@ -143,7 +149,7 @@ class TodaysDate(DateSummary):
@property @property
def date_format(self): def date_format(self):
return u'%b %d, %Y (%H:%M {utc})'.format(utc=_('UTC')) return u'%b %d, %Y (%H:%M {tz_abbr})'.format(tz_abbr=get_time_zone_abbr(self.time_zone))
# The date is shown in the title, no need to display it again. # The date is shown in the title, no need to display it again.
def get_context(self): def get_context(self):
...@@ -153,12 +159,12 @@ class TodaysDate(DateSummary): ...@@ -153,12 +159,12 @@ class TodaysDate(DateSummary):
@property @property
def date(self): def date(self):
return datetime.now(pytz.UTC) return datetime.now(utc)
@property @property
def title(self): def title(self):
return _(u'Today is {date}').format( return _(u'Today is {date}').format(
date=datetime.now(pytz.UTC).strftime(self.date_format.encode('utf-8')).decode('utf-8') date=self.date.astimezone(self.time_zone).strftime(self.date_format.encode('utf-8')).decode('utf-8')
) )
...@@ -187,7 +193,7 @@ class CourseEndDate(DateSummary): ...@@ -187,7 +193,7 @@ class CourseEndDate(DateSummary):
@property @property
def description(self): def description(self):
if datetime.now(pytz.UTC) <= self.date: if datetime.now(utc) <= self.date:
mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id) mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id)
if is_active and CourseMode.is_eligible_for_certificate(mode): if is_active and CourseMode.is_eligible_for_certificate(mode):
return _('To earn a certificate, you must complete all requirements before this date.') return _('To earn a certificate, you must complete all requirements before this date.')
...@@ -332,7 +338,7 @@ class VerificationDeadlineDate(DateSummary): ...@@ -332,7 +338,7 @@ class VerificationDeadlineDate(DateSummary):
Return True if a verification deadline exists, and has already passed. Return True if a verification deadline exists, and has already passed.
""" """
deadline = self.date deadline = self.date
return deadline is not None and deadline <= datetime.now(pytz.UTC) return deadline is not None and deadline <= datetime.now(utc)
def must_retry(self): def must_retry(self):
"""Return True if the user must re-submit verification, False otherwise.""" """Return True if the user must re-submit verification, False otherwise."""
......
...@@ -4,9 +4,9 @@ from datetime import datetime, timedelta ...@@ -4,9 +4,9 @@ from datetime import datetime, timedelta
import ddt import ddt
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
import freezegun from freezegun import freeze_time
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
import pytz from pytz import utc
from commerce.models import CommerceConfiguration from commerce.models import CommerceConfiguration
from course_modes.tests.factories import CourseModeFactory from course_modes.tests.factories import CourseModeFactory
...@@ -21,6 +21,7 @@ from courseware.date_summary import ( ...@@ -21,6 +21,7 @@ from courseware.date_summary import (
VerifiedUpgradeDeadlineDate, VerifiedUpgradeDeadlineDate,
) )
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.djangoapps.user_api.preferences.api import set_user_preference
from student.tests.factories import CourseEnrollmentFactory, UserFactory from student.tests.factories import CourseEnrollmentFactory, UserFactory
from lms.djangoapps.verify_student.models import VerificationDeadline from lms.djangoapps.verify_student.models import VerificationDeadline
from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
...@@ -50,7 +51,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): ...@@ -50,7 +51,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
sku=None sku=None
): ):
"""Set up the course and user for this test.""" """Set up the course and user for this test."""
now = datetime.now(pytz.UTC) now = datetime.now(utc)
self.course = CourseFactory.create( # pylint: disable=attribute-defined-outside-init self.course = CourseFactory.create( # pylint: disable=attribute-defined-outside-init
start=now + timedelta(days=days_till_start) start=now + timedelta(days=days_till_start)
) )
...@@ -175,21 +176,49 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): ...@@ -175,21 +176,49 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
## TodaysDate ## TodaysDate
@freezegun.freeze_time('2015-01-02') def _today_date_helper(self, expected_display_date):
def test_todays_date(self): """
Helper function to test that today's date block renders correctly
and displays the correct time, accounting for daylight savings
"""
self.setup_course_and_user() self.setup_course_and_user()
set_user_preference(self.user, "time_zone", "America/Los_Angeles")
block = TodaysDate(self.course, self.user) block = TodaysDate(self.course, self.user)
self.assertTrue(block.is_enabled) self.assertTrue(block.is_enabled)
self.assertEqual(block.date, datetime.now(pytz.UTC)) self.assertEqual(block.date, datetime.now(utc))
self.assertEqual(block.title, 'Today is Jan 02, 2015 (00:00 UTC)') self.assertEqual(block.title, 'Today is {date}'.format(date=expected_display_date))
self.assertNotIn('date-summary-date', block.render()) self.assertNotIn('date-summary-date', block.render())
@freezegun.freeze_time('2015-01-02') @freeze_time('2015-11-01 08:59:00')
def test_todays_date_time_zone_daylight(self):
"""
Test today's date block displays correctly during
daylight savings hours
"""
self._today_date_helper('Nov 01, 2015 (01:59 PDT)')
@freeze_time('2015-11-01 09:00:00')
def test_todays_date_time_zone_normal(self):
"""
Test today's date block displays correctly during
normal daylight hours
"""
self._today_date_helper('Nov 01, 2015 (01:00 PST)')
@freeze_time('2015-01-02')
def test_todays_date_render(self): def test_todays_date_render(self):
self.setup_course_and_user() self.setup_course_and_user()
block = TodaysDate(self.course, self.user) block = TodaysDate(self.course, self.user)
self.assertIn('Jan 02, 2015', block.render()) self.assertIn('Jan 02, 2015', block.render())
@freeze_time('2015-01-02')
def test_todays_date_render_time_zone(self):
self.setup_course_and_user()
set_user_preference(self.user, "time_zone", "America/Los_Angeles")
block = TodaysDate(self.course, self.user)
# Today is 'Jan 01, 2015' because of time zone offset
self.assertIn('Jan 01, 2015', block.render())
## CourseStartDate ## CourseStartDate
def test_course_start_date(self): def test_course_start_date(self):
...@@ -197,12 +226,20 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): ...@@ -197,12 +226,20 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
block = CourseStartDate(self.course, self.user) block = CourseStartDate(self.course, self.user)
self.assertEqual(block.date, self.course.start) self.assertEqual(block.date, self.course.start)
@freezegun.freeze_time('2015-01-02') @freeze_time('2015-01-02')
def test_start_date_render(self): def test_start_date_render(self):
self.setup_course_and_user() self.setup_course_and_user()
block = CourseStartDate(self.course, self.user) block = CourseStartDate(self.course, self.user)
self.assertIn('in 1 day - Jan 03, 2015', block.render()) self.assertIn('in 1 day - Jan 03, 2015', block.render())
@freeze_time('2015-01-02')
def test_start_date_render_time_zone(self):
self.setup_course_and_user()
set_user_preference(self.user, "time_zone", "America/Los_Angeles")
block = CourseStartDate(self.course, self.user)
# Jan 02 is in 1 day because of time zone offset
self.assertIn('in 1 day - Jan 02, 2015', block.render())
## CourseEndDate ## CourseEndDate
def test_course_end_date_for_certificate_eligible_mode(self): def test_course_end_date_for_certificate_eligible_mode(self):
...@@ -231,11 +268,11 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): ...@@ -231,11 +268,11 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
## VerifiedUpgradeDeadlineDate ## VerifiedUpgradeDeadlineDate
@freezegun.freeze_time('2015-01-02') @freeze_time('2015-01-02')
def test_verified_upgrade_deadline_date(self): def test_verified_upgrade_deadline_date(self):
self.setup_course_and_user(days_till_upgrade_deadline=1) self.setup_course_and_user(days_till_upgrade_deadline=1)
block = VerifiedUpgradeDeadlineDate(self.course, self.user) block = VerifiedUpgradeDeadlineDate(self.course, self.user)
self.assertEqual(block.date, datetime.now(pytz.UTC) + timedelta(days=1)) self.assertEqual(block.date, datetime.now(utc) + timedelta(days=1))
self.assertEqual(block.link, reverse('verify_student_upgrade_and_verify', args=(self.course.id,))) self.assertEqual(block.link, reverse('verify_student_upgrade_and_verify', args=(self.course.id,)))
def test_without_upgrade_deadline(self): def test_without_upgrade_deadline(self):
...@@ -267,13 +304,13 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): ...@@ -267,13 +304,13 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
block = VerificationDeadlineDate(self.course, self.user) block = VerificationDeadlineDate(self.course, self.user)
self.assertFalse(block.is_enabled) self.assertFalse(block.is_enabled)
@freezegun.freeze_time('2015-01-02') @freeze_time('2015-01-02')
def test_verification_deadline_date_upcoming(self): def test_verification_deadline_date_upcoming(self):
self.setup_course_and_user(days_till_start=-1) self.setup_course_and_user(days_till_start=-1)
block = VerificationDeadlineDate(self.course, self.user) block = VerificationDeadlineDate(self.course, self.user)
self.assertEqual(block.css_class, 'verification-deadline-upcoming') self.assertEqual(block.css_class, 'verification-deadline-upcoming')
self.assertEqual(block.title, 'Verification Deadline') self.assertEqual(block.title, 'Verification Deadline')
self.assertEqual(block.date, datetime.now(pytz.UTC) + timedelta(days=14)) self.assertEqual(block.date, datetime.now(utc) + timedelta(days=14))
self.assertEqual( self.assertEqual(
block.description, block.description,
'You must successfully complete verification before this date to qualify for a Verified Certificate.' 'You must successfully complete verification before this date to qualify for a Verified Certificate.'
...@@ -281,13 +318,13 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): ...@@ -281,13 +318,13 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
self.assertEqual(block.link_text, 'Verify My Identity') self.assertEqual(block.link_text, 'Verify My Identity')
self.assertEqual(block.link, reverse('verify_student_verify_now', args=(self.course.id,))) self.assertEqual(block.link, reverse('verify_student_verify_now', args=(self.course.id,)))
@freezegun.freeze_time('2015-01-02') @freeze_time('2015-01-02')
def test_verification_deadline_date_retry(self): def test_verification_deadline_date_retry(self):
self.setup_course_and_user(days_till_start=-1, verification_status='denied') self.setup_course_and_user(days_till_start=-1, verification_status='denied')
block = VerificationDeadlineDate(self.course, self.user) block = VerificationDeadlineDate(self.course, self.user)
self.assertEqual(block.css_class, 'verification-deadline-retry') self.assertEqual(block.css_class, 'verification-deadline-retry')
self.assertEqual(block.title, 'Verification Deadline') self.assertEqual(block.title, 'Verification Deadline')
self.assertEqual(block.date, datetime.now(pytz.UTC) + timedelta(days=14)) self.assertEqual(block.date, datetime.now(utc) + timedelta(days=14))
self.assertEqual( self.assertEqual(
block.description, block.description,
'You must successfully complete verification before this date to qualify for a Verified Certificate.' 'You must successfully complete verification before this date to qualify for a Verified Certificate.'
...@@ -295,7 +332,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): ...@@ -295,7 +332,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
self.assertEqual(block.link_text, 'Retry Verification') self.assertEqual(block.link_text, 'Retry Verification')
self.assertEqual(block.link, reverse('verify_student_reverify')) self.assertEqual(block.link, reverse('verify_student_reverify'))
@freezegun.freeze_time('2015-01-02') @freeze_time('2015-01-02')
def test_verification_deadline_date_denied(self): def test_verification_deadline_date_denied(self):
self.setup_course_and_user( self.setup_course_and_user(
days_till_start=-10, days_till_start=-10,
...@@ -305,7 +342,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): ...@@ -305,7 +342,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
block = VerificationDeadlineDate(self.course, self.user) block = VerificationDeadlineDate(self.course, self.user)
self.assertEqual(block.css_class, 'verification-deadline-passed') self.assertEqual(block.css_class, 'verification-deadline-passed')
self.assertEqual(block.title, 'Missed Verification Deadline') self.assertEqual(block.title, 'Missed Verification Deadline')
self.assertEqual(block.date, datetime.now(pytz.UTC) + timedelta(days=-1)) self.assertEqual(block.date, datetime.now(utc) + timedelta(days=-1))
self.assertEqual( self.assertEqual(
block.description, block.description,
"Unfortunately you missed this course's deadline for a successful verification." "Unfortunately you missed this course's deadline for a successful verification."
...@@ -313,7 +350,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): ...@@ -313,7 +350,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
self.assertEqual(block.link_text, 'Learn More') self.assertEqual(block.link_text, 'Learn More')
self.assertEqual(block.link, '') self.assertEqual(block.link, '')
@freezegun.freeze_time('2015-01-02') @freeze_time('2015-01-02')
@ddt.data( @ddt.data(
(-1, '1 day ago - Jan 01, 2015'), (-1, '1 day ago - Jan 01, 2015'),
(1, 'in 1 day - Jan 03, 2015') (1, 'in 1 day - Jan 03, 2015')
...@@ -327,3 +364,20 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): ...@@ -327,3 +364,20 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
) )
block = VerificationDeadlineDate(self.course, self.user) block = VerificationDeadlineDate(self.course, self.user)
self.assertEqual(block.get_context()['date'], expected_date_string) self.assertEqual(block.get_context()['date'], expected_date_string)
@freeze_time('2015-01-02')
@ddt.data(
# dates reflected from Jan 01, 2015 because of time zone offset
(-1, '1 day ago - Dec 31, 2014'),
(1, 'in 1 day - Jan 02, 2015')
)
@ddt.unpack
def test_render_date_string_time_zone(self, delta, expected_date_string):
self.setup_course_and_user(
days_till_start=-10,
verification_status='denied',
days_till_verification_deadline=delta,
)
set_user_preference(self.user, "time_zone", "America/Los_Angeles")
block = VerificationDeadlineDate(self.course, self.user)
self.assertEqual(block.get_context()['date'], expected_date_string)
...@@ -26,6 +26,7 @@ from lang_pref import LANGUAGE_KEY ...@@ -26,6 +26,7 @@ from lang_pref import LANGUAGE_KEY
from xblock.fragment import Fragment from xblock.fragment import Fragment
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from openedx.core.lib.gating import api as gating_api from openedx.core.lib.gating import api as gating_api
from openedx.core.lib.time_zone_utils import get_user_time_zone
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
from shoppingcart.models import CourseRegistrationCode from shoppingcart.models import CourseRegistrationCode
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -514,6 +515,7 @@ def render_accordion(request, course, table_of_contents): ...@@ -514,6 +515,7 @@ def render_accordion(request, course, table_of_contents):
('course_id', unicode(course.id)), ('course_id', unicode(course.id)),
('csrf', csrf(request)['csrf_token']), ('csrf', csrf(request)['csrf_token']),
('due_date_display_format', course.due_date_display_format), ('due_date_display_format', course.due_date_display_format),
('time_zone', get_user_time_zone(request.user).zone),
] + TEMPLATE_IMPORTS.items() ] + TEMPLATE_IMPORTS.items()
) )
return render_to_string('courseware/accordion.html', context) return render_to_string('courseware/accordion.html', context)
......
...@@ -31,7 +31,7 @@ from openedx.core.djangoapps.programs.models import ProgramsApiConfig ...@@ -31,7 +31,7 @@ from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site, get_value as get_themed_value from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site, get_value as get_themed_value
from openedx.core.djangoapps.user_api.accounts.api import request_password_change from openedx.core.djangoapps.user_api.accounts.api import request_password_change
from openedx.core.djangoapps.user_api.errors import UserNotFound from openedx.core.djangoapps.user_api.errors import UserNotFound
from openedx.core.djangoapps.user_api.models import UserPreference from openedx.core.lib.time_zone_utils import TIME_ZONE_CHOICES
from openedx.core.lib.edx_api_utils import get_edx_api_data from openedx.core.lib.edx_api_utils import get_edx_api_data
from student.models import UserProfile from student.models import UserProfile
from student.views import ( from student.views import (
...@@ -449,7 +449,7 @@ def account_settings_context(request): ...@@ -449,7 +449,7 @@ def account_settings_context(request):
}, 'preferred_language': { }, 'preferred_language': {
'options': all_languages(), 'options': all_languages(),
}, 'time_zone': { }, 'time_zone': {
'options': UserPreference.TIME_ZONE_CHOICES, 'options': TIME_ZONE_CHOICES,
'enabled': settings.FEATURES.get('ENABLE_TIME_ZONE_PREFERENCE'), 'enabled': settings.FEATURES.get('ENABLE_TIME_ZONE_PREFERENCE'),
} }
}, },
......
...@@ -129,6 +129,10 @@ ...@@ -129,6 +129,10 @@
color: $alert-color; color: $alert-color;
} }
} }
.subtitle-name {
margin-right: 5px;
}
} }
&:hover, &:hover,
......
...@@ -36,7 +36,7 @@ else: ...@@ -36,7 +36,7 @@ else:
if section.get('due') is None: if section.get('due') is None:
due_date = '' due_date = ''
else: else:
formatted_string = get_time_display(section['due'], due_date_display_format, coerce_tz=settings.TIME_ZONE_DISPLAYED_FOR_DEADLINES) formatted_string = get_time_display(section['due'], due_date_display_format, coerce_tz=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)
%> %>
......
...@@ -10,6 +10,7 @@ from course_modes.models import CourseMode ...@@ -10,6 +10,7 @@ from course_modes.models import CourseMode
from course_modes.helpers import enrollment_mode_display from course_modes.helpers import enrollment_mode_display
from openedx.core.djangolib.js_utils import dump_js_escaped_json from openedx.core.djangolib.js_utils import dump_js_escaped_json
from openedx.core.djangolib.markup import HTML, Text from openedx.core.djangolib.markup import HTML, Text
from openedx.core.lib.time_zone_utils import get_user_time_zone
from student.helpers import ( from student.helpers import (
VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_NEED_TO_VERIFY,
VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_SUBMITTED,
...@@ -103,16 +104,17 @@ from student.helpers import ( ...@@ -103,16 +104,17 @@ from student.helpers import (
<span class="info-university">${course_overview.display_org_with_default} - </span> <span class="info-university">${course_overview.display_org_with_default} - </span>
<span class="info-course-id">${course_overview.display_number_with_default}</span> <span class="info-course-id">${course_overview.display_number_with_default}</span>
<span class="info-date-block" data-tooltip="Hi"> <span class="info-date-block" data-tooltip="Hi">
<% time_zone = get_user_time_zone(user) %>
% if course_overview.has_ended(): % if course_overview.has_ended():
${_("Ended - {end_date}").format(end_date=course_overview.end_datetime_text("SHORT_DATE"))} ${_("Ended - {end_date}").format(end_date=course_overview.end_datetime_text("SHORT_DATE", time_zone))}
% elif course_overview.has_started(): % elif course_overview.has_started():
${_("Started - {start_date}").format(start_date=course_overview.start_datetime_text("SHORT_DATE"))} ${_("Started - {start_date}").format(start_date=course_overview.start_datetime_text("SHORT_DATE", time_zone))}
% elif course_overview.start_date_is_still_default: # Course start date TBD % elif course_overview.start_date_is_still_default: # Course start date TBD
${_("Coming Soon")} ${_("Coming Soon")}
% elif course_overview.starts_within(days=5): # hasn't started yet % elif course_overview.starts_within(days=5): # hasn't started yet
${_("Starts - {start_date}").format(start_date=course_overview.start_datetime_text("DAY_AND_TIME"))} ${_("Starts - {start_date}").format(start_date=course_overview.start_datetime_text("DAY_AND_TIME", time_zone))}
% else: # hasn't started yet % else: # hasn't started yet
${_("Starts - {start_date}").format(start_date=course_overview.start_datetime_text("SHORT_DATE"))} ${_("Starts - {start_date}").format(start_date=course_overview.start_datetime_text("SHORT_DATE", time_zone))}
% endif % endif
</span> </span>
</div> </div>
......
...@@ -18,6 +18,7 @@ from opaque_keys.edx.keys import CourseKey ...@@ -18,6 +18,7 @@ from opaque_keys.edx.keys import CourseKey
from config_models.models import ConfigurationModel from config_models.models import ConfigurationModel
from lms.djangoapps import django_comment_client from lms.djangoapps import django_comment_client
from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.djangoapps.models.course_details import CourseDetails
from pytz import utc
from static_replace.models import AssetBaseUrlConfig from static_replace.models import AssetBaseUrlConfig
from util.date_utils import strftime_localized from util.date_utils import strftime_localized
from xmodule import course_metadata_utils, block_metadata_utils from xmodule import course_metadata_utils, block_metadata_utils
...@@ -359,15 +360,17 @@ class CourseOverview(TimeStampedModel): ...@@ -359,15 +360,17 @@ class CourseOverview(TimeStampedModel):
return course_metadata_utils.course_starts_within(self.start, days) return course_metadata_utils.course_starts_within(self.start, days)
def start_datetime_text(self, format_string="SHORT_DATE"): def start_datetime_text(self, format_string="SHORT_DATE", time_zone=utc):
""" """
Returns the desired text corresponding the course's start date and Returns the desired text corresponding to the course's start date and
time in UTC. Prefers .advertised_start, then falls back to .start. time in the specified time zone, or utc if no time zone given.
Prefers .advertised_start, then falls back to .start.
""" """
return course_metadata_utils.course_start_datetime_text( return course_metadata_utils.course_start_datetime_text(
self.start, self.start,
self.advertised_start, self.advertised_start,
format_string, format_string,
time_zone,
ugettext, ugettext,
strftime_localized strftime_localized
) )
...@@ -383,13 +386,14 @@ class CourseOverview(TimeStampedModel): ...@@ -383,13 +386,14 @@ class CourseOverview(TimeStampedModel):
self.advertised_start, self.advertised_start,
) )
def end_datetime_text(self, format_string="SHORT_DATE"): def end_datetime_text(self, format_string="SHORT_DATE", time_zone=utc):
""" """
Returns the end date or datetime for the course formatted as a string. Returns the end date or datetime for the course formatted as a string.
""" """
return course_metadata_utils.course_end_datetime_text( return course_metadata_utils.course_end_datetime_text(
self.end, self.end,
format_string, format_string,
time_zone,
strftime_localized strftime_localized
) )
......
...@@ -8,9 +8,6 @@ from django.db.models.signals import post_delete, pre_save, post_save ...@@ -8,9 +8,6 @@ from django.db.models.signals import post_delete, pre_save, post_save
from django.dispatch import receiver from django.dispatch import receiver
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from pytz import common_timezones
from util.date_utils import get_formatted_time_zone
from util.model_utils import get_changed_fields_dict, emit_setting_changed_event from util.model_utils import get_changed_fields_dict, emit_setting_changed_event
from xmodule_django.models import CourseKeyField from xmodule_django.models import CourseKeyField
...@@ -30,10 +27,6 @@ class UserPreference(models.Model): ...@@ -30,10 +27,6 @@ class UserPreference(models.Model):
key = models.CharField(max_length=255, db_index=True, validators=[RegexValidator(KEY_REGEX)]) key = models.CharField(max_length=255, db_index=True, validators=[RegexValidator(KEY_REGEX)])
value = models.TextField() value = models.TextField()
TIME_ZONE_CHOICES = [
(tz, get_formatted_time_zone(tz)) for tz in common_timezones
]
class Meta(object): class Meta(object):
unique_together = ("user", "key") unique_together = ("user", "key")
......
...@@ -396,7 +396,7 @@ def validate_user_preference_serializer(serializer, preference_key, preference_v ...@@ -396,7 +396,7 @@ def validate_user_preference_serializer(serializer, preference_key, preference_v
}) })
if preference_key == "time_zone" and preference_value not in common_timezones_set: if preference_key == "time_zone" and preference_value not in common_timezones_set:
developer_message = ugettext_noop(u"Value '{preference_value}' not valid for preference '{preference_key}': Not in timezone set.") # pylint: disable=line-too-long developer_message = ugettext_noop(u"Value '{preference_value}' not valid for preference '{preference_key}': Not in timezone set.") # pylint: disable=line-too-long
user_message = ugettext_noop(u"Value '{preference_value}' is not valid for user preference '{preference_key}'.") user_message = ugettext_noop(u"Value '{preference_value}' is not a valid time zone selection.")
raise PreferenceValidationError({ raise PreferenceValidationError({
preference_key: { preference_key: {
"developer_message": developer_message.format( "developer_message": developer_message.format(
......
...@@ -248,7 +248,7 @@ class TestPreferencesAPI(UserAPITestCase): ...@@ -248,7 +248,7 @@ class TestPreferencesAPI(UserAPITestCase):
"time_zone": { "time_zone": {
"developer_message": u"Value 'Asia/Africa' not valid for preference 'time_zone': Not in " "developer_message": u"Value 'Asia/Africa' not valid for preference 'time_zone': Not in "
u"timezone set.", u"timezone set.",
"user_message": u"Value 'Asia/Africa' is not valid for user preference 'time_zone'." "user_message": u"Value 'Asia/Africa' is not a valid time zone selection."
}, },
} }
) )
......
"""Tests covering time zone utilities."""
from freezegun import freeze_time
from student.tests.factories import UserFactory
from openedx.core.djangoapps.user_api.preferences.api import set_user_preference
from openedx.core.lib.time_zone_utils import (
get_formatted_time_zone, get_time_zone_abbr, get_time_zone_offset, get_user_time_zone
)
from pytz import timezone, utc
from unittest import TestCase
class TestTimeZoneUtils(TestCase):
"""
Tests the time zone utilities
"""
def setUp(self):
"""
Sets up user for testing with time zone utils.
"""
super(TestTimeZoneUtils, self).setUp()
self.user = UserFactory.build()
self.user.save()
def test_get_user_time_zone(self):
"""
Test to ensure get_user_time_zone() returns the correct time zone
or UTC if user has not specified time zone.
"""
# User time zone should be UTC when no time zone has been chosen
user_tz = get_user_time_zone(self.user)
self.assertEqual(user_tz, utc)
# User time zone should change when user specifies time zone
set_user_preference(self.user, 'time_zone', 'Asia/Tokyo')
user_tz = get_user_time_zone(self.user)
self.assertEqual(user_tz, timezone('Asia/Tokyo'))
def _formatted_time_zone_helper(self, time_zone_string):
"""
Helper function to return all info from get_formatted_time_zone()
"""
time_zone = timezone(time_zone_string)
tz_str = get_formatted_time_zone(time_zone)
tz_abbr = get_time_zone_abbr(time_zone)
tz_offset = get_time_zone_offset(time_zone)
return {'str': tz_str, 'abbr': tz_abbr, 'offset': tz_offset}
def _assert_time_zone_info_equal(self, formatted_tz_info, expected_name, expected_abbr, expected_offset):
"""
Asserts that all formatted_tz_info is equal to the expected inputs
"""
self.assertEqual(formatted_tz_info['str'], '{name} ({abbr}, UTC{offset})'.format(name=expected_name,
abbr=expected_abbr,
offset=expected_offset))
self.assertEqual(formatted_tz_info['abbr'], expected_abbr)
self.assertEqual(formatted_tz_info['offset'], expected_offset)
@freeze_time("2015-02-09")
def test_formatted_time_zone_without_dst(self):
"""
Test to ensure get_formatted_time_zone() returns full formatted string when no kwargs specified
and returns just abbreviation or offset when specified
"""
tz_info = self._formatted_time_zone_helper('America/Los_Angeles')
self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PST', '-0800')
@freeze_time("2015-04-02")
def test_formatted_time_zone_with_dst(self):
"""
Test to ensure get_formatted_time_zone() returns modified abbreviations and
offsets during daylight savings time.
"""
tz_info = self._formatted_time_zone_helper('America/Los_Angeles')
self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PDT', '-0700')
@freeze_time("2015-11-01 08:59:00")
def test_formatted_time_zone_ambiguous_before(self):
"""
Test to ensure get_formatted_time_zone() returns correct abbreviations and offsets
during ambiguous time periods (e.g. when DST is about to start/end) before the change
"""
tz_info = self._formatted_time_zone_helper('America/Los_Angeles')
self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PDT', '-0700')
@freeze_time("2015-11-01 09:00:00")
def test_formatted_time_zone_ambiguous_after(self):
"""
Test to ensure get_formatted_time_zone() returns correct abbreviations and offsets
during ambiguous time periods (e.g. when DST is about to start/end) after the change
"""
tz_info = self._formatted_time_zone_helper('America/Los_Angeles')
self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PST', '-0800')
"""
Utilities related to timezones
"""
from datetime import datetime
from pytz import common_timezones, timezone, utc
def get_user_time_zone(user):
"""
Returns pytz time zone object of the user's time zone if available or UTC time zone if unavailable
"""
#TODO: exception for unknown timezones?
time_zone = user.preferences.model.get_value(user, "time_zone")
if time_zone is not None:
return timezone(time_zone)
return utc
def _format_time_zone_string(time_zone, date_time, format_string):
"""
Returns a string, specified by format string, of the current date/time of the time zone.
:param time_zone: Pytz time zone object
:param date_time: datetime object of date to convert
:param format_string: A list of format codes can be found at:
https://docs.python.org/2/library/datetime.html#strftime-and-strptime-behavior
"""
return date_time.astimezone(time_zone).strftime(format_string)
def get_time_zone_abbr(time_zone, date_time=None):
"""
Returns the time zone abbreviation (e.g. EST) of the time zone for given datetime
"""
date_time = datetime.now(utc) if date_time is None else date_time
return _format_time_zone_string(time_zone, date_time, '%Z')
def get_time_zone_offset(time_zone, date_time=None):
"""
Returns the time zone offset (e.g. -0800) of the time zone for given datetime
"""
date_time = datetime.now(utc) if date_time is None else date_time
return _format_time_zone_string(time_zone, date_time, '%z')
def get_formatted_time_zone(time_zone):
"""
Returns a formatted time zone (e.g. 'Asia/Tokyo (JST, UTC+0900)')
:param time_zone: Pytz time zone object
"""
tz_abbr = get_time_zone_abbr(time_zone)
tz_offset = get_time_zone_offset(time_zone)
return "{name} ({abbr}, UTC{offset})".format(name=time_zone, abbr=tz_abbr, offset=tz_offset).replace("_", " ")
TIME_ZONE_CHOICES = sorted(
[(tz, get_formatted_time_zone(timezone(tz))) for tz in common_timezones],
key=lambda tz_tuple: tz_tuple[1]
)
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