Refactor CourseDetails

This commit is contained in:
Nimisha Asthagiri
2015-12-03 16:29:59 -05:00
parent d170c92f3b
commit d6ced072c4
10 changed files with 435 additions and 351 deletions

View 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

View 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))