Refactor CourseDetails
This commit is contained in:
0
openedx/core/djangoapps/models/__init__.py
Normal file
0
openedx/core/djangoapps/models/__init__.py
Normal file
312
openedx/core/djangoapps/models/course_details.py
Normal file
312
openedx/core/djangoapps/models/course_details.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
CourseDetails
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.fields import Date
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
|
||||
from openedx.core.lib.courses import course_image_url
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
# This list represents the attribute keys for a course's 'about' info.
|
||||
# Note: The 'video' attribute is intentionally excluded as it must be
|
||||
# handled separately; its value maps to an alternate key name.
|
||||
ABOUT_ATTRIBUTES = [
|
||||
'syllabus',
|
||||
'short_description',
|
||||
'overview',
|
||||
'effort',
|
||||
'entrance_exam_enabled',
|
||||
'entrance_exam_id',
|
||||
'entrance_exam_minimum_score_pct',
|
||||
]
|
||||
|
||||
|
||||
class CourseDetails(object):
|
||||
"""
|
||||
An interface for extracting course information from the modulestore.
|
||||
"""
|
||||
def __init__(self, org, course_id, run):
|
||||
# still need these for now b/c the client's screen shows these 3
|
||||
# fields
|
||||
self.org = org
|
||||
self.course_id = course_id
|
||||
self.run = run
|
||||
self.language = None
|
||||
self.start_date = None # 'start'
|
||||
self.end_date = None # 'end'
|
||||
self.enrollment_start = None
|
||||
self.enrollment_end = None
|
||||
self.syllabus = None # a pdf file asset
|
||||
self.short_description = ""
|
||||
self.overview = "" # html to render as the overview
|
||||
self.intro_video = None # a video pointer
|
||||
self.effort = None # hours/week
|
||||
self.license = "all-rights-reserved" # default course license is all rights reserved
|
||||
self.course_image_name = ""
|
||||
self.course_image_asset_path = "" # URL of the course image
|
||||
self.pre_requisite_courses = [] # pre-requisite courses
|
||||
self.entrance_exam_enabled = "" # is entrance exam enabled
|
||||
self.entrance_exam_id = "" # the content location for the entrance exam
|
||||
self.entrance_exam_minimum_score_pct = settings.FEATURES.get(
|
||||
'ENTRANCE_EXAM_MIN_SCORE_PCT',
|
||||
'50'
|
||||
) # minimum passing score for entrance exam content module/tree,
|
||||
self.has_cert_config = None # course has active certificate configuration
|
||||
self.self_paced = None
|
||||
|
||||
@classmethod
|
||||
def _fetch_about_attribute(cls, course_key, attribute):
|
||||
"""
|
||||
Retrieve an attribute from a course's "about" info
|
||||
"""
|
||||
usage_key = course_key.make_usage_key('about', attribute)
|
||||
try:
|
||||
value = modulestore().get_item(usage_key).data
|
||||
except ItemNotFoundError:
|
||||
value = None
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, course_key):
|
||||
"""
|
||||
Fetch the course details for the given course from persistence
|
||||
and return a CourseDetails model.
|
||||
"""
|
||||
descriptor = modulestore().get_course(course_key)
|
||||
course_details = cls(course_key.org, course_key.course, course_key.run)
|
||||
|
||||
course_details.start_date = descriptor.start
|
||||
course_details.end_date = descriptor.end
|
||||
course_details.enrollment_start = descriptor.enrollment_start
|
||||
course_details.enrollment_end = descriptor.enrollment_end
|
||||
course_details.pre_requisite_courses = descriptor.pre_requisite_courses
|
||||
course_details.course_image_name = descriptor.course_image
|
||||
course_details.course_image_asset_path = course_image_url(descriptor)
|
||||
course_details.language = descriptor.language
|
||||
course_details.self_paced = descriptor.self_paced
|
||||
|
||||
# Default course license is "All Rights Reserved"
|
||||
course_details.license = getattr(descriptor, "license", "all-rights-reserved")
|
||||
|
||||
course_details.has_cert_config = has_active_web_certificate(descriptor)
|
||||
course_details.intro_video = cls.fetch_youtube_video_id(course_key)
|
||||
|
||||
for attribute in ABOUT_ATTRIBUTES:
|
||||
value = cls._fetch_about_attribute(course_key, attribute)
|
||||
if value is not None:
|
||||
setattr(course_details, attribute, value)
|
||||
|
||||
return course_details
|
||||
|
||||
@classmethod
|
||||
def fetch_youtube_video_id(cls, course_key):
|
||||
"""
|
||||
Returns the course about video ID.
|
||||
"""
|
||||
raw_video = cls._fetch_about_attribute(course_key, 'video')
|
||||
if raw_video:
|
||||
return cls.parse_video_tag(raw_video)
|
||||
|
||||
@classmethod
|
||||
def fetch_video_url(cls, course_key):
|
||||
"""
|
||||
Returns the course about video URL.
|
||||
"""
|
||||
video_id = cls.fetch_youtube_video_id(course_key)
|
||||
if video_id:
|
||||
return "http://www.youtube.com/watch?v={0}".format(video_id)
|
||||
|
||||
@classmethod
|
||||
def fetch_effort(cls, course_key):
|
||||
"""
|
||||
Returns the hours per week of effort for the course.
|
||||
"""
|
||||
return cls._fetch_about_attribute(course_key, 'effort')
|
||||
|
||||
@classmethod
|
||||
def update_about_item(cls, course, about_key, data, user_id, store=None):
|
||||
"""
|
||||
Update the about item with the new data blob. If data is None,
|
||||
then delete the about item.
|
||||
"""
|
||||
temploc = course.id.make_usage_key('about', about_key)
|
||||
store = store or modulestore()
|
||||
if data is None:
|
||||
try:
|
||||
store.delete_item(temploc, user_id)
|
||||
# Ignore an attempt to delete an item that doesn't exist
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
about_item = store.get_item(temploc)
|
||||
except ItemNotFoundError:
|
||||
about_item = store.create_xblock(course.runtime, course.id, 'about', about_key)
|
||||
about_item.data = data
|
||||
store.update_item(about_item, user_id, allow_not_found=True)
|
||||
|
||||
@classmethod
|
||||
def update_about_video(cls, course, video_id, user_id):
|
||||
"""
|
||||
Updates the Course's about video to the given video ID.
|
||||
"""
|
||||
recomposed_video_tag = CourseDetails.recompose_video_tag(video_id)
|
||||
cls.update_about_item(course, 'video', recomposed_video_tag, user_id)
|
||||
|
||||
@classmethod
|
||||
def update_from_json(cls, course_key, jsondict, user): # pylint: disable=too-many-statements
|
||||
"""
|
||||
Decode the json into CourseDetails and save any changed attrs to the db
|
||||
"""
|
||||
module_store = modulestore()
|
||||
descriptor = module_store.get_course(course_key)
|
||||
|
||||
dirty = False
|
||||
|
||||
# In the descriptor's setter, the date is converted to JSON
|
||||
# using Date's to_json method. Calling to_json on something that
|
||||
# is already JSON doesn't work. Since reaching directly into the
|
||||
# model is nasty, convert the JSON Date to a Python date, which
|
||||
# is what the setter expects as input.
|
||||
date = Date()
|
||||
|
||||
if 'start_date' in jsondict:
|
||||
converted = date.from_json(jsondict['start_date'])
|
||||
else:
|
||||
converted = None
|
||||
if converted != descriptor.start:
|
||||
dirty = True
|
||||
descriptor.start = converted
|
||||
|
||||
if 'end_date' in jsondict:
|
||||
converted = date.from_json(jsondict['end_date'])
|
||||
else:
|
||||
converted = None
|
||||
|
||||
if converted != descriptor.end:
|
||||
dirty = True
|
||||
descriptor.end = converted
|
||||
|
||||
if 'enrollment_start' in jsondict:
|
||||
converted = date.from_json(jsondict['enrollment_start'])
|
||||
else:
|
||||
converted = None
|
||||
|
||||
if converted != descriptor.enrollment_start:
|
||||
dirty = True
|
||||
descriptor.enrollment_start = converted
|
||||
|
||||
if 'enrollment_end' in jsondict:
|
||||
converted = date.from_json(jsondict['enrollment_end'])
|
||||
else:
|
||||
converted = None
|
||||
|
||||
if converted != descriptor.enrollment_end:
|
||||
dirty = True
|
||||
descriptor.enrollment_end = converted
|
||||
|
||||
if 'course_image_name' in jsondict and jsondict['course_image_name'] != descriptor.course_image:
|
||||
descriptor.course_image = jsondict['course_image_name']
|
||||
dirty = True
|
||||
|
||||
if 'pre_requisite_courses' in jsondict \
|
||||
and sorted(jsondict['pre_requisite_courses']) != sorted(descriptor.pre_requisite_courses):
|
||||
descriptor.pre_requisite_courses = jsondict['pre_requisite_courses']
|
||||
dirty = True
|
||||
|
||||
if 'license' in jsondict:
|
||||
descriptor.license = jsondict['license']
|
||||
dirty = True
|
||||
|
||||
if 'language' in jsondict and jsondict['language'] != descriptor.language:
|
||||
descriptor.language = jsondict['language']
|
||||
dirty = True
|
||||
|
||||
if (SelfPacedConfiguration.current().enabled
|
||||
and descriptor.can_toggle_course_pacing
|
||||
and 'self_paced' in jsondict
|
||||
and jsondict['self_paced'] != descriptor.self_paced):
|
||||
descriptor.self_paced = jsondict['self_paced']
|
||||
dirty = True
|
||||
|
||||
if dirty:
|
||||
module_store.update_item(descriptor, user.id)
|
||||
|
||||
# NOTE: below auto writes to the db w/o verifying that any of
|
||||
# the fields actually changed to make faster, could compare
|
||||
# against db or could have client send over a list of which
|
||||
# fields changed.
|
||||
for attribute in ABOUT_ATTRIBUTES:
|
||||
if attribute in jsondict:
|
||||
cls.update_about_item(descriptor, attribute, jsondict[attribute], user.id)
|
||||
|
||||
cls.update_about_video(descriptor, jsondict['intro_video'], user.id)
|
||||
|
||||
# Could just return jsondict w/o doing any db reads, but I put
|
||||
# the reads in as a means to confirm it persisted correctly
|
||||
return CourseDetails.fetch(course_key)
|
||||
|
||||
@staticmethod
|
||||
def parse_video_tag(raw_video):
|
||||
"""
|
||||
Because the client really only wants the author to specify the
|
||||
youtube key, that's all we send to and get from the client. The
|
||||
problem is that the db stores the html markup as well (which, of
|
||||
course, makes any site-wide changes to how we do videos next to
|
||||
impossible.)
|
||||
"""
|
||||
if not raw_video:
|
||||
return None
|
||||
|
||||
keystring_matcher = re.search(r'(?<=embed/)[a-zA-Z0-9_-]+', raw_video)
|
||||
if keystring_matcher is None:
|
||||
keystring_matcher = re.search(r'<?=\d+:[a-zA-Z0-9_-]+', raw_video)
|
||||
|
||||
if keystring_matcher:
|
||||
return keystring_matcher.group(0)
|
||||
else:
|
||||
logging.warn("ignoring the content because it doesn't not conform to expected pattern: " + raw_video)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def recompose_video_tag(video_key):
|
||||
"""
|
||||
Returns HTML string to embed the video in an iFrame.
|
||||
"""
|
||||
# TODO should this use a mako template? Of course, my hope is
|
||||
# that this is a short-term workaround for the db not storing
|
||||
# the right thing
|
||||
result = None
|
||||
if video_key:
|
||||
result = (
|
||||
'<iframe title="YouTube Video" width="560" height="315" src="//www.youtube.com/embed/' +
|
||||
video_key +
|
||||
'?rel=0" frameborder="0" allowfullscreen=""></iframe>'
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# TODO - we may no longer need this - check with Zia
|
||||
def has_active_web_certificate(course):
|
||||
"""
|
||||
Returns True if given course has active web certificate
|
||||
configuration. If given course has no active web certificate
|
||||
configuration returns False. Returns None If `CERTIFICATES_HTML_VIEW`
|
||||
is not enabled or course has not enabled `cert_html_view_enabled`
|
||||
settings.
|
||||
"""
|
||||
cert_config = None
|
||||
if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False) and course.cert_html_view_enabled:
|
||||
cert_config = False
|
||||
certificates = getattr(course, 'certificates', {})
|
||||
configurations = certificates.get('certificates', [])
|
||||
for config in configurations:
|
||||
if config.get('is_active'):
|
||||
return True
|
||||
return cert_config
|
||||
127
openedx/core/djangoapps/models/tests/test_course_details.py
Normal file
127
openedx/core/djangoapps/models/tests/test_course_details.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Tests for CourseDetails
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from django.utils.timezone import UTC
|
||||
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
|
||||
from openedx.core.djangoapps.models.course_details import CourseDetails
|
||||
|
||||
|
||||
class CourseDetailsTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests the first course settings page (course dates, overview, etc.).
|
||||
"""
|
||||
def setUp(self):
|
||||
super(CourseDetailsTestCase, self).setUp()
|
||||
self.course = CourseFactory.create()
|
||||
|
||||
def test_virgin_fetch(self):
|
||||
details = CourseDetails.fetch(self.course.id)
|
||||
self.assertEqual(details.org, self.course.location.org, "Org not copied into")
|
||||
self.assertEqual(details.course_id, self.course.location.course, "Course_id not copied into")
|
||||
self.assertEqual(details.run, self.course.location.name, "Course name not copied into")
|
||||
self.assertEqual(details.course_image_name, self.course.course_image)
|
||||
self.assertIsNotNone(details.start_date.tzinfo)
|
||||
self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date))
|
||||
self.assertIsNone(
|
||||
details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start)
|
||||
)
|
||||
self.assertIsNone(
|
||||
details.enrollment_end, "enrollment_end date somehow initialized " + str(details.enrollment_end)
|
||||
)
|
||||
self.assertIsNone(details.syllabus, "syllabus somehow initialized" + str(details.syllabus))
|
||||
self.assertIsNone(details.intro_video, "intro_video somehow initialized" + str(details.intro_video))
|
||||
self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort))
|
||||
self.assertIsNone(details.language, "language somehow initialized" + str(details.language))
|
||||
self.assertIsNone(details.has_cert_config)
|
||||
self.assertFalse(details.self_paced)
|
||||
|
||||
def test_update_and_fetch(self):
|
||||
SelfPacedConfiguration(enabled=True).save()
|
||||
jsondetails = CourseDetails.fetch(self.course.id)
|
||||
jsondetails.syllabus = "<a href='foo'>bar</a>"
|
||||
# encode - decode to convert date fields and other data which changes form
|
||||
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, self.course.id):
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).syllabus,
|
||||
jsondetails.syllabus, "After set syllabus"
|
||||
)
|
||||
jsondetails.short_description = "Short Description"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).short_description,
|
||||
jsondetails.short_description, "After set short_description"
|
||||
)
|
||||
jsondetails.overview = "Overview"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).overview,
|
||||
jsondetails.overview, "After set overview"
|
||||
)
|
||||
jsondetails.intro_video = "intro_video"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).intro_video,
|
||||
jsondetails.intro_video, "After set intro_video"
|
||||
)
|
||||
jsondetails.effort = "effort"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).effort,
|
||||
jsondetails.effort, "After set effort"
|
||||
)
|
||||
jsondetails.self_paced = True
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).self_paced,
|
||||
jsondetails.self_paced
|
||||
)
|
||||
jsondetails.start_date = datetime.datetime(2010, 10, 1, 0, tzinfo=UTC())
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).start_date,
|
||||
jsondetails.start_date
|
||||
)
|
||||
jsondetails.end_date = datetime.datetime(2011, 10, 1, 0, tzinfo=UTC())
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).end_date,
|
||||
jsondetails.end_date
|
||||
)
|
||||
jsondetails.course_image_name = "an_image.jpg"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).course_image_name,
|
||||
jsondetails.course_image_name
|
||||
)
|
||||
jsondetails.language = "hr"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).language,
|
||||
jsondetails.language
|
||||
)
|
||||
|
||||
def test_toggle_pacing_during_course_run(self):
|
||||
SelfPacedConfiguration(enabled=True).save()
|
||||
self.course.start = datetime.datetime.now()
|
||||
self.store.update_item(self.course, self.user.id)
|
||||
|
||||
details = CourseDetails.fetch(self.course.id)
|
||||
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, self.course.id):
|
||||
updated_details = CourseDetails.update_from_json(
|
||||
self.course.id,
|
||||
dict(details.__dict__, self_paced=True),
|
||||
self.user
|
||||
)
|
||||
self.assertFalse(updated_details.self_paced)
|
||||
|
||||
def test_fetch_effort(self):
|
||||
effort_value = 'test_hours_of_effort'
|
||||
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, self.course.id):
|
||||
CourseDetails.update_about_item(self.course, 'effort', effort_value, self.user.id)
|
||||
self.assertEqual(CourseDetails.fetch_effort(self.course.id), effort_value)
|
||||
|
||||
def test_fetch_video(self):
|
||||
video_value = 'test_video_id'
|
||||
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, self.course.id):
|
||||
CourseDetails.update_about_video(self.course, video_value, self.user.id)
|
||||
self.assertEqual(CourseDetails.fetch_youtube_video_id(self.course.id), video_value)
|
||||
video_url = CourseDetails.fetch_video_url(self.course.id)
|
||||
self.assertRegexpMatches(video_url, r'http://.*{}'.format(video_value))
|
||||
Reference in New Issue
Block a user