Example #1
0
class CoursePerformancePresenter(CourseAPIPresenterMixin, BasePresenter):
    """
    Presenter for the performance page.
    """

    # limit for the number of bars to display in the answer distribution chart
    CHART_LIMIT = 12

    # minimum screen space a grading policy bar will take up (even if a policy is 0%, display some bar)
    MIN_POLICY_DISPLAY_PERCENT = 5

    def __init__(self,
                 access_token,
                 course_id,
                 timeout=settings.LMS_DEFAULT_TIMEOUT):
        super(CoursePerformancePresenter,
              self).__init__(access_token, course_id, timeout)
        # the deprecated course structure API has grading policy. This will be replaced in AN-7716
        self.grading_policy_client = CourseStructureApiClient(
            settings.GRADING_POLICY_API_URL, access_token)

    def course_module_data(self):
        try:
            return self._course_module_data()
        except BaseCourseError:
            raise NotFoundError

    def get_answer_distribution(self, problem_id, problem_part_id):
        """
        Retrieve answer distributions for a particular module/problem and problem part.
        """

        module = self.client.modules(self.course_id, problem_id)

        api_response = module.answer_distribution()
        questions = self._build_questions(api_response)

        filtered_active_question = [
            i for i in questions if i['part_id'] == problem_part_id
        ]
        if len(filtered_active_question) == 0:
            raise NotFoundError
        else:
            active_question = filtered_active_question[0]['question']

        answer_distributions = self._build_answer_distribution(
            api_response, problem_part_id)
        problem_part_description = self._build_problem_description(
            problem_part_id, questions)

        is_random = self._is_answer_distribution_random(answer_distributions)
        answer_distribution_limited = None
        if not is_random:
            # only display the top in the chart
            answer_distribution_limited = answer_distributions[:self.
                                                               CHART_LIMIT]

        answer_type = self._get_answer_type(answer_distributions)
        last_updated = self.parse_api_datetime(
            answer_distributions[0]['created'])
        self._last_updated = last_updated

        return AnswerDistributionEntry(last_updated, questions,
                                       active_question, answer_distributions,
                                       answer_distribution_limited, is_random,
                                       answer_type, problem_part_description)

    def _build_problem_description(self, problem_part_id, questions):
        """ Returns the displayable problem name. """
        problem = [q for q in questions if q['part_id'] == problem_part_id][0]
        return problem['short_description']

    def _get_answer_type(self, answer_distributions):
        """
        Returns either 'text' or 'numeric' to describe the answer and used in the JS table to format
        and sort the dataset.
        """
        field = 'answer_value'
        for ad in answer_distributions:
            if ad[field] is not None and not utils.number.is_number(ad[field]):
                return 'text'
        return 'numeric'

    def _is_answer_distribution_random(self, answer_distributions):
        """
        Problems are considered randomized if variant is populated with values
        greater than 1.
        """
        for ad in answer_distributions:
            variant = ad['variant']
            if variant is not None and variant is not 1:
                return True
        return False

    # pylint: disable=redefined-variable-type
    def _build_questions(self, answer_distributions):
        """
        Builds the questions and part_id from the answer distribution. Displayed
        drop down.
        """
        questions = []
        part_id_to_problem = {}

        # Collect unique questions from the answer distribution
        for question_answer in answer_distributions:
            question = question_answer.get('question_text', None)
            problem_name = question_answer.get('problem_display_name', None)
            part_id_to_problem[question_answer['part_id']] = {
                'question': question,
                'problem_name': problem_name
            }

        for part_id, problem in part_id_to_problem.iteritems():
            questions.append({
                'part_id': part_id,
                'question': problem['question'],
                'problem_name': problem['problem_name']
            })

        utils.sorting.natural_sort(questions, 'part_id')

        # add an enumerated label
        has_parts = len(questions) > 1
        for i, question in enumerate(questions):
            text = question['question']
            question_num = i + 1
            question_template = _('Submissions')
            short_description_template = ''
            if text:
                if has_parts:
                    question_template = _(
                        'Submissions for Part {part_number}: {part_description}'
                    )
                    short_description_template = _(
                        'Part {part_number}: {part_description}')
                else:
                    question_template = _('Submissions: {part_description}')
                    short_description_template = _('{part_description}')
            else:
                if has_parts:
                    question_template = _('Submissions for Part {part_number}')
                    short_description_template = _('Part {part_number}')

            # pylint: disable=no-member
            question['question'] = question_template.format(
                part_number=question_num, part_description=text)
            question['short_description'] = short_description_template.format(
                part_number=question_num, part_description=text)

        return questions

    def _build_answer_distribution(self, api_response, problem_part_id):
        """ Filter for this problem part and sort descending order. """
        answer_distributions = [
            i for i in api_response if i['part_id'] == problem_part_id
        ]
        for answer_dist in answer_distributions:
            # First and last response counts were added, we can handle both types of API responses at the moment.
            # If just the count is specified it is assumed to be the last response count.
            # TODO: teach downstream logic about first and last response counts
            count = answer_dist.get('last_response_count')
            if count is not None:
                answer_dist['count'] = count
        answer_distributions = sorted(answer_distributions,
                                      key=lambda a: -a['count'])
        return answer_distributions

    def grading_policy(self):
        """ Returns the grading policy for the represented course."""
        key = self.get_cache_key('grading_policy')
        grading_policy = cache.get(key)

        if not grading_policy:
            logger.debug('Retrieving grading policy for course: %s',
                         self.course_id)
            grading_policy = self.grading_policy_client.grading_policies(
                self.course_id).get()

            # Remove empty assignment types as they are not useful and will cause issues downstream.
            grading_policy = [
                item for item in grading_policy if item['assignment_type']
            ]

            cache.set(key, grading_policy)

        return grading_policy

    def get_max_policy_display_percent(self, grading_policy):
        """
        Returns the maximum width that a grading bar can be for display, given
        the min width, MIN_POLICY_DISPLAY_PERCENT.
        """
        max_percent = 100
        for policy in grading_policy:
            if policy['weight'] < (self.MIN_POLICY_DISPLAY_PERCENT / 100.0):
                max_percent -= self.MIN_POLICY_DISPLAY_PERCENT
        return max_percent

    def assignment_types(self):
        """ Returns the assignment types for the represented course."""
        grading_policy = self.grading_policy()

        # return the results in a similar format to the course structure for standard parsing
        return [{
            'name':
            gp['assignment_type'],
            'url':
            reverse('courses:performance:graded_content_by_type',
                    kwargs={
                        'course_id': self.course_id,
                        'assignment_type': slugify(gp['assignment_type'])
                    })
        } for gp in grading_policy]

    def fetch_course_module_data(self):
        # Implementation of abstract method.  Returns problems from data api.
        try:
            problems = self.client.courses(self.course_id).problems()
        except NotFoundError:
            raise NoAnswerSubmissionsError(course_id=self.course_id)
        return problems

    def attach_computed_data(self, problem):
        # Change the id key name
        problem['id'] = problem.pop('module_id')
        # Add an percent and incorrect_submissions field
        total = problem['total_submissions']
        problem['correct_percent'] = utils.math.calculate_percent(
            problem['correct_submissions'], total)
        problem[
            'incorrect_submissions'] = total - problem['correct_submissions']
        problem['incorrect_percent'] = utils.math.calculate_percent(
            problem['incorrect_submissions'], total)

    def post_process_adding_data_to_blocks(self,
                                           data,
                                           parent_block,
                                           child_block,
                                           url_func=None):
        # not all problems have submissions
        if len(data['part_ids']) > 0:
            utils.sorting.natural_sort(data['part_ids'])
            if url_func:
                data['url'] = url_func(parent_block, child_block, data)

    @property
    def default_block_data(self):
        return {
            'total_submissions': 0,
            'correct_submissions': 0,
            'correct_percent': 0,
            'incorrect_submissions': 0,
            'incorrect_percent': 0,
            'part_ids': []
        }

    def assignments(self, assignment_type=None):
        """ Returns the assignments (and problems) for the represented course. """

        assignment_type_name = None if assignment_type is None else assignment_type[
            'name']
        assignment_type_key = self.get_cache_key(
            u'assignments_{}'.format(assignment_type_name))
        assignments = cache.get(assignment_type_key)

        if not assignments:
            all_assignments_key = self.get_cache_key(u'assignments')
            assignments = cache.get(all_assignments_key)

            if not assignments:
                structure = self._get_structure()
                assignments = CourseStructure.course_structure_to_assignments(
                    structure, graded=True, assignment_type=None)
                cache.set(all_assignments_key, assignments)

            if assignment_type:
                assignment_type['name'] = assignment_type['name'].lower()
                assignments = [
                    assignment for assignment in assignments
                    if assignment['assignment_type'].lower() ==
                    assignment_type['name']
                ]

            self.add_child_data_to_parent_blocks(
                assignments, self._build_graded_answer_distribution_url)
            self.attach_data_to_parents(assignments,
                                        self._build_assignment_url)

            # Cache the data for the course-assignment_type combination.
            cache.set(assignment_type_key, assignments)

        return assignments

    def attach_aggregated_data_to_parent(self, index, parent, url_func=None):
        children = parent['children']
        total_submissions = sum(
            child.get('total_submissions', 0) for child in children)
        correct_submissions = sum(
            child.get('correct_submissions', 0) for child in children)
        incorrect_submissions = total_submissions - correct_submissions
        parent.update({
            'total_submissions':
            total_submissions,
            'correct_submissions':
            correct_submissions,
            'correct_percent':
            utils.math.calculate_percent(correct_submissions,
                                         total_submissions),
            'incorrect_submissions':
            incorrect_submissions,
            'incorrect_percent':
            utils.math.calculate_percent(incorrect_submissions,
                                         total_submissions),
            'index':
            index + 1,
            'average_submissions':
            0,
            'average_correct_submissions':
            0,
            'average_incorrect_submissions':
            0,
        })

        if parent['num_modules']:
            num_modules = float(parent['num_modules'])
            parent.update({
                'average_submissions':
                total_submissions / num_modules,
                'average_correct_submissions':
                correct_submissions / num_modules,
                'average_incorrect_submissions':
                incorrect_submissions / num_modules,
            })

        # removing the URL keeps navigation between the menu and bar chart consistent
        if url_func and parent['total_submissions'] > 0:
            parent['url'] = url_func(parent)

    def _build_graded_answer_distribution_url(self, parent, problem, parts):
        return reverse('courses:performance:answer_distribution',
                       kwargs={
                           'course_id': self.course_id,
                           'assignment_id': parent['id'],
                           'problem_id': problem['id'],
                           'problem_part_id': parts['part_ids'][0]
                       })

    def build_module_url_func(self, section_id):
        def build_url(parent, problem, parts):
            return reverse('courses:performance:ungraded_answer_distribution',
                           kwargs={
                               'course_id': self.course_id,
                               'section_id': section_id,
                               'subsection_id': parent['id'],
                               'problem_id': problem['id'],
                               'problem_part_id': parts['part_ids'][0]
                           })

        return build_url

    def _build_assignment_url(self, assignment):
        return reverse('courses:performance:assignment',
                       kwargs={
                           'course_id': self.course_id,
                           'assignment_id': assignment['id']
                       })

    def build_section_url(self, section):
        return reverse('courses:performance:ungraded_section',
                       kwargs={
                           'course_id': self.course_id,
                           'section_id': section['id']
                       })

    def build_subsection_url_func(self, section_id):
        """
        Returns a function for creating the ungraded subsection URL.
        """

        # Using closures to keep the section ID available
        def subsection_url(subsection):
            return reverse('courses:performance:ungraded_subsection',
                           kwargs={
                               'course_id': self.course_id,
                               'section_id': section_id,
                               'subsection_id': subsection['id']
                           })

        return subsection_url

    def blocks_have_data(self, assignments):
        if assignments:
            for assignment in assignments:
                if assignment['total_submissions'] > 0:
                    return True
        return False

    def assignment(self, assignment_id):
        """ Retrieve a specific assignment. """
        filtered = [
            assignment for assignment in self.assignments()
            if assignment['id'] == assignment_id
        ]
        if filtered:
            return filtered[0]
        else:
            return None

    @property
    def section_type_template(self):
        return u'ungraded_sections_{}_{}'

    @property
    def all_sections_key(self):
        return u'ungraded_sections'

    @property
    def module_type(self):
        return 'problem'

    @property
    def module_graded_type(self):
        """
        Get ungraded blocks.

        This is a bit confusing as this presenter is used to show both graded and
        ungraded content.  The ungraded content uses CourseAPIPresenterMixin::course_structure
        which then gets the module grade type for filtering.
        """
        return False