eligibility.py 15.7 KB
Newer Older
1 2 3 4 5 6 7
"""
APIs for configuring credit eligibility requirements and tracking
whether a user has satisfied those requirements.
"""

import logging

Clinton Blackburn committed
8 9
from opaque_keys.edx.keys import CourseKey

10
from openedx.core.djangoapps.credit.email_utils import send_credit_notifications
11
from openedx.core.djangoapps.credit.exceptions import InvalidCreditRequirements, InvalidCreditCourse
12
from openedx.core.djangoapps.credit.models import (
13
    CreditCourse, CreditRequirement, CreditRequirementStatus, CreditEligibility, CreditRequest
14 15
)

16 17 18
from course_modes.models import CourseMode
from student.models import CourseEnrollment

Clinton Blackburn committed
19
# TODO: Cleanup this mess! ECOM-2908
20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149

log = logging.getLogger(__name__)


def is_credit_course(course_key):
    """
    Check whether the course has been configured for credit.

    Args:
        course_key (CourseKey): Identifier of the course.

    Returns:
        bool: True iff this is a credit course.

    """
    return CreditCourse.is_credit_course(course_key=course_key)


def set_credit_requirements(course_key, requirements):
    """
    Add requirements to given course.

    Args:
        course_key(CourseKey): The identifier for course
        requirements(list): List of requirements to be added

    Example:
        >>> set_credit_requirements(
                "course-v1-edX-DemoX-1T2015",
                [
                    {
                        "namespace": "proctored_exam",
                        "name": "i4x://edX/DemoX/proctoring-block/final_uuid",
                        "display_name": "Final Exam",
                        "criteria": {},
                    },
                    {
                        "namespace": "grade",
                        "name": "grade",
                        "display_name": "Grade",
                        "criteria": {"min_grade": 0.8},
                    },
                ])

    Raises:
        InvalidCreditRequirements

    Returns:
        None
    """

    invalid_requirements = _validate_requirements(requirements)
    if invalid_requirements:
        invalid_requirements = ", ".join(invalid_requirements)
        raise InvalidCreditRequirements(invalid_requirements)

    try:
        credit_course = CreditCourse.get_credit_course(course_key=course_key)
    except CreditCourse.DoesNotExist:
        raise InvalidCreditCourse()

    old_requirements = CreditRequirement.get_course_requirements(course_key=course_key)
    requirements_to_disable = _get_requirements_to_disable(old_requirements, requirements)
    if requirements_to_disable:
        CreditRequirement.disable_credit_requirements(requirements_to_disable)

    for order, requirement in enumerate(requirements):
        CreditRequirement.add_or_update_course_requirement(credit_course, requirement, order)


def get_credit_requirements(course_key, namespace=None):
    """
    Get credit eligibility requirements of a given course and namespace.

    Args:
        course_key(CourseKey): The identifier for course
        namespace(str): Namespace of requirements

    Example:
        >>> get_credit_requirements("course-v1-edX-DemoX-1T2015")
            {
                requirements =
                [
                    {
                        "namespace": "proctored_exam",
                        "name": "i4x://edX/DemoX/proctoring-block/final_uuid",
                        "display_name": "Final Exam",
                        "criteria": {},
                    },
                    {
                        "namespace": "grade",
                        "name": "grade",
                        "display_name": "Grade",
                        "criteria": {"min_grade": 0.8},
                    },
                ]
            }

    Returns:
        Dict of requirements in the given namespace

    """

    requirements = CreditRequirement.get_course_requirements(course_key, namespace)
    return [
        {
            "namespace": requirement.namespace,
            "name": requirement.name,
            "display_name": requirement.display_name,
            "criteria": requirement.criteria
        }
        for requirement in requirements
    ]


def is_user_eligible_for_credit(username, course_key):
    """
    Returns a boolean indicating if the user is eligible for credit for
    the given course

    Args:
        username(str): The identifier for user
        course_key (CourseKey): The identifier for course

    Returns:
        True if user is eligible for the course else False
    """
    return CreditEligibility.is_user_eligible_for_credit(course_key, username)


150
def get_eligibilities_for_user(username, course_key=None):
151
    """
152 153
    Retrieve all courses or particular course for which the user is eligible
    for credit.
154 155 156

    Arguments:
        username (unicode): Identifier of the user.
157
        course_key (unicode): Identifier of the course.
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175

    Example:
        >>> get_eligibilities_for_user("ron")
        [
            {
                "course_key": "edX/Demo_101/Fall",
                "deadline": "2015-10-23"
            },
            {
                "course_key": "edX/Demo_201/Spring",
                "deadline": "2015-11-15"
            },
            ...
        ]

    Returns: list

    """
