Converts the dates on the dashboard, sidebar navigation, and important course dates to user specified time zone.

This commit is contained in:
Kevin Kim
2016-06-27 14:25:11 +00:00
parent 4bc81ac016
commit 0bf8fc4b44
21 changed files with 444 additions and 135 deletions

View File

@@ -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

View File

@@ -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",
}))

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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)

View File

@@ -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'),
}
},