courses.py 15 KB
Newer Older
1
from collections import defaultdict
2 3
from fs.errors import ResourceNotFoundError
import logging
4
import inspect
5

6
from path import path
7
from django.http import Http404
8
from django.conf import settings
9 10

from edxmako.shortcuts import render_to_string
11
from xmodule.modulestore import ModuleStoreEnum
12
from opaque_keys.edx.keys import CourseKey
13
from xmodule.modulestore.django import modulestore
14
from xmodule.contentstore.content import StaticContent
Don Mitchell committed
15
from xmodule.modulestore.exceptions import ItemNotFoundError
16
from static_replace import replace_static_urls
17
from xmodule.modulestore import ModuleStoreEnum
18
from xmodule.x_module import STUDENT_VIEW
19
from microsite_configuration import microsite
20

21
from courseware.access import has_access
22 23
from courseware.model_data import FieldDataCache
from courseware.module_render import get_module
24
from student.models import CourseEnrollment
25
import branding
26

27
from opaque_keys.edx.keys import UsageKey
28

29
log = logging.getLogger(__name__)
30

Calen Pennington committed
31

32 33 34 35 36 37 38 39 40 41 42 43 44 45
def get_request_for_thread():
    """Walk up the stack, return the nearest first argument named "request"."""
    frame = None
    try:
        for f in inspect.stack()[1:]:
            frame = f[0]
            code = frame.f_code
            if code.co_varnames[:1] == ("request",):
                return frame.f_locals["request"]
            elif code.co_varnames[:2] == ("self", "request",):
                return frame.f_locals["request"]
    finally:
        del frame

46

47 48 49 50
def get_course(course_id, depth=0):
    """
    Given a course id, return the corresponding course descriptor.

Don Mitchell committed
51
    If the course does not exist, raises a ValueError.  This is appropriate
52 53 54 55 56
    for internal use.

    depth: The number of levels of children for the modulestore to cache.
    None means infinite depth.  Default is to fetch no children.
    """
Don Mitchell committed
57 58
    course = modulestore().get_course(course_id, depth=depth)
    if course is None:
59
        raise ValueError(u"Course not found: {0}".format(course_id))
60
    return course
61 62


63 64
# TODO please rename this function to get_course_by_key at next opportunity!
def get_course_by_id(course_key, depth=0):
65
    """
66
    Given a course id, return the corresponding course descriptor.
67

Don Mitchell committed
68
    If such a course does not exist, raises a 404.
69

70
    depth: The number of levels of children for the modulestore to cache. None means infinite depth
71
    """
72 73
    with modulestore().bulk_operations(course_key):
        course = modulestore().get_course(course_key, depth=depth)
Don Mitchell committed
74 75 76
    if course:
        return course
    else:
77
        raise Http404("Course not found.")
78

79

80 81 82 83 84 85 86
class UserNotEnrolled(Http404):
    def __init__(self, course_key):
        super(UserNotEnrolled, self).__init__()
        self.course_key = course_key


def get_course_with_access(user, action, course_key, depth=0, check_if_enrolled=False):
87
    """
88
    Given a course_key, look up the corresponding course descriptor,
89 90
    check that the user has the access to perform the specified action
    on the course, and return the descriptor.
91

92
    Raises a 404 if the course_key is invalid, or the user doesn't have access.
93 94

    depth: The number of levels of children for the modulestore to cache. None means infinite depth
95 96 97

    check_if_enrolled: If true, additionally verifies that the user is either enrolled in the course
      or has staff access.
98
    """
99 100 101 102
    assert isinstance(course_key, CourseKey)
    course = get_course_by_id(course_key, depth=depth)

    if not has_access(user, action, course, course_key):
103 104 105
        # Deliberately return a non-specific error message to avoid
        # leaking info about access control settings
        raise Http404("Course not found.")
106

