diff --git a/common/djangoapps/util/date_utils.py b/common/djangoapps/util/date_utils.py
index 0156c1a2b7..5772238289 100644
--- a/common/djangoapps/util/date_utils.py
+++ b/common/djangoapps/util/date_utils.py
@@ -5,7 +5,6 @@ Convenience methods for working with datetime objects
from datetime import datetime, timedelta
import re
-from django.utils.timezone import now
from django.utils.translation import pgettext, ugettext
from pytz import timezone, utc, UnknownTimeZoneError
@@ -63,15 +62,6 @@ def get_time_display(dtime, format_string=None, coerce_tz=None):
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)):
"""
Returns true if these are w/in a minute of each other. (in case secs saved to db
diff --git a/common/djangoapps/util/tests/test_date_utils.py b/common/djangoapps/util/tests/test_date_utils.py
index 05b2e56e4f..a2222813cb 100644
--- a/common/djangoapps/util/tests/test_date_utils.py
+++ b/common/djangoapps/util/tests/test_date_utils.py
@@ -9,8 +9,7 @@ import unittest
import ddt
from mock import patch
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 (
get_default_time_display, get_time_display, almost_same_datetime,
strftime_localized,
@@ -19,7 +18,7 @@ from util.date_utils import (
def test_get_default_time_display():
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(
"Mar 12, 1992 at 15:03 UTC",
get_default_time_display(test_time))
@@ -34,12 +33,12 @@ def test_get_dflt_time_disp_notz():
def test_get_time_disp_ret_empty():
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, ""))
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("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'))
@@ -47,15 +46,15 @@ def test_get_time_display():
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, None))
assert_equals("Mar 12, 1992 at 15:03 UTC", get_time_display(test_time, "%"))
def test_get_time_display_coerce():
- 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_standard = datetime(1992, 1, 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",
get_time_display(test_time_standard, None, coerce_tz="US/Pacific"))
assert_equals("Jan 12, 1992 at 15:03 UTC",
diff --git a/common/lib/xmodule/xmodule/course_metadata_utils.py b/common/lib/xmodule/xmodule/course_metadata_utils.py
index 0586d10274..726104ff54 100644
--- a/common/lib/xmodule/xmodule/course_metadata_utils.py
+++ b/common/lib/xmodule/xmodule/course_metadata_utils.py
@@ -10,11 +10,12 @@ from datetime import datetime, timedelta
import dateutil.parser
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
-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):
@@ -56,7 +57,7 @@ def has_course_started(start_date):
start_date (datetime): The start datetime of the course in question.
"""
# 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):
@@ -68,7 +69,7 @@ def has_course_ended(end_date):
Arguments:
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):
@@ -80,7 +81,7 @@ def course_starts_within(start_date, look_ahead_days):
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.
"""
- 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):
@@ -95,30 +96,31 @@ def course_start_date_is_default(start, advertised_start):
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.
- 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:
date_time (datetime): the datetime to be formatted
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
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, format_string)
+ result = strftime_localized(date_time.astimezone(time_zone), format_string)
+ abbr = get_time_zone_abbr(time_zone, date_time)
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
)
-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
- datetime in UTC.
+ datetime in specified time zone.
Prefers .advertised_start, then falls back to .start.
@@ -126,6 +128,7 @@ def course_start_datetime_text(start_date, advertised_start, format_string, uget
start_date (datetime): the course's start datetime
advertised_start (str): the course's advertised start date
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
strftime_localized ((datetime, str) -> str): a localized string
formatting function
@@ -138,12 +141,12 @@ def course_start_datetime_text(start_date, advertised_start, format_string, uget
if parsed_advertised_start is not None:
# In the Django implementation of strftime_localized, if
# 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:
pass
return advertised_start.title()
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:
_ = ugettext
# 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
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.
@@ -160,11 +163,12 @@ def course_end_datetime_text(end_date, format_string, strftime_localized):
Arguments:
end_date (datetime): the end datetime of a course
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
formatting function
"""
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 ''
)
@@ -220,10 +224,10 @@ def sorting_dates(start, advertised_start, announcement):
try:
start = dateutil.parser.parse(advertised_start)
if start.tzinfo is None:
- start = start.replace(tzinfo=UTC())
+ start = start.replace(tzinfo=utc)
except (ValueError, AttributeError):
start = start
- now = datetime.now(UTC())
+ now = datetime.now(utc)
return announcement, start, now
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index bd9ff594ab..f452d65cbf 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -7,10 +7,10 @@ from cStringIO import StringIO
from datetime import datetime
import requests
-from django.utils.timezone import UTC
from lazy import lazy
from lxml import etree
from path import Path as path
+from pytz import utc
from xblock.fields import Scope, List, String, Dict, Boolean, Integer, Float
from xmodule import course_metadata_utils
@@ -106,7 +106,7 @@ class Textbook(object):
# see if we already fetched this
if toc_url in _cached_toc:
(table_of_contents, timestamp) = _cached_toc[toc_url]
- age = datetime.now(UTC) - timestamp
+ age = datetime.now(utc) - timestamp
# expire every 10 minutes
if age.seconds < 600:
return table_of_contents
@@ -1190,16 +1190,17 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
"""Return the course_id for this course"""
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,
- then falls back to .start
+ Returns the desired text corresponding the course's start date and time in specified time zone, defaulted
+ to UTC. Prefers .advertised_start, then falls back to .start
"""
i18n = self.runtime.service(self, "i18n")
return course_metadata_utils.course_start_datetime_text(
self.start,
self.advertised_start,
format_string,
+ time_zone,
i18n.ugettext,
i18n.strftime
)
@@ -1215,13 +1216,14 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
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.
"""
return course_metadata_utils.course_end_datetime_text(
self.end,
format_string,
+ time_zone,
self.runtime.service(self, "i18n").strftime
)
@@ -1256,7 +1258,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
setting
"""
blackouts = self.get_discussion_blackout_datetimes()
- now = datetime.now(UTC())
+ now = datetime.now(utc)
for blackout in blackouts:
if blackout["start"] <= now <= blackout["end"]:
return False
@@ -1384,7 +1386,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
Returns:
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):
diff --git a/common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py b/common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py
index f733bba3d6..05df588a7e 100644
--- a/common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py
+++ b/common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py
@@ -5,8 +5,7 @@ from collections import namedtuple
from datetime import timedelta, datetime
from unittest import TestCase
-from django.utils.timezone import UTC
-
+from pytz import timezone, utc
from xmodule.block_metadata_utils import (
url_name_for_block,
display_name_with_default,
@@ -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_WEEK = _TODAY - timedelta(days=7)
_NEXT_WEEK = _TODAY + timedelta(days=7)
@@ -107,14 +106,18 @@ class CourseMetadataUtilsTestCase(TestCase):
else:
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."""
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_bad_date = "215-01-01 10:10:10"
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
TestScenario = namedtuple('TestScenario', 'arguments expected_return') # pylint: disable=invalid-name
@@ -170,48 +173,73 @@ class CourseMetadataUtilsTestCase(TestCase):
# Test parsable advertised start date.
# Expect start datetime to be parsed and formatted back into a string.
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"
),
# Test un-parsable advertised start date.
# Expect date parsing to throw a ValueError, and the advertised
# start to be returned in Title Case.
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()
),
# Test parsable advertised start date from before January 1, 1900.
# Expect mock_strftime_localized to throw a ValueError, and the
# advertised start to be returned in Title Case.
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()
),
# Test without advertised start date, but with a set start datetime.
# Expect formatted datetime to be returned.
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')
),
# Test without advertised start date and with default start datetime.
# Expect TBD to be returned.
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'
+ ),
+ # 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, [
# Test with a set end datetime.
# Expect formatted datetime to be returned.
TestScenario(
- (test_datetime, 'TIME', mock_strftime_localized),
+ (test_datetime, 'TIME', utc, mock_strftime_localized),
mock_strftime_localized(test_datetime, 'TIME') + " UTC"
),
# Test with default end datetime.
# Expect empty string to be returned.
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, [
diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py
index 1ccb50c0fa..72a4d8d176 100644
--- a/common/lib/xmodule/xmodule/tests/test_course_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_course_module.py
@@ -1,25 +1,25 @@
+"""Tests the course modules and their functions"""
+import ddt
import unittest
from datetime import datetime, timedelta
-from fs.memoryfs import MemoryFS
-
-from mock import Mock, patch
import itertools
-
+from fs.memoryfs import MemoryFS
+from mock import Mock, patch
+from pytz import timezone, utc
from xblock.runtime import KvsFieldData, DictKeyValueStore
import xmodule.course_module
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore
from opaque_keys.edx.locations import SlashSeparatedCourseKey
-from django.utils.timezone import UTC
ORG = 'test_org'
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)
_NEXT_WEEK = _TODAY + timedelta(days=7)
@@ -28,7 +28,7 @@ class CourseFieldsTestCase(unittest.TestCase):
def test_default_start_date(self):
self.assertEqual(
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):
self.assertFalse(self.future_noshow_certs.may_certify())
+@ddt.ddt
class IsNewCourseTestCase(unittest.TestCase):
"""Make sure the property is_new works on courses"""
@@ -224,6 +225,20 @@ class IsNewCourseTestCase(unittest.TestCase):
print "Checking start=%s advertised=%s" % (setting[0], setting[1])
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):
for s in self.start_advertised_settings:
d = get_dummy_course(start=s[0], advertised_start=s[1])
@@ -277,6 +292,20 @@ class IsNewCourseTestCase(unittest.TestCase):
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"))
+ @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):
def test_default_discussion_topics(self):
diff --git a/lms/djangoapps/ccx/models.py b/lms/djangoapps/ccx/models.py
index 1daec6e9dd..5c6e8dd103 100644
--- a/lms/djangoapps/ccx/models.py
+++ b/lms/djangoapps/ccx/models.py
@@ -7,9 +7,10 @@ from datetime import datetime
from django.contrib.auth.models import User
from django.db import models
-from django.utils.timezone import UTC
+from pytz import utc
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.error_module import ErrorDescriptor
from xmodule.modulestore.django import modulestore
@@ -72,43 +73,43 @@ class CustomCourseForEdX(models.Model):
def has_started(self):
"""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):
"""Return True if the CCX due date is set and is in the past"""
if self.due is None:
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
- 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")
strftime = i18n.strftime
- value = strftime(self.start, format_string)
+ value = strftime(self.start.astimezone(time_zone), format_string)
if format_string == 'DATE_TIME':
- value += u' UTC'
+ value += ' ' + get_time_zone_abbr(time_zone, self.start)
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
If the due date for the CCX is not set, the value returned is the empty
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:
return ''
i18n = self.course.runtime.service(self.course, "i18n")
strftime = i18n.strftime
- value = strftime(self.due, format_string)
+ value = strftime(self.due.astimezone(time_zone), format_string)
if format_string == 'DATE_TIME':
- value += u' UTC'
+ value += ' ' + get_time_zone_abbr(time_zone, self.due)
return value
@property
diff --git a/lms/djangoapps/ccx/tests/test_models.py b/lms/djangoapps/ccx/tests/test_models.py
index ccc5ede839..e81681c43e 100644
--- a/lms/djangoapps/ccx/tests/test_models.py
+++ b/lms/djangoapps/ccx/tests/test_models.py
@@ -1,11 +1,12 @@
"""
tests for the models
"""
+import ddt
import json
from datetime import datetime, timedelta
-from django.utils.timezone import UTC
from mock import patch
from nose.plugins.attrib import attr
+from pytz import timezone, utc
from student.roles import CourseCcxCoachRole
from student.tests.factories import (
AdminFactory,
@@ -23,6 +24,7 @@ from .factories import (
from ..overrides import override_field_for_ccx
+@ddt.ddt
@attr('shard_1')
class TestCCX(ModuleStoreTestCase):
"""Unit tests for the CustomCourseForEdX model
@@ -65,7 +67,7 @@ class TestCCX(ModuleStoreTestCase):
For this reason we test the difference between and make sure it is less
than one second.
"""
- expected = datetime.now(UTC())
+ expected = datetime.now(utc)
self.set_ccx_override('start', expected)
actual = self.ccx.start # pylint: disable=no-member
diff = expected - actual
@@ -73,7 +75,7 @@ class TestCCX(ModuleStoreTestCase):
def test_ccx_start_caching(self):
"""verify that caching the start property works to limit queries"""
- now = datetime.now(UTC())
+ now = datetime.now(utc)
self.set_ccx_override('start', now)
with check_mongo_calls(1):
# these statements are used entirely to demonstrate the
@@ -90,7 +92,7 @@ class TestCCX(ModuleStoreTestCase):
def test_ccx_due_is_correct(self):
"""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)
actual = self.ccx.due # pylint: disable=no-member
diff = expected - actual
@@ -98,7 +100,7 @@ class TestCCX(ModuleStoreTestCase):
def test_ccx_due_caching(self):
"""verify that caching the due property works to limit queries"""
- expected = datetime.now(UTC())
+ expected = datetime.now(utc)
self.set_ccx_override('due', expected)
with check_mongo_calls(1):
# these statements are used entirely to demonstrate the
@@ -110,7 +112,7 @@ class TestCCX(ModuleStoreTestCase):
def test_ccx_has_started(self):
"""verify that a ccx marked as starting yesterday has started"""
- now = datetime.now(UTC())
+ now = datetime.now(utc)
delta = timedelta(1)
then = now - delta
self.set_ccx_override('start', then)
@@ -118,7 +120,7 @@ class TestCCX(ModuleStoreTestCase):
def test_ccx_has_not_started(self):
"""verify that a ccx marked as starting tomorrow has not started"""
- now = datetime.now(UTC())
+ now = datetime.now(utc)
delta = timedelta(1)
then = now + delta
self.set_ccx_override('start', then)
@@ -126,7 +128,7 @@ class TestCCX(ModuleStoreTestCase):
def test_ccx_has_ended(self):
"""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)
then = now - delta
self.set_ccx_override('due', then)
@@ -135,7 +137,7 @@ class TestCCX(ModuleStoreTestCase):
def test_ccx_has_not_ended(self):
"""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)
then = now + delta
self.set_ccx_override('due', then)
@@ -152,7 +154,7 @@ class TestCCX(ModuleStoreTestCase):
}))
def test_start_datetime_short_date(self):
"""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"
self.set_ccx_override('start', start)
actual = self.ccx.start_datetime_text() # pylint: disable=no-member
@@ -163,18 +165,34 @@ class TestCCX(ModuleStoreTestCase):
}))
def test_start_datetime_date_time_format(self):
"""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"
self.set_ccx_override('start', start)
actual = self.ccx.start_datetime_text('DATE_TIME') # pylint: disable=no-member
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={
"SHORT_DATE_FORMAT": "%b %d, %Y",
}))
def test_end_datetime_short_date(self):
"""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"
self.set_ccx_override('due', end)
actual = self.ccx.end_datetime_text() # pylint: disable=no-member
@@ -185,12 +203,28 @@ class TestCCX(ModuleStoreTestCase):
}))
def test_end_datetime_date_time_format(self):
"""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"
self.set_ccx_override('due', end)
actual = self.ccx.end_datetime_text('DATE_TIME') # pylint: disable=no-member
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={
"DATE_TIME_FORMAT": "%b %d, %Y at %H:%M",
}))
diff --git a/lms/djangoapps/courseware/date_summary.py b/lms/djangoapps/courseware/date_summary.py
index c0fd9daad0..abda7d96af 100644
--- a/lms/djangoapps/courseware/date_summary.py
+++ b/lms/djangoapps/courseware/date_summary.py
@@ -12,11 +12,12 @@ from django.utils.translation import ugettext_lazy
from django.utils.translation import to_locale, get_language
from edxmako.shortcuts import render_to_string
from lazy import lazy
-import pytz
+from pytz import utc
from course_modes.models import CourseMode
from lms.djangoapps.commerce.utils import EcommerceService
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
@@ -64,6 +65,11 @@ class DateSummary(object):
"""The text of the link."""
return ''
+ @property
+ def time_zone(self):
+ """The time zone to display in"""
+ return get_user_time_zone(self.user)
+
def __init__(self, course, user):
self.course = course
self.user = user
@@ -93,7 +99,7 @@ class DateSummary(object):
if self.date is None:
return ''
locale = to_locale(get_language())
- delta = self.date - datetime.now(pytz.UTC)
+ delta = self.date - datetime.now(utc)
try:
relative_date = format_timedelta(delta, locale=locale)
# Babel doesn't have translations for Esperanto, so we get
@@ -111,7 +117,7 @@ class DateSummary(object):
date_format = _(u"{relative} ago - {absolute}") if date_has_passed else _(u"in {relative} - {absolute}")
return date_format.format(
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
@@ -123,7 +129,7 @@ class DateSummary(object):
future.
"""
if self.date is not None:
- return datetime.now(pytz.UTC) <= self.date
+ return datetime.now(utc) <= self.date
return False
def __repr__(self):
@@ -143,7 +149,7 @@ class TodaysDate(DateSummary):
@property
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.
def get_context(self):
@@ -153,12 +159,12 @@ class TodaysDate(DateSummary):
@property
def date(self):
- return datetime.now(pytz.UTC)
+ return datetime.now(utc)
@property
def title(self):
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):
@property
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)
if is_active and CourseMode.is_eligible_for_certificate(mode):
return _('To earn a certificate, you must complete all requirements before this date.')
@@ -332,7 +338,7 @@ class VerificationDeadlineDate(DateSummary):
Return True if a verification deadline exists, and has already passed.
"""
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):
"""Return True if the user must re-submit verification, False otherwise."""
diff --git a/lms/djangoapps/courseware/tests/test_date_summary.py b/lms/djangoapps/courseware/tests/test_date_summary.py
index 69683d7602..6a79e94599 100644
--- a/lms/djangoapps/courseware/tests/test_date_summary.py
+++ b/lms/djangoapps/courseware/tests/test_date_summary.py
@@ -4,9 +4,9 @@ from datetime import datetime, timedelta
import ddt
from django.core.urlresolvers import reverse
-import freezegun
+from freezegun import freeze_time
from nose.plugins.attrib import attr
-import pytz
+from pytz import utc
from commerce.models import CommerceConfiguration
from course_modes.tests.factories import CourseModeFactory
@@ -21,6 +21,7 @@ from courseware.date_summary import (
VerifiedUpgradeDeadlineDate,
)
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 lms.djangoapps.verify_student.models import VerificationDeadline
from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
@@ -50,7 +51,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
sku=None
):
"""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
start=now + timedelta(days=days_till_start)
)
@@ -175,21 +176,49 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
## TodaysDate
- @freezegun.freeze_time('2015-01-02')
- def test_todays_date(self):
+ def _today_date_helper(self, expected_display_date):
+ """
+ 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()
+ set_user_preference(self.user, "time_zone", "America/Los_Angeles")
block = TodaysDate(self.course, self.user)
self.assertTrue(block.is_enabled)
- self.assertEqual(block.date, datetime.now(pytz.UTC))
- self.assertEqual(block.title, 'Today is Jan 02, 2015 (00:00 UTC)')
+ self.assertEqual(block.date, datetime.now(utc))
+ self.assertEqual(block.title, 'Today is {date}'.format(date=expected_display_date))
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):
self.setup_course_and_user()
block = TodaysDate(self.course, self.user)
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
def test_course_start_date(self):
@@ -197,12 +226,20 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
block = CourseStartDate(self.course, self.user)
self.assertEqual(block.date, self.course.start)
- @freezegun.freeze_time('2015-01-02')
+ @freeze_time('2015-01-02')
def test_start_date_render(self):
self.setup_course_and_user()
block = CourseStartDate(self.course, self.user)
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
def test_course_end_date_for_certificate_eligible_mode(self):
@@ -231,11 +268,11 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
## VerifiedUpgradeDeadlineDate
- @freezegun.freeze_time('2015-01-02')
+ @freeze_time('2015-01-02')
def test_verified_upgrade_deadline_date(self):
self.setup_course_and_user(days_till_upgrade_deadline=1)
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,)))
def test_without_upgrade_deadline(self):
@@ -267,13 +304,13 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
block = VerificationDeadlineDate(self.course, self.user)
self.assertFalse(block.is_enabled)
- @freezegun.freeze_time('2015-01-02')
+ @freeze_time('2015-01-02')
def test_verification_deadline_date_upcoming(self):
self.setup_course_and_user(days_till_start=-1)
block = VerificationDeadlineDate(self.course, self.user)
self.assertEqual(block.css_class, 'verification-deadline-upcoming')
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(
block.description,
'You must successfully complete verification before this date to qualify for a Verified Certificate.'
@@ -281,13 +318,13 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
self.assertEqual(block.link_text, 'Verify My Identity')
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):
self.setup_course_and_user(days_till_start=-1, verification_status='denied')
block = VerificationDeadlineDate(self.course, self.user)
self.assertEqual(block.css_class, 'verification-deadline-retry')
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(
block.description,
'You must successfully complete verification before this date to qualify for a Verified Certificate.'
@@ -295,7 +332,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
self.assertEqual(block.link_text, 'Retry Verification')
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):
self.setup_course_and_user(
days_till_start=-10,
@@ -305,7 +342,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
block = VerificationDeadlineDate(self.course, self.user)
self.assertEqual(block.css_class, 'verification-deadline-passed')
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(
block.description,
"Unfortunately you missed this course's deadline for a successful verification."
@@ -313,7 +350,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
self.assertEqual(block.link_text, 'Learn More')
self.assertEqual(block.link, '')
- @freezegun.freeze_time('2015-01-02')
+ @freeze_time('2015-01-02')
@ddt.data(
(-1, '1 day ago - Jan 01, 2015'),
(1, 'in 1 day - Jan 03, 2015')
@@ -327,3 +364,20 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
)
block = VerificationDeadlineDate(self.course, self.user)
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)
diff --git a/lms/djangoapps/courseware/views/index.py b/lms/djangoapps/courseware/views/index.py
index 5597b29745..e102eb27d4 100644
--- a/lms/djangoapps/courseware/views/index.py
+++ b/lms/djangoapps/courseware/views/index.py
@@ -26,6 +26,7 @@ from lang_pref import LANGUAGE_KEY
from xblock.fragment import Fragment
from opaque_keys.edx.keys import CourseKey
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 shoppingcart.models import CourseRegistrationCode
from student.models import CourseEnrollment
@@ -514,6 +515,7 @@ def render_accordion(request, course, table_of_contents):
('course_id', unicode(course.id)),
('csrf', csrf(request)['csrf_token']),
('due_date_display_format', course.due_date_display_format),
+ ('time_zone', get_user_time_zone(request.user).zone),
] + TEMPLATE_IMPORTS.items()
)
return render_to_string('courseware/accordion.html', context)
diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py
index f57798ecda..758e5bbc77 100644
--- a/lms/djangoapps/student_account/views.py
+++ b/lms/djangoapps/student_account/views.py
@@ -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.user_api.accounts.api import request_password_change
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 student.models import UserProfile
from student.views import (
@@ -449,7 +449,7 @@ def account_settings_context(request):
}, 'preferred_language': {
'options': all_languages(),
}, 'time_zone': {
- 'options': UserPreference.TIME_ZONE_CHOICES,
+ 'options': TIME_ZONE_CHOICES,
'enabled': settings.FEATURES.get('ENABLE_TIME_ZONE_PREFERENCE'),
}
},
diff --git a/lms/static/sass/course/courseware/_sidebar.scss b/lms/static/sass/course/courseware/_sidebar.scss
index c609960215..63624989d3 100644
--- a/lms/static/sass/course/courseware/_sidebar.scss
+++ b/lms/static/sass/course/courseware/_sidebar.scss
@@ -129,6 +129,10 @@
color: $alert-color;
}
}
+
+ .subtitle-name {
+ margin-right: 5px;
+ }
}
&:hover,
diff --git a/lms/templates/courseware/accordion.html b/lms/templates/courseware/accordion.html
index b684299539..621ee43842 100644
--- a/lms/templates/courseware/accordion.html
+++ b/lms/templates/courseware/accordion.html
@@ -36,7 +36,7 @@ else:
if section.get('due') is None:
due_date = ''
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)
%>
diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html
index de1e6b354c..9f34081c62 100644
--- a/lms/templates/dashboard/_dashboard_course_listing.html
+++ b/lms/templates/dashboard/_dashboard_course_listing.html
@@ -10,6 +10,7 @@ from course_modes.models import CourseMode
from course_modes.helpers import enrollment_mode_display
from openedx.core.djangolib.js_utils import dump_js_escaped_json
from openedx.core.djangolib.markup import HTML, Text
+from openedx.core.lib.time_zone_utils import get_user_time_zone
from student.helpers import (
VERIFY_STATUS_NEED_TO_VERIFY,
VERIFY_STATUS_SUBMITTED,
@@ -103,16 +104,17 @@ from student.helpers import (
${course_overview.display_org_with_default} -
${course_overview.display_number_with_default}
+ <% time_zone = get_user_time_zone(user) %>
% 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():
- ${_("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
${_("Coming Soon")}
% 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
- ${_("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
diff --git a/openedx/core/djangoapps/content/course_overviews/models.py b/openedx/core/djangoapps/content/course_overviews/models.py
index 8cfdb6e515..a16d83fb0d 100644
--- a/openedx/core/djangoapps/content/course_overviews/models.py
+++ b/openedx/core/djangoapps/content/course_overviews/models.py
@@ -18,6 +18,7 @@ from opaque_keys.edx.keys import CourseKey
from config_models.models import ConfigurationModel
from lms.djangoapps import django_comment_client
from openedx.core.djangoapps.models.course_details import CourseDetails
+from pytz import utc
from static_replace.models import AssetBaseUrlConfig
from util.date_utils import strftime_localized
from xmodule import course_metadata_utils, block_metadata_utils
@@ -359,15 +360,17 @@ class CourseOverview(TimeStampedModel):
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
- time in UTC. Prefers .advertised_start, then falls back to .start.
+ Returns the desired text corresponding to the course's start date and
+ 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(
self.start,
self.advertised_start,
format_string,
+ time_zone,
ugettext,
strftime_localized
)
@@ -383,13 +386,14 @@ class CourseOverview(TimeStampedModel):
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.
"""
return course_metadata_utils.course_end_datetime_text(
self.end,
format_string,
+ time_zone,
strftime_localized
)
diff --git a/openedx/core/djangoapps/user_api/models.py b/openedx/core/djangoapps/user_api/models.py
index b6d519d11a..6d893ef6f3 100644
--- a/openedx/core/djangoapps/user_api/models.py
+++ b/openedx/core/djangoapps/user_api/models.py
@@ -8,9 +8,6 @@ from django.db.models.signals import post_delete, pre_save, post_save
from django.dispatch import receiver
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 xmodule_django.models import CourseKeyField
@@ -30,10 +27,6 @@ class UserPreference(models.Model):
key = models.CharField(max_length=255, db_index=True, validators=[RegexValidator(KEY_REGEX)])
value = models.TextField()
- TIME_ZONE_CHOICES = [
- (tz, get_formatted_time_zone(tz)) for tz in common_timezones
- ]
-
class Meta(object):
unique_together = ("user", "key")
diff --git a/openedx/core/djangoapps/user_api/preferences/api.py b/openedx/core/djangoapps/user_api/preferences/api.py
index 4976a7600e..a3184ddf8b 100644
--- a/openedx/core/djangoapps/user_api/preferences/api.py
+++ b/openedx/core/djangoapps/user_api/preferences/api.py
@@ -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:
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({
preference_key: {
"developer_message": developer_message.format(
diff --git a/openedx/core/djangoapps/user_api/preferences/tests/test_views.py b/openedx/core/djangoapps/user_api/preferences/tests/test_views.py
index 111fff536b..e55ecba99e 100644
--- a/openedx/core/djangoapps/user_api/preferences/tests/test_views.py
+++ b/openedx/core/djangoapps/user_api/preferences/tests/test_views.py
@@ -248,7 +248,7 @@ class TestPreferencesAPI(UserAPITestCase):
"time_zone": {
"developer_message": u"Value 'Asia/Africa' not valid for preference 'time_zone': Not in "
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."
},
}
)
diff --git a/openedx/core/lib/tests/test_time_zone_utils.py b/openedx/core/lib/tests/test_time_zone_utils.py
new file mode 100644
index 0000000000..3133841612
--- /dev/null
+++ b/openedx/core/lib/tests/test_time_zone_utils.py
@@ -0,0 +1,94 @@
+"""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')
diff --git a/openedx/core/lib/time_zone_utils.py b/openedx/core/lib/time_zone_utils.py
new file mode 100644
index 0000000000..35f792e579
--- /dev/null
+++ b/openedx/core/lib/time_zone_utils.py
@@ -0,0 +1,63 @@
+"""
+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]
+)