diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bbbb023527..d78d1c4cb6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -34,6 +34,9 @@ Blades: Staff debug info is now accessible for Graphical Slider Tool problems. Blades: For Video Alpha the events ready, play, pause, seek, and speed change are logged on the server (in the logs). +Common: all dates and times are not time zone aware datetimes. No code should create or use struct_times nor naive +datetimes. + Common: Developers can now have private Django settings files. Common: Safety code added to prevent anything above the vertical level in the diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index d0333cbe36..5dff0e1a20 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -10,7 +10,6 @@ import dateutil.parser from xmodule.modulestore import Location from xmodule.seq_module import SequenceDescriptor, SequenceModule -from xmodule.timeparse import parse_time from xmodule.util.decorators import lazyproperty from xmodule.graders import grader_from_conf import json @@ -645,8 +644,11 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): def start_date_text(self): def try_parse_iso_8601(text): try: - result = datetime.strptime(text, "%Y-%m-%dT%H:%M") - result = result.strftime("%b %d, %Y") + result = Date().from_json(text) + if result is None: + result = text.title() + else: + result = result.strftime("%b %d, %Y") except ValueError: result = text.title() @@ -670,8 +672,10 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): @property def forum_posts_allowed(self): + datestandin = Date() try: - blackout_periods = [(parse_time(start), parse_time(end)) + blackout_periods = [(datestandin.from_json(start), + datestandin.from_json(end)) for start, end in self.discussion_blackouts] now = datetime.now(UTC()) @@ -701,7 +705,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): if self.last_eligible_appointment_date is None: raise ValueError("Last appointment date must be specified") self.registration_start_date = (self._try_parse_time('Registration_Start_Date') or - datetime.utcfromtimestamp(0)) + datetime.fromtimestamp(0, UTC())) self.registration_end_date = self._try_parse_time('Registration_End_Date') or self.last_eligible_appointment_date # do validation within the exam info: if self.registration_start_date > self.registration_end_date: @@ -720,7 +724,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): """ if key in self.exam_info: try: - return parse_time(self.exam_info[key]) + return Date().from_json(self.exam_info[key]) except ValueError as e: msg = "Exam {0} in course {1} loaded with a bad exam_info key '{2}': '{3}'".format(self.exam_name, self.course_id, self.exam_info[key], e) log.warning(msg) diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py index 963b70204e..e4e0a19d34 100644 --- a/common/lib/xmodule/xmodule/fields.py +++ b/common/lib/xmodule/xmodule/fields.py @@ -6,7 +6,7 @@ from xblock.core import ModelType import datetime import dateutil.parser -from django.utils.timezone import UTC +from pytz import UTC log = logging.getLogger(__name__) @@ -15,6 +15,10 @@ class Date(ModelType): ''' Date fields know how to parse and produce json (iso) compatible formats. Converts to tz aware datetimes. ''' + # See note below about not defaulting these + CURRENT_YEAR = datetime.datetime.now(UTC).year + DEFAULT_DATE0 = datetime.datetime(CURRENT_YEAR, 1, 1, tzinfo=UTC) + DEFAULT_DATE1 = datetime.datetime(CURRENT_YEAR, 2, 2, tzinfo=UTC) def from_json(self, field): """ Parse an optional metadata key containing a time: if present, complain @@ -26,14 +30,21 @@ class Date(ModelType): elif field is "": return None elif isinstance(field, basestring): - result = dateutil.parser.parse(field) + # It's not trivial to replace dateutil b/c parsing timezones as Z, +03:30, -400 is hard in python + # however, we don't want dateutil to default the month or day (but some tests at least expect + # us to default year); so, we'll see if dateutil uses the defaults for these the hard way + result = dateutil.parser.parse(field, default=self.DEFAULT_DATE0) + result_other = dateutil.parser.parse(field, default=self.DEFAULT_DATE1) + if result != result_other: + log.warning("Field {0} is missing month or day".format(self._name, field)) + return None if result.tzinfo is None: - result = result.replace(tzinfo=UTC()) + result = result.replace(tzinfo=UTC) return result elif isinstance(field, (int, long, float)): - return datetime.datetime.fromtimestamp(field / 1000, UTC()) + return datetime.datetime.fromtimestamp(field / 1000, UTC) elif isinstance(field, time.struct_time): - return datetime.datetime.fromtimestamp(time.mktime(field), UTC()) + return datetime.datetime.fromtimestamp(time.mktime(field), UTC) elif isinstance(field, datetime.datetime): return field else: diff --git a/common/lib/xmodule/xmodule/tests/test_fields.py b/common/lib/xmodule/xmodule/tests/test_fields.py index 884f218d6d..ae3a15ab88 100644 --- a/common/lib/xmodule/xmodule/tests/test_fields.py +++ b/common/lib/xmodule/xmodule/tests/test_fields.py @@ -3,6 +3,7 @@ import datetime import unittest from django.utils.timezone import UTC from xmodule.fields import Date, Timedelta +from xmodule.timeinfo import TimeInfo class DateTest(unittest.TestCase): @@ -52,6 +53,7 @@ class DateTest(unittest.TestCase): self.assertEqual( datetime.datetime(current.year, 12, 4, 16, 30, tzinfo=UTC()), DateTest.date.from_json("December 4 16:30")) + self.assertIsNone(DateTest.date.from_json("12 12:00")) def test_to_json(self): ''' @@ -90,3 +92,12 @@ class TimedeltaTest(unittest.TestCase): '1 days 46799 seconds', TimedeltaTest.delta.to_json(datetime.timedelta(days=1, hours=12, minutes=59, seconds=59)) ) + + +class TimeInfoTest(unittest.TestCase): + def test_time_info(self): + due_date = datetime.datetime(2000, 4, 14, 10, tzinfo=UTC()) + grace_pd_string = '1 day 12 hours 59 minutes 59 seconds' + timeinfo = TimeInfo(due_date, grace_pd_string) + self.assertEqual(timeinfo.close_date, + due_date + Timedelta().from_json(grace_pd_string)) diff --git a/common/lib/xmodule/xmodule/timeinfo.py b/common/lib/xmodule/xmodule/timeinfo.py index 9a63c0477d..8f4d99506a 100644 --- a/common/lib/xmodule/xmodule/timeinfo.py +++ b/common/lib/xmodule/xmodule/timeinfo.py @@ -1,6 +1,5 @@ -from .timeparse import parse_timedelta - import logging +from xmodule.fields import Timedelta log = logging.getLogger(__name__) class TimeInfo(object): @@ -14,6 +13,7 @@ class TimeInfo(object): self.close_date - the real due date """ + _delta_standin = Timedelta() def __init__(self, due_date, grace_period_string): if due_date is not None: self.display_due_date = due_date @@ -23,7 +23,7 @@ class TimeInfo(object): if grace_period_string is not None and self.display_due_date: try: - self.grace_period = parse_timedelta(grace_period_string) + self.grace_period = TimeInfo._delta_standin.from_json(grace_period_string) self.close_date = self.display_due_date + self.grace_period except: log.error("Error parsing the grace period {0}".format(grace_period_string)) diff --git a/common/lib/xmodule/xmodule/timeparse.py b/common/lib/xmodule/xmodule/timeparse.py deleted file mode 100644 index b189262761..0000000000 --- a/common/lib/xmodule/xmodule/timeparse.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Helper functions for handling time in the format we like. -""" -import re -from datetime import timedelta, datetime - -TIME_FORMAT = "%Y-%m-%dT%H:%M" - -TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$') - -def parse_time(time_str): - """ - Takes a time string in TIME_FORMAT - - Returns it as a time_struct. - - Raises ValueError if the string is not in the right format. - """ - return datetime.strptime(time_str, TIME_FORMAT) - - -def stringify_time(dt): - """ - Convert a datetime struct to a string - """ - return dt.isoformat() - -def parse_timedelta(time_str): - """ - time_str: A string with the following components: - day[s] (optional) - hour[s] (optional) - minute[s] (optional) - second[s] (optional) - - Returns a datetime.timedelta parsed from the string - """ - parts = TIMEDELTA_REGEX.match(time_str) - if not parts: - return - parts = parts.groupdict() - time_params = {} - for (name, param) in parts.iteritems(): - if param: - time_params[name] = int(param) - return timedelta(**time_params)