def __init__(self, access_token, course_id, timeout=settings.LMS_DEFAULT_TIMEOUT): super(CourseAPIPresenterMixin, self).__init__(course_id, timeout) self.course_api_client = CourseStructureApiClient( settings.COURSE_API_URL, access_token)
def __init__(self, course_id, analytics_client): super().__init__(course_id, analytics_client) self.course_api_client = CourseStructureApiClient( settings.BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL, settings.BACKEND_SERVICE_EDX_OAUTH2_KEY, settings.BACKEND_SERVICE_EDX_OAUTH2_SECRET, )
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.course_api = CourseStructureApiClient( settings.BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL, settings.BACKEND_SERVICE_EDX_OAUTH2_KEY, settings.BACKEND_SERVICE_EDX_OAUTH2_SECRET, ) return super().dispatch(request, *args, **kwargs)
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 EdxRestApiClient.get_and_cache_jwt_oauth_access_token( settings.BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL, settings.BACKEND_SERVICE_EDX_OAUTH2_KEY, settings.BACKEND_SERVICE_EDX_OAUTH2_SECRET, )[0] self.course_api = CourseStructureApiClient(settings.COURSE_API_URL, self.access_token) return super(CourseAPIMixin, self).dispatch(request, *args, **kwargs)
def __init__(self, access_token, course_id, timeout=settings.LMS_DEFAULT_TIMEOUT): super().__init__(access_token, course_id, timeout) self.grading_policy_client = CourseStructureApiClient( settings.GRADING_POLICY_API_URL, access_token)
def test_default_timeout(self): client = CourseStructureApiClient( settings.BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL, settings.BACKEND_SERVICE_EDX_OAUTH2_KEY, settings.BACKEND_SERVICE_EDX_OAUTH2_SECRET, ) # pylint: disable=protected-access self.assertEqual(client._timeout, settings.LMS_DEFAULT_TIMEOUT)
def test_explicit_timeout(self): client = CourseStructureApiClient( settings.BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL, settings.BACKEND_SERVICE_EDX_OAUTH2_KEY, settings.BACKEND_SERVICE_EDX_OAUTH2_SECRET, timeout=(2.05, 4), ) # pylint: disable=protected-access self.assertEqual(client._timeout, (2.05, 4))
class CourseAPIPresenterMixin(metaclass=abc.ABCMeta): """ This mixin provides access to the course structure API and processes the hierarchy for sections, subsections, modules, and leaves (e.g. videos, problems, etc.). """ _last_updated = None def __init__(self, course_id, analytics_client): super().__init__(course_id, analytics_client) self.course_api_client = CourseStructureApiClient( settings.BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL, settings.BACKEND_SERVICE_EDX_OAUTH2_KEY, settings.BACKEND_SERVICE_EDX_OAUTH2_SECRET, ) 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) blocks_kwargs = { 'course_id': self.course_id, 'depth': 'all', 'all_blocks': 'true', 'requested_fields': 'children,format,graded', } structure = self.course_api_client.get( urljoin(settings.COURSE_API_URL + '/', 'blocks/'), params=blocks_kwargs ).json() 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_{}_{}" """ @abc.abstractproperty def all_sections_key(self): """ Cache key for storing/retrieving structure for all sections. """ @abc.abstractproperty def module_type(self): """ Module type to retrieve structure for. E.g. video, problem. """ @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(f'{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. """ @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. """ @abc.abstractmethod def fetch_course_module_data(self): """ Fetch course module data from the data API. Use _course_module_data() for cached data. """ @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. """ 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 = OrderedDict() 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(f'{self.module_type}_last_updated') 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 = OrderedDict() 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. """ 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 = next((index for index, sibling in enumerate(siblings) if sibling['id'] == block_id)) sibling_index = block_index + sibling_offset if sibling_index < 0: return None 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. """ @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(f'{self.module_type}_last_updated') 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 = f'{base_url}/{self.course_id}/jump_to/{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)
class CourseAPIMixin: 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 EdxRestApiClient.get_and_cache_jwt_oauth_access_token( settings.BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL, settings.BACKEND_SERVICE_EDX_OAUTH2_KEY, settings.BACKEND_SERVICE_EDX_OAUTH2_SECRET, timeout=(3.05, 55), )[0] self.course_api = CourseStructureApiClient(settings.COURSE_API_URL, self.access_token) return super().dispatch(request, *args, **kwargs) def _course_detail_cache_key(self, course_id): return sanitize_cache_key(f'course_{course_id}_details') 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.courses(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(f'user_{self.request.user.pk}_courses') 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.courses.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['pagination']['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 test_explicit_timeout(self): client = CourseStructureApiClient('http://example.com/', 'arbitrary_access_token', timeout=2.5) # pylint: disable=protected-access self.assertEqual(client._store['session'].timeout, 2.5)
def test_default_timeout(self): client = CourseStructureApiClient('http://example.com/', 'arbitrary_access_token') # pylint: disable=protected-access self.assertEqual(client._store['session'].timeout, settings.LMS_DEFAULT_TIMEOUT)
def __init__(self, access_token, course_id, analytics_client): super().__init__(access_token, course_id, analytics_client) self.grading_policy_client = CourseStructureApiClient( settings.GRADING_POLICY_API_URL, access_token)
class CoursePerformancePresenter(CourseAPIPresenterMixin, CoursePresenter): """ 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, course_id, analytics_client): super().__init__(course_id, analytics_client) self.grading_policy_client = CourseStructureApiClient( settings.BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL, settings.BACKEND_SERVICE_EDX_OAUTH2_KEY, settings.BACKEND_SERVICE_EDX_OAUTH2_SECRET, ) 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 not filtered_active_question: raise NotFoundError 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 != 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.items(): 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.get( urljoin(settings.GRADING_POLICY_API_URL + '/', f'policy/courses/{self.course_id}'), ).json() # 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 data['part_ids']: 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( f'assignments_{assignment_type_name}') assignments = cache.get(assignment_type_key) if not assignments: all_assignments_key = self.get_cache_key('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] return None @property def section_type_template(self): return 'ungraded_sections_{}_{}' @property def all_sections_key(self): return '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