course_details.py 12.9 KB
Newer Older
1 2 3
"""
CourseDetails
"""
4
import re
5
import logging
Don Mitchell committed
6

7
from django.conf import settings
8 9 10

from xmodule.fields import Date
from xmodule.modulestore.exceptions import ItemNotFoundError
11
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
12
from openedx.core.lib.courses import course_image_url
13
from xmodule.modulestore.django import modulestore
14

15

16 17 18 19 20
# 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',
21 22 23 24
    'title',
    'subtitle',
    'duration',
    'description',
25 26 27
    'short_description',
    'overview',
    'effort',
28 29 30
    'entrance_exam_enabled',
    'entrance_exam_id',
    'entrance_exam_minimum_score_pct',
31 32
]

33

34
class CourseDetails(object):
35 36 37
    """
    An interface for extracting course information from the modulestore.
    """
Don Mitchell committed
38
    def __init__(self, org, course_id, run):
39 40
        # still need these for now b/c the client's screen shows these 3
        # fields
Don Mitchell committed
41 42 43
        self.org = org
        self.course_id = course_id
        self.run = run
44
        self.language = None
45
        self.start_date = None  # 'start'
46
        self.end_date = None  # 'end'
47 48
        self.enrollment_start = None
        self.enrollment_end = None
49
        self.syllabus = None  # a pdf file asset
50 51 52 53
        self.title = ""
        self.subtitle = ""
        self.duration = ""
        self.description = ""
54
        self.short_description = ""
55 56
        self.overview = ""  # html to render as the overview
        self.intro_video = None  # a video pointer
57
        self.effort = None  # hours/week
58
        self.license = "all-rights-reserved"  # default course license is all rights reserved
59 60
        self.course_image_name = ""
        self.course_image_asset_path = ""  # URL of the course image
61 62 63 64
        self.banner_image_name = ""
        self.banner_image_asset_path = ""
        self.video_thumbnail_image_name = ""
        self.video_thumbnail_image_asset_path = ""
65
        self.pre_requisite_courses = []  # pre-requisite courses
66 67 68 69 70
        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'
71
        )  # minimum passing score for entrance exam content module/tree,
72
        self.self_paced = None
73 74
        self.learning_info = []
        self.instructor_info = []
Don Mitchell committed
75 76

    @classmethod
77
    def fetch_about_attribute(cls, course_key, attribute):
78 79 80
        """
        Retrieve an attribute from a course's "about" info
        """
81 82 83
        if attribute not in ABOUT_ATTRIBUTES + ['video']:
            raise ValueError("'{0}' is not a valid course about attribute.".format(attribute))

84 85 86 87 88 89 90 91
        usage_key = course_key.make_usage_key('about', attribute)
        try:
            value = modulestore().get_item(usage_key).data
        except ItemNotFoundError:
            value = None
        return value

    @classmethod
92
    def fetch(cls, course_key):
Don Mitchell committed
93
        """
94 95
        Fetch the course details for the given course from persistence
        and return a CourseDetails model.
Don Mitchell committed
96
        """
97
        return cls.populate(modulestore().get_course(course_key))
98

99 100 101 102 103 104 105 106 107
    @classmethod
    def populate(cls, course_descriptor):
        """
        Returns a fully populated CourseDetails model given the course descriptor
        """
        course_key = course_descriptor.id
        course_details = cls(course_key.org, course_key.course, course_key.run)
        course_details.start_date = course_descriptor.start
        course_details.end_date = course_descriptor.end
108
        course_details.certificate_available_date = course_descriptor.certificate_available_date
109 110 111 112 113 114 115 116 117 118 119 120 121
        course_details.enrollment_start = course_descriptor.enrollment_start
        course_details.enrollment_end = course_descriptor.enrollment_end
        course_details.pre_requisite_courses = course_descriptor.pre_requisite_courses
        course_details.course_image_name = course_descriptor.course_image
        course_details.course_image_asset_path = course_image_url(course_descriptor, 'course_image')
        course_details.banner_image_name = course_descriptor.banner_image
        course_details.banner_image_asset_path = course_image_url(course_descriptor, 'banner_image')
        course_details.video_thumbnail_image_name = course_descriptor.video_thumbnail_image
        course_details.video_thumbnail_image_asset_path = course_image_url(course_descriptor, 'video_thumbnail_image')
        course_details.language = course_descriptor.language
        course_details.self_paced = course_descriptor.self_paced
        course_details.learning_info = course_descriptor.learning_info
        course_details.instructor_info = course_descriptor.instructor_info
122

123
        # Default course license is "All Rights Reserved"
124
        course_details.license = getattr(course_descriptor, "license", "all-rights-reserved")
125 126

        course_details.intro_video = cls.fetch_youtube_video_id(course_key)
127

128
        for attribute in ABOUT_ATTRIBUTES:
129
            value = cls.fetch_about_attribute(course_key, attribute)
130 131
            if value is not None:
                setattr(course_details, attribute, value)
Calen Pennington committed
132

133 134 135 136 137 138 139
        return course_details

    @classmethod
    def fetch_youtube_video_id(cls, course_key):
        """
        Returns the course about video ID.
        """
140
        raw_video = cls.fetch_about_attribute(course_key, 'video')
141
        if raw_video:
142
            return cls.parse_video_tag(raw_video)
Calen Pennington committed
143

144 145 146 147 148 149 150 151 152 153 154
    @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 update_about_item(cls, course, about_key, data, user_id, store=None):
155
        """
156 157
        Update the about item with the new data blob. If data is None,
        then delete the about item.
158
        """
159 160
        temploc = course.id.make_usage_key('about', about_key)
        store = store or modulestore()
161
        if data is None:
162
            try:
163
                store.delete_item(temploc, user_id)
164 165 166
            # Ignore an attempt to delete an item that doesn't exist
            except ValueError:
                pass
167 168 169 170
        else:
            try:
                about_item = store.get_item(temploc)
            except ItemNotFoundError:
171
                about_item = store.create_xblock(course.runtime, course.id, 'about', about_key)
172
            about_item.data = data
173
            store.update_item(about_item, user_id, allow_not_found=True)
174 175

    @classmethod
176 177 178 179 180 181 182 183 184
    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
185 186 187
        """
        Decode the json into CourseDetails and save any changed attrs to the db
        """
188
        module_store = modulestore()
189
        descriptor = module_store.get_course(course_key)
Calen Pennington committed
190

191
        dirty = False
192

193 194 195 196 197
        # 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.
198 199
        date = Date()

200
        if 'start_date' in jsondict:
201
            converted = date.from_json(jsondict['start_date'])
202 203 204
        else:
            converted = None
        if converted != descriptor.start:
205
            dirty = True
206
            descriptor.start = converted
Calen Pennington committed
207

208
        if 'end_date' in jsondict:
209
            converted = date.from_json(jsondict['end_date'])
210 211 212 213
        else:
            converted = None

        if converted != descriptor.end:
214
            dirty = True
215
            descriptor.end = converted
Calen Pennington committed
216

217
        if 'enrollment_start' in jsondict:
218
            converted = date.from_json(jsondict['enrollment_start'])
219 220 221 222
        else:
            converted = None

        if converted != descriptor.enrollment_start:
223
            dirty = True
224
            descriptor.enrollment_start = converted
Calen Pennington committed
225

226
        if 'enrollment_end' in jsondict:
227
            converted = date.from_json(jsondict['enrollment_end'])
228 229 230 231
        else:
            converted = None

        if converted != descriptor.enrollment_end:
232
            dirty = True
233
            descriptor.enrollment_end = converted
Calen Pennington committed
234

235 236 237 238 239 240 241 242 243
        if 'certificate_available_date' in jsondict:
            converted = date.from_json(jsondict['certificate_available_date'])
        else:
            converted = None

        if converted != descriptor.certificate_available_date:
            dirty = True
            descriptor.certificate_available_date = converted

244 245 246 247
        if 'course_image_name' in jsondict and jsondict['course_image_name'] != descriptor.course_image:
            descriptor.course_image = jsondict['course_image_name']
            dirty = True

248 249 250 251 252 253 254 255 256
        if 'banner_image_name' in jsondict and jsondict['banner_image_name'] != descriptor.banner_image:
            descriptor.banner_image = jsondict['banner_image_name']
            dirty = True

        if 'video_thumbnail_image_name' in jsondict \
                and jsondict['video_thumbnail_image_name'] != descriptor.video_thumbnail_image:
            descriptor.video_thumbnail_image = jsondict['video_thumbnail_image_name']
            dirty = True

257 258 259 260 261
        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

262 263 264 265
        if 'license' in jsondict:
            descriptor.license = jsondict['license']
            dirty = True

266 267 268 269 270 271 272 273
        if 'learning_info' in jsondict:
            descriptor.learning_info = jsondict['learning_info']
            dirty = True

        if 'instructor_info' in jsondict:
            descriptor.instructor_info = jsondict['instructor_info']
            dirty = True

274 275 276 277
        if 'language' in jsondict and jsondict['language'] != descriptor.language:
            descriptor.language = jsondict['language']
            dirty = True

278
        if (SelfPacedConfiguration.current().enabled
279
                and descriptor.can_toggle_course_pacing
280 281
                and 'self_paced' in jsondict
                and jsondict['self_paced'] != descriptor.self_paced):
282 283 284
            descriptor.self_paced = jsondict['self_paced']
            dirty = True

285
        if dirty:
286
            module_store.update_item(descriptor, user.id)
Calen Pennington committed
287

288 289 290 291
        # 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.
292
        for attribute in ABOUT_ATTRIBUTES:
293
            if attribute in jsondict:
294
                cls.update_about_item(descriptor, attribute, jsondict[attribute], user.id)
295

296
        cls.update_about_video(descriptor, jsondict['intro_video'], user.id)
Calen Pennington committed
297

298 299
        # Could just return jsondict w/o doing any db reads, but I put
        # the reads in as a means to confirm it persisted correctly
300
        return CourseDetails.fetch(course_key)
Calen Pennington committed
301

302 303 304
    @staticmethod
    def parse_video_tag(raw_video):
        """
305 306 307 308 309
        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.)
310 311 312
        """
        if not raw_video:
            return None
Calen Pennington committed
313

314
        keystring_matcher = re.search(r'(?<=embed/)[a-zA-Z0-9_-]+', raw_video)
315
        if keystring_matcher is None:
316
            keystring_matcher = re.search(r'<?=\d+:[a-zA-Z0-9_-]+', raw_video)
Calen Pennington committed
317

318 319 320
        if keystring_matcher:
            return keystring_matcher.group(0)
        else:
321
            logging.warn("ignoring the content because it doesn't not conform to expected pattern: " + raw_video)
322
            return None
Calen Pennington committed
323

324 325
    @staticmethod
    def recompose_video_tag(video_key):
326 327 328 329 330 331
        """
        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
332 333
        result = None
        if video_key:
334 335 336 337 338
            result = (
                '<iframe title="YouTube Video" width="560" height="315" src="//www.youtube.com/embed/' +
                video_key +
                '?rel=0" frameborder="0" allowfullscreen=""></iframe>'
            )
339
        return result