Skip to content

Commit c3f93b2

Browse files
authored
chore: move fields from xmodule to core (#881)
1 parent 218f1be commit c3f93b2

File tree

2 files changed

+599
-4
lines changed

2 files changed

+599
-4
lines changed

xblock/fields.py

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,29 @@
1111
import hashlib
1212
import itertools
1313
import json
14+
import logging
1415
import re
16+
import time
1517
import traceback
1618
import warnings
1719

1820
import dateutil.parser
1921
from lxml import etree
2022
import pytz
2123
import yaml
24+
from pytz import UTC
25+
26+
from xblock.scorable import Score
27+
28+
log = logging.getLogger(__name__)
2229

2330

2431
# __all__ controls what classes end up in the docs, and in what order.
2532
__all__ = [
2633
'BlockScope', 'UserScope', 'Scope', 'ScopeIds',
2734
'Field',
2835
'Boolean', 'Dict', 'Float', 'Integer', 'List', 'Set', 'String', 'XMLString',
36+
"Date", "DateTime", "Timedelta", "RelativeTime", "ScoreField", "ListScoreField",
2937
]
3038

3139

@@ -939,6 +947,89 @@ def enforce_type(self, value):
939947
return value
940948

941949

950+
class Date(JSONField):
951+
"""
952+
Date fields know how to parse and produce json (iso) compatible formats. Converts to tz aware datetimes.
953+
"""
954+
955+
# See note below about not defaulting these
956+
CURRENT_YEAR = datetime.datetime.now(UTC).year
957+
PREVENT_DEFAULT_DAY_MON_SEED1 = datetime.datetime(CURRENT_YEAR, 1, 1, tzinfo=UTC)
958+
PREVENT_DEFAULT_DAY_MON_SEED2 = datetime.datetime(CURRENT_YEAR, 2, 2, tzinfo=UTC)
959+
960+
MUTABLE = False
961+
962+
def _parse_date_wo_default_month_day(self, field):
963+
"""
964+
Parse the field as an iso string but prevent dateutils from defaulting the day or month while
965+
allowing it to default the other fields.
966+
"""
967+
# It's not trivial to replace dateutil b/c parsing timezones as Z, +03:30, -400 is hard in python
968+
# however, we don't want dateutil to default the month or day (but some tests at least expect
969+
# us to default year); so, we'll see if dateutil uses the defaults for these the hard way
970+
result = dateutil.parser.parse(field, default=self.PREVENT_DEFAULT_DAY_MON_SEED1)
971+
result_other = dateutil.parser.parse(field, default=self.PREVENT_DEFAULT_DAY_MON_SEED2)
972+
if result != result_other:
973+
log.warning("Field %s is missing month or day", self.name)
974+
return None
975+
if result.tzinfo is None:
976+
result = result.replace(tzinfo=UTC)
977+
return result
978+
979+
def from_json(self, value):
980+
"""
981+
Parse an optional metadata key containing a time: if present, complain
982+
if it doesn't parse.
983+
Return None if not present or invalid.
984+
"""
985+
if value is None:
986+
return value
987+
988+
if value == "":
989+
return None
990+
991+
if isinstance(value, str):
992+
return self._parse_date_wo_default_month_day(value)
993+
994+
if isinstance(value, (int, float)):
995+
return datetime.datetime.fromtimestamp(value / 1000, UTC)
996+
997+
if isinstance(value, time.struct_time):
998+
return datetime.datetime.fromtimestamp(time.mktime(value), UTC)
999+
1000+
if isinstance(value, datetime.datetime):
1001+
return value
1002+
1003+
msg = f"value {self.name} has bad value '{value}'"
1004+
raise TypeError(msg)
1005+
1006+
def to_json(self, value):
1007+
"""
1008+
Convert a time struct to a string
1009+
"""
1010+
if value is None:
1011+
return None
1012+
1013+
if isinstance(value, time.struct_time):
1014+
# struct_times are always utc
1015+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", value)
1016+
1017+
if isinstance(value, datetime.datetime):
1018+
if value.tzinfo is None or value.utcoffset().total_seconds() == 0:
1019+
if value.year < 1900:
1020+
# strftime doesn't work for pre-1900 dates, so use
1021+
# isoformat instead
1022+
return value.isoformat()
1023+
# isoformat adds +00:00 rather than Z
1024+
return value.strftime("%Y-%m-%dT%H:%M:%SZ")
1025+
1026+
return value.isoformat()
1027+
1028+
raise TypeError(f"Cannot convert {value!r} to json")
1029+
1030+
enforce_type = from_json
1031+
1032+
9421033
class DateTime(JSONField):
9431034
"""
9441035
A field for representing a datetime.
@@ -1010,6 +1101,178 @@ def to_string(self, value):
10101101
enforce_type = from_json
10111102

10121103

1104+
TIMEDELTA_REGEX = re.compile(
1105+
r"^((?P<days>\d+?) day(?:s?))?(\s)?"
1106+
r"((?P<hours>\d+?) hour(?:s?))?(\s)?"
1107+
r"((?P<minutes>\d+?) minute(?:s)?)?(\s)?"
1108+
r"((?P<seconds>\d+?) second(?:s)?)?$"
1109+
)
1110+
1111+
1112+
class Timedelta(JSONField):
1113+
"""Field type for serializing/deserializing timedelta values to/from human-readable strings."""
1114+
1115+
# Timedeltas are immutable, see https://docs.python.org/3/library/datetime.html#available-types
1116+
MUTABLE = False
1117+
1118+
def from_json(self, value):
1119+
"""
1120+
value: A string with the following components:
1121+
<D> day[s] (optional)
1122+
<H> hour[s] (optional)
1123+
<M> minute[s] (optional)
1124+
<S> second[s] (optional)
1125+
1126+
Returns a datetime.timedelta parsed from the string
1127+
"""
1128+
if value is None:
1129+
return None
1130+
1131+
if isinstance(value, datetime.timedelta):
1132+
return value
1133+
1134+
parts = TIMEDELTA_REGEX.match(value)
1135+
if not parts:
1136+
return None
1137+
parts = parts.groupdict()
1138+
time_params = {}
1139+
for name, param in parts.items():
1140+
if param:
1141+
time_params[name] = int(param)
1142+
return datetime.timedelta(**time_params)
1143+
1144+
def to_json(self, value):
1145+
"""Serialize a datetime.timedelta object into a human-readable string."""
1146+
if value is None:
1147+
return None
1148+
1149+
values = []
1150+
for attr in ("days", "hours", "minutes", "seconds"):
1151+
cur_value = getattr(value, attr, 0)
1152+
if cur_value > 0:
1153+
values.append(f"{cur_value} {attr}")
1154+
return " ".join(values)
1155+
1156+
def enforce_type(self, value):
1157+
"""
1158+
Ensure that when set explicitly the Field is set to a timedelta
1159+
"""
1160+
if isinstance(value, datetime.timedelta) or value is None:
1161+
return value
1162+
1163+
return self.from_json(value)
1164+
1165+
1166+
class RelativeTime(JSONField):
1167+
"""
1168+
Field for start_time and end_time video block properties.
1169+
1170+
It was decided, that python representation of start_time and end_time
1171+
should be python datetime.timedelta object, to be consistent with
1172+
common time representation.
1173+
1174+
At the same time, serialized representation should be "HH:MM:SS"
1175+
This format is convenient to use in XML (and it is used now),
1176+
and also it is used in frond-end studio editor of video block as format
1177+
for start and end time fields.
1178+
1179+
In database we previously had float type for start_time and end_time fields,
1180+
so we are checking it also.
1181+
1182+
Python object of RelativeTime is datetime.timedelta.
1183+
JSONed representation of RelativeTime is "HH:MM:SS"
1184+
"""
1185+
1186+
# Timedeltas are immutable, see https://docs.python.org/3/library/datetime.html#available-types
1187+
MUTABLE = False
1188+
1189+
@classmethod
1190+
def isotime_to_timedelta(cls, value):
1191+
"""
1192+
Validate that value in "HH:MM:SS" format and convert to timedelta.
1193+
1194+
Validate that user, that edits XML, sets proper format, and
1195+
that max value that can be used by user is "23:59:59".
1196+
"""
1197+
try:
1198+
obj_time = time.strptime(value, "%H:%M:%S")
1199+
except ValueError as e:
1200+
raise ValueError(
1201+
f"Incorrect RelativeTime value {value!r} was set in XML or serialized. Original parse message is {e}"
1202+
) from e
1203+
return datetime.timedelta(hours=obj_time.tm_hour, minutes=obj_time.tm_min, seconds=obj_time.tm_sec)
1204+
1205+
def from_json(self, value):
1206+
"""
1207+
Convert value is in 'HH:MM:SS' format to datetime.timedelta.
1208+
1209+
If not value, returns 0.
1210+
If value is float (backward compatibility issue), convert to timedelta.
1211+
"""
1212+
if not value:
1213+
return datetime.timedelta(seconds=0)
1214+
1215+
if isinstance(value, datetime.timedelta):
1216+
return value
1217+
1218+
# We've seen serialized versions of float in this field
1219+
if isinstance(value, float):
1220+
return datetime.timedelta(seconds=value)
1221+
1222+
if isinstance(value, str):
1223+
return self.isotime_to_timedelta(value)
1224+
1225+
msg = f"RelativeTime Field {self.name} has bad value '{value!r}'"
1226+
raise TypeError(msg)
1227+
1228+
def to_json(self, value):
1229+
"""
1230+
Convert datetime.timedelta to "HH:MM:SS" format.
1231+
1232+
If not value, return "00:00:00"
1233+
1234+
Backward compatibility: check if value is float, and convert it. No exceptions here.
1235+
1236+
If value is not float, but is exceed 23:59:59, raise exception.
1237+
"""
1238+
if not value:
1239+
return "00:00:00"
1240+
1241+
if isinstance(value, float): # backward compatibility
1242+
value = min(value, 86400)
1243+
return self.timedelta_to_string(datetime.timedelta(seconds=value))
1244+
1245+
if isinstance(value, datetime.timedelta):
1246+
if value.total_seconds() > 86400: # sanity check
1247+
raise ValueError(
1248+
f"RelativeTime max value is 23:59:59=86400.0 seconds, but {value.total_seconds()} seconds is passed"
1249+
)
1250+
return self.timedelta_to_string(value)
1251+
1252+
raise TypeError(f"RelativeTime: cannot convert {value!r} to json")
1253+
1254+
def timedelta_to_string(self, value):
1255+
"""
1256+
Makes first 'H' in str representation non-optional.
1257+
1258+
str(timedelta) has [H]H:MM:SS format, which is not suitable
1259+
for front-end (and ISO time standard), so we force HH:MM:SS format.
1260+
"""
1261+
stringified = str(value)
1262+
if len(stringified) == 7:
1263+
stringified = "0" + stringified
1264+
return stringified
1265+
1266+
def enforce_type(self, value):
1267+
"""
1268+
Ensure that when set explicitly the Field is set to a timedelta
1269+
"""
1270+
if isinstance(value, datetime.timedelta) or value is None:
1271+
return value
1272+
1273+
return self.from_json(value)
1274+
1275+
10131276
class Any(JSONField):
10141277
"""
10151278
A field class for representing any piece of data; type is not enforced.
@@ -1051,6 +1314,69 @@ class ReferenceValueDict(Dict):
10511314
# but since Reference doesn't stipulate a definition for from/to, that seems unnecessary at this time.
10521315

10531316

1317+
class ScoreField(JSONField):
1318+
"""
1319+
Field for blocks that need to store a Score. XBlocks that implement
1320+
the ScorableXBlockMixin may need to store their score separately
1321+
from their problem state, specifically for use in staff override
1322+
of problem scores.
1323+
"""
1324+
1325+
MUTABLE = False
1326+
1327+
def from_json(self, value):
1328+
"""Deserialize a dict with 'raw_earned' and 'raw_possible' into a Score object, validating values."""
1329+
if value is None:
1330+
return value
1331+
if isinstance(value, Score):
1332+
return value
1333+
1334+
if set(value) != {"raw_earned", "raw_possible"}:
1335+
raise TypeError(f"Scores must contain only a raw earned and raw possible value. Got {set(value)}")
1336+
1337+
raw_earned = value["raw_earned"]
1338+
raw_possible = value["raw_possible"]
1339+
1340+
if raw_possible < 0:
1341+
raise ValueError(
1342+
f"Error deserializing field of type {self.display_name}: "
1343+
f"Expected a positive number for raw_possible, got {raw_possible}."
1344+
)
1345+
1346+
if not 0 <= raw_earned <= raw_possible:
1347+
raise ValueError(
1348+
f"Error deserializing field of type {self.display_name}: "
1349+
f"Expected raw_earned between 0 and {raw_possible}, got {raw_earned}."
1350+
)
1351+
1352+
return Score(raw_earned, raw_possible)
1353+
1354+
enforce_type = from_json
1355+
1356+
1357+
class ListScoreField(ScoreField, List):
1358+
"""
1359+
Field for blocks that need to store a list of Scores.
1360+
"""
1361+
1362+
MUTABLE = True
1363+
_default = []
1364+
1365+
def from_json(self, value):
1366+
if value is None:
1367+
return value
1368+
if isinstance(value, list):
1369+
scores = []
1370+
for score_json in value:
1371+
score = super().from_json(score_json)
1372+
scores.append(score)
1373+
return scores
1374+
1375+
raise TypeError(f"Value must be a list of Scores. Got {type(value)}")
1376+
1377+
enforce_type = from_json
1378+
1379+
10541380
def scope_key(instance, xblock):
10551381
"""Generate a unique key for a scope that can be used as a
10561382
filename, in a URL, or in a KVS.

0 commit comments

Comments
 (0)