Пример #1
0
    def dispatch(self, request, *args, **kwargs):
        self.course_api_enabled = switch_is_active('enable_course_api')

        if self.course_api_enabled and request.user.is_authenticated():
            self.access_token = settings.COURSE_API_KEY or request.user.access_token
            self.course_api = CourseStructureApiClient(settings.COURSE_API_URL, self.access_token).courses

        return super(CourseAPIMixin, self).dispatch(request, *args, **kwargs)
Пример #2
0
    def dispatch(self, request, *args, **kwargs):
        self.course_api_enabled = switch_is_active('enable_course_api')

        if self.course_api_enabled and request.user.is_authenticated():
            self.access_token = settings.COURSE_API_KEY or request.user.access_token
            self.course_api = CourseStructureApiClient(settings.COURSE_API_URL, self.access_token).courses

        return super(CourseAPIMixin, self).dispatch(request, *args, **kwargs)
 def __init__(self, course_id, http_cookies=None):
     self.course_id = course_id
     self.analytics_api_client = Client(base_url=API_SERVER_URL,
                                        auth_token=API_AUTH_TOKEN,
                                        timeout=1000)
     self.course_api_client = CourseStructureApiClient(
         COURSE_API_URL, COURSE_API_KEY, 5)
     self.http_client = requests.Session()
     self.http_client.cookies = http_cookies
Пример #4
0
class CourseApiMixin(object):
    course_api_client = None

    def setUp(self):
        super(CourseApiMixin, self).setUp()

        if ENABLE_COURSE_API:
            self.course_api_client = CourseStructureApiClient(COURSE_API_URL, COURSE_API_KEY, 5)

    def get_course_name_or_id(self, course_id):
        """ Returns the course name if the course API is enabled; otherwise, the course ID. """
        course_name = course_id
        if ENABLE_COURSE_API:
            course_name = self.course_api_client.courses(course_id).get()['name']

        return course_name
Пример #5
0
def get_courses():
    filename = 'courses.json'

    try:
        with io.open(filename, 'r', encoding='utf-8') as f:
            courses = json.load(f)
    except Exception as e:  # pylint: disable=broad-except
        logger.warning('Failed to read courses from file: %s', e)
        courses = []

    if not courses:
        logger.info('Retrieving courses from API...')
        client = CourseStructureApiClient(COURSE_API_URL, COURSE_API_KEY, 5)
        courses = client.all_courses
        courses = [course['id'] for course in courses]
        courses.sort(key=lambda course: course.lower())

        with io.open(filename, 'w', encoding='utf-8') as f:
            f.write(six.text_type(json.dumps(courses, ensure_ascii=False)))

    logger.info('Retrieved %s courses.', len(courses))

    return courses
Пример #6
0
    def setUp(self):
        super().setUp()

        if ENABLE_COURSE_API:
            self.course_api_client = CourseStructureApiClient(
                COURSE_API_URL, COURSE_API_KEY, 5)
Пример #7
0
class CourseAPIMixin(object):
    access_token = None
    course_api_enabled = False
    course_api = None
    course_id = None

    @cached_property
    def course_info(self):
        """
        Returns course info.

        All requests for course info should be made against this property to take advantage of caching.
        """
        return self.get_course_info(self.course_id)

    def dispatch(self, request, *args, **kwargs):
        self.course_api_enabled = switch_is_active('enable_course_api')

        if self.course_api_enabled and request.user.is_authenticated():
            self.access_token = settings.COURSE_API_KEY or request.user.access_token
            self.course_api = CourseStructureApiClient(settings.COURSE_API_URL, self.access_token).courses

        return super(CourseAPIMixin, self).dispatch(request, *args, **kwargs)

    def _course_detail_cache_key(self, course_id):
        return sanitize_cache_key(u'course_{}_details'.format(course_id))

    def get_course_info(self, course_id):
        """
        Retrieve course info from the Course API.

        Retrieved data is cached.

        Arguments
            course_id       -- ID of the course for which data should be retrieved
        """
        key = self._course_detail_cache_key(course_id)
        info = cache.get(key)

        if not info:
            try:
                logger.debug("Retrieving detail for course: %s", course_id)
                info = self.course_api(course_id).get()
                cache.set(key, info)
            except HttpClientError as e:
                logger.error("Unable to retrieve course info for %s: %s", course_id, e)
                info = {}

        return info

    def get_courses(self):
        # Check the cache for the user's courses
        key = sanitize_cache_key(u'user_{}_courses'.format(self.request.user.pk))
        courses = cache.get(key)

        # If no cached courses, iterate over the data from the course API.
        if not courses:
            courses = []
            page = 1

            while page:
                try:
                    logger.debug('Retrieving page %d of course info...', page)
                    response = self.course_api.get(page=page, page_size=100)
                    course_details = response['results']

                    # Cache the information so that it doesn't need to be retrieved later.
                    for course in course_details:
                        course_id = course['id']
                        _key = self._course_detail_cache_key(course_id)
                        cache.set(_key, course)

                    courses += course_details

                    if response['next']:
                        page += 1
                    else:
                        page = None
                        logger.debug('Completed retrieval of course info. Retrieved info for %d courses.', len(courses))
                except HttpClientError as e:
                    logger.error("Unable to retrieve course data: %s", e)
                    page = None
                    break

        cache.set(key, courses)
        return courses
 def __init__(self, access_token, course_id, timeout=10):
     super(CourseAPIPresenterMixin, self).__init__(course_id, timeout)
     self.course_api_client = CourseStructureApiClient(settings.COURSE_API_URL, access_token)
