|
11 | 11 | import hashlib |
12 | 12 | import itertools |
13 | 13 | import json |
| 14 | +import logging |
14 | 15 | import re |
| 16 | +import time |
15 | 17 | import traceback |
16 | 18 | import warnings |
17 | 19 |
|
18 | 20 | import dateutil.parser |
19 | 21 | from lxml import etree |
20 | 22 | import pytz |
21 | 23 | import yaml |
| 24 | +from pytz import UTC |
| 25 | + |
| 26 | +from xblock.scorable import Score |
| 27 | + |
| 28 | +log = logging.getLogger(__name__) |
22 | 29 |
|
23 | 30 |
|
24 | 31 | # __all__ controls what classes end up in the docs, and in what order. |
25 | 32 | __all__ = [ |
26 | 33 | 'BlockScope', 'UserScope', 'Scope', 'ScopeIds', |
27 | 34 | 'Field', |
28 | 35 | 'Boolean', 'Dict', 'Float', 'Integer', 'List', 'Set', 'String', 'XMLString', |
| 36 | + "Date", "DateTime", "Timedelta", "RelativeTime", "ScoreField", "ListScoreField", |
29 | 37 | ] |
30 | 38 |
|
31 | 39 |
|
@@ -939,6 +947,89 @@ def enforce_type(self, value): |
939 | 947 | return value |
940 | 948 |
|
941 | 949 |
|
| 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 | + |
942 | 1033 | class DateTime(JSONField): |
943 | 1034 | """ |
944 | 1035 | A field for representing a datetime. |
@@ -1010,6 +1101,178 @@ def to_string(self, value): |
1010 | 1101 | enforce_type = from_json |
1011 | 1102 |
|
1012 | 1103 |
|
| 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 | + |
1013 | 1276 | class Any(JSONField): |
1014 | 1277 | """ |
1015 | 1278 | A field class for representing any piece of data; type is not enforced. |
@@ -1051,6 +1314,69 @@ class ReferenceValueDict(Dict): |
1051 | 1314 | # but since Reference doesn't stipulate a definition for from/to, that seems unnecessary at this time. |
1052 | 1315 |
|
1053 | 1316 |
|
| 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 | + |
1054 | 1380 | def scope_key(instance, xblock): |
1055 | 1381 | """Generate a unique key for a scope that can be used as a |
1056 | 1382 | filename, in a URL, or in a KVS. |
|
0 commit comments