forked from openedx/edx-platform
-
Notifications
You must be signed in to change notification settings - Fork 2
/
fields.py
325 lines (264 loc) · 11.3 KB
/
fields.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
# lint-amnesty, pylint: disable=missing-module-docstring
import datetime
import logging
import re
import time
import dateutil.parser
from pytz import UTC
from xblock.fields import JSONField, List
from xblock.scorable import Score
log = logging.getLogger(__name__)
class Date(JSONField):
'''
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
PREVENT_DEFAULT_DAY_MON_SEED1 = datetime.datetime(CURRENT_YEAR, 1, 1, tzinfo=UTC)
PREVENT_DEFAULT_DAY_MON_SEED2 = datetime.datetime(CURRENT_YEAR, 2, 2, tzinfo=UTC)
MUTABLE = False
def _parse_date_wo_default_month_day(self, field):
"""
Parse the field as an iso string but prevent dateutils from defaulting the day or month while
allowing it to default the other fields.
"""
# 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.PREVENT_DEFAULT_DAY_MON_SEED1)
result_other = dateutil.parser.parse(field, default=self.PREVENT_DEFAULT_DAY_MON_SEED2)
if result != result_other:
log.warning(f"Field {self.name} is missing month or day")
return None
if result.tzinfo is None:
result = result.replace(tzinfo=UTC)
return result
def from_json(self, field): # lint-amnesty, pylint: disable=arguments-differ
"""
Parse an optional metadata key containing a time: if present, complain
if it doesn't parse.
Return None if not present or invalid.
"""
if field is None:
return field
elif field == "":
return None
elif isinstance(field, str):
return self._parse_date_wo_default_month_day(field)
elif isinstance(field, int) or isinstance(field, float): # lint-amnesty, pylint: disable=consider-merging-isinstance
return datetime.datetime.fromtimestamp(field / 1000, UTC)
elif isinstance(field, time.struct_time):
return datetime.datetime.fromtimestamp(time.mktime(field), UTC)
elif isinstance(field, datetime.datetime):
return field
else:
msg = "Field {} has bad value '{}'".format(
self.name, field)
raise TypeError(msg)
def to_json(self, value):
"""
Convert a time struct to a string
"""
if value is None:
return None
if isinstance(value, time.struct_time):
# struct_times are always utc
return time.strftime('%Y-%m-%dT%H:%M:%SZ', value)
elif isinstance(value, datetime.datetime):
if value.tzinfo is None or value.utcoffset().total_seconds() == 0:
if value.year < 1900:
# strftime doesn't work for pre-1900 dates, so use
# isoformat instead
return value.isoformat()
# isoformat adds +00:00 rather than Z
return value.strftime('%Y-%m-%dT%H:%M:%SZ')
else:
return value.isoformat()
else:
raise TypeError(f"Cannot convert {value!r} to json")
enforce_type = from_json
TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$') # lint-amnesty, pylint: disable=line-too-long
class Timedelta(JSONField): # lint-amnesty, pylint: disable=missing-class-docstring
# Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types
MUTABLE = False
def from_json(self, time_str): # lint-amnesty, pylint: disable=arguments-differ
"""
time_str: A string with the following components:
<D> day[s] (optional)
<H> hour[s] (optional)
<M> minute[s] (optional)
<S> second[s] (optional)
Returns a datetime.timedelta parsed from the string
"""
if time_str is None:
return None
if isinstance(time_str, datetime.timedelta):
return time_str
parts = TIMEDELTA_REGEX.match(time_str)
if not parts:
return
parts = parts.groupdict()
time_params = {}
for (name, param) in parts.items():
if param:
time_params[name] = int(param)
return datetime.timedelta(**time_params)
def to_json(self, value):
if value is None:
return None
values = []
for attr in ('days', 'hours', 'minutes', 'seconds'):
cur_value = getattr(value, attr, 0)
if cur_value > 0:
values.append("%d %s" % (cur_value, attr))
return ' '.join(values)
def enforce_type(self, value):
"""
Ensure that when set explicitly the Field is set to a timedelta
"""
if isinstance(value, datetime.timedelta) or value is None:
return value
return self.from_json(value)
class RelativeTime(JSONField):
"""
Field for start_time and end_time video block properties.
It was decided, that python representation of start_time and end_time
should be python datetime.timedelta object, to be consistent with
common time representation.
At the same time, serialized representation should be "HH:MM:SS"
This format is convenient to use in XML (and it is used now),
and also it is used in frond-end studio editor of video block as format
for start and end time fields.
In database we previously had float type for start_time and end_time fields,
so we are checking it also.
Python object of RelativeTime is datetime.timedelta.
JSONed representation of RelativeTime is "HH:MM:SS"
"""
# Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types
MUTABLE = False
@classmethod
def isotime_to_timedelta(cls, value):
"""
Validate that value in "HH:MM:SS" format and convert to timedelta.
Validate that user, that edits XML, sets proper format, and
that max value that can be used by user is "23:59:59".
"""
try:
obj_time = time.strptime(value, '%H:%M:%S')
except ValueError as e:
raise ValueError( # lint-amnesty, pylint: disable=raise-missing-from
"Incorrect RelativeTime value {!r} was set in XML or serialized. "
"Original parse message is {}".format(value, str(e))
)
return datetime.timedelta(
hours=obj_time.tm_hour,
minutes=obj_time.tm_min,
seconds=obj_time.tm_sec
)
def from_json(self, value):
"""
Convert value is in 'HH:MM:SS' format to datetime.timedelta.
If not value, returns 0.
If value is float (backward compatibility issue), convert to timedelta.
"""
if not value:
return datetime.timedelta(seconds=0)
if isinstance(value, datetime.timedelta):
return value
# We've seen serialized versions of float in this field
if isinstance(value, float):
return datetime.timedelta(seconds=value)
if isinstance(value, str):
return self.isotime_to_timedelta(value)
msg = f"RelativeTime Field {self.name} has bad value '{value!r}'"
raise TypeError(msg)
def to_json(self, value):
"""
Convert datetime.timedelta to "HH:MM:SS" format.
If not value, return "00:00:00"
Backward compatibility: check if value is float, and convert it. No exceptions here.
If value is not float, but is exceed 23:59:59, raise exception.
"""
if not value:
return "00:00:00"
if isinstance(value, float): # backward compatibility
value = min(value, 86400)
return self.timedelta_to_string(datetime.timedelta(seconds=value))
if isinstance(value, datetime.timedelta):
if value.total_seconds() > 86400: # sanity check
raise ValueError(
"RelativeTime max value is 23:59:59=86400.0 seconds, "
"but {} seconds is passed".format(value.total_seconds())
)
return self.timedelta_to_string(value)
raise TypeError(f"RelativeTime: cannot convert {value!r} to json")
def timedelta_to_string(self, value):
"""
Makes first 'H' in str representation non-optional.
str(timedelta) has [H]H:MM:SS format, which is not suitable
for front-end (and ISO time standard), so we force HH:MM:SS format.
"""
stringified = str(value)
if len(stringified) == 7:
stringified = '0' + stringified
return stringified
def enforce_type(self, value):
"""
Ensure that when set explicitly the Field is set to a timedelta
"""
if isinstance(value, datetime.timedelta) or value is None:
return value
return self.from_json(value)
class ScoreField(JSONField):
"""
Field for blocks that need to store a Score. XBlocks that implement
the ScorableXBlockMixin may need to store their score separately
from their problem state, specifically for use in staff override
of problem scores.
"""
MUTABLE = False
def from_json(self, value):
if value is None:
return value
if isinstance(value, Score):
return value
if set(value) != {'raw_earned', 'raw_possible'}:
raise TypeError('Scores must contain only a raw earned and raw possible value. Got {}'.format(
set(value)
))
raw_earned = value['raw_earned']
raw_possible = value['raw_possible']
if raw_possible < 0:
raise ValueError(
'Error deserializing field of type {}: Expected a positive number for raw_possible, got {}.'.format(
self.display_name,
raw_possible,
)
)
if not (0 <= raw_earned <= raw_possible): # lint-amnesty, pylint: disable=superfluous-parens
raise ValueError(
'Error deserializing field of type {}: Expected raw_earned between 0 and {}, got {}.'.format(
self.display_name,
raw_possible,
raw_earned
)
)
return Score(raw_earned, raw_possible)
enforce_type = from_json
class ListScoreField(ScoreField, List):
"""
Field for blocks that need to store a list of Scores.
"""
MUTABLE = True
_default = []
def from_json(self, value):
if value is None:
return value
if isinstance(value, list):
scores = []
for score_json in value:
score = super().from_json(score_json)
scores.append(score)
return scores
raise TypeError("Value must be a list of Scores. Got {}".format(type(value)))
enforce_type = from_json