courses.py 19.1 KB
Newer Older
1 2 3 4
"""
Functions for accessing and displaying courses within the
courseware.
"""
5
import logging
6 7
from collections import defaultdict
from datetime import datetime
8

9
import branding
10
import pytz
11
from courseware.access import has_access
12
from courseware.access_response import StartDateError, MilestoneAccessError
13 14 15 16 17
from courseware.date_summary import (
    CourseEndDate,
    CourseStartDate,
    TodaysDate,
    VerificationDeadlineDate,
18
    VerifiedUpgradeDeadlineDate
19
)
20
from courseware.model_data import FieldDataCache
21
from courseware.module_render import get_module
22 23 24
from django.conf import settings
from django.core.urlresolvers import reverse
from django.http import Http404, QueryDict
25
from edxmako.shortcuts import render_to_string
26
from fs.errors import ResourceNotFoundError
27
from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException
28
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
29
from opaque_keys.edx.keys import UsageKey
30
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
31
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
32
from path import Path as path
33 34
from static_replace import replace_static_urls
from student.models import CourseEnrollment
35
from survey.utils import is_survey_required_and_unanswered
36
from util.date_utils import strftime_localized
37 38 39
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.x_module import STUDENT_VIEW
40

41
log = logging.getLogger(__name__)
42

Calen Pennington committed
43

44 45 46 47
def get_course(course_id, depth=0):
    """
    Given a course id, return the corresponding course descriptor.

Don Mitchell committed
48
    If the course does not exist, raises a ValueError.  This is appropriate
49 50 51 52 53
    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
54 55
    course = modulestore().get_course(course_id, depth=depth)
    if course is None:
56
        raise ValueError(u"Course not found: {0}".format(course_id))
57
    return course
58 59


60
def get_course_by_id(course_key, depth=0):
61
    """
62
    Given a course id, return the corresponding course descriptor.
63

Don Mitchell committed
64
    If such a course does not exist, raises a 404.
65

66
    depth: The number of levels of children for the modulestore to cache. None means infinite depth
67
    """
68 69
    with modulestore().bulk_operations(course_key):
        course = modulestore().get_course(course_key, depth=depth)
Don Mitchell committed
70 71 72
    if course:
        return course
    else:
73
        raise Http404("Course not found: {}.".format(unicode(course_key)))
74

75

76
def get_course_with_access(user, action, course_key, depth=0, check_if_enrolled=False, check_survey_complete=True):
77
    """
78
    Given a course_key, look up the corresponding course descriptor,
79 80
    check that the user has the access to perform the specified action
    on the course, and return the descriptor.
81

82
    Raises a 404 if the course_key is invalid, or the user doesn't have access.
83 84

    depth: The number of levels of children for the modulestore to cache. None means infinite depth
85 86 87

    check_if_enrolled: If true, additionally verifies that the user is either enrolled in the course
      or has staff access.
88 89 90 91 92
    check_survey_complete: If true, additionally verifies that the user has either completed the course survey
      or has staff access.
      Note: We do not want to continually add these optional booleans.  Ideally,
      these special cases could not only be handled inside has_access, but could
      be plugged in as additional callback checks for different actions.
93
    """
94
    course = get_course_by_id(course_key, depth)
95
    check_course_access(course, user, action, check_if_enrolled, check_survey_complete)
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117
    return course


def get_course_overview_with_access(user, action, course_key, check_if_enrolled=False):
    """
    Given a course_key, look up the corresponding course overview,
    check that the user has the access to perform the specified action
    on the course, and return the course overview.

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

    check_if_enrolled: If true, additionally verifies that the user is either enrolled in the course
      or has staff access.
    """
    try:
        course_overview = CourseOverview.get_from_id(course_key)
    except CourseOverview.DoesNotExist:
        raise Http404("Course not found.")
    check_course_access(course_overview, user, action, check_if_enrolled)
    return course_overview


118
def check_course_access(course, user, action, check_if_enrolled=False, check_survey_complete=True):
119 120 121 122
    """
    Check that the user has the access to perform the specified action
    on the course (CourseDescriptor|CourseOverview).

