diff --git a/cms/envs/common.py b/cms/envs/common.py index 70a68fefddc8..3e57f1b064fa 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -74,6 +74,7 @@ HEARTBEAT_CHECKS, HEARTBEAT_EXTENDED_CHECKS, HEARTBEAT_CELERY_TIMEOUT, + CELERY_CHECK_ROUTING_KEY, # Default site to use if no site exists matching request headers SITE_ID, diff --git a/cms/envs/production.py b/cms/envs/production.py index 389cafc88cd8..b2418fc49e45 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -292,6 +292,12 @@ def get_env_setting(setting): if "TRACKING_IGNORE_URL_PATTERNS" in ENV_TOKENS: TRACKING_IGNORE_URL_PATTERNS = ENV_TOKENS.get("TRACKING_IGNORE_URL_PATTERNS") +# Heartbeat +HEARTBEAT_CHECKS = ENV_TOKENS.get('HEARTBEAT_CHECKS', HEARTBEAT_CHECKS) +HEARTBEAT_EXTENDED_CHECKS = ENV_TOKENS.get('HEARTBEAT_EXTENDED_CHECKS', HEARTBEAT_EXTENDED_CHECKS) +HEARTBEAT_CELERY_TIMEOUT = ENV_TOKENS.get('HEARTBEAT_CELERY_TIMEOUT', HEARTBEAT_CELERY_TIMEOUT) +CELERY_CHECK_ROUTING_KEY = ENV_TOKENS.get('CELERY_CHECK_ROUTING_KEY', HIGH_PRIORITY_QUEUE) + LOGIN_REDIRECT_WHITELIST = [reverse_lazy('home')] diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 077d457fce68..e31c74990c9a 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -158,37 +158,40 @@ def make_selection(cls, selected, children, max_count, mode): """ rand = random.Random() - selected = set(tuple(k) for k in selected) # set of (block_type, block_id) tuples assigned to this student + selected_keys = set(tuple(k) for k in selected) # set of (block_type, block_id) tuples assigned to this student # Determine which of our children we will show: - valid_block_keys = set([(c.block_type, c.block_id) for c in children]) + valid_block_keys = set((c.block_type, c.block_id) for c in children) # Remove any selected blocks that are no longer valid: - invalid_block_keys = (selected - valid_block_keys) + invalid_block_keys = (selected_keys - valid_block_keys) if invalid_block_keys: - selected -= invalid_block_keys + selected_keys -= invalid_block_keys # If max_count has been decreased, we may have to drop some previously selected blocks: overlimit_block_keys = set() - if len(selected) > max_count: - num_to_remove = len(selected) - max_count - overlimit_block_keys = set(rand.sample(selected, num_to_remove)) - selected -= overlimit_block_keys + if len(selected_keys) > max_count: + num_to_remove = len(selected_keys) - max_count + overlimit_block_keys = set(rand.sample(selected_keys, num_to_remove)) + selected_keys -= overlimit_block_keys # Do we have enough blocks now? - num_to_add = max_count - len(selected) + num_to_add = max_count - len(selected_keys) added_block_keys = None if num_to_add > 0: # We need to select [more] blocks to display to this user: - pool = valid_block_keys - selected + pool = valid_block_keys - selected_keys if mode == "random": num_to_add = min(len(pool), num_to_add) added_block_keys = set(rand.sample(pool, num_to_add)) # We now have the correct n random children to show for this user. else: raise NotImplementedError("Unsupported mode.") - selected |= added_block_keys + selected_keys |= added_block_keys + + if any([invalid_block_keys, overlimit_block_keys, added_block_keys]): + selected = selected_keys return { 'selected': selected, @@ -268,19 +271,15 @@ def publish_selected_children_events(cls, block_keys, format_block_keys, publish def selected_children(self): """ - Returns a set() of block_ids indicating which of the possible children + Returns a list() of block_ids indicating which of the possible children have been selected to display to the current user. This reads and updates the "selected" field, which has user_state scope. - Note: self.selected and the return value contain block_ids. To get + Note: the return value (self.selected) contains block_ids. To get actual BlockUsageLocators, it is necessary to use self.children, because the block_ids alone do not specify the block type. """ - if hasattr(self, "_selected_set"): - # Already done: - return self._selected_set # pylint: disable=access-member-before-definition - block_keys = self.make_selection(self.selected, self.children, self.max_count, "random") # pylint: disable=no-member # Publish events for analytics purposes: @@ -292,13 +291,13 @@ def selected_children(self): self._publish_event, ) - # Save our selections to the user state, to ensure consistency: - selected = block_keys['selected'] - self.selected = list(selected) # TODO: this doesn't save from the LMS "Progress" page. - # Cache the results - self._selected_set = selected # pylint: disable=attribute-defined-outside-init + if any(block_keys[changed] for changed in ('invalid', 'overlimit', 'added')): + # Save our selections to the user state, to ensure consistency: + selected = list(block_keys['selected']) + random.shuffle(selected) + self.selected = selected # TODO: this doesn't save from the LMS "Progress" page. - return selected + return self.selected def _get_selected_child_blocks(self): """ diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index cfe4ce2b5df5..3e5fbd6fd4b1 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -272,9 +272,8 @@ def _change_count_and_refresh_children(self, count): Helper method that changes the max_count of self.lc_block, refreshes children, and asserts that the number of selected children equals the count provided. """ - # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access - if hasattr(self.lc_block._xmodule, '_selected_set'): - del self.lc_block._xmodule._selected_set + # Construct the XModule for the descriptor, if not present already. + self.lc_block._xmodule # pylint: disable=pointless-statement,protected-access self.lc_block.max_count = count selected = self.lc_block.get_child_descriptors() self.assertEqual(len(selected), count) @@ -287,7 +286,7 @@ class TestLibraryContentModuleNoSearchIndex(LibraryContentModuleTestMixin, Libra Tests for library container when no search index is available. Tests fallback low-level CAPA problem introspection """ - pass + pass # pylint: disable=unnecessary-pass search_index_mock = Mock(spec=SearchEngine) # pylint: disable=invalid-name @@ -365,8 +364,8 @@ def _assert_event_was_published(self, event_type): Check that a LibraryContentModule analytics event was published by self.lc_block. """ self.assertTrue(self.publisher.called) - self.assertTrue(len(self.publisher.call_args[0]), 3) - _, event_name, event_data = self.publisher.call_args[0] + self.assertTrue(len(self.publisher.call_args[0]), 3) # pylint: disable=unsubscriptable-object + _, event_name, event_data = self.publisher.call_args[0] # pylint: disable=unsubscriptable-object self.assertEqual(event_name, "edx.librarycontentblock.content.{}".format(event_type)) self.assertEqual(event_data["location"], six.text_type(self.lc_block.location)) return event_data @@ -397,8 +396,6 @@ def test_assigned_event(self): # Now increase max_count so that one more child will be added: self.lc_block.max_count = 2 - # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access - del self.lc_block._xmodule._selected_set children = self.lc_block.get_child_descriptors() self.assertEqual(len(children), 2) child, new_child = children if children[0].location == child.location else reversed(children) @@ -478,8 +475,6 @@ def test_removed_overlimit(self): self.lc_block.get_child_descriptors() # This line is needed in the test environment or the change has no effect self.publisher.reset_mock() # Clear the "assigned" event that was just published. self.lc_block.max_count = 0 - # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access - del self.lc_block._xmodule._selected_set # Check that the event says that one block was removed, leaving no blocks left: children = self.lc_block.get_child_descriptors() @@ -497,8 +492,6 @@ def test_removed_invalid(self): # Start by assigning two blocks to the student: self.lc_block.get_child_descriptors() # This line is needed in the test environment or the change has no effect self.lc_block.max_count = 2 - # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access - del self.lc_block._xmodule._selected_set initial_blocks_assigned = self.lc_block.get_child_descriptors() self.assertEqual(len(initial_blocks_assigned), 2) self.publisher.reset_mock() # Clear the "assigned" event that was just published. @@ -512,8 +505,6 @@ def test_removed_invalid(self): self.library.children = [keep_block_lib_usage_key] self.store.update_item(self.library, self.user_id) self.lc_block.refresh_children() - # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access - del self.lc_block._xmodule._selected_set # Check that the event says that one block was removed, leaving one block left: children = self.lc_block.get_child_descriptors() diff --git a/lms/djangoapps/course_blocks/api.py b/lms/djangoapps/course_blocks/api.py index 569eeb947eab..a8eb5bf0e55c 100644 --- a/lms/djangoapps/course_blocks/api.py +++ b/lms/djangoapps/course_blocks/api.py @@ -40,6 +40,7 @@ def get_course_block_access_transformers(user): """ course_block_access_transformers = [ library_content.ContentLibraryTransformer(), + library_content.ContentLibraryOrderTransformer(), start_date.StartDateTransformer(), ContentTypeGateTransformer(), user_partitions.UserPartitionTransformer(), diff --git a/lms/djangoapps/course_blocks/transformers/library_content.py b/lms/djangoapps/course_blocks/transformers/library_content.py index 004177d6027f..0adf46cb5b47 100644 --- a/lms/djangoapps/course_blocks/transformers/library_content.py +++ b/lms/djangoapps/course_blocks/transformers/library_content.py @@ -4,6 +4,8 @@ import json +import logging +import random import six from eventtracking import tracker @@ -19,6 +21,8 @@ from ..utils import get_student_module_as_dict +logger = logging.getLogger(__name__) + class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransformer): """ @@ -40,17 +44,8 @@ def name(cls): return "library_content" @classmethod - def collect(cls, block_structure): - """ - Collects any information that's necessary to execute this - transformer's transform method. - """ - block_structure.request_xblock_fields('mode') - block_structure.request_xblock_fields('max_count') - block_structure.request_xblock_fields('category') - store = modulestore() - - # needed for analytics purposes + def set_block_analytics_summary(cls, block_structure, store): + """Set the block analytics summary information in the children fields.""" def summarize_block(usage_key): """ Basic information about the given block """ orig_key, orig_version = store.get_block_original_usage(usage_key) @@ -71,6 +66,20 @@ def summarize_block(usage_key): summary = summarize_block(child_key) block_structure.set_transformer_block_field(child_key, cls, 'block_analytics_summary', summary) + @classmethod + def collect(cls, block_structure): + """ + Collects any information that's necessary to execute this + transformer's transform method. + """ + block_structure.request_xblock_fields('mode') + block_structure.request_xblock_fields('max_count') + block_structure.request_xblock_fields('category') + store = modulestore() + + # needed for analytics purposes + cls.set_block_analytics_summary(block_structure, store) + def transform_block_filters(self, usage_info, block_structure): all_library_children = set() all_selected_children = set() @@ -102,6 +111,7 @@ def transform_block_filters(self, usage_info, block_structure): # Save back any changes if any(block_keys[changed] for changed in ('invalid', 'overlimit', 'added')): state_dict['selected'] = list(selected) + random.shuffle(state_dict['selected']) StudentModule.save_state( student=usage_info.user, course_id=usage_info.course_key, @@ -112,7 +122,7 @@ def transform_block_filters(self, usage_info, block_structure): ) # publish events for analytics - self._publish_events( + self.publish_events( block_structure, block_key, previous_count, @@ -137,7 +147,8 @@ def check_child_removal(block_key): return [block_structure.create_removal_filter(check_child_removal)] - def _publish_events(self, block_structure, location, previous_count, max_count, block_keys, user_id): + @staticmethod + def publish_events(block_structure, location, previous_count, max_count, block_keys, user_id): """ Helper method to publish events for analytics purposes """ @@ -177,3 +188,68 @@ def publish_event(event_name, result, **kwargs): format_block_keys, publish_event, ) + + +class ContentLibraryOrderTransformer(BlockStructureTransformer): + """ + A transformer that manipulates the block structure by modifying the order of the + selected blocks within a library_content module to match the order of the selections + made by the ContentLibraryTransformer or the corresponding XBlock. So this transformer + requires the selections for the randomized content block to be already + made either by the ContentLibraryTransformer or the XBlock. + + Staff users are *not* exempted from library content pathways/ + """ + WRITE_VERSION = 1 + READ_VERSION = 1 + + @classmethod + def name(cls): + """ + Unique identifier for the transformer's class; + same identifier used in setup.py + """ + return "library_content_randomize" + + @classmethod + def collect(cls, block_structure): + """ + Collects any information that's necessary to execute this + transformer's transform method. + """ + block_structure.request_xblock_fields('mode') + block_structure.request_xblock_fields('max_count') + + ContentLibraryTransformer.set_block_analytics_summary(block_structure, modulestore()) + + def transform(self, usage_info, block_structure): + """ + Transforms the order of the children of the randomized content block + to match the order of the selections made and stored in the XBlock 'selected' field. + """ + for block_key in block_structure: + if block_key.block_type != 'library_content': + continue + + library_children = block_structure.get_children(block_key) + + if library_children: + state_dict = get_student_module_as_dict(usage_info.user, usage_info.course_key, block_key) + current_children_blocks = set(block.block_id for block in library_children) + current_selected_blocks = set(item[1] for item in state_dict['selected']) + + # As the selections should have already been made by the ContentLibraryTransformer, + # the current children of the library_content block should be the same as the stored + # selections. If they aren't, some other transformer that ran before this transformer + # has modified those blocks (for example, content gating may have affected this). So do not + # transform the order in that case. + if current_children_blocks != current_selected_blocks: + logger.info( + u'Mismatch between the children of %s in the stored state and the actual children for user %s. ' + 'Continuing without order transformation.', + str(block_key), + usage_info.user.username + ) + else: + ordering_data = {block[1]: position for position, block in enumerate(state_dict['selected'])} + library_children.sort(key=lambda block, data=ordering_data: data[block.block_id]) diff --git a/lms/djangoapps/course_blocks/transformers/tests/test_library_content.py b/lms/djangoapps/course_blocks/transformers/tests/test_library_content.py index ecf6eac39a8c..1e720f4debf5 100644 --- a/lms/djangoapps/course_blocks/transformers/tests/test_library_content.py +++ b/lms/djangoapps/course_blocks/transformers/tests/test_library_content.py @@ -4,13 +4,14 @@ from six.moves import range +import mock from openedx.core.djangoapps.content.block_structure.api import clear_course_from_cache from openedx.core.djangoapps.content.block_structure.transformers import BlockStructureTransformers from student.tests.factories import CourseEnrollmentFactory from ...api import get_course_blocks -from ..library_content import ContentLibraryTransformer +from ..library_content import ContentLibraryTransformer, ContentLibraryOrderTransformer from .helpers import CourseStructureTestCase @@ -167,3 +168,159 @@ def test_content_library(self): ), u"Expected 'selected' equality failed in iteration {}.".format(i) ) + + +class ContentLibraryOrderTransformerTestCase(CourseStructureTestCase): + """ + ContentLibraryOrderTransformer Test + """ + TRANSFORMER_CLASS_TO_TEST = ContentLibraryOrderTransformer + + def setUp(self): + """ + Setup course structure and create user for content library order transformer test. + """ + super(ContentLibraryOrderTransformerTestCase, self).setUp() + self.course_hierarchy = self.get_course_hierarchy() + self.blocks = self.build_course(self.course_hierarchy) + self.course = self.blocks['course'] + clear_course_from_cache(self.course.id) + + # Enroll user in course. + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, is_active=True) + + def get_course_hierarchy(self): + """ + Get a course hierarchy to test with. + """ + return [{ + 'org': 'ContentLibraryTransformer', + 'course': 'CL101F', + 'run': 'test_run', + '#type': 'course', + '#ref': 'course', + '#children': [ + { + '#type': 'chapter', + '#ref': 'chapter1', + '#children': [ + { + '#type': 'sequential', + '#ref': 'lesson1', + '#children': [ + { + '#type': 'vertical', + '#ref': 'vertical1', + '#children': [ + { + 'metadata': {'category': 'library_content'}, + '#type': 'library_content', + '#ref': 'library_content1', + '#children': [ + { + 'metadata': {'display_name': "CL Vertical 2"}, + '#type': 'vertical', + '#ref': 'vertical2', + '#children': [ + { + 'metadata': {'display_name': "HTML1"}, + '#type': 'html', + '#ref': 'html1', + } + ] + }, + { + 'metadata': {'display_name': "CL Vertical 3"}, + '#type': 'vertical', + '#ref': 'vertical3', + '#children': [ + { + 'metadata': {'display_name': "HTML2"}, + '#type': 'html', + '#ref': 'html2', + } + ] + }, + { + 'metadata': {'display_name': "CL Vertical 4"}, + '#type': 'vertical', + '#ref': 'vertical4', + '#children': [ + { + 'metadata': {'display_name': "HTML3"}, + '#type': 'html', + '#ref': 'html3', + } + ] + } + ] + } + ], + } + ], + } + ], + } + ] + }] + + @mock.patch('lms.djangoapps.course_blocks.transformers.library_content.get_student_module_as_dict') + def test_content_library_randomize(self, mocked): + """ + Test whether the order of the children blocks matches the order of the selected blocks when + course has content library section + """ + mocked.return_value = { + 'selected': [ + ['vertical', 'vertical_vertical3'], + ['vertical', 'vertical_vertical2'], + ['vertical', 'vertical_vertical4'], + ] + } + for i in range(5): + trans_block_structure = get_course_blocks( + self.user, + self.course.location, + self.transformers, + ) + children = [] + for block_key in trans_block_structure.topological_traversal(): + if block_key.block_type == 'library_content': + children = trans_block_structure.get_children(block_key) + break + + expected_children = ['vertical_vertical3', 'vertical_vertical2', 'vertical_vertical4'] + self.assertEqual( + expected_children, + [child.block_id for child in children], + u"Expected 'selected' equality failed in iteration {}.".format(i) + ) + + @mock.patch('lms.djangoapps.course_blocks.transformers.library_content.get_student_module_as_dict') + def test_content_library_randomize_selected_blocks_mismatch(self, mocked): + # The block structure will contain all the blocks in the course. So the selections + # returned from the database will trigger a mismatch + mocked.return_value = { + 'selected': [ + ['vertical', 'vertical_vertical3'], + ] + } + + expected_children_without_hiding_or_gating = ['vertical_vertical3', ] + + for _ in range(5): + trans_block_structure = get_course_blocks( + self.user, + self.course.location, + self.transformers, + ) + children = [] + for block_key in trans_block_structure.topological_traversal(): + if block_key.block_type == 'library_content': + children = trans_block_structure.get_children(block_key) + break + + self.assertNotEqual( + expected_children_without_hiding_or_gating, + [child.block_id for child in children], + ) diff --git a/lms/djangoapps/courseware/access_utils.py b/lms/djangoapps/courseware/access_utils.py index 17534049eab6..a7eb5270fac0 100644 --- a/lms/djangoapps/courseware/access_utils.py +++ b/lms/djangoapps/courseware/access_utils.py @@ -76,7 +76,7 @@ def check_start_date(user, days_early_for_beta, start, course_key, display_error Returns: AccessResponse: Either ACCESS_GRANTED or StartDateError. """ - start_dates_disabled = settings.FEATURES['DISABLE_START_DATES'] + start_dates_disabled = settings.FEATURES.get('DISABLE_START_DATES', False) masquerading_as_student = is_masquerading_as_student(user, course_key) if start_dates_disabled and not masquerading_as_student: diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py index a899bc572a9c..b1847cc5ffc7 100644 --- a/lms/djangoapps/support/tests/test_views.py +++ b/lms/djangoapps/support/tests/test_views.py @@ -317,7 +317,7 @@ def test_change_enrollment(self, search_string_type): 'course_id': six.text_type(self.course.id), 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.VERIFIED, - 'reason': 'Financial Assistance' + 'reason': u'Financial Assistance' }) self.assertEqual(response.status_code, 200) self.assertIsNotNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email)) diff --git a/lms/envs/common.py b/lms/envs/common.py index c4d73d0d7bf8..195e1fcf08e3 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -879,21 +879,6 @@ def _make_mako_template_dirs(settings): USERNAME_PATTERN = r'(?P{regex})'.format(regex=USERNAME_REGEX_PARTIAL) -############################## HEARTBEAT ###################################### - -# Checks run in normal mode by the heartbeat djangoapp -HEARTBEAT_CHECKS = [ - 'openedx.core.djangoapps.heartbeat.default_checks.check_modulestore', - 'openedx.core.djangoapps.heartbeat.default_checks.check_database', -] - -# Other checks to run by default in "extended"/heavy mode -HEARTBEAT_EXTENDED_CHECKS = ( - 'openedx.core.djangoapps.heartbeat.default_checks.check_celery', -) - -HEARTBEAT_CELERY_TIMEOUT = 5 - ############################## EVENT TRACKING ################################# LMS_SEGMENT_KEY = None @@ -2164,6 +2149,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # let logging work as configured: CELERYD_HIJACK_ROOT_LOGGER = False + CELERY_BROKER_VHOST = '' CELERY_BROKER_USE_SSL = False CELERY_EVENT_QUEUE_TTL = None @@ -2173,6 +2159,22 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring CELERY_BROKER_USER = 'celery' CELERY_BROKER_PASSWORD = 'celery' +############################## HEARTBEAT ###################################### + +# Checks run in normal mode by the heartbeat djangoapp +HEARTBEAT_CHECKS = [ + 'openedx.core.djangoapps.heartbeat.default_checks.check_modulestore', + 'openedx.core.djangoapps.heartbeat.default_checks.check_database', +] + +# Other checks to run by default in "extended"/heavy mode +HEARTBEAT_EXTENDED_CHECKS = ( + 'openedx.core.djangoapps.heartbeat.default_checks.check_celery', +) + +HEARTBEAT_CELERY_TIMEOUT = 5 +CELERY_CHECK_ROUTING_KEY = HIGH_PRIORITY_QUEUE + ################################ Block Structures ################################### BLOCK_STRUCTURES_SETTINGS = dict( diff --git a/lms/envs/production.py b/lms/envs/production.py index a98a5764c55c..c7ac75cade02 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -173,6 +173,9 @@ def get_env_setting(setting): if not STATIC_URL.endswith("/"): STATIC_URL += "/" +# Allow overriding build profile used by RequireJS with one +# contained on a custom theme +REQUIRE_BUILD_PROFILE = ENV_TOKENS.get('REQUIRE_BUILD_PROFILE', REQUIRE_BUILD_PROFILE) # The following variables use (or) instead of the default value inside (get). This is to enforce using the Lazy Text # values when the varibale is an empty string. Therefore, setting these variable as empty text in related @@ -564,6 +567,12 @@ def get_env_setting(setting): ) TRACKING_SEGMENTIO_SOURCE_MAP = ENV_TOKENS.get("TRACKING_SEGMENTIO_SOURCE_MAP", TRACKING_SEGMENTIO_SOURCE_MAP) +# Heartbeat +HEARTBEAT_CHECKS = ENV_TOKENS.get('HEARTBEAT_CHECKS', HEARTBEAT_CHECKS) +HEARTBEAT_EXTENDED_CHECKS = ENV_TOKENS.get('HEARTBEAT_EXTENDED_CHECKS', HEARTBEAT_EXTENDED_CHECKS) +HEARTBEAT_CELERY_TIMEOUT = ENV_TOKENS.get('HEARTBEAT_CELERY_TIMEOUT', HEARTBEAT_CELERY_TIMEOUT) +CELERY_CHECK_ROUTING_KEY = ENV_TOKENS.get('CELERY_CHECK_ROUTING_KEY', HIGH_PRIORITY_QUEUE) + # Student identity verification settings VERIFY_STUDENT = AUTH_TOKENS.get("VERIFY_STUDENT", VERIFY_STUDENT) DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH = ENV_TOKENS.get( diff --git a/lms/envs/test.py b/lms/envs/test.py index e269776214f4..0d95f15e8419 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -590,3 +590,20 @@ ############### Settings for Django Rate limit ##################### RATELIMIT_RATE = '2/m' + +COURSE_ENROLLMENT_MODES['test'] = { + "id": 8, + "slug": u"test", + "display_name": u"Test", + "min_price": 0 +} + +COURSE_ENROLLMENT_MODES['test_mode'] = { + "id": 9, + "slug": u"test_mode", + "display_name": u"Test Mode", + "min_price": 0 +} + +##### LOGISTRATION RATE LIMIT SETTINGS ##### +LOGISTRATION_RATELIMIT_RATE = '5/5m' diff --git a/lms/templates/instructor/instructor_dashboard_2/certificates.html b/lms/templates/instructor/instructor_dashboard_2/certificates.html index ce9e955289fa..b7d65f9d5f21 100644 --- a/lms/templates/instructor/instructor_dashboard_2/certificates.html +++ b/lms/templates/instructor/instructor_dashboard_2/certificates.html @@ -50,7 +50,6 @@

