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] +)