""" 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