${_('Example Certificates')}

% endif - % if not section_data['is_self_paced']:
@@ -72,7 +71,6 @@

${_("Student-Generated Certificates")}

% endif
- % endif % if section_data['instructor_generation_enabled'] and not (section_data['enabled_for_course'] and section_data['html_cert_enabled']):
diff --git a/openedx/core/djangoapps/content/block_structure/store.py b/openedx/core/djangoapps/content/block_structure/store.py index 28e76dc4a00b..2f10db7e590e 100644 --- a/openedx/core/djangoapps/content/block_structure/store.py +++ b/openedx/core/djangoapps/content/block_structure/store.py @@ -233,7 +233,7 @@ def _encode_root_cache_key(bs_model): return six.text_type(bs_model) else: - return "v{version}.root.key.{root_usage_key}".format( + return u"v{version}.root.key.{root_usage_key}".format( version=six.text_type(BlockStructureBlockData.VERSION), root_usage_key=six.text_type(bs_model.data_usage_key), ) diff --git a/openedx/core/djangoapps/enrollments/serializers.py b/openedx/core/djangoapps/enrollments/serializers.py index 8a1c7750ab5e..aaa0d4054391 100644 --- a/openedx/core/djangoapps/enrollments/serializers.py +++ b/openedx/core/djangoapps/enrollments/serializers.py @@ -8,7 +8,10 @@ from rest_framework import serializers from course_modes.models import CourseMode +from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from student.models import CourseEnrollment +from xmodule.modulestore.django import modulestore +from django.core.exceptions import PermissionDenied log = logging.getLogger(__name__) @@ -76,15 +79,49 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer): """ course_details = CourseSerializer(source="course_overview") - user = serializers.SerializerMethodField('get_username') + user = serializers.SerializerMethodField("get_username") + finished = serializers.SerializerMethodField() + grading = serializers.SerializerMethodField() def get_username(self, model): """Retrieves the username from the associated model.""" return model.username + def get_finished(self, model): + """Retrieve finished course.""" + course = modulestore().get_course(model.course_id) + if course: + try: + coursegrade = CourseGradeFactory().read(model.user, course).passed + except PermissionDenied: + return False + return coursegrade + return False + + def get_grading(self, model): + """Retrieve course grade.""" + course = modulestore().get_course(model.course_id) + course_grade = None + summary = [] + current_grade = 0 + if course: + try: + course_grade = CourseGradeFactory().read(model.user, course) + current_grade = int(course_grade.percent * 100) + for section in course_grade.summary.get(u'section_breakdown'): + if section.get(u'prominent'): + summary.append(section) + except PermissionDenied: + pass + return [ + {u'current_grade': current_grade, + u'certificate_eligible': course_grade.passed if course_grade else False, + u'summary': summary} + ] + class Meta(object): model = CourseEnrollment - fields = ('created', 'mode', 'is_active', 'course_details', 'user') + fields = ('created', 'mode', 'is_active', 'course_details', 'user', 'finished', 'grading') lookup_field = 'username' diff --git a/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json b/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json index e9fd2f55eca7..60324c0f1555 100644 --- a/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json +++ b/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json @@ -9,14 +9,74 @@ "is_active": true, "mode": "honor", "user": "student1", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] }, { "course_id": "e/d/X", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] } ] ], @@ -30,21 +90,111 @@ "is_active": true, "mode": "verified", "user": "staff", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] }, { "course_id": "x/y/Z", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] }, { "course_id": "x/y/Z", "is_active": true, "mode": "verified", "user": "student3", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] } ] ], @@ -59,14 +209,74 @@ "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] }, { "course_id": "x/y/Z", "is_active": true, "mode": "verified", "user": "student3", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] } ] ], @@ -81,7 +291,37 @@ "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] } ] ], @@ -95,21 +335,111 @@ "is_active": true, "mode": "verified", "user": "staff", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] }, { "course_id": "e/d/X", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] }, { "course_id": "x/y/Z", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] } ] @@ -122,35 +452,185 @@ "is_active": true, "mode": "honor", "user": "student1", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] }, { "course_id": "e/d/X", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] }, { "course_id": "x/y/Z", "is_active": true, "mode": "verified", "user": "student3", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] }, { "course_id": "x/y/Z", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] }, { "course_id": "x/y/Z", "is_active": true, "mode": "verified", "user": "staff", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "finished": false, + "grading": [{ + "certificate_eligible": false, + "current_grade": 0, + "summary": [{ + "category": "Homework", + "prominent": true, + "percent": 0.0, + "detail": "Homework Average = 0%", + "label": "HW Avg" + },{ + "category": "Lab", + "prominent": true, + "percent": 0.0, + "detail": "Lab Average = 0%", + "label": "Lab Avg" + },{ + "category": "Midterm Exam", + "prominent": true, + "percent": 0.0, + "detail": "Midterm Exam = 0%", + "label": "Midterm" + },{ + "category": "Final Exam", + "prominent": true, + "percent": 0.0, + "detail": "Final Exam = 0%", + "label": "Final" + }] + }] } ] ] diff --git a/openedx/core/djangoapps/enrollments/tests/test_views.py b/openedx/core/djangoapps/enrollments/tests/test_views.py index e9a6bfa49fb6..c59cdfc4fb22 100644 --- a/openedx/core/djangoapps/enrollments/tests/test_views.py +++ b/openedx/core/djangoapps/enrollments/tests/test_views.py @@ -65,7 +65,7 @@ def assert_enrollment_status( is_active=None, enrollment_attributes=None, min_mongo_calls=0, - max_mongo_calls=0, + max_mongo_calls=8, linked_enterprise_customer=None, cohort=None, ): @@ -382,10 +382,7 @@ def test_enrollment_list_permissions(self): mode_slug=CourseMode.DEFAULT_MODE_SLUG, mode_display_name=CourseMode.DEFAULT_MODE_SLUG, ) - self.assert_enrollment_status( - course_id=six.text_type(course.id), - max_mongo_calls=0, - ) + self.assert_enrollment_status(course_id=six.text_type(course.id)) # Verify the user himself can see both of his enrollments. self._assert_enrollments_visible_in_list([self.course, other_course]) # Verify that self.other_user can't see any of the enrollments. diff --git a/openedx/core/djangoapps/enrollments/urls.py b/openedx/core/djangoapps/enrollments/urls.py index 719701f0efc1..d9961c253069 100644 --- a/openedx/core/djangoapps/enrollments/urls.py +++ b/openedx/core/djangoapps/enrollments/urls.py @@ -13,7 +13,8 @@ EnrollmentListView, EnrollmentUserRolesView, EnrollmentView, - UnenrollmentView + UnenrollmentView, + SubmissionHistoryView, ) urlpatterns = [ @@ -29,4 +30,5 @@ EnrollmentCourseDetailView.as_view(), name='courseenrollmentdetails'), url(r'^unenroll/$', UnenrollmentView.as_view(), name='unenrollment'), url(r'^roles/$', EnrollmentUserRolesView.as_view(), name='roles'), + url(r'^submission_history$', SubmissionHistoryView.as_view(), name='submissionhistory'), ] diff --git a/openedx/core/djangoapps/enrollments/views.py b/openedx/core/djangoapps/enrollments/views.py index 5bc14fbcfdc9..ee56a89bfef6 100644 --- a/openedx/core/djangoapps/enrollments/views.py +++ b/openedx/core/djangoapps/enrollments/views.py @@ -7,6 +7,7 @@ import logging +import json from six import text_type from course_modes.models import CourseMode @@ -14,6 +15,8 @@ from django.utils.decorators import method_decorator from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from lms.djangoapps.courseware.courses import get_course +from lms.djangoapps.courseware.models import StudentModule, BaseStudentModuleHistory from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf @@ -30,7 +33,7 @@ from openedx.core.djangoapps.user_api.accounts.permissions import CanRetireUser from openedx.core.djangoapps.user_api.models import UserRetirementStatus from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in -from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser +from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser from openedx.core.lib.api.permissions import ApiKeyHeaderPermission, ApiKeyHeaderPermissionIsAuthenticated from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.core.lib.exceptions import CourseNotFoundError @@ -50,6 +53,7 @@ from student.models import CourseEnrollment, User from student.roles import CourseStaffRole, GlobalStaff from util.disable_rate_limit import can_disable_rate_limit +from opaque_keys.edx.locator import CourseLocator log = logging.getLogger(__name__) REQUIRED_ATTRIBUTES = { @@ -966,3 +970,164 @@ def get_queryset(self): if usernames: queryset = queryset.filter(user__username__in=usernames) return queryset + + +@can_disable_rate_limit +class SubmissionHistoryView(APIView, ApiKeyPermissionMixIn): + """ + Submission history view. + """ + authentication_classes = (OAuth2AuthenticationAllowInactiveUser, EnrollmentCrossDomainSessionAuth) + permission_classes = (ApiKeyHeaderPermissionIsAuthenticated, ) + + def get(self, request): + """ + Get submission history details. + + **Usecases**: + + Regular users can only retrieve their own submission history and users with GlobalStaff status + can retrieve everyone's submission history. + + **Example Requests**: + + GET /api/enrollment/v1/submission_history?course_id=course_id + GET /api/enrollment/v1/submission_history?course_id=course_id&user=username + GET /api/enrollment/v1/submission_history?course_id=course_id&all_users=true + + **Query Parameters for GET** + + * course_id: Course id to retrieve submission history. + * username: Single username for which this view will retrieve the submission history details. + If no username specified the requester's username will be used. + * all_users: If true and if the requester has the correct permissions, + retrieve history submission from every user in a course id. + + **Response Values**: + + If there's an error while getting the submission history an empty response will + be returned. + The submission history response has the following attributes: + + * Results: A list of submission history: + * course_id: Course id + * course_name: Course name + * user: Username + * problems: List of problems + * location: problem location + * name: problem's display name + * submission_history: List of submission history + * state: State of submission. + * grade: Grade. + * max_grade: Maximum possible grade. + * data: problem's data. + """ + username = request.GET.get('username', request.user.username) + data = [] + if GlobalStaff().has_user(request.user): + all_users = bool(request.GET.get('all', False)) + else: + all_users = False + course_id = request.GET.get('course_id') + + if not (all_users or username == request.user.username or GlobalStaff().has_user(request.user) or + self.has_api_key_permissions(request)): + return Response(data) + + course_enrollments = CourseEnrollment.objects.select_related('user').filter(is_active=True) + if course_id: + if not course_id.startswith("course-v1:"): + course_id = "course-v1:{}".format(course_id) + try: + course_enrollments = course_enrollments.filter( + course_id=CourseLocator.from_string(course_id.replace(' ', '+')) + ).order_by('created') + except KeyError: + return Response(data) + + if not all_users: + course_enrollments = course_enrollments.filter(user__username=username).order_by('created') + + courses = {} + for course_enrollment in course_enrollments: + try: + course_list = courses.get(course_enrollment.course_id) + if course_list: + course, course_children = course_list + else: + course = get_course(course_enrollment.course_id, depth=4) + course_children = course.get_children() + courses[course_enrollment.course_id] = [course, course_children] + except ValueError: + continue + course_data = self._get_course_data(course_enrollment, course, course_children) + data.append(course_data) + + return Response({'results': data}) + + def _get_problem_data(self, course_enrollment, component): + """ + Get problem data from a course enrollment. + + Args: + ----- + course_enrollment: Course Enrollment. + component: Component to analyze. + """ + problem_data = { + 'location': str(component.location), + 'name': component.display_name, + 'submission_history': [], + 'data': component.data + } + + csm = StudentModule.objects.filter( + module_state_key=component.location, + student__username=course_enrollment.user.username, + course_id=course_enrollment.course_id) + + scores = BaseStudentModuleHistory.get_history(csm) + for i, score in enumerate(scores): + if i % 2 == 1: + continue + + state = score.state + if state is not None: + state = json.loads(state) + + history_data = { + 'state': state, + 'grade': score.grade, + 'max_grade': score.max_grade + } + problem_data['submission_history'].append(history_data) + + return problem_data + + def _get_course_data(self, course_enrollment, course, course_children): + """ + Get course data. + + Params: + -------- + + course_enrollment (CourseEnrollment): course enrollment + course: course + course_children: course children + """ + + course_data = { + 'course_id': str(course_enrollment.course_id), + 'course_name': course.display_name_with_default, + 'user': course_enrollment.user.username, + 'problems': [] + } + for section in course_children: + for subsection in section.get_children(): + for vertical in subsection.get_children(): + for component in vertical.get_children(): + if component.location.category == 'problem' and getattr(component, 'has_score', False): + problem_data = self._get_problem_data(course_enrollment, component) + course_data['problems'].append(problem_data) + + return course_data diff --git a/openedx/core/djangoapps/heartbeat/tasks.py b/openedx/core/djangoapps/heartbeat/tasks.py index b7dbfc60077c..d41206355278 100644 --- a/openedx/core/djangoapps/heartbeat/tasks.py +++ b/openedx/core/djangoapps/heartbeat/tasks.py @@ -4,8 +4,9 @@ from celery.task import task +from django.conf import settings -@task() +@task(routing_key=settings.CELERY_CHECK_ROUTING_KEY) def sample_task(): return True diff --git a/openedx/core/djangoapps/user_api/helpers.py b/openedx/core/djangoapps/user_api/helpers.py index 8b077625dfd3..0937f0a6a124 100644 --- a/openedx/core/djangoapps/user_api/helpers.py +++ b/openedx/core/djangoapps/user_api/helpers.py @@ -102,6 +102,7 @@ class FormDescription(object): "password": ["min_length", "max_length", "min_upper", "min_lower", "min_punctuation", "min_symbol", "min_numeric", "min_alphabetic"], "email": ["min_length", "max_length", "readonly"], + "name": ["readonly"], } FIELD_TYPE_MAP = { diff --git a/openedx/core/djangoapps/user_authn/views/registration_form.py b/openedx/core/djangoapps/user_authn/views/registration_form.py index 4ce0a8917c22..0799313edd43 100644 --- a/openedx/core/djangoapps/user_authn/views/registration_form.py +++ b/openedx/core/djangoapps/user_authn/views/registration_form.py @@ -4,6 +4,7 @@ import copy +import json from importlib import import_module import re @@ -1098,3 +1099,13 @@ def _apply_third_party_auth_overrides(self, request, form_desc): default=current_provider.name if current_provider.name else "Third Party", required=False, ) + + if hasattr(current_provider, 'other_settings'): + if current_provider.other_settings: + other_settings = json.loads(current_provider.other_settings) + if 'PROVIDER_READ_ONLY_FIELDS' in other_settings: + for field in other_settings['PROVIDER_READ_ONLY_FIELDS']: + form_desc.override_field_properties( + field, + restrictions={"readonly": "readonly"} + ) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index c890fae8b2b0..70a6d9586500 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -673,6 +673,45 @@ def test_register_form_third_party_auth_running_google( } ) + def test_third_party_auth_disable_registration_fields(self): + no_extra_fields_setting = {} + self.configure_google_provider(enabled=True, + other_settings='{"PROVIDER_READ_ONLY_FIELDS": ["email", "name"]}') + with simulate_running_pipeline( + "openedx.core.djangoapps.user_authn.views.login_form.third_party_auth.pipeline", "google-oauth2", + email="bob@example.com", + fullname="Bob", + ): + self._assert_reg_field( + no_extra_fields_setting, + { + u"name": u"name", + u"defaultValue": u"Bob", + u"type": u"text", + u"required": True, + u"label": u"Full Name", + u"instructions": u"This name will be used on any certificates that you earn.", + u"restrictions": { + "readonly": "readonly", + } + } + ) + + self._assert_reg_field( + no_extra_fields_setting, + { + u"name": u"email", + u"defaultValue": u"bob@example.com", + u"type": u"email", + u"required": True, + u"label": u"Email", + u"instructions": u"This is what you will use to login.", + u"restrictions": { + "readonly": "readonly", + }, + } + ) + def test_register_form_level_of_education(self): self._assert_reg_field( {"level_of_education": "optional"}, diff --git a/setup.py b/setup.py index 257131cb2e1e..4b56c4861db7 100644 --- a/setup.py +++ b/setup.py @@ -53,6 +53,7 @@ ], "openedx.block_structure_transformer": [ "library_content = lms.djangoapps.course_blocks.transformers.library_content:ContentLibraryTransformer", + "library_content_randomize = lms.djangoapps.course_blocks.transformers.library_content:ContentLibraryOrderTransformer", "split_test = lms.djangoapps.course_blocks.transformers.split_test:SplitTestTransformer", "start_date = lms.djangoapps.course_blocks.transformers.start_date:StartDateTransformer", "user_partitions = lms.djangoapps.course_blocks.transformers.user_partitions:UserPartitionTransformer",