123
    check_if_enrolled: If true, additionally verifies that the user is enrolled.
124
    check_survey_complete: If true, additionally verifies that the user has completed the survey.
125
    """
126 127 128
    # Allow staff full access to the course even if not enrolled
    if has_access(user, 'staff', course.id):
        return
129

130
    access_response = has_access(user, action, course, course.id)
131
    if not access_response:
132 133 134 135 136 137 138 139
        # Redirect if StartDateError
        if isinstance(access_response, StartDateError):
            start_date = strftime_localized(course.start, 'SHORT_DATE')
            params = QueryDict(mutable=True)
            params['notlive'] = start_date
            raise CourseAccessRedirect('{dashboard_url}?{params}'.format(
                dashboard_url=reverse('dashboard'),
                params=params.urlencode()
140 141 142 143 144 145 146
            ), access_response)

        # Redirect if the user must answer a survey before entering the course.
        if isinstance(access_response, MilestoneAccessError):
            raise CourseAccessRedirect('{dashboard_url}'.format(
                dashboard_url=reverse('dashboard'),
            ), access_response)
147

148 149
        # Deliberately return a non-specific error message to avoid
        # leaking info about access control settings
150
        raise CoursewareAccessException(access_response)
151

152
    if check_if_enrolled:
153 154
        # If the user is not enrolled, redirect them to the about page
        if not CourseEnrollment.is_enrolled(user, course.id):
155
            raise CourseAccessRedirect(reverse('about_course', args=[unicode(course.id)]))
156

157 158 159 160 161
    # Redirect if the user must answer a survey before entering the course.
    if check_survey_complete and action == 'load':
        if is_survey_required_and_unanswered(user, course):
            raise CourseAccessRedirect(reverse('course_survey', args=[unicode(course.id)]))

162

163 164 165 166 167 168 169 170 171 172 173 174 175
def can_self_enroll_in_course(course_key):
    """
    Returns True if the user can enroll themselves in a course.

    Note: an example of a course that a user cannot enroll in directly
    is a CCX course. For such courses, a user can only be enrolled by
    a CCX coach.
    """
    if hasattr(course_key, 'ccx'):
        return False
    return True


Don Mitchell committed
176
def find_file(filesystem, dirs, filename):
177 178 179
    """
    Looks for a filename in a list of dirs on a filesystem, in the specified order.

Don Mitchell committed
180
    filesystem: an OSFS filesystem
181 182 183 184 185
    dirs: a list of path objects
    filename: a string

    Returns d / filename if found in dir d, else raises ResourceNotFoundError.
    """
Don Mitchell committed
186 187 188
    for directory in dirs:
        filepath = path(directory) / filename
        if filesystem.exists(filepath):
189
            return filepath
190
    raise ResourceNotFoundError(u"Could not find {0}".format(filename))
191

192

193
def get_course_about_section(request, course, section_key):
194
    """
Victor Shnayder committed
195 196 197
    This returns the snippet of html to be rendered on the course about page,
    given the key for the section.

198 199 200 201 202 203 204 205 206 207 208 209
    Valid keys:
    - overview
    - short_description
    - description
    - key_dates (includes start, end, exams, etc)
    - video
    - course_staff_short
    - course_staff_extended
    - requirements
    - syllabus
    - textbook
    - faq
210
    - effort
211
    - more_info
212
    - ocw_links
213 214
    """

Victor Shnayder committed
215 216 217
    # 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.
218

219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238
    html_sections = {
        'short_description',
        'description',
        'key_dates',
        'video',
        'course_staff_short',
        'course_staff_extended',
        'requirements',
        'syllabus',
        'textbook',
        'faq',
        'more_info',
        'overview',
        'effort',
        'end_date',
        'prerequisites',
        'ocw_links'
    }

    if section_key in html_sections:
239
        try:
Don Mitchell committed
240
            loc = course.location.replace(category='about', name=section_key)
241 242

            # Use an empty cache
Calen Pennington committed
243
            field_data_cache = FieldDataCache([], course.id, request.user)
244 245 246 247
            about_module = get_module(
                request.user,
                request,
                loc,
Calen Pennington committed
248
                field_data_cache,
249
                log_if_not_found=False,
250
                wrap_xmodule_display=False,
251 252
                static_asset_path=course.static_asset_path,
                course=course
253
            )
254 255 256

            html = ''

257
            if about_module is not None:
258
                try:
259
                    html = about_module.render(STUDENT_VIEW).content
260 261
                except Exception:  # pylint: disable=broad-except
                    html = render_to_string('courseware/error-message.html', None)
262
                    log.exception(
263 264 265
                        u"Error rendering course=%s, section_key=%s",
                        course, section_key
                    )
266
            return html
267 268

        except ItemNotFoundError:
269
            log.warning(
270 271
                u"Missing about section %s in course %s",
                section_key, course.location.to_deprecated_string()
272
            )
273 274 275 276
            return None

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

277

278 279 280 281 282 283 284
def get_course_info_usage_key(course, section_key):
    """
    Returns the usage key for the specified section's course info module.
    """
    return course.id.make_usage_key('course_info', section_key)


285
def get_course_info_section_module(request, user, course, section_key):
286
    """
287
    This returns the course info module for a given section_key.
Victor Shnayder committed
288

289 290 291 292 293 294
    Valid keys:
    - handouts
    - guest_handouts
    - updates
    - guest_updates
    """
295
    usage_key = get_course_info_usage_key(course, section_key)
296 297

    # Use an empty cache
298
    field_data_cache = FieldDataCache([], course.id, user)
299 300

    return get_module(
301
        user,
302
        request,
303
        usage_key,
Calen Pennington committed
304
        field_data_cache,
305
        log_if_not_found=False,
306
        wrap_xmodule_display=False,
307 308
        static_asset_path=course.static_asset_path,
        course=course
309
    )
310

311

312
def get_course_info_section(request, user, course, section_key):
313 314 315
    """
    This returns the snippet of html to be rendered on the course info page,
    given the key for the section.
316

317 318 319 320 321 322
    Valid keys:
    - handouts
    - guest_handouts
    - updates
    - guest_updates
    """
323
    info_module = get_course_info_section_module(request, user, course, section_key)
324 325

    html = ''
326
    if info_module is not None:
327
        try:
328
            html = info_module.render(STUDENT_VIEW).content.strip()
329 330
        except Exception:  # pylint: disable=broad-except
            html = render_to_string('courseware/error-message.html', None)
331
            log.exception(
332 333
                u"Error rendering course_id=%s, section_key=%s",
                unicode(course.id), section_key
334
            )
335

336
    return html
337

338

339
def get_course_date_blocks(course, user):
340 341 342 343 344 345 346 347 348 349 350 351 352
    """
    Return the list of blocks to display on the course info page,
    sorted by date.
    """
    block_classes = (
        CourseEndDate,
        CourseStartDate,
        TodaysDate,
        VerificationDeadlineDate,
        VerifiedUpgradeDeadlineDate,
    )

    blocks = (cls(course, user) for cls in block_classes)
353 354 355 356 357 358 359 360 361 362

    def block_key_fn(block):
        """
        If the block's date is None, return the maximum datetime in order
        to force it to the end of the list of displayed blocks.
        """
        if block.date is None:
            return datetime.max.replace(tzinfo=pytz.UTC)
        return block.date
    return sorted((b for b in blocks if b.is_enabled), key=block_key_fn)
363 364


365 366
# 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
367
#       then.
368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383
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
384
            filesys = course.system.resources_fs
385 386
            # first look for a run-specific version
            dirs = [path("syllabus") / course.url_name, path("syllabus")]
Don Mitchell committed
387 388
            filepath = find_file(filesys, dirs, section_key + ".html")
            with filesys.open(filepath) as html_file:
389
                return replace_static_urls(
Don Mitchell committed
390
                    html_file.read().decode('utf-8'),
391
                    getattr(course, 'data_dir', None),
392
                    course_id=course.id,
Calen Pennington committed
393
                    static_asset_path=course.static_asset_path,
394
                )
395
        except ResourceNotFoundError:
396
            log.exception(
397 398
                u"Missing syllabus section %s in course %s",
                section_key, course.location.to_deprecated_string()
399
            )
400 401 402 403
            return "! Syllabus missing !"

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

404

405
def get_courses(user, org=None, filter_=None):
406 407 408 409
    """
    Returns a list of courses available, sorted by course.number and optionally
    filtered by org code (case-insensitive).
    """
410
    courses = branding.get_visible_courses(org=org, filter_=filter_)
411

412
    permission_name = configuration_helpers.get_value(
413 414 415 416 417
        'COURSE_CATALOG_VISIBILITY_PERMISSION',
        settings.COURSE_CATALOG_VISIBILITY_PERMISSION
    )

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

419 420 421
    return courses


422 423 424 425
def get_permission_for_course_about():
    """
    Returns the CourseOverview object for the course after checking for access.
    """
426
    return configuration_helpers.get_value(
427 428 429 430 431
        'COURSE_ABOUT_VISIBILITY_PERMISSION',
        settings.COURSE_ABOUT_VISIBILITY_PERMISSION
    )


432 433 434 435 436 437 438 439 440 441
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)

442
    return courses
443

444

445 446 447 448
def sort_by_start_date(courses):
    """
    Returns a list of courses sorted by their start date, latest first.
    """
449 450 451 452 453
    courses = sorted(
        courses,
        key=lambda course: (course.has_ended(), course.start is None, course.start),
        reverse=False
    )
454 455 456 457

    return courses


458
def get_cms_course_link(course, page='course'):
459
    """