class CourseAPIPresenterMixin(object):
    """
    This mixin provides access to the course structure API and processes the hierarchy
    for sections, subsections, modules, and leaves (e.g. videos, problems, etc.).
    """
    __metaclass__ = abc.ABCMeta

    _last_updated = None

    def __init__(self, access_token, course_id, timeout=10):
        super(CourseAPIPresenterMixin, self).__init__(course_id, timeout)
        self.course_api_client = CourseStructureApiClient(settings.COURSE_API_URL, access_token)

    def _get_structure(self):
        """ Retrieves course structure from the course API. """
        key = self.get_cache_key('structure')
        structure = cache.get(key)

        if not structure:
            logger.debug('Retrieving structure for course: %s', self.course_id)
            structure = self.course_api_client.course_structures(self.course_id).get()
            cache.set(key, structure)

        return structure

    @abc.abstractproperty
    def section_type_template(self):
        """ Template for key generation to store/retrieve and cached structure data. E.g. "video_{}_{}" """
        pass

    @abc.abstractproperty
    def all_sections_key(self):
        """ Cache key for storing/retrieving structure for all sections. """
        pass

    @abc.abstractproperty
    def module_type(self):
        """ Module type to retrieve structure for. E.g. video, problem. """
        pass

    def get_cache_key(self, name):
        """ Returns sanitized key for caching. """
        return sanitize_cache_key(u'{}_{}'.format(self.course_id, name))

    def course_structure(self, section_id=None, subsection_id=None):
        """
        Returns course structure from cache.  If structure isn't found, it is fetched from the
        course structure API.  If no arguments are provided, all sections and children are returned.
        If only section_id is provided, that section is returned.  If both section_id and
        subsection_id is provided, the structure for the subsection is returned.
        """
        if section_id is None and subsection_id is not None:
            raise ValueError('section_id must be specified if subsection_id is specified.')

        structure_type_key = self.get_cache_key(self.section_type_template.format(section_id, subsection_id))
        found_structure = cache.get(structure_type_key)

        if not found_structure:
            all_sections_key = self.get_cache_key(self.all_sections_key)
            found_structure = cache.get(all_sections_key)

            if not found_structure:
                structure = self._get_structure()
                found_structure = CourseStructure.course_structure_to_sections(structure, self.module_type,
                                                                               graded=False)
                cache.set(all_sections_key, found_structure)

            for section in found_structure:
                self.add_child_data_to_parent_blocks(section['children'],
                                                     self.build_module_url_func(section['id']))
                self.attach_data_to_parents(section['children'],
                                            self.build_subsection_url_func(section['id']))

            self.attach_data_to_parents(found_structure, self.build_section_url)

            if found_structure:
                if section_id:
                    found_structure = [section for section in found_structure if section['id'] == section_id]

                    if found_structure and subsection_id:
                        found_structure = \
                            [section for section in found_structure[0]['children'] if section['id'] == subsection_id]

            cache.set(structure_type_key, found_structure)

        return found_structure

    def attach_data_to_parents(self, parents, url_func=None):
        """ Convenience method for adding aggregated data from children."""
        for index, parent in enumerate(parents):
            self.attach_aggregated_data_to_parent(index, parent, url_func)

    @abc.abstractmethod
    def attach_aggregated_data_to_parent(self, index, parent, url_func=None):
        """ Adds aggregate data from the child modules to the parent. """
        pass

    @abc.abstractproperty
    def default_block_data(self):
        """
        Returns a dictionary of default data for a block.  Typically, this would be the expected fields
        with empty/zero values.
        """
        pass

    @abc.abstractmethod
    def fetch_course_module_data(self):
        """
        Fetch course module data from the data API.  Use _course_module_data() for cached data.
        """
        pass

    @abc.abstractmethod
    def attach_computed_data(self, module_data):
        """
        Called by _course_module_data() to attach computed data (e.g. percentages, new IDs, etc.) to
        data returned from the analytics data api.
        """
        pass

    def _course_module_data(self):
        """ Retrieves course problems (from cache or course API) and calls process_module_data to attach data. """

        key = self.get_cache_key(self.module_type)
        module_data = cache.get(key)

        if not module_data:
            module_data = self.fetch_course_module_data()

            # Create a lookup table so that submission data can be quickly retrieved by downstream consumers.
            table = {}
            last_updated = datetime.datetime.min

            for datum in module_data:
                self.attach_computed_data(datum)
                table[datum['id']] = datum

                # Set the last_updated value
                created = datum.pop('created', None)
                if created:
                    created = self.parse_api_datetime(created)
                    last_updated = max(last_updated, created)

            if last_updated is not datetime.datetime.min:
                _key = self.get_cache_key('{}_last_updated'.format(self.module_type))
                cache.set(_key, last_updated)
                self._last_updated = last_updated

            module_data = table
            cache.set(key, module_data)

        return module_data

    def module_id_to_data_id(self, module):
        """ Translates the course structure module to the ID used by the analytics data API. """
        return module['id']

    def add_child_data_to_parent_blocks(self, parent_blocks, url_func=None):
        """ Attaches data from the analytics data API to the course structure modules. """
        try:
            module_data = self._course_module_data()
        except BaseCourseError as e:
            logger.warning(e)
            module_data = {}

        for parent_block in parent_blocks:
            for index, child in enumerate(parent_block['children']):
                data = module_data.get(self.module_id_to_data_id(child), self.default_block_data)

                # map empty names to None so that the UI catches them and displays as '(empty)'
                if len(child['name']) < 1:
                    child['name'] = None
                data['index'] = index + 1
                self.post_process_adding_data_to_blocks(data, parent_block, child, url_func)
                child.update(data)

    def post_process_adding_data_to_blocks(self, data, parent_block, child, url_func=None):
        """
        Override this if additional data is needed on the child block (e.g. problem part data).

        Arguments:
            data: Data for data API.
            parent_block: Parent of the child .
            child: Block that will be processed.
            url_func: URL generating function if needed to attach a URL to the child.
        """
        pass

    def build_section_url(self, _section):
        return None

    def build_subsection_url_func(self, _section_id):
        """
        Optionally override to return a function for creating the subsection URL.
        """
        return None

    def build_module_url_func(self, _section_id):
        """ Returns a function for generating a URL to the module (subsection child). """
        return None

    def sections(self):
        return self.course_structure()

    def section(self, section_id):
        section = None
        if section_id:
            section = self.course_structure(section_id)
            section = section[0] if section else None
        return section

    def subsections(self, section_id):
        sections = self.section(section_id)
        if sections:
            return sections.get('children', None)
        return None

    def subsection(self, section_id, subsection_id):
        subsection = None
        if section_id and subsection_id:
            subsection = self.course_structure(section_id, subsection_id)
            subsection = subsection[0] if subsection else None
        return subsection

    def subsection_children(self, section_id, subsection_id):
        """ Returns children (e.g. problems, videos) of a subsection. """
        subsections = self.subsection(section_id, subsection_id)
        if subsections:
            return subsections.get('children', None)
        return None

    def block(self, block_id):
        """ Retrieve a specific block (e.g. problem, video). """
        block = self._get_structure()['blocks'][block_id]
        block['name'] = block.get('display_name')
        return block

    @abc.abstractmethod
    def blocks_have_data(self, blocks):
        """ Returns whether blocks contains any displayable data. """
        pass

    @property
    def last_updated(self):
        """ Returns when data was last updated according to the data api. """
        if not self._last_updated:
            key = self.get_cache_key('{}_last_updated'.format(self.module_type))
            self._last_updated = cache.get(key)

        return self._last_updated
