251 lines
7.6 KiB
Python
251 lines
7.6 KiB
Python
"""
|
|
This file contains celery tasks for credit course views.
|
|
"""
|
|
|
|
import datetime
|
|
from pytz import UTC
|
|
|
|
from django.conf import settings
|
|
|
|
from celery import task
|
|
from celery.utils.log import get_task_logger
|
|
from opaque_keys import InvalidKeyError
|
|
from opaque_keys.edx.keys import CourseKey
|
|
|
|
from .api import set_credit_requirements
|
|
from openedx.core.djangoapps.credit.exceptions import InvalidCreditRequirements
|
|
from openedx.core.djangoapps.credit.models import CreditCourse
|
|
from xmodule.modulestore.django import modulestore
|
|
from xmodule.modulestore import ModuleStoreEnum
|
|
from xmodule.modulestore.exceptions import ItemNotFoundError
|
|
|
|
LOGGER = get_task_logger(__name__)
|
|
|
|
|
|
# XBlocks that can be added as credit requirements
|
|
CREDIT_REQUIREMENT_XBLOCK_CATEGORIES = [
|
|
"edx-reverification-block",
|
|
]
|
|
|
|
|
|
# pylint: disable=not-callable
|
|
@task(default_retry_delay=settings.CREDIT_TASK_DEFAULT_RETRY_DELAY, max_retries=settings.CREDIT_TASK_MAX_RETRIES)
|
|
def update_credit_course_requirements(course_id): # pylint: disable=invalid-name
|
|
"""
|
|
Updates course requirements table for a course.
|
|
|
|
Args:
|
|
course_id(str): A string representation of course identifier
|
|
|
|
Returns:
|
|
None
|
|
|
|
"""
|
|
try:
|
|
course_key = CourseKey.from_string(course_id)
|
|
is_credit_course = CreditCourse.is_credit_course(course_key)
|
|
if is_credit_course:
|
|
requirements = _get_course_credit_requirements(course_key)
|
|
set_credit_requirements(course_key, requirements)
|
|
except (InvalidKeyError, ItemNotFoundError, InvalidCreditRequirements) as exc:
|
|
LOGGER.error('Error on adding the requirements for course %s - %s', course_id, unicode(exc))
|
|
raise update_credit_course_requirements.retry(args=[course_id], exc=exc)
|
|
else:
|
|
LOGGER.info('Requirements added for course %s', course_id)
|
|
|
|
|
|
def _get_course_credit_requirements(course_key):
|
|
"""
|
|
Returns the list of credit requirements for the given course.
|
|
|
|
This will also call into the edx-proctoring subsystem to also
|
|
produce proctored exam requirements for credit bearing courses
|
|
|
|
It returns the minimum_grade_credit and also the ICRV checkpoints
|
|
if any were added in the course
|
|
|
|
Args:
|
|
course_key (CourseKey): Identifier for the course.
|
|
|
|
Returns:
|
|
List of credit requirements (dictionaries)
|
|
|
|
"""
|
|
credit_xblock_requirements = _get_credit_course_requirement_xblocks(course_key)
|
|
min_grade_requirement = _get_min_grade_requirement(course_key)
|
|
proctored_exams_requirements = _get_proctoring_requirements(course_key)
|
|
credit_requirements = (
|
|
min_grade_requirement + credit_xblock_requirements + proctored_exams_requirements
|
|
)
|
|
return credit_requirements
|
|
|
|
|
|
def _get_min_grade_requirement(course_key):
|
|
"""
|
|
Get list of 'minimum_grade_credit' requirement for the given course.
|
|
|
|
Args:
|
|
course_key (CourseKey): Identifier for the course.
|
|
|
|
Returns:
|
|
The list of minimum_grade_credit requirements
|
|
|
|
"""
|
|
course = modulestore().get_course(course_key, depth=0)
|
|
try:
|
|
return [
|
|
{
|
|
"namespace": "grade",
|
|
"name": "grade",
|
|
"display_name": "Minimum Grade",
|
|
"criteria": {
|
|
"min_grade": getattr(course, "minimum_grade_credit")
|
|
},
|
|
}
|
|
]
|
|
except AttributeError:
|
|
LOGGER.error("The course %s does not has minimum_grade_credit attribute", unicode(course.id))
|
|
else:
|
|
return []
|
|
|
|
|
|
def _get_credit_course_requirement_xblocks(course_key): # pylint: disable=invalid-name
|
|
"""Generate a course structure dictionary for the specified course.
|
|
|
|
Args:
|
|
course_key (CourseKey): Identifier for the course.
|
|
|
|
Returns:
|
|
The list of credit requirements xblocks dicts
|
|
|
|
"""
|
|
requirements = []
|
|
|
|
# Retrieve all XBlocks from the course that we know to be credit requirements.
|
|
# For performance reasons, we look these up by their "category" to avoid
|
|
# loading and searching the entire course tree.
|
|
for category in CREDIT_REQUIREMENT_XBLOCK_CATEGORIES:
|
|
requirements.extend([
|
|
{
|
|
"namespace": block.get_credit_requirement_namespace(),
|
|
"name": block.get_credit_requirement_name(),
|
|
"display_name": block.get_credit_requirement_display_name(),
|
|
"criteria": {},
|
|
}
|
|
for block in _get_xblocks(course_key, category)
|
|
if _is_credit_requirement(block)
|
|
])
|
|
|
|
return requirements
|
|
|
|
|
|
def _is_in_course_tree(block):
|
|
"""
|
|
Check that the XBlock is in the course tree.
|
|
|
|
It's possible that the XBlock is not in the course tree
|
|
if its parent has been deleted and is now an orphan.
|
|
"""
|
|
ancestor = block.get_parent()
|
|
while ancestor is not None and ancestor.location.category != "course":
|
|
ancestor = ancestor.get_parent()
|
|
|
|
return ancestor is not None
|
|
|
|
|
|
def _get_xblocks(course_key, category):
|
|
"""
|
|
Retrieve all XBlocks in the course for a particular category.
|
|
|
|
Returns only XBlocks that are published and haven't been deleted.
|
|
"""
|
|
xblocks = [
|
|
block for block in modulestore().get_items(
|
|
course_key,
|
|
qualifiers={"category": category},
|
|
revision=ModuleStoreEnum.RevisionOption.published_only,
|
|
)
|
|
if _is_in_course_tree(block)
|
|
]
|
|
|
|
# Secondary sort on credit requirement name
|
|
xblocks = sorted(xblocks, key=lambda block: block.get_credit_requirement_display_name())
|
|
|
|
# Primary sort on start date
|
|
xblocks = sorted(xblocks, key=lambda block: (
|
|
block.start if block.start is not None
|
|
else datetime.datetime(datetime.MINYEAR, 1, 1).replace(tzinfo=UTC)
|
|
))
|
|
|
|
return xblocks
|
|
|
|
|
|
def _is_credit_requirement(xblock):
|
|
"""
|
|
Check if the given XBlock is a credit requirement.
|
|
|
|
Args:
|
|
xblock(XBlock): The given XBlock object
|
|
|
|
Returns:
|
|
True if XBlock is a credit requirement else False
|
|
|
|
"""
|
|
required_methods = [
|
|
"get_credit_requirement_namespace",
|
|
"get_credit_requirement_name",
|
|
"get_credit_requirement_display_name"
|
|
]
|
|
|
|
for method_name in required_methods:
|
|
if not callable(getattr(xblock, method_name, None)):
|
|
LOGGER.error(
|
|
"XBlock %s is marked as a credit requirement but does not "
|
|
"implement %s", unicode(xblock), method_name
|
|
)
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def _get_proctoring_requirements(course_key):
|
|
"""
|
|
Will return list of requirements regarding any exams that have been
|
|
marked as proctored exams. For credit-bearing courses, all
|
|
proctored exams must be validated and confirmed from a proctoring
|
|
standpoint. The passing grade on an exam is not enough.
|
|
|
|
Args:
|
|
course_key: The key of the course in question
|
|
|
|
Returns:
|
|
list of requirements dictionary, one per active proctored exam
|
|
|
|
"""
|
|
|
|
# Note: Need to import here as there appears to be
|
|
# a circular reference happening when launching Studio
|
|
# process
|
|
from edx_proctoring.api import get_all_exams_for_course
|
|
|
|
requirements = [
|
|
{
|
|
'namespace': 'proctored_exam',
|
|
'name': exam['content_id'],
|
|
'display_name': exam['exam_name'],
|
|
'criteria': {},
|
|
}
|
|
for exam in get_all_exams_for_course(unicode(course_key))
|
|
# practice exams do not count towards eligibility
|
|
if exam['is_proctored'] and exam['is_active'] and not exam['is_practice_exam']
|
|
]
|
|
|
|
log_msg = (
|
|
'Registering the following as \'proctored_exam\' credit requirements: {log_msg}'.format(
|
|
log_msg=requirements
|
|
)
|
|
)
|
|
LOGGER.info(log_msg)
|
|
|
|
return requirements
|