460 461
    Returns a link to course_index for editing the course in cms,
    assuming that the course is actually cms-backed.
462
    """
463 464 465
    # 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))
466 467 468 469 470 471 472


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.
    """
473 474 475
    # 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)
476 477


478
def get_studio_url(course, page):
479 480
    """
    Get the Studio URL of the page that is passed in.
481 482 483

    Args:
        course (CourseDescriptor)
484 485
    """
    studio_link = None
486
    if course.course_edit_method == "Studio":
487
        studio_link = get_cms_course_link(course, page)
488
    return studio_link
489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514


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
515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567


def get_current_child(xmodule, min_depth=None, requested_child=None):
    """
    Get the xmodule.position's display item of an xmodule that has a position and
    children.  If xmodule has no position or is out of bounds, return the first
    child with children of min_depth.

    For example, if chapter_one has no position set, with two child sections,
    section-A having no children and section-B having a discussion unit,
    `get_current_child(chapter, min_depth=1)`  will return section-B.

    Returns None only if there are no children at all.
    """
    # TODO: convert this method to use the Course Blocks API
    def _get_child(children):
        """
        Returns either the first or last child based on the value of
        the requested_child parameter.  If requested_child is None,
        returns the first child.
        """
        if requested_child == 'first':
            return children[0]
        elif requested_child == 'last':
            return children[-1]
        else:
            return children[0]

    def _get_default_child_module(child_modules):
        """Returns the first child of xmodule, subject to min_depth."""
        if min_depth <= 0:
            return _get_child(child_modules)
        else:
            content_children = [
                child for child in child_modules
                if child.has_children_at_depth(min_depth - 1) and child.get_display_items()
            ]
            return _get_child(content_children) if content_children else None

    child = None
    if hasattr(xmodule, 'position'):
        children = xmodule.get_display_items()
        if len(children) > 0:
            if xmodule.position is not None and not requested_child:
                pos = xmodule.position - 1  # position is 1-indexed
                if 0 <= pos < len(children):
                    child = children[pos]
                    if min_depth > 0 and not child.has_children_at_depth(min_depth - 1):
                        child = None
            if child is None:
                child = _get_default_child_module(children)

    return child