176 177
    eligibilities = CreditEligibility.get_user_eligibilities(username)
    if course_key:
178 179
        course_key = CourseKey.from_string(unicode(course_key))
        eligibilities = eligibilities.filter(course__course_key=course_key)
180

181 182
    return [
        {
183
            "course_key": unicode(eligibility.course.course_key),
184 185
            "deadline": eligibility.deadline,
        }
186
        for eligibility in eligibilities
187 188 189
    ]


190
def set_credit_requirement_status(user, course_key, req_namespace, req_name, status="satisfied", reason=None):
191 192 193 194 195 196 197 198
    """
    Update the user's requirement status.

    This will record whether the user satisfied or failed a particular requirement
    in a course.  If the user has satisfied all requirements, the user will be marked
    as eligible for credit in the course.

    Args:
199
        user(User): User object to set credit requirement for.
200 201 202 203 204 205 206 207
        course_key (CourseKey): Identifier for the course associated with the requirement.
        req_namespace (str): Namespace of the requirement (e.g. "grade" or "reverification")
        req_name (str): Name of the requirement (e.g. "grade" or the location of the ICRV XBlock)

    Keyword Arguments:
        status (str): Status of the requirement (either "satisfied" or "failed")
        reason (dict): Reason of the status
    """
208 209 210 211 212 213 214 215
    # Check whether user has credit eligible enrollment.
    enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(user, course_key)
    has_credit_eligible_enrollment = (CourseMode.is_credit_eligible_slug(enrollment_mode) and is_active)

    # Refuse to set status of requirement if the user enrollment is not credit eligible.
    if not has_credit_eligible_enrollment:
        return

216
    # Do not allow students who have requested credit to change their eligibility
217
    if CreditRequest.get_user_request_status(user.username, course_key):
218 219 220
        log.info(
            u'Refusing to set status of requirement with namespace "%s" and name "%s" because the '
            u'user "%s" has already requested credit for the course "%s".',
221
            req_namespace, req_name, user.username, course_key
222 223 224 225
        )
        return

    # Do not allow a student who has earned eligibility to un-earn eligibility
226
    eligible_before_update = CreditEligibility.is_user_eligible_for_credit(course_key, user.username)
227
    if eligible_before_update and status == 'failed':
228
        log.info(
229 230
            u'Refusing to set status of requirement with namespace "%s" and name "%s" to "failed" because the '
            u'user "%s" is already eligible for credit in the course "%s".',
231
            req_namespace, req_name, user.username, course_key
232 233 234 235 236 237 238 239 240 241 242
        )
        return

    # Retrieve all credit requirements for the course
    # We retrieve all of them to avoid making a second query later when
    # we need to check whether all requirements have been satisfied.
    reqs = CreditRequirement.get_course_requirements(course_key)

    # Find the requirement we're trying to set
    req_to_update = next((
        req for req in reqs
Clinton Blackburn committed
243
        if req.namespace == req_namespace and req.name == req_name
244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259
    ), None)

    # If we can't find the requirement, then the most likely explanation
    # is that there was a lag updating the credit requirements after the course
    # was published.  We *could* attempt to create the requirement here,
    # but that could cause serious performance issues if many users attempt to
    # lock the row at the same time.
    # Instead, we skip updating the requirement and log an error.
    if req_to_update is None:
        log.error(
            (
                u'Could not update credit requirement in course "%s" '
                u'with namespace "%s" and name "%s" '
                u'because the requirement does not exist. '
                u'The user "%s" should have had his/her status updated to "%s".'
            ),
260
            unicode(course_key), req_namespace, req_name, user.username, status
261 262 263 264 265
        )
        return

    # Update the requirement status
    CreditRequirementStatus.add_or_update_requirement_status(
266
        user.username, req_to_update, status=status, reason=reason
267 268
    )

269 270 271
    # If we're marking this requirement as "satisfied", there's a chance that the user has met all eligibility
    # requirements, and should be notified. However, if the user was already eligible, do not send another notification.
    if status == "satisfied" and not eligible_before_update:
272
        is_eligible, eligibility_record_created = CreditEligibility.update_eligibility(reqs, user.username, course_key)
273 274
        if eligibility_record_created and is_eligible:
            try:
275
                send_credit_notifications(user.username, course_key)
276
            except Exception:  # pylint: disable=broad-except
277
                log.exception("Error sending email")
278 279