Пример #10
0
    def setUp(self):
        super(CourseApiMixin, self).setUp()

        if ENABLE_COURSE_API:
            self.course_api_client = CourseStructureApiClient(COURSE_API_URL, COURSE_API_KEY, 5)
Пример #11
0
 def __init__(self, access_token, course_id, timeout=10):
     super(CoursePerformancePresenter, self).__init__(course_id, timeout)
     self.course_api_client = CourseStructureApiClient(
         settings.COURSE_API_URL, access_token)
Пример #12
0
class CoursePerformancePresenter(BasePresenter):
    """
    Presenter for the performance page.
    """
    _last_updated = None

    # 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=10):
        super(CoursePerformancePresenter, self).__init__(course_id, timeout)
        self.course_api_client = CourseStructureApiClient(
            settings.COURSE_API_URL, access_token)

    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

    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
        ]
        answer_distributions = sorted(answer_distributions,
                                      key=lambda a: -a['count'])
        return answer_distributions

    def get_cache_key(self, name):
        return sanitize_cache_key(u'{}_{}'.format(self.course_id, name))

    @property
    def last_updated(self):
        if not self._last_updated:
            key = self.get_cache_key('problems_last_updated')
            self._last_updated = cache.get(key)

        return self._last_updated

    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.course_api_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']} for gp in grading_policy]

    def _course_problems(self):
        """ Retrieves course problems (from cache or course API) and adds submission data. """

        key = self.get_cache_key('problems')
        problems = cache.get(key)

        if not problems:
            # Get the problems from the API
            logger.debug('Retrieving problem submissions for course: %s',
                         self.course_id)

            try:
                problems = self.client.courses(self.course_id).problems()
            except NotFoundError:
                raise NoAnswerSubmissionsError(course_id=self.course_id)

            # Create a lookup table so that submission data can be quickly retrieved by downstream consumers.
            table = {}
            last_updated = datetime.datetime.min

            for problem in problems:
                # 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)

                table[problem['id']] = problem

                # Set the last_updated value
                created = problem.pop('created', None)
                if created:
                    created = self.parse_api_datetime(created)
                    last_updated = max(last_updated, created)

            if last_updated is not datetime.datetime.min:
                _key = self.get_cache_key('problems_last_updated')
                cache.set(_key, last_updated)
                self._last_updated = last_updated

            problems = table
            cache.set(key, problems)

        return problems

    def _add_submissions_and_part_ids(self, assignments, url_func=None):
        """ Adds submissions and part IDs to the given assignments. """

        DEFAULT_DATA = {
            'total_submissions': 0,
            'correct_submissions': 0,
            'correct_percent': 0,
            'incorrect_submissions': 0,
            'incorrect_percent': 0,
            'part_ids': []
        }

        try:
            course_problems = self._course_problems()
        except NoAnswerSubmissionsError as e:
            logger.warning(e)
            course_problems = {}

        for assignment in assignments:
            problems = assignment['children']

            for index, problem in enumerate(problems):
                data = course_problems.get(problem['id'], DEFAULT_DATA)

                # map empty names to None so that the UI catches them and displays as '(empty)'
                if len(problem['name']) < 1:
                    problem['name'] = None
                data['index'] = index + 1

                # 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(assignment, problem, data)
                problem.update(data)

    def _structure(self):
        """ Retrieves course structure from the course API. """
        key = self.get_cache_key('structure')
        structure = cache.get(key)

        if not structure:
            logger.debug('Retrieving structure for course: %s', self.course_id)
            structure = self.course_api_client.course_structures(
                self.course_id).get()
            cache.set(key, structure)

        return structure

    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._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_submissions_and_part_ids(
                assignments, self._build_graded_answer_distribution_url)
            self._build_submission_collections(assignments,
                                               self._build_assignment_url)

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

        return assignments

    def _build_submission_collections(self, collections, url_func=None):
        for index, submission_collection in enumerate(collections):
            children = submission_collection['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)
            submission_collection['num_children'] = len(children)
            submission_collection['total_submissions'] = total_submissions
            submission_collection['correct_submissions'] = correct_submissions
            submission_collection[
                'correct_percent'] = utils.math.calculate_percent(
                    correct_submissions, total_submissions)
            submission_collection[
                'incorrect_submissions'] = total_submissions - correct_submissions
            submission_collection[
                'incorrect_percent'] = utils.math.calculate_percent(
                    submission_collection['incorrect_submissions'],
                    total_submissions)
            submission_collection['index'] = index + 1
            # removing the URL keeps navigation between the menu and bar chart consistent
            if url_func and submission_collection['total_submissions'] > 0:
                submission_collection['url'] = url_func(submission_collection)

    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_ungraded_answer_distribution_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 has_submissions(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

    def problem(self, problem_id):
        """ Retrieve a specific problem. """
        problem = self._structure()['blocks'][problem_id]
        problem['name'] = problem.pop('display_name')
        return problem

    def _ungraded_structure(self, section_id=None, subsection_id=None):
        section_type_key = self.get_cache_key(u'sections_{}_{}'.format(
            section_id, subsection_id))
        found_structure = cache.get(section_type_key)

        if not found_structure:
            all_sections_key = self.get_cache_key(u'sections')
            found_structure = cache.get(all_sections_key)

            if not found_structure:
                structure = self._structure()
                found_structure = CourseStructure.course_structure_to_sections(
                    structure, graded=False)
                cache.set(all_sections_key, found_structure)

            for section in found_structure:
                self._add_submissions_and_part_ids(
                    section['children'],
                    self._build_ungraded_answer_distribution_url_func(
                        section['id']))
                self._build_submission_collections(
                    section['children'],
                    self._build_subsection_url_func(section['id']))

            self._build_submission_collections(found_structure,
                                               self._build_section_url)

            if found_structure:
                if section_id:
                    found_structure = [
                        section for section in found_structure
                        if section['id'] == section_id
                    ]

                if found_structure and subsection_id:
                    found_structure = \
                        [section for section in found_structure[0]['children'] if section['id'] == subsection_id]

            cache.set(section_type_key, found_structure)

        return found_structure

    def sections(self):
        return self._ungraded_structure()

    def section(self, section_id):
        section = None
        if section_id:
            section = self._ungraded_structure(section_id)
            section = section[0] if section else None
        return section

    def subsections(self, section_id):
        sections = self.section(section_id)
        if sections:
            return sections.get('children', None)
        return None

    def subsection(self, section_id, subsection_id):
        subsection = None
        if section_id and subsection_id:
            subsection = self._ungraded_structure(section_id, subsection_id)
            subsection = subsection[0] if subsection else None
        return subsection

    def subsection_problems(self, section_id, subsection_id):
        subsections = self.subsection(section_id, subsection_id)
        if subsections:
            return subsections.get('children', None)
        return None
class CoursePerformancePresenter(BasePresenter):
    """
    Presenter for the performance page.
    """
    _last_updated = None

    # 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=10):
        super(CoursePerformancePresenter, self).__init__(course_id, timeout)
        self.course_api_client = CourseStructureApiClient(settings.COURSE_API_URL, access_token)

    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

    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]
        answer_distributions = sorted(answer_distributions, key=lambda a: -a['count'])
        return answer_distributions

    def get_cache_key(self, name):
        return sanitize_cache_key(u'{}_{}'.format(self.course_id, name))

    @property
    def last_updated(self):
        if not self._last_updated:
            key = self.get_cache_key('problems_last_updated')
            self._last_updated = cache.get(key)

        return self._last_updated

    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.course_api_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']} for gp in grading_policy]

    def _course_problems(self):
        """ Retrieves course problems (from cache or course API) and adds submission data. """

        key = self.get_cache_key('problems')
        problems = cache.get(key)

        if not problems:
            # Get the problems from the API
            logger.debug('Retrieving problem submissions for course: %s', self.course_id)

            try:
                problems = self.client.courses(self.course_id).problems()
            except NotFoundError:
                raise NoAnswerSubmissionsError(course_id=self.course_id)

            # Create a lookup table so that submission data can be quickly retrieved by downstream consumers.
            table = {}
            last_updated = datetime.datetime.min

            for problem in problems:
                # 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)

                table[problem['id']] = problem

                # Set the last_updated value
                created = problem.pop('created', None)
                if created:
                    created = self.parse_api_datetime(created)
                    last_updated = max(last_updated, created)

            if last_updated is not datetime.datetime.min:
                _key = self.get_cache_key('problems_last_updated')
                cache.set(_key, last_updated)
                self._last_updated = last_updated

            problems = table
            cache.set(key, problems)

        return problems

    def _add_submissions_and_part_ids(self, assignments, url_func=None):
        """ Adds submissions and part IDs to the given assignments. """

        DEFAULT_DATA = {
            'total_submissions': 0,
            'correct_submissions': 0,
            'correct_percent': 0,
            'incorrect_submissions': 0,
            'incorrect_percent': 0,
            'part_ids': []
        }

        try:
            course_problems = self._course_problems()
        except NoAnswerSubmissionsError as e:
            logger.warning(e)
            course_problems = {}

        for assignment in assignments:
            problems = assignment['children']

            for index, problem in enumerate(problems):
                data = course_problems.get(problem['id'], DEFAULT_DATA)

                # map empty names to None so that the UI catches them and displays as '(empty)'
                if len(problem['name']) < 1:
                    problem['name'] = None
                data['index'] = index + 1

                # 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(assignment, problem, data)
                problem.update(data)

    def _structure(self):
        """ Retrieves course structure from the course API. """
        key = self.get_cache_key('structure')
        structure = cache.get(key)

        if not structure:
            logger.debug('Retrieving structure for course: %s', self.course_id)
            structure = self.course_api_client.course_structures(self.course_id).get()
            cache.set(key, structure)

        return structure

    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._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_submissions_and_part_ids(assignments, self._build_graded_answer_distribution_url)
            self._build_submission_collections(assignments, self._build_assignment_url)

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

        return assignments

    def _build_submission_collections(self, collections, url_func=None):
        for index, submission_collection in enumerate(collections):
            children = submission_collection['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)
            submission_collection['num_children'] = len(children)
            submission_collection['total_submissions'] = total_submissions
            submission_collection['correct_submissions'] = correct_submissions
            submission_collection['correct_percent'] = utils.math.calculate_percent(
                correct_submissions, total_submissions)
            submission_collection['incorrect_submissions'] = total_submissions - correct_submissions
            submission_collection['incorrect_percent'] = utils.math.calculate_percent(
                submission_collection['incorrect_submissions'], total_submissions)
            submission_collection['index'] = index + 1
            # removing the URL keeps navigation between the menu and bar chart consistent
            if url_func and submission_collection['total_submissions'] > 0:
                submission_collection['url'] = url_func(submission_collection)

    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_ungraded_answer_distribution_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 has_submissions(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

    def problem(self, problem_id):
        """ Retrieve a specific problem. """
        problem = self._structure()['blocks'][problem_id]
        problem['name'] = problem.pop('display_name')
        return problem

    def _ungraded_structure(self, section_id=None, subsection_id=None):
        section_type_key = self.get_cache_key(u'sections_{}_{}'.format(section_id, subsection_id))
        found_structure = cache.get(section_type_key)

        if not found_structure:
            all_sections_key = self.get_cache_key(u'sections')
            found_structure = cache.get(all_sections_key)

            if not found_structure:
                structure = self._structure()
                found_structure = CourseStructure.course_structure_to_sections(structure, graded=False)
                cache.set(all_sections_key, found_structure)

            for section in found_structure:
                self._add_submissions_and_part_ids(section['children'],
                                                   self._build_ungraded_answer_distribution_url_func(
                                                       section['id']))
                self._build_submission_collections(section['children'],
                                                   self._build_subsection_url_func(section['id']))

            self._build_submission_collections(found_structure, self._build_section_url)

            if found_structure:
                if section_id:
                    found_structure = [section for section in found_structure if section['id'] == section_id]

                if found_structure and subsection_id:
                    found_structure = \
                        [section for section in found_structure[0]['children'] if section['id'] == subsection_id]

            cache.set(section_type_key, found_structure)

        return found_structure

    def sections(self):
        return self._ungraded_structure()

    def section(self, section_id):
        section = None
        if section_id:
            section = self._ungraded_structure(section_id)
            section = section[0] if section else None
        return section

    def subsections(self, section_id):
        sections = self.section(section_id)
        if sections:
            return sections.get('children', None)
        return None

    def subsection(self, section_id, subsection_id):
        subsection = None
        if section_id and subsection_id:
            subsection = self._ungraded_structure(section_id, subsection_id)
            subsection = subsection[0] if subsection else None
        return subsection

    def subsection_problems(self, section_id, subsection_id):
        subsections = self.subsection(section_id, subsection_id)
        if subsections:
            return subsections.get('children', None)
        return None
Пример #14
0
class CourseAPIMixin(object):
    access_token = None
    course_api_enabled = False
    course_api = None
    course_id = None

    @cached_property
    def course_info(self):
        """
        Returns course info.

        All requests for course info should be made against this property to take advantage of caching.
        """
        return self.get_course_info(self.course_id)

    def dispatch(self, request, *args, **kwargs):
        self.course_api_enabled = switch_is_active('enable_course_api')

        if self.course_api_enabled and request.user.is_authenticated():
            self.access_token = settings.COURSE_API_KEY or request.user.access_token
            self.course_api = CourseStructureApiClient(settings.COURSE_API_URL, self.access_token).courses

        return super(CourseAPIMixin, self).dispatch(request, *args, **kwargs)

    def _course_detail_cache_key(self, course_id):
        return sanitize_cache_key(u'course_{}_details'.format(course_id))

    def get_course_info(self, course_id):
        """
        Retrieve course info from the Course API.

        Retrieved data is cached.

        Arguments
            course_id       -- ID of the course for which data should be retrieved
        """
        key = self._course_detail_cache_key(course_id)
        info = cache.get(key)

        if not info:
            try:
                logger.debug("Retrieving detail for course: %s", course_id)
                info = self.course_api(course_id).get()
                cache.set(key, info)
            except HttpClientError as e:
                logger.error("Unable to retrieve course info for %s: %s", course_id, e)
                info = {}

        return info

    def get_courses(self):
        # Check the cache for the user's courses
        key = sanitize_cache_key(u'user_{}_courses'.format(self.request.user.pk))
        courses = cache.get(key)

        # If no cached courses, iterate over the data from the course API.
        if not courses:
            courses = []
            page = 1

            while page:
                try:
                    logger.debug('Retrieving page %d of course info...', page)
                    response = self.course_api.get(page=page, page_size=100)
                    course_details = response['results']

                    # Cache the information so that it doesn't need to be retrieved later.
                    for course in course_details:
                        course_id = course['id']
                        _key = self._course_detail_cache_key(course_id)
                        cache.set(_key, course)

                    courses += course_details

                    if response['next']:
                        page += 1
                    else:
                        page = None
                        logger.debug('Completed retrieval of course info. Retrieved info for %d courses.', len(courses))
                except HttpClientError as e:
                    logger.error("Unable to retrieve course data: %s", e)
                    page = None
                    break

        cache.set(key, courses)
        return courses
Пример #15
0
class CourseAPIPresenterMixin(object):
    """
    This mixin provides access to the course structure API and processes the hierarchy
    for sections, subsections, modules, and leaves (e.g. videos, problems, etc.).
    """
    __metaclass__ = abc.ABCMeta

    _last_updated = None

    def __init__(self, access_token, course_id, timeout=10):
        super(CourseAPIPresenterMixin, self).__init__(course_id, timeout)
        self.course_api_client = CourseStructureApiClient(
            settings.COURSE_API_URL, access_token)

    def _get_structure(self):
        """ Retrieves course structure from the course API. """
        key = self.get_cache_key('structure')
        structure = cache.get(key)

        if not structure:
            logger.debug('Retrieving structure for course: %s', self.course_id)
            structure = self.course_api_client.course_structures(
                self.course_id).get()
            cache.set(key, structure)

        return structure

    @abc.abstractproperty
    def section_type_template(self):
        """ Template for key generation to store/retrieve and cached structure data. E.g. "video_{}_{}" """
        pass

    @abc.abstractproperty
    def all_sections_key(self):
        """ Cache key for storing/retrieving structure for all sections. """
        pass

    @abc.abstractproperty
    def module_type(self):
        """ Module type to retrieve structure for. E.g. video, problem. """
        pass

    @property
    def module_graded_type(self):
        """
        Property used to filter modules by.  True/False will include only modules with
        that grade field.  Set to None if not filtering by the graded value.
        """
        return None

    def get_cache_key(self, name):
        """ Returns sanitized key for caching. """
        return sanitize_cache_key(u'{}_{}'.format(self.course_id, name))

    def course_structure(self, section_id=None, subsection_id=None):
        """
        Returns course structure from cache.  If structure isn't found, it is fetched from the
        course structure API.  If no arguments are provided, all sections and children are returned.
        If only section_id is provided, that section is returned.  If both section_id and
        subsection_id is provided, the structure for the subsection is returned.
        """
        if section_id is None and subsection_id is not None:
            raise ValueError(
                'section_id must be specified if subsection_id is specified.')

        structure_type_key = self.get_cache_key(
            self.section_type_template.format(section_id, subsection_id))
        found_structure = cache.get(structure_type_key)

        if not found_structure:
            all_sections_key = self.get_cache_key(self.all_sections_key)
            found_structure = cache.get(all_sections_key)

            if not found_structure:
                structure = self._get_structure()
                found_structure = CourseStructure.course_structure_to_sections(
                    structure,
                    self.module_type,
                    graded=self.module_graded_type)
                cache.set(all_sections_key, found_structure)

            for section in found_structure:
                self.add_child_data_to_parent_blocks(
                    section['children'],
                    self.build_module_url_func(section['id']))
                self.attach_data_to_parents(
                    section['children'],
                    self.build_subsection_url_func(section['id']))
                section['num_modules'] = sum(
                    child.get('num_modules', 0)
                    for child in section['children'])

            self.attach_data_to_parents(found_structure,
                                        self.build_section_url)

            if found_structure:
                if section_id:
                    found_structure = [
                        section for section in found_structure
                        if section['id'] == section_id
                    ]

                    if found_structure and subsection_id:
                        found_structure = \
                            [section for section in found_structure[0]['children'] if section['id'] == subsection_id]

            cache.set(structure_type_key, found_structure)

        return found_structure

    def attach_data_to_parents(self, parents, url_func=None):
        """ Convenience method for adding aggregated data from children."""
        for index, parent in enumerate(parents):
            self.attach_aggregated_data_to_parent(index, parent, url_func)

    @abc.abstractmethod
    def attach_aggregated_data_to_parent(self, index, parent, url_func=None):
        """ Adds aggregate data from the child modules to the parent. """
        pass

    @abc.abstractproperty
    def default_block_data(self):
        """
        Returns a dictionary of default data for a block.  Typically, this would be the expected fields
        with empty/zero values.
        """
        pass

    @abc.abstractmethod
    def fetch_course_module_data(self):
        """
        Fetch course module data from the data API.  Use _course_module_data() for cached data.
        """
        pass

    @abc.abstractmethod
    def attach_computed_data(self, module_data):
        """
        Called by _course_module_data() to attach computed data (e.g. percentages, new IDs, etc.) to
        data returned from the analytics data api.
        """
        pass

    def _course_module_data(self):
        """ Retrieves course problems (from cache or course API) and calls process_module_data to attach data. """

        key = self.get_cache_key(self.module_type)
        module_data = cache.get(key)

        if not module_data:
            module_data = self.fetch_course_module_data()

            # Create a lookup table so that submission data can be quickly retrieved by downstream consumers.
            table = {}
            last_updated = datetime.datetime.min

            for datum in module_data:
                self.attach_computed_data(datum)
                table[datum['id']] = datum

                # Set the last_updated value
                created = datum.pop('created', None)
                if created:
                    created = self.parse_api_datetime(created)
                    last_updated = max(last_updated, created)

            if last_updated is not datetime.datetime.min:
                _key = self.get_cache_key('{}_last_updated'.format(
                    self.module_type))
                cache.set(_key, last_updated)
                self._last_updated = last_updated

            module_data = table
            cache.set(key, module_data)

        return module_data

    def module_id_to_data_id(self, module):
        """ Translates the course structure module to the ID used by the analytics data API. """
        return module['id']

    def add_child_data_to_parent_blocks(self, parent_blocks, url_func=None):
        """ Attaches data from the analytics data API to the course structure modules. """
        try:
            module_data = self._course_module_data()
        except BaseCourseError as e:
            logger.warning(e)
            module_data = {}

        for parent_block in parent_blocks:
            parent_block['num_modules'] = len(parent_block['children'])
            for index, child in enumerate(parent_block['children']):
                data = module_data.get(self.module_id_to_data_id(child),
                                       self.default_block_data)

                # map empty names to None so that the UI catches them and displays as '(empty)'
                if len(child['name']) < 1:
                    child['name'] = None
                data['index'] = index + 1
                self.post_process_adding_data_to_blocks(
                    data, parent_block, child, url_func)
                child.update(data)

    def post_process_adding_data_to_blocks(self,
                                           data,
                                           parent_block,
                                           child,
                                           url_func=None):
        """
        Override this if additional data is needed on the child block (e.g. problem part data).

        Arguments:
            data: Data for data API.
            parent_block: Parent of the child .
            child: Block that will be processed.
            url_func: URL generating function if needed to attach a URL to the child.
        """
        pass

    def build_section_url(self, _section):
        return None

    def build_subsection_url_func(self, _section_id):
        """
        Optionally override to return a function for creating the subsection URL.
        """
        return None

    def build_module_url_func(self, _section_id):
        """ Returns a function for generating a URL to the module (subsection child). """
        return None

    def sections(self):
        return self.course_structure()

    def section(self, section_id):
        section = None
        if section_id:
            section = self.course_structure(section_id)
            section = section[0] if section else None
        return section

    def subsections(self, section_id):
        sections = self.section(section_id)
        if sections:
            return sections.get('children', None)
        return None

    def subsection(self, section_id, subsection_id):
        subsection = None
        if section_id and subsection_id:
            subsection = self.course_structure(section_id, subsection_id)
            subsection = subsection[0] if subsection else None
        return subsection

    def subsection_children(self, section_id, subsection_id):
        """ Returns children (e.g. problems, videos) of a subsection. """
        subsections = self.subsection(section_id, subsection_id)
        if subsections:
            return subsections.get('children', None)
        return None

    def subsection_child(self, section_id, subsection_id, child_id):
        """ Return the specified child of a subsection (e.g. problem, video). """
        found_child = None
        children = self.subsection_children(section_id, subsection_id)
        if children:
            found_children = [
                child for child in children if child['id'] == child_id
            ]
            found_child = found_children[0] if found_children else None
        return found_child

    def block(self, block_id):
        """ Retrieve a specific block (e.g. problem, video). """
        block = self._get_structure()['blocks'][block_id]
        block['name'] = block.get('display_name')
        return block

    def sibling_block(self, block_id, sibling_offset):
        """
        Returns a sibling block of the same type as the one denoted by
        `block_id`, where order is course ordering.  The sibling is chosen by
        `sibling_offset` which is the difference in index between the block and
        its requested sibling.  Returns `None` if no such sibling is found.
        Only siblings with data are returned.
        """
        sections = self.sections()
        siblings = [
            component for section in sections
            for subsection in section['children']
            for component in subsection['children'] if component.get(
                'url')  # Only consider siblings with data, hence with URLs
        ]
        try:
            block_index = (index for index, sibling in enumerate(siblings)
                           if sibling['id'] == block_id).next()
            sibling_index = block_index + sibling_offset
            if sibling_index < 0:
                return None
            else:
                return siblings[sibling_index]
        except (StopIteration, IndexError):
            # StopIteration: requested video not found in the course structure
            # IndexError: No such video with the requested offset
            return None

    def next_block(self, block_id):
        """
        Get the next block in the course with the same block type as the block
        denoted by `block_id`.
        """
        return self.sibling_block(block_id, 1)

    def previous_block(self, block_id):
        """
        Get the previous block in the course with the same block type as the
        block denoted by `block_id`.
        """
        return self.sibling_block(block_id, -1)

    @abc.abstractmethod
    def blocks_have_data(self, blocks):
        """ Returns whether blocks contains any displayable data. """
        pass

    @property
    def last_updated(self):
        """ Returns when data was last updated according to the data api. """
        if not self._last_updated:
            key = self.get_cache_key('{}_last_updated'.format(
                self.module_type))
            self._last_updated = cache.get(key)

        return self._last_updated

    def build_view_live_url(self, base_url, module_id):
        """ Returns URL to view the module on the LMS. """
        view_live_url = None
        if base_url:
            view_live_url = u'{0}/{1}/jump_to/{2}'.format(
                base_url, self.course_id, module_id)
        return view_live_url

    def build_render_xblock_url(self, base_url, module_id):
        xblock_url = None
        if base_url:
            xblock_url = self._build_url(base_url, module_id)
        return xblock_url

    def _build_url(self, *args):
        """
        Removes trailing slashes from urls.  urllib.urljoin doesn't work because
        paths in our urls can include module IDs (e.g. i4x://edx/demo/video/12345ab2).
        """
        return '/'.join(str(arg).rstrip('/') for arg in args)
Пример #16
0
class CourseAPIPresenterMixin(object):
    """
    This mixin provides access to the course structure API and processes the hierarchy
    for sections, subsections, modules, and leaves (e.g. videos, problems, etc.).
    """
    __metaclass__ = abc.ABCMeta

    _last_updated = None

    def __init__(self, access_token, course_id, timeout=10):
        super(CourseAPIPresenterMixin, self).__init__(course_id, timeout)
        self.course_api_client = CourseStructureApiClient(settings.COURSE_API_URL, access_token)

    def _get_structure(self):
        """ Retrieves course structure from the course API. """
        key = self.get_cache_key('structure')
        structure = cache.get(key)

        if not structure:
            logger.debug('Retrieving structure for course: %s', self.course_id)
            structure = self.course_api_client.course_structures(self.course_id).get()
            cache.set(key, structure)

        return structure

    @abc.abstractproperty
    def section_type_template(self):
        """ Template for key generation to store/retrieve and cached structure data. E.g. "video_{}_{}" """
        pass

    @abc.abstractproperty
    def all_sections_key(self):
        """ Cache key for storing/retrieving structure for all sections. """
        pass

    @abc.abstractproperty
    def module_type(self):
        """ Module type to retrieve structure for. E.g. video, problem. """
        pass

    @property
    def module_graded_type(self):
        """
        Property used to filter modules by.  True/False will include only modules with
        that grade field.  Set to None if not filtering by the graded value.
        """
        return None

    def get_cache_key(self, name):
        """ Returns sanitized key for caching. """
        return sanitize_cache_key(u'{}_{}'.format(self.course_id, name))

    def course_structure(self, section_id=None, subsection_id=None):
        """
        Returns course structure from cache.  If structure isn't found, it is fetched from the
        course structure API.  If no arguments are provided, all sections and children are returned.
        If only section_id is provided, that section is returned.  If both section_id and
        subsection_id is provided, the structure for the subsection is returned.
        """
        if section_id is None and subsection_id is not None:
            raise ValueError('section_id must be specified if subsection_id is specified.')

        structure_type_key = self.get_cache_key(self.section_type_template.format(section_id, subsection_id))
        found_structure = cache.get(structure_type_key)

        if not found_structure:
            all_sections_key = self.get_cache_key(self.all_sections_key)
            found_structure = cache.get(all_sections_key)

            if not found_structure:
                structure = self._get_structure()
                found_structure = CourseStructure.course_structure_to_sections(structure, self.module_type,
                                                                               graded=self.module_graded_type)
                cache.set(all_sections_key, found_structure)

            for section in found_structure:
                self.add_child_data_to_parent_blocks(section['children'],
                                                     self.build_module_url_func(section['id']))
                self.attach_data_to_parents(section['children'],
                                            self.build_subsection_url_func(section['id']))
                section['num_modules'] = sum(child.get('num_modules', 0) for child in section['children'])

            self.attach_data_to_parents(found_structure, self.build_section_url)

            if found_structure:
                if section_id:
                    found_structure = [section for section in found_structure if section['id'] == section_id]

                    if found_structure and subsection_id:
                        found_structure = \
                            [section for section in found_structure[0]['children'] if section['id'] == subsection_id]

            cache.set(structure_type_key, found_structure)

        return found_structure

    def attach_data_to_parents(self, parents, url_func=None):
        """ Convenience method for adding aggregated data from children."""
        for index, parent in enumerate(parents):
            self.attach_aggregated_data_to_parent(index, parent, url_func)

    @abc.abstractmethod
    def attach_aggregated_data_to_parent(self, index, parent, url_func=None):
        """ Adds aggregate data from the child modules to the parent. """
        pass

    @abc.abstractproperty
    def default_block_data(self):
        """
        Returns a dictionary of default data for a block.  Typically, this would be the expected fields
        with empty/zero values.
        """
        pass

    @abc.abstractmethod
    def fetch_course_module_data(self):
        """
        Fetch course module data from the data API.  Use _course_module_data() for cached data.
        """
        pass

    @abc.abstractmethod
    def attach_computed_data(self, module_data):
        """
        Called by _course_module_data() to attach computed data (e.g. percentages, new IDs, etc.) to
        data returned from the analytics data api.
        """
        pass

    def _course_module_data(self):
        """ Retrieves course problems (from cache or course API) and calls process_module_data to attach data. """

        key = self.get_cache_key(self.module_type)
        module_data = cache.get(key)

        if not module_data:
            module_data = self.fetch_course_module_data()

            # Create a lookup table so that submission data can be quickly retrieved by downstream consumers.
            table = {}
            last_updated = datetime.datetime.min

            for datum in module_data:
                self.attach_computed_data(datum)
                table[datum['id']] = datum

                # Set the last_updated value
                created = datum.pop('created', None)
                if created:
                    created = self.parse_api_datetime(created)
                    last_updated = max(last_updated, created)

            if last_updated is not datetime.datetime.min:
                _key = self.get_cache_key('{}_last_updated'.format(self.module_type))
                cache.set(_key, last_updated)
                self._last_updated = last_updated

            module_data = table
            cache.set(key, module_data)

        return module_data

    def module_id_to_data_id(self, module):
        """ Translates the course structure module to the ID used by the analytics data API. """
        return module['id']

    def add_child_data_to_parent_blocks(self, parent_blocks, url_func=None):
        """ Attaches data from the analytics data API to the course structure modules. """
        try:
            module_data = self._course_module_data()
        except BaseCourseError as e:
            logger.warning(e)
            module_data = {}

        for parent_block in parent_blocks:
            parent_block['num_modules'] = len(parent_block['children'])
            for index, child in enumerate(parent_block['children']):
                data = module_data.get(self.module_id_to_data_id(child), self.default_block_data)

                # map empty names to None so that the UI catches them and displays as '(empty)'
                if len(child['name']) < 1:
                    child['name'] = None
                data['index'] = index + 1
                self.post_process_adding_data_to_blocks(data, parent_block, child, url_func)
                child.update(data)

    def post_process_adding_data_to_blocks(self, data, parent_block, child, url_func=None):
        """
        Override this if additional data is needed on the child block (e.g. problem part data).

        Arguments:
            data: Data for data API.
            parent_block: Parent of the child .
            child: Block that will be processed.
            url_func: URL generating function if needed to attach a URL to the child.
        """
        pass

    def build_section_url(self, _section):
        return None

    def build_subsection_url_func(self, _section_id):
        """
        Optionally override to return a function for creating the subsection URL.
        """
        return None

    def build_module_url_func(self, _section_id):
        """ Returns a function for generating a URL to the module (subsection child). """
        return None

    def sections(self):
        return self.course_structure()

    def section(self, section_id):
        section = None
        if section_id:
            section = self.course_structure(section_id)
            section = section[0] if section else None
        return section

    def subsections(self, section_id):
        sections = self.section(section_id)
        if sections:
            return sections.get('children', None)
        return None

    def subsection(self, section_id, subsection_id):
        subsection = None
        if section_id and subsection_id:
            subsection = self.course_structure(section_id, subsection_id)
            subsection = subsection[0] if subsection else None
        return subsection

    def subsection_children(self, section_id, subsection_id):
        """ Returns children (e.g. problems, videos) of a subsection. """
        subsections = self.subsection(section_id, subsection_id)
        if subsections:
            return subsections.get('children', None)
        return None

    def subsection_child(self, section_id, subsection_id, child_id):
        """ Return the specified child of a subsection (e.g. problem, video). """
        found_child = None
        children = self.subsection_children(section_id, subsection_id)
        if children:
            found_children = [child for child in children if child['id'] == child_id]
            found_child = found_children[0] if found_children else None
        return found_child

    def block(self, block_id):
        """ Retrieve a specific block (e.g. problem, video). """
        block = self._get_structure()['blocks'][block_id]
        block['name'] = block.get('display_name')
        return block

    def sibling_block(self, block_id, sibling_offset):
        """
        Returns a sibling block of the same type as the one denoted by
        `block_id`, where order is course ordering.  The sibling is chosen by
        `sibling_offset` which is the difference in index between the block and
        its requested sibling.  Returns `None` if no such sibling is found.
        Only siblings with data are returned.
        """
        sections = self.sections()
        siblings = [
            component
            for section in sections
            for subsection in section['children']
            for component in subsection['children']
            if component.get('url')  # Only consider siblings with data, hence with URLs
        ]
        try:
            block_index = (index for index, sibling in enumerate(siblings) if sibling['id'] == block_id).next()
            sibling_index = block_index + sibling_offset
            if sibling_index < 0:
                return None
            else:
                return siblings[sibling_index]
        except (StopIteration, IndexError):
            # StopIteration: requested video not found in the course structure
            # IndexError: No such video with the requested offset
            return None

    def next_block(self, block_id):
        """
        Get the next block in the course with the same block type as the block
        denoted by `block_id`.
        """
        return self.sibling_block(block_id, 1)

    def previous_block(self, block_id):
        """
        Get the previous block in the course with the same block type as the
        block denoted by `block_id`.
        """
        return self.sibling_block(block_id, -1)

    @abc.abstractmethod
    def blocks_have_data(self, blocks):
        """ Returns whether blocks contains any displayable data. """
        pass

    @property
    def last_updated(self):
        """ Returns when data was last updated according to the data api. """
        if not self._last_updated:
            key = self.get_cache_key('{}_last_updated'.format(self.module_type))
            self._last_updated = cache.get(key)

        return self._last_updated

    def build_view_live_url(self, base_url, module_id):
        """ Returns URL to view the module on the LMS. """
        view_live_url = None
        if base_url:
            view_live_url = u'{0}/{1}/jump_to/{2}'.format(base_url, self.course_id, module_id)
        return view_live_url

    def build_render_xblock_url(self, base_url, module_id):
        xblock_url = None
        if base_url:
            xblock_url = self._build_url(base_url, module_id)
        return xblock_url

    def _build_url(self, *args):
        """
        Removes trailing slashes from urls.  urllib.urljoin doesn't work because
        paths in our urls can include module IDs (e.g. i4x://edx/demo/video/12345ab2).
        """
        return '/'.join(str(arg).rstrip('/') for arg in args)