courses.py 20 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 19
    VerifiedUpgradeDeadlineDate,
    CertificateAvailableDate
20
)
21
from courseware.model_data import FieldDataCache
22
from courseware.module_render import get_module
23 24 25
from django.conf import settings
from django.core.urlresolvers import reverse
from django.http import Http404, QueryDict
26
from enrollment.api import get_course_enrollment_details
27
from edxmako.shortcuts import render_to_string
28
from fs.errors import ResourceNotFoundError
29
from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException
30
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
31
from opaque_keys.edx.keys import UsageKey
32
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
33
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
34
from path import Path as path
35 36
from static_replace import replace_static_urls
from student.models import CourseEnrollment
37
from survey.utils import is_survey_required_and_unanswered
38
from util.date_utils import strftime_localized
39 40 41
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.x_module import STUDENT_VIEW
42

43
log = logging.getLogger(__name__)
44

Calen Pennington committed
45

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

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


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

Don Mitchell committed
66
    If such a course does not exist, raises a 404.
67

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

77

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

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

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

    check_if_enrolled: If true, additionally verifies that the user is either enrolled in the course
      or has staff access.
90 91 92 93 94
    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.
95
    """
96
    course = get_course_by_id(course_key, depth)
97
    check_course_access(course, user, action, check_if_enrolled, check_survey_complete)
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
    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


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

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

132
    access_response = has_access(user, action, course, course.id)
133
    if not access_response:
134 135 136 137 138 139 140 141
        # 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()
142 143 144 145 146 147 148
            ), 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)
149

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

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

159 160 161 162 163
    # 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)]))

164

165 166 167 168 169 170 171 172 173 174 175 176 177
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


178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201
def course_open_for_self_enrollment(course_key):
    """
    For a given course_key, determine if the course is available for enrollment
    """
    # Check to see if learners can enroll themselves.
    if not can_self_enroll_in_course(course_key):
        return False

    # Check the enrollment start and end dates.
    course_details = get_course_enrollment_details(unicode(course_key))
    now = datetime.now().replace(tzinfo=pytz.UTC)
    start = course_details['enrollment_start']
    end = course_details['enrollment_end']

    start = start if start is not None else now
    end = end if end is not None else now

    # If we are not within the start and end date for enrollment.
    if now < start or end < now:
        return False

    return True


Don Mitchell committed
202
def find_file(filesystem, dirs, filename):
203 204 205
    """
    Looks for a filename in a list of dirs on a filesystem, in the specified order.

Don Mitchell committed
206
    filesystem: an OSFS filesystem
207 208 209 210 211
    dirs: a list of path objects
    filename: a string

    Returns d / filename if found in dir d, else raises ResourceNotFoundError.
    """
Don Mitchell committed
212 213 214
    for directory in dirs:
        filepath = path(directory) / filename
        if filesystem.exists(filepath):
215
            return filepath
216
    raise ResourceNotFoundError(u"Could not find {0}".format(filename))
217

218

219
def get_course_about_section(request, course, section_key):
220
    """
Victor Shnayder committed
221 222 223
    This returns the snippet of html to be rendered on the course about page,
    given the key for the section.

224 225 226 227 228 229 230 231 232 233 234 235
    Valid keys:
    - overview
    - short_description
    - description
    - key_dates (includes start, end, exams, etc)
    - video
    - course_staff_short
    - course_staff_extended
    - requirements
    - syllabus
    - textbook
    - faq
236
    - effort
237
    - more_info
238
    - ocw_links
239 240
    """

Victor Shnayder committed
241 242 243
    # 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.
244

245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264
    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:
265
        try:
Don Mitchell committed
266
            loc = course.location.replace(category='about', name=section_key)
267 268

            # Use an empty cache
Calen Pennington committed
269
            field_data_cache = FieldDataCache([], course.id, request.user)
270 271 272 273
            about_module = get_module(
                request.user,
                request,
                loc,
Calen Pennington committed
274
                field_data_cache,
275
                log_if_not_found=False,
276
                wrap_xmodule_display=False,
277 278
                static_asset_path=course.static_asset_path,
                course=course
279
            )
280 281 282

            html = ''

283
            if about_module is not None:
284
                try:
285
                    html = about_module.render(STUDENT_VIEW).content
286 287
                except Exception:  # pylint: disable=broad-except
                    html = render_to_string('courseware/error-message.html', None)
288
                    log.exception(
289 290 291
                        u"Error rendering course=%s, section_key=%s",
                        course, section_key
                    )
292
            return html
293 294

        except ItemNotFoundError:
295
            log.warning(
296 297
                u"Missing about section %s in course %s",
                section_key, course.location.to_deprecated_string()
298
            )
299 300 301 302
            return None

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

303

304 305 306 307 308 309 310
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)


311
def get_course_info_section_module(request, user, course, section_key):
312
    """