Muhammad Shoaib committed
280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299
# pylint: disable=invalid-name
def remove_credit_requirement_status(username, course_key, req_namespace, req_name):
    """
    Remove the user's requirement status.

    This will remove the record from the credit requirement status table.
    The user will still be eligible for the credit in a course.

    Args:
        username (str): Username of the user
        course_key (CourseKey): Identifier for the course associated
                                with the requirement.
        req_namespace (str): Namespace of the requirement
                            (e.g. "grade" or "reverification")
        req_name (str): Name of the requirement
                        (e.g. "grade" or the location of the ICRV XBlock)

    """

    # Find the requirement we're trying to remove
300
    req_to_remove = CreditRequirement.get_course_requirement(course_key, req_namespace, req_name)
Muhammad Shoaib committed
301 302 303 304 305 306 307

    # If we can't find the requirement, then the most likely explanation
    # is that there was a lag removing the credit requirements after the course
    # was published.  We *could* attempt to remove the requirement here,
    # but that could cause serious performance issues if many users attempt to
    # lock the row at the same time.
    # Instead, we skip removing the requirement and log an error.
308
    if not req_to_remove:
Muhammad Shoaib committed
309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324
        log.error(
            (
                u'Could not remove credit requirement in course "%s" '
                u'with namespace "%s" and name "%s" '
                u'because the requirement does not exist. '
            ),
            unicode(course_key), req_namespace, req_name
        )
        return

    # Remove the requirement status
    CreditRequirementStatus.remove_requirement_status(
        username, req_to_remove
    )


325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340
def get_credit_requirement_status(course_key, username, namespace=None, name=None):
    """ Retrieve the user's status for each credit requirement in the course.

    Args:
        course_key (CourseKey): The identifier for course
        username (str): The identifier of the user

    Example:
        >>> get_credit_requirement_status("course-v1-edX-DemoX-1T2015", "john")

                [
                    {
                        "namespace": "proctored_exam",
                        "name": "i4x://edX/DemoX/proctoring-block/final_uuid",
                        "display_name": "Proctored Mid Term Exam",
                        "criteria": {},
341
                        "reason": {},
342 343
                        "status": "satisfied",
                        "status_date": "2015-06-26 11:07:42",
344
                        "order": 1,
345 346 347 348 349 350
                    },
                    {
                        "namespace": "grade",
                        "name": "i4x://edX/DemoX/proctoring-block/final_uuid",
                        "display_name": "Minimum Passing Grade",
                        "criteria": {"min_grade": 0.8},
351 352
                        "reason": {"final_grade": 0.95},
                        "status": "satisfied",
353
                        "status_date": "2015-06-26 11:07:44",
354
                        "order": 2,
355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371
                    },
                ]

    Returns:
        list of requirement statuses
    """
    requirements = CreditRequirement.get_course_requirements(course_key, namespace=namespace, name=name)
    requirement_statuses = CreditRequirementStatus.get_statuses(requirements, username)
    requirement_statuses = dict((o.requirement, o) for o in requirement_statuses)
    statuses = []
    for requirement in requirements:
        requirement_status = requirement_statuses.get(requirement)
        statuses.append({
            "namespace": requirement.namespace,
            "name": requirement.name,
            "display_name": requirement.display_name,
            "criteria": requirement.criteria,
372
            "reason": requirement_status.reason if requirement_status else None,
373 374
            "status": requirement_status.status if requirement_status else None,
            "status_date": requirement_status.modified if requirement_status else None,
375
            "order": requirement.order,
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434
        })
    return statuses


def _get_requirements_to_disable(old_requirements, new_requirements):
    """
    Get the ids of 'CreditRequirement' entries to be disabled that are
    deleted from the courseware.

    Args:
        old_requirements(QuerySet): QuerySet of CreditRequirement
        new_requirements(list): List of requirements being added

    Returns:
        List of ids of CreditRequirement that are not in new_requirements
    """
    requirements_to_disable = []
    for old_req in old_requirements:
        found_flag = False
        for req in new_requirements:
            # check if an already added requirement is modified
            if req["namespace"] == old_req.namespace and req["name"] == old_req.name:
                found_flag = True
                break
        if not found_flag:
            requirements_to_disable.append(old_req.id)
    return requirements_to_disable


def _validate_requirements(requirements):
    """
    Validate the requirements.

    Args:
        requirements(list): List of requirements

    Returns:
        List of strings of invalid requirements
    """
    invalid_requirements = []
    for requirement in requirements:
        invalid_params = []
        if not requirement.get("namespace"):
            invalid_params.append("namespace")
        if not requirement.get("name"):
            invalid_params.append("name")
        if not requirement.get("display_name"):
            invalid_params.append("display_name")
        if "criteria" not in requirement:
            invalid_params.append("criteria")

        if invalid_params:
            invalid_requirements.append(
                u"{requirement} has missing/invalid parameters: {params}".format(
                    requirement=requirement,
                    params=invalid_params,
                )
            )
    return invalid_requirements