Move fields.py, inheritance.py, and partitions to openedx/core.
This commit is contained in:
0
openedx/core/lib/xblock_fields/__init__.py
Normal file
0
openedx/core/lib/xblock_fields/__init__.py
Normal file
290
openedx/core/lib/xblock_fields/fields.py
Normal file
290
openedx/core/lib/xblock_fields/fields.py
Normal file
@@ -0,0 +1,290 @@
|
||||
import time
|
||||
import logging
|
||||
import re
|
||||
|
||||
from xblock.fields import JSONField
|
||||
import datetime
|
||||
import dateutil.parser
|
||||
|
||||
from pytz import UTC
|
||||
|
||||
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("Field {0} is missing month or day".format(self.name))
|
||||
return None
|
||||
if result.tzinfo is None:
|
||||
result = result.replace(tzinfo=UTC)
|
||||
return result
|
||||
|
||||
def from_json(self, field):
|
||||
"""
|
||||
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 is "":
|
||||
return None
|
||||
elif isinstance(field, basestring):
|
||||
return self._parse_date_wo_default_month_day(field)
|
||||
elif isinstance(field, (int, long, float)):
|
||||
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 {0} has bad value '{1}'".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("Cannot convert {!r} to json".format(value))
|
||||
|
||||
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)?)?$')
|
||||
|
||||
|
||||
class Timedelta(JSONField):
|
||||
# Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types
|
||||
MUTABLE = False
|
||||
|
||||
def from_json(self, time_str):
|
||||
"""
|
||||
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.iteritems():
|
||||
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 module 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 module 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(
|
||||
"Incorrect RelativeTime value {!r} was set in XML or serialized. "
|
||||
"Original parse message is {}".format(value, e.message)
|
||||
)
|
||||
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, basestring):
|
||||
return self.isotime_to_timedelta(value)
|
||||
|
||||
msg = "RelativeTime Field {0} has bad value '{1!r}'".format(self.name, value)
|
||||
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("RelativeTime: cannot convert {!r} to json".format(value))
|
||||
|
||||
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 TimeInfo(object):
|
||||
"""
|
||||
This is a simple object that calculates and stores datetime information for an XModule
|
||||
based on the due date and the grace period string
|
||||
|
||||
So far it parses out three different pieces of time information:
|
||||
self.display_due_date - the 'official' due date that gets displayed to students
|
||||
self.grace_period - the length of the grace period
|
||||
self.close_date - the real due date
|
||||
|
||||
"""
|
||||
_delta_standin = Timedelta()
|
||||
|
||||
def __init__(self, due_date, grace_period_string_or_timedelta):
|
||||
if due_date is not None:
|
||||
self.display_due_date = due_date
|
||||
|
||||
else:
|
||||
self.display_due_date = None
|
||||
|
||||
if grace_period_string_or_timedelta is not None and self.display_due_date:
|
||||
if isinstance(grace_period_string_or_timedelta, basestring):
|
||||
try:
|
||||
self.grace_period = TimeInfo._delta_standin.from_json(grace_period_string_or_timedelta)
|
||||
except:
|
||||
log.error("Error parsing the grace period {0}".format(grace_period_string_or_timedelta))
|
||||
raise
|
||||
else:
|
||||
self.grace_period = grace_period_string_or_timedelta
|
||||
self.close_date = self.display_due_date + self.grace_period
|
||||
else:
|
||||
self.grace_period = None
|
||||
self.close_date = self.display_due_date
|
||||
239
openedx/core/lib/xblock_fields/inherited_fields.py
Normal file
239
openedx/core/lib/xblock_fields/inherited_fields.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Inherited fields for all XBlocks.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from pytz import utc
|
||||
|
||||
from xblock.fields import Scope, Boolean, String, Float, XBlockMixin, Dict, Integer, List
|
||||
from openedx.core.lib.partitions.partitions import UserPartition
|
||||
from .fields import Date, Timedelta
|
||||
|
||||
|
||||
DEFAULT_START_DATE = datetime(2030, 1, 1, tzinfo=utc)
|
||||
|
||||
# Make '_' a no-op so we can scrape strings
|
||||
# Using lambda instead of `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
|
||||
_ = lambda text: text
|
||||
|
||||
|
||||
class UserPartitionList(List):
|
||||
"""Special List class for listing UserPartitions"""
|
||||
def from_json(self, values):
|
||||
return [UserPartition.from_json(v) for v in values]
|
||||
|
||||
def to_json(self, values):
|
||||
return [user_partition.to_json()
|
||||
for user_partition in values]
|
||||
|
||||
|
||||
class InheritanceMixin(XBlockMixin):
|
||||
"""Field definitions for inheritable fields."""
|
||||
|
||||
graded = Boolean(
|
||||
help="Whether this module contributes to the final course grade",
|
||||
scope=Scope.settings,
|
||||
default=False,
|
||||
)
|
||||
start = Date(
|
||||
help="Start time when this module is visible",
|
||||
default=DEFAULT_START_DATE,
|
||||
scope=Scope.settings
|
||||
)
|
||||
due = Date(
|
||||
display_name=_("Due Date"),
|
||||
help=_("Enter the default date by which problems are due."),
|
||||
scope=Scope.settings,
|
||||
)
|
||||
visible_to_staff_only = Boolean(
|
||||
help=_("If true, can be seen only by course staff, regardless of start date."),
|
||||
default=False,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
course_edit_method = String(
|
||||
display_name=_("Course Editor"),
|
||||
help=_("Enter the method by which this course is edited (\"XML\" or \"Studio\")."),
|
||||
default="Studio",
|
||||
scope=Scope.settings,
|
||||
deprecated=True # Deprecated because user would not change away from Studio within Studio.
|
||||
)
|
||||
giturl = String(
|
||||
display_name=_("GIT URL"),
|
||||
help=_("Enter the URL for the course data GIT repository."),
|
||||
scope=Scope.settings
|
||||
)
|
||||
xqa_key = String(
|
||||
display_name=_("XQA Key"),
|
||||
help=_("This setting is not currently supported."), scope=Scope.settings,
|
||||
deprecated=True
|
||||
)
|
||||
annotation_storage_url = String(
|
||||
help=_("Enter the location of the annotation storage server. The textannotation, videoannotation, and imageannotation advanced modules require this setting."),
|
||||
scope=Scope.settings,
|
||||
default="http://your_annotation_storage.com",
|
||||
display_name=_("URL for Annotation Storage")
|
||||
)
|
||||
annotation_token_secret = String(
|
||||
help=_("Enter the secret string for annotation storage. The textannotation, videoannotation, and imageannotation advanced modules require this string."),
|
||||
scope=Scope.settings,
|
||||
default="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
display_name=_("Secret Token String for Annotation")
|
||||
)
|
||||
graceperiod = Timedelta(
|
||||
help="Amount of time after the due date that submissions will be accepted",
|
||||
scope=Scope.settings,
|
||||
)
|
||||
group_access = Dict(
|
||||
help=_("Enter the ids for the content groups this problem belongs to."),
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
showanswer = String(
|
||||
display_name=_("Show Answer"),
|
||||
help=_(
|
||||
# Translators: DO NOT translate the words in quotes here, they are
|
||||
# specific words for the acceptable values.
|
||||
'Specify when the Show Answer button appears for each problem. '
|
||||
'Valid values are "always", "answered", "attempted", "closed", '
|
||||
'"finished", "past_due", "correct_or_past_due", and "never".'
|
||||
),
|
||||
scope=Scope.settings,
|
||||
default="finished",
|
||||
)
|
||||
|
||||
show_correctness = String(
|
||||
display_name=_("Show Results"),
|
||||
help=_(
|
||||
# Translators: DO NOT translate the words in quotes here, they are
|
||||
# specific words for the acceptable values.
|
||||
'Specify when to show answer correctness and score to learners. '
|
||||
'Valid values are "always", "never", and "past_due".'
|
||||
),
|
||||
scope=Scope.settings,
|
||||
default="always",
|
||||
)
|
||||
|
||||
rerandomize = String(
|
||||
display_name=_("Randomization"),
|
||||
help=_(
|
||||
# Translators: DO NOT translate the words in quotes here, they are
|
||||
# specific words for the acceptable values.
|
||||
'Specify the default for how often variable values in a problem are randomized. '
|
||||
'This setting should be set to "never" unless you plan to provide a Python '
|
||||
'script to identify and randomize values in most of the problems in your course. '
|
||||
'Valid values are "always", "onreset", "never", and "per_student".'
|
||||
),
|
||||
scope=Scope.settings,
|
||||
default="never",
|
||||
)
|
||||
days_early_for_beta = Float(
|
||||
display_name=_("Days Early for Beta Users"),
|
||||
help=_("Enter the number of days before the start date that beta users can access the course."),
|
||||
scope=Scope.settings,
|
||||
default=None,
|
||||
)
|
||||
static_asset_path = String(
|
||||
display_name=_("Static Asset Path"),
|
||||
help=_("Enter the path to use for files on the Files & Uploads page. This value overrides the Studio default, c4x://."),
|
||||
scope=Scope.settings,
|
||||
default='',
|
||||
)
|
||||
use_latex_compiler = Boolean(
|
||||
display_name=_("Enable LaTeX Compiler"),
|
||||
help=_("Enter true or false. If true, you can use the LaTeX templates for HTML components and advanced Problem components."),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
max_attempts = Integer(
|
||||
display_name=_("Maximum Attempts"),
|
||||
help=_("Enter the maximum number of times a student can try to answer problems. By default, Maximum Attempts is set to null, meaning that students have an unlimited number of attempts for problems. You can override this course-wide setting for individual problems. However, if the course-wide setting is a specific number, you cannot set the Maximum Attempts for individual problems to unlimited."),
|
||||
values={"min": 0}, scope=Scope.settings
|
||||
)
|
||||
matlab_api_key = String(
|
||||
display_name=_("Matlab API key"),
|
||||
help=_("Enter the API key provided by MathWorks for accessing the MATLAB Hosted Service. "
|
||||
"This key is granted for exclusive use in this course for the specified duration. "
|
||||
"Do not share the API key with other courses. Notify MathWorks immediately "
|
||||
"if you believe the key is exposed or compromised. To obtain a key for your course, "
|
||||
"or to report an issue, please contact moocsupport@mathworks.com"),
|
||||
scope=Scope.settings
|
||||
)
|
||||
# This is should be scoped to content, but since it's defined in the policy
|
||||
# file, it is currently scoped to settings.
|
||||
user_partitions = UserPartitionList(
|
||||
display_name=_("Group Configurations"),
|
||||
help=_("Enter the configurations that govern how students are grouped together."),
|
||||
default=[],
|
||||
scope=Scope.settings
|
||||
)
|
||||
video_speed_optimizations = Boolean(
|
||||
display_name=_("Enable video caching system"),
|
||||
help=_("Enter true or false. If true, video caching will be used for HTML5 videos."),
|
||||
default=True,
|
||||
scope=Scope.settings
|
||||
)
|
||||
video_bumper = Dict(
|
||||
display_name=_("Video Pre-Roll"),
|
||||
help=_(
|
||||
"Identify a video, 5-10 seconds in length, to play before course videos. Enter the video ID from "
|
||||
"the Video Uploads page and one or more transcript files in the following format: {format}. "
|
||||
"For example, an entry for a video with two transcripts looks like this: {example}"
|
||||
).format(
|
||||
format='{"video_id": "ID", "transcripts": {"language": "/static/filename.srt"}}',
|
||||
example=(
|
||||
'{'
|
||||
'"video_id": "77cef264-d6f5-4cf2-ad9d-0178ab8c77be", '
|
||||
'"transcripts": {"en": "/static/DemoX-D01_1.srt", "uk": "/static/DemoX-D01_1_uk.srt"}'
|
||||
'}'
|
||||
),
|
||||
),
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
# TODO: Remove this Django dependency! It really doesn't belong here.
|
||||
reset_key = "DEFAULT_SHOW_RESET_BUTTON"
|
||||
default_reset_button = getattr(settings, reset_key) if hasattr(settings, reset_key) else False
|
||||
show_reset_button = Boolean(
|
||||
display_name=_("Show Reset Button for Problems"),
|
||||
help=_(
|
||||
"Enter true or false. If true, problems in the course default to always displaying a 'Reset' button. "
|
||||
"You can override this in each problem's settings. All existing problems are affected when "
|
||||
"this course-wide setting is changed."
|
||||
),
|
||||
scope=Scope.settings,
|
||||
default=default_reset_button
|
||||
)
|
||||
edxnotes = Boolean(
|
||||
display_name=_("Enable Student Notes"),
|
||||
help=_("Enter true or false. If true, students can use the Student Notes feature."),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
edxnotes_visibility = Boolean(
|
||||
display_name="Student Notes Visibility",
|
||||
help=_("Indicates whether Student Notes are visible in the course. "
|
||||
"Students can also show or hide their notes in the courseware."),
|
||||
default=True,
|
||||
scope=Scope.user_info
|
||||
)
|
||||
|
||||
in_entrance_exam = Boolean(
|
||||
display_name=_("Tag this module as part of an Entrance Exam section"),
|
||||
help=_("Enter true or false. If true, answer submissions for problem modules will be "
|
||||
"considered in the Entrance Exam scoring/gating algorithm."),
|
||||
scope=Scope.settings,
|
||||
default=False
|
||||
)
|
||||
|
||||
self_paced = Boolean(
|
||||
display_name=_('Self Paced'),
|
||||
help=_(
|
||||
'Set this to "true" to mark this course as self-paced. Self-paced courses do not have '
|
||||
'due dates for assignments, and students can progress through the course at any rate before '
|
||||
'the course ends.'
|
||||
),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
Reference in New Issue
Block a user