313
    This returns the course info module for a given section_key.
Victor Shnayder committed
314

315 316 317 318 319 320
    Valid keys:
    - handouts
    - guest_handouts
    - updates
    - guest_updates
    """
321
    usage_key = get_course_info_usage_key(course, section_key)
322 323

    # Use an empty cache
324
    field_data_cache = FieldDataCache([], course.id, user)
325 326

    return get_module(
327
        user,
328
        request,
329
        usage_key,
Calen Pennington committed
330
        field_data_cache,
331
        log_if_not_found=False,
332
        wrap_xmodule_display=False,
333 334
        static_asset_path=course.static_asset_path,
        course=course
335
    )
336

337

338
def get_course_info_section(request, user, course, section_key):
339 340 341
    """
    This returns the snippet of html to be rendered on the course info page,
    given the key for the section.
342

343 344 345 346 347 348
    Valid keys:
    - handouts
    - guest_handouts
    - updates
    - guest_updates
    """
349
    info_module = get_course_info_section_module(request, user, course, section_key)
350 351

    html = ''
352
    if info_module is not None:
353
        try:
354
            html = info_module.render(STUDENT_VIEW).content.strip()
355 356
        except Exception:  # pylint: disable=broad-except
            html = render_to_string('courseware/error-message.html', None)
357
            log.exception(
358 359
                u"Error rendering course_id=%s, section_key=%s",
                unicode(course.id), section_key
360
            )
361

362
    return html
363

364

365
def get_course_date_blocks(course, user):
366 367 368 369 370
    """
    Return the list of blocks to display on the course info page,
    sorted by date.
    """
    block_classes = (
371
        CertificateAvailableDate,
372 373 374 375 376 377 378 379
        CourseEndDate,
        CourseStartDate,
        TodaysDate,
        VerificationDeadlineDate,
        VerifiedUpgradeDeadlineDate,
    )

    blocks = (cls(course, user) for cls in block_classes)
380 381 382 383 384 385 386 387 388 389

    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)
390 391


392 393
# 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
394
#       then.
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410
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
411
            filesys = course.system.resources_fs
412 413
            # first look for a run-specific version
            dirs = [path("syllabus") / course.url_name, path("syllabus")]
Don Mitchell committed
414 415
            filepath = find_file(filesys, dirs, section_key + ".html")
            with filesys.open(filepath) as html_file:
416
                return replace_static_urls(
Don Mitchell committed
417
                    html_file.read().decode('utf-8'),
418
                    getattr(course, 'data_dir', None),
419
                    course_id=course.id,
Calen Pennington committed
420
                    static_asset_path=course.static_asset_path,
421
                )
422
        except ResourceNotFoundError:
423
            log.exception(
424 425
                u"Missing syllabus section %s in course %s",
                section_key, course.location.to_deprecated_string()
426
            )
427 428 429 430
            return "! Syllabus missing !"

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

431

432
def get_courses(user, org=None, filter_=None):
433 434 435 436
    """
    Returns a list of courses available, sorted by course.number and optionally
    filtered by org code (case-insensitive).
    """
437
    courses = branding.get_visible_courses(org=org, filter_=filter_)
438

439
    permission_name = configuration_helpers.get_value(
440 441 442 443 444
        'COURSE_CATALOG_VISIBILITY_PERMISSION',
        settings.COURSE_CATALOG_VISIBILITY_PERMISSION
    )

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

446 447 448
    return courses


449 450 451 452
def get_permission_for_course_about():
    """
    Returns the CourseOverview object for the course after checking for access.
    """
453
    return configuration_helpers.get_value(
454 455 456 457 458
        'COURSE_ABOUT_VISIBILITY_PERMISSION',
        settings.COURSE_ABOUT_VISIBILITY_PERMISSION
    )


459 460 461 462 463 464 465 466 467 468
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)

469
    return courses
470

471

472 473 474 475
def sort_by_start_date(courses):
    """
    Returns a list of courses sorted by their start date, latest first.
    """
476 477 478 479 480
    courses = sorted(
        courses,
        key=lambda course: (course.has_ended(), course.start is None, course.start),
        reverse=False
    )
481 482 483 484

    return courses


485
def get_cms_course_link(course, page='course'):
486
    """
487 488
    Returns a link to course_index for editing the course in cms,
    assuming that the course is actually cms-backed.
489
    """
490 491 492
    # 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))
493 494 495 496 497 498 499


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.
    """
500 501 502
    # 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)
503 504


505
def get_studio_url(course, page):
506 507
    """
    Get the Studio URL of the page that is passed in.
508 509 510

    Args:
        course (CourseDescriptor)
511 512
    """
    studio_link = None
513
    if course.course_edit_method == "Studio":
514
        studio_link = get_cms_course_link(course, page)
515
    return studio_link
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


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
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 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594


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