107 108 109 110 111
    if check_if_enrolled:
        # Verify that the user is either enrolled in the course or a staff member.
        # If user is not enrolled, raise UserNotEnrolled exception that will be caught by middleware.
        if not ((user.id and CourseEnrollment.is_enrolled(user, course_key)) or has_access(user, 'staff', course)):
            raise UserNotEnrolled(course_key)
112

113
    return course
114 115


116
def course_image_url(course):
117 118
    """Try to look up the image url for the course.  If it's not found,
    log an error and return the dead link"""
119
    if course.static_asset_path or modulestore().get_modulestore_type(course.id) == ModuleStoreEnum.Type.xml:
120 121 122 123
        # If we are a static course with the course_image attribute
        # set different than the default, return that path so that
        # courses can use custom course image paths, otherwise just
        # return the default static path.
Ned Batchelder committed
124
        url = '/static/' + (course.static_asset_path or getattr(course, 'data_dir', ''))
125
        if hasattr(course, 'course_image') and course.course_image != course.fields['course_image'].default:
Ned Batchelder committed
126
            url += '/' + course.course_image
127
        else:
Ned Batchelder committed
128
            url += '/images/course_image.jpg'
129 130 131 132
    elif course.course_image == '':
        # if course_image is empty the url will be blank as location
        # of the course_image does not exist
        url = ''
133
    else:
Calen Pennington committed
134
        loc = StaticContent.compute_location(course.id, course.course_image)
135
        url = StaticContent.serialize_asset_key_with_slash(loc)
Ned Batchelder committed
136
    return url
137 138


Don Mitchell committed
139
def find_file(filesystem, dirs, filename):
140 141 142
    """
    Looks for a filename in a list of dirs on a filesystem, in the specified order.

Don Mitchell committed
143
    filesystem: an OSFS filesystem
144 145 146 147 148
    dirs: a list of path objects
    filename: a string

    Returns d / filename if found in dir d, else raises ResourceNotFoundError.
    """
Don Mitchell committed
149 150 151
    for directory in dirs:
        filepath = path(directory) / filename
        if filesystem.exists(filepath):
152
            return filepath
153
    raise ResourceNotFoundError(u"Could not find {0}".format(filename))
154

155

156 157
def get_course_about_section(course, section_key):
    """
Victor Shnayder committed
158 159 160
    This returns the snippet of html to be rendered on the course about page,
    given the key for the section.

161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176
    Valid keys:
    - overview
    - title
    - university
    - number
    - short_description
    - description
    - key_dates (includes start, end, exams, etc)
    - video
    - course_staff_short
    - course_staff_extended
    - requirements
    - syllabus
    - textbook
    - faq
    - more_info
177
    - ocw_links
178 179
    """

Victor Shnayder committed
180 181 182
    # Many of these are stored as html files instead of some semantic
    # markup. This can change without effecting this interface when we find a
    # good format for defining so many snippets of text/html.
183

184
    # TODO: Remove number, instructors from this list
Victor Shnayder committed
185 186 187 188
    if section_key in ['short_description', 'description', 'key_dates', 'video',
                       'course_staff_short', 'course_staff_extended',
                       'requirements', 'syllabus', 'textbook', 'faq', 'more_info',
                       'number', 'instructors', 'overview',
189
                       'effort', 'end_date', 'prerequisites', 'ocw_links']:
190

191
        try:
192 193 194

            request = get_request_for_thread()

Don Mitchell committed
195
            loc = course.location.replace(category='about', name=section_key)
196 197

            # Use an empty cache
Calen Pennington committed
198
            field_data_cache = FieldDataCache([], course.id, request.user)
199 200 201 202
            about_module = get_module(
                request.user,
                request,
                loc,
Calen Pennington committed
203
                field_data_cache,
204
                log_if_not_found=False,
205
                wrap_xmodule_display=False,
206 207
                static_asset_path=course.static_asset_path,
                course=course
208
            )
209 210 211

            html = ''

212
            if about_module is not None:
213
                try:
214
                    html = about_module.render(STUDENT_VIEW).content
215 216
                except Exception:  # pylint: disable=broad-except
                    html = render_to_string('courseware/error-message.html', None)
217 218 219 220
                    log.exception(
                        u"Error rendering course={course}, section_key={section_key}".format(
                            course=course, section_key=section_key
                        ))
221
            return html
222 223

        except ItemNotFoundError:
224
            log.warning(
225
                u"Missing about section {key} in course {url}".format(key=section_key, url=course.location.to_deprecated_string())
226
            )
227 228
            return None
    elif section_key == "title":
229
        return course.display_name_with_default
230
    elif section_key == "university":
Chris Dodge committed
231
        return course.display_org_with_default
232
    elif section_key == "number":
Chris Dodge committed
233
        return course.display_number_with_default
234 235 236

    raise KeyError("Invalid about key " + str(section_key))

237

238
def get_course_info_section_module(request, course, section_key):
239
    """
240
    This returns the course info module for a given section_key.
Victor Shnayder committed
241

242 243 244 245 246 247
    Valid keys:
    - handouts
    - guest_handouts
    - updates
    - guest_updates
    """
248
    usage_key = course.id.make_usage_key('course_info', section_key)
249 250

    # Use an empty cache
Calen Pennington committed
251
    field_data_cache = FieldDataCache([], course.id, request.user)
252 253

    return get_module(
254 255
        request.user,
        request,
256
        usage_key,
Calen Pennington committed
257
        field_data_cache,
258
        log_if_not_found=False,
259
        wrap_xmodule_display=False,
260 261
        static_asset_path=course.static_asset_path,
        course=course
262
    )
263

264

265 266 267 268
def get_course_info_section(request, course, section_key):
    """
    This returns the snippet of html to be rendered on the course info page,
    given the key for the section.
269

270 271 272 273 274 275 276 277 278
    Valid keys:
    - handouts
    - guest_handouts
    - updates
    - guest_updates
    """
    info_module = get_course_info_section_module(request, course, section_key)

    html = ''
279
    if info_module is not None:
280
        try:
281
            html = info_module.render(STUDENT_VIEW).content
282 283
        except Exception:  # pylint: disable=broad-except
            html = render_to_string('courseware/error-message.html', None)
284 285 286 287
            log.exception(
                u"Error rendering course={course}, section_key={section_key}".format(
                    course=course, section_key=section_key
                ))
288

289
    return html
290

291

292 293
# TODO: Fix this such that these are pulled in as extra course-specific tabs.
#       arjun will address this by the end of October if no one does so prior to
294
#       then.
295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310
def get_course_syllabus_section(course, section_key):
    """
    This returns the snippet of html to be rendered on the syllabus page,
    given the key for the section.

    Valid keys:
    - syllabus
    - guest_syllabus
    """

    # Many of these are stored as html files instead of some semantic
    # markup. This can change without effecting this interface when we find a
    # good format for defining so many snippets of text/html.

    if section_key in ['syllabus', 'guest_syllabus']:
        try:
Don Mitchell committed
311
            filesys = course.system.resources_fs
312 313
            # first look for a run-specific version
            dirs = [path("syllabus") / course.url_name, path("syllabus")]
Don Mitchell committed
314 315
            filepath = find_file(filesys, dirs, section_key + ".html")
            with filesys.open(filepath) as html_file:
316
                return replace_static_urls(
Don Mitchell committed
317
                    html_file.read().decode('utf-8'),
318
                    getattr(course, 'data_dir', None),
319
                    course_id=course.id,
Calen Pennington committed
320
                    static_asset_path=course.static_asset_path,
321
                )
322
        except ResourceNotFoundError:
323
            log.exception(
324
                u"Missing syllabus section {key} in course {url}".format(key=section_key, url=course.location.to_deprecated_string())
325
            )
326 327 328 329
            return "! Syllabus missing !"

    raise KeyError("Invalid about key " + str(section_key))

330 331

def get_courses_by_university(user, domain=None):
332 333 334 335 336 337
    '''
    Returns dict of lists of courses available, keyed by course.org (ie university).
    Courses are sorted by course.number.
    '''
    # TODO: Clean up how 'error' is done.
    # filter out any courses that errored.
338
    visible_courses = get_courses(user, domain)
339

340
    universities = defaultdict(list)
341
    for course in visible_courses:
342
        universities[course.org].append(course)
343

344
    return universities
345 346 347 348 349 350


def get_courses(user, domain=None):
    '''
    Returns a list of courses available, sorted by course.number
    '''
351
    courses = branding.get_visible_courses()
352 353 354 355 356 357 358

    permission_name = microsite.get_value(
        'COURSE_CATALOG_VISIBILITY_PERMISSION',
        settings.COURSE_CATALOG_VISIBILITY_PERMISSION
    )

    courses = [c for c in courses if has_access(user, permission_name, c)]
359

Calen Pennington committed
360
    courses = sorted(courses, key=lambda course: course.number)
361 362 363 364 365 366 367 368 369 370 371 372 373 374

    return courses


def sort_by_announcement(courses):
    """
    Sorts a list of courses by their announcement date. If the date is
    not available, sort them by their start date.
    """

    # Sort courses by how far are they from they start day
    key = lambda course: course.sorting_score
    courses = sorted(courses, key=key)

375
    return courses
376

377

378 379 380 381
def sort_by_start_date(courses):
    """
    Returns a list of courses sorted by their start date, latest first.
    """
382 383 384 385 386
    courses = sorted(
        courses,
        key=lambda course: (course.has_ended(), course.start is None, course.start),
        reverse=False
    )
387 388 389 390

    return courses


391
def get_cms_course_link(course, page='course'):
392
    """
393 394
    Returns a link to course_index for editing the course in cms,
    assuming that the course is actually cms-backed.
395
    """
396 397 398
    # This is fragile, but unfortunately the problem is that within the LMS we
    # can't use the reverse calls from the CMS
    return u"//{}/{}/{}".format(settings.CMS_BASE, page, unicode(course.id))
399 400 401 402 403 404 405


def get_cms_block_link(block, page):
    """
    Returns a link to block_index for editing the course in cms,
    assuming that the block is actually cms-backed.
    """
406 407 408
    # This is fragile, but unfortunately the problem is that within the LMS we
    # can't use the reverse calls from the CMS
    return u"//{}/{}/{}".format(settings.CMS_BASE, page, block.location)
409 410


411
def get_studio_url(course, page):
412 413
    """
    Get the Studio URL of the page that is passed in.
414 415 416

    Args:
        course (CourseDescriptor)
417 418
    """
    is_studio_course = course.course_edit_method == "Studio"
419
    is_mongo_course = modulestore().get_modulestore_type(course.id) != ModuleStoreEnum.Type.xml
420 421
    studio_link = None
    if is_studio_course and is_mongo_course:
422
        studio_link = get_cms_course_link(course, page)
423
    return studio_link
424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449


def get_problems_in_section(section):
    """
    This returns a dict having problems in a section.
    Returning dict has problem location as keys and problem
    descriptor as values.
    """

    problem_descriptors = defaultdict()
    if not isinstance(section, UsageKey):
        section_key = UsageKey.from_string(section)
    else:
        section_key = section
    # it will be a Mongo performance boost, if you pass in a depth=3 argument here
    # as it will optimize round trips to the database to fetch all children for the current node
    section_descriptor = modulestore().get_item(section_key, depth=3)

    # iterate over section, sub-section, vertical
    for subsection in section_descriptor.get_children():
        for vertical in subsection.get_children():
            for component in vertical.get_children():
                if component.location.category == 'problem' and getattr(component, 'has_score', False):
                    problem_descriptors[unicode(component.location)] = component

    return problem_descriptors