def test_job_removes_deleted_uncategorized_skill_ids(self): """Tests that the RemoveDeletedSkillsFromTopicOneOffJob job removes deleted uncategorized skills ids from the topic. """ valid_skill_1 = skill_domain.Skill.create_default_skill( 'valid_skill_1', 'A description', self.rubrics) valid_skill_2 = skill_domain.Skill.create_default_skill( 'valid_skill_2', 'A description', self.rubrics) valid_skill_3 = skill_domain.Skill.create_default_skill( 'valid_skill_3', 'A description', self.rubrics) skill_services.save_new_skill(self.albert_id, valid_skill_1) skill_services.save_new_skill(self.albert_id, valid_skill_2) skill_services.save_new_skill(self.albert_id, valid_skill_3) # Create a new topic that should not be affected by the # job. topic = topic_domain.Topic.create_default_topic( self.TOPIC_ID, 'A name', 'abbrev', 'description') topic.add_subtopic(1, 'A subtitle') topic.add_uncategorized_skill_id('valid_skill_1') topic.add_uncategorized_skill_id('valid_skill_2') topic.add_uncategorized_skill_id('valid_skill_3') topic.add_uncategorized_skill_id('deleted_skill_1') topic.add_uncategorized_skill_id('deleted_skill_2') topic.add_uncategorized_skill_id('deleted_skill_3') topic.move_skill_id_to_subtopic(None, 1, 'valid_skill_3') topic.move_skill_id_to_subtopic(None, 1, 'deleted_skill_3') topic_services.save_new_topic(self.albert_id, topic) # Pre-assert that all skills are added correctly. self.assertEqual( set(topic.uncategorized_skill_ids), set([ 'valid_skill_1', 'valid_skill_2', 'deleted_skill_1', 'deleted_skill_2' ])) self.assertEqual(set(topic.subtopics[0].skill_ids), set(['valid_skill_3', 'deleted_skill_3'])) # Start RemoveDeletedSkillsFromTopicOneOffJob. job_id = (topic_jobs_one_off.RemoveDeletedSkillsFromTopicOneOffJob. create_new()) topic_jobs_one_off.RemoveDeletedSkillsFromTopicOneOffJob.enqueue( job_id) self.process_and_flush_pending_mapreduce_tasks() # Assert that only valid skills remain after # RemoveDeletedSkillsFromTopicOneOffJob. updated_topic = topic_fetchers.get_topic_by_id(self.TOPIC_ID) self.assertEqual(updated_topic.uncategorized_skill_ids, ['valid_skill_1', 'valid_skill_2']) self.assertEqual(updated_topic.subtopics[0].skill_ids, ['valid_skill_3']) output = (topic_jobs_one_off.RemoveDeletedSkillsFromTopicOneOffJob. get_output(job_id)) expected = [[ u'Skill IDs deleted for topic topic_id:', [ u'[u\'deleted_skill_1\', u\'deleted_skill_2\',' ' u\'deleted_skill_3\']' ] ], [u'topic_processed', [u'Processed 1 topics.']]] self.assertEqual(expected, [ast.literal_eval(x) for x in output])
def post(self, story_id, node_id): story = story_fetchers.get_story_by_id(story_id) if story is None: logging.error( 'Could not find a story corresponding to ' '%s id.' % story_id) self.render_json({}) return topic = topic_fetchers.get_topic_by_id(story.corresponding_topic_id) completed_nodes = story_fetchers.get_completed_nodes_in_story( self.user_id, story_id) completed_node_ids = [ completed_node.id for completed_node in completed_nodes] ordered_nodes = story.story_contents.get_ordered_nodes() (next_exp_ids, next_node_id, completed_node_ids) = ( self._record_node_completion( story_id, node_id, completed_node_ids, ordered_nodes)) ready_for_review_test = False exp_summaries = ( summary_services.get_displayable_exp_summary_dicts_matching_ids( next_exp_ids)) # If there are no questions for any of the acquired skills that the # learner has completed, do not show review tests. acquired_skills = skill_fetchers.get_multi_skills( story.get_acquired_skill_ids_for_node_ids( completed_node_ids )) acquired_skill_ids = [skill.id for skill in acquired_skills] questions_available = len( question_services.get_questions_by_skill_ids( 1, acquired_skill_ids, False)) > 0 learner_completed_story = len(completed_node_ids) == len(ordered_nodes) learner_at_review_point_in_story = ( len(exp_summaries) != 0 and ( len(completed_node_ids) & constants.NUM_EXPLORATIONS_PER_REVIEW_TEST == 0) ) if questions_available and ( learner_at_review_point_in_story or learner_completed_story): ready_for_review_test = True # If there is no next_node_id, the story is marked as completed else # mark the story as incomplete. if next_node_id is None: learner_progress_services.mark_story_as_completed( self.user_id, story_id) else: learner_progress_services.record_story_started( self.user_id, story.id) completed_story_ids = ( learner_progress_services.get_all_completed_story_ids( self.user_id)) story_ids_in_topic = [] for story in topic.canonical_story_references: story_ids_in_topic.append(story.story_id) is_topic_completed = set(story_ids_in_topic).intersection( set(completed_story_ids)) # If at least one story in the topic is completed, # mark the topic as learnt else mark it as partially learnt. if not is_topic_completed: learner_progress_services.record_topic_started( self.user_id, topic.id) else: learner_progress_services.mark_topic_as_learnt( self.user_id, topic.id) return self.render_json({ 'summaries': exp_summaries, 'ready_for_review_test': ready_for_review_test, 'next_node_id': next_node_id })
def put(self, topic_id): """Updates properties of the given topic. Also, each change_dict given for editing should have an additional property called is_topic_change, which would be a boolean. If True, it means that change is for a topic (includes adding and removing subtopics), while False would mean it is for a Subtopic Page (this includes editing its html data as of now). """ topic = topic_fetchers.get_topic_by_id(topic_id, strict=False) version = self.payload.get('version') self._require_valid_version(version, topic.version) commit_message = self.payload.get('commit_message') if (commit_message is not None and len(commit_message) > feconf.MAX_COMMIT_MESSAGE_LENGTH): raise self.InvalidInputException( 'Commit messages must be at most %s characters long.' % feconf.MAX_COMMIT_MESSAGE_LENGTH) topic_and_subtopic_page_change_dicts = self.payload.get( 'topic_and_subtopic_page_change_dicts') topic_and_subtopic_page_change_list = [] for change in topic_and_subtopic_page_change_dicts: if change['cmd'] == ( subtopic_page_domain.CMD_UPDATE_SUBTOPIC_PAGE_PROPERTY): topic_and_subtopic_page_change_list.append( subtopic_page_domain.SubtopicPageChange(change)) else: topic_and_subtopic_page_change_list.append( topic_domain.TopicChange(change)) try: topic_services.update_topic_and_subtopic_pages( self.user_id, topic_id, topic_and_subtopic_page_change_list, commit_message) except utils.ValidationError as e: raise self.InvalidInputException(e) topic = topic_fetchers.get_topic_by_id(topic_id, strict=False) skill_id_to_description_dict, deleted_skill_ids = ( skill_services.get_descriptions_of_skills( topic.get_all_skill_ids())) skill_id_to_rubrics_dict, deleted_skill_ids = ( skill_services.get_rubrics_of_skills(topic.get_all_skill_ids()) ) if deleted_skill_ids: deleted_skills_string = ', '.join(deleted_skill_ids) logging.error( 'The deleted skills: %s are still present in topic with id %s' % (deleted_skills_string, topic_id) ) if feconf.CAN_SEND_EMAILS: email_manager.send_mail_to_admin( 'Deleted skills present in topic', 'The deleted skills: %s are still present in topic with ' 'id %s' % (deleted_skills_string, topic_id)) self.values.update({ 'topic_dict': topic.to_dict(), 'skill_id_to_description_dict': skill_id_to_description_dict, 'skill_id_to_rubrics_dict': skill_id_to_rubrics_dict }) self.render_json(self.values)
def apply_change_list(topic_id, change_list): """Applies a changelist to a topic and returns the result. The incoming changelist should not have simultaneuous creations and deletion of subtopics. Args: topic_id: str. ID of the given topic. change_list: list(TopicChange). A change list to be applied to the given topic. Raises: Exception. The incoming changelist had simultaneuous creation and deletion of subtopics. Returns: Topic, dict, list(int), list(int), list(SubtopicPageChange). The modified topic object, the modified subtopic pages dict keyed by subtopic page id containing the updated domain objects of each subtopic page, a list of ids of the deleted subtopics, a list of ids of the newly created subtopics and a list of changes applied to modified subtopic pages. """ topic = topic_fetchers.get_topic_by_id(topic_id) newly_created_subtopic_ids = [] existing_subtopic_page_ids_to_be_modified = [] deleted_subtopic_ids = [] modified_subtopic_pages_list = [] modified_subtopic_pages = {} modified_subtopic_change_cmds = collections.defaultdict(list) for change in change_list: if (change.cmd == subtopic_page_domain.CMD_UPDATE_SUBTOPIC_PAGE_PROPERTY): if change.subtopic_id < topic.next_subtopic_id: existing_subtopic_page_ids_to_be_modified.append( change.subtopic_id) subtopic_page_id = ( subtopic_page_domain.SubtopicPage.get_subtopic_page_id( topic_id, change.subtopic_id)) modified_subtopic_change_cmds[subtopic_page_id].append( change) modified_subtopic_pages_list = ( subtopic_page_services.get_subtopic_pages_with_ids( topic_id, existing_subtopic_page_ids_to_be_modified)) for subtopic_page in modified_subtopic_pages_list: modified_subtopic_pages[subtopic_page.id] = subtopic_page try: for change in change_list: if change.cmd == topic_domain.CMD_ADD_SUBTOPIC: topic.add_subtopic(change.subtopic_id, change.title) subtopic_page_id = ( subtopic_page_domain.SubtopicPage.get_subtopic_page_id( topic_id, change.subtopic_id)) modified_subtopic_pages[subtopic_page_id] = ( subtopic_page_domain.SubtopicPage.create_default_subtopic_page( #pylint: disable=line-too-long change.subtopic_id, topic_id) ) modified_subtopic_change_cmds[subtopic_page_id].append( subtopic_page_domain.SubtopicPageChange({ 'cmd': 'create_new', 'topic_id': topic_id, 'subtopic_id': change.subtopic_id })) newly_created_subtopic_ids.append(change.subtopic_id) elif change.cmd == topic_domain.CMD_DELETE_SUBTOPIC: topic.delete_subtopic(change.subtopic_id) if change.subtopic_id in newly_created_subtopic_ids: raise Exception( 'The incoming changelist had simultaneous' ' creation and deletion of subtopics.') deleted_subtopic_ids.append(change.subtopic_id) elif change.cmd == topic_domain.CMD_ADD_CANONICAL_STORY: topic.add_canonical_story(change.story_id) elif change.cmd == topic_domain.CMD_DELETE_CANONICAL_STORY: topic.delete_canonical_story(change.story_id) elif change.cmd == topic_domain.CMD_ADD_ADDITIONAL_STORY: topic.add_additional_story(change.story_id) elif change.cmd == topic_domain.CMD_DELETE_ADDITIONAL_STORY: topic.delete_additional_story(change.story_id) elif change.cmd == topic_domain.CMD_ADD_UNCATEGORIZED_SKILL_ID: topic.add_uncategorized_skill_id( change.new_uncategorized_skill_id) elif change.cmd == topic_domain.CMD_REMOVE_UNCATEGORIZED_SKILL_ID: topic.remove_uncategorized_skill_id( change.uncategorized_skill_id) elif change.cmd == topic_domain.CMD_MOVE_SKILL_ID_TO_SUBTOPIC: topic.move_skill_id_to_subtopic( change.old_subtopic_id, change.new_subtopic_id, change.skill_id) elif change.cmd == topic_domain.CMD_REMOVE_SKILL_ID_FROM_SUBTOPIC: topic.remove_skill_id_from_subtopic( change.subtopic_id, change.skill_id) elif change.cmd == topic_domain.CMD_UPDATE_TOPIC_PROPERTY: if (change.property_name == topic_domain.TOPIC_PROPERTY_NAME): topic.update_name(change.new_value) elif (change.property_name == topic_domain.TOPIC_PROPERTY_ABBREVIATED_NAME): topic.update_abbreviated_name(change.new_value) elif (change.property_name == topic_domain.TOPIC_PROPERTY_DESCRIPTION): topic.update_description(change.new_value) elif (change.property_name == topic_domain.TOPIC_PROPERTY_LANGUAGE_CODE): topic.update_language_code(change.new_value) elif (change.property_name == topic_domain.TOPIC_PROPERTY_THUMBNAIL_FILENAME): topic.update_thumbnail_filename(change.new_value) elif (change.cmd == subtopic_page_domain.CMD_UPDATE_SUBTOPIC_PAGE_PROPERTY): subtopic_page_id = ( subtopic_page_domain.SubtopicPage.get_subtopic_page_id( topic_id, change.subtopic_id)) if ((modified_subtopic_pages[subtopic_page_id] is None) or (change.subtopic_id in deleted_subtopic_ids)): raise Exception( 'The subtopic with id %s doesn\'t exist' % ( change.subtopic_id)) if (change.property_name == subtopic_page_domain. SUBTOPIC_PAGE_PROPERTY_PAGE_CONTENTS_HTML): modified_subtopic_pages[ subtopic_page_id].update_page_contents_html( state_domain.SubtitledHtml.from_dict( change.new_value)) elif (change.property_name == subtopic_page_domain. SUBTOPIC_PAGE_PROPERTY_PAGE_CONTENTS_AUDIO): modified_subtopic_pages[ subtopic_page_id].update_page_contents_audio( state_domain.RecordedVoiceovers.from_dict( change.new_value)) elif change.cmd == topic_domain.CMD_UPDATE_SUBTOPIC_PROPERTY: if (change.property_name == topic_domain.SUBTOPIC_PROPERTY_TITLE): topic.update_subtopic_title( change.subtopic_id, change.new_value) elif ( change.cmd == topic_domain.CMD_MIGRATE_SUBTOPIC_SCHEMA_TO_LATEST_VERSION): # Loading the topic model from the datastore into a # Topic domain object automatically converts it to use the # latest schema version. As a result, simply resaving the # topic is sufficient to apply the schema migration. continue return ( topic, modified_subtopic_pages, deleted_subtopic_ids, newly_created_subtopic_ids, modified_subtopic_change_cmds) except Exception as e: logging.error( '%s %s %s %s' % ( e.__class__.__name__, e, topic_id, change_list) ) raise
def publish_story(topic_id, story_id, committer_id): """Marks the given story as published. Args: topic_id: str. The id of the topic. story_id: str. The id of the given story. committer_id: str. ID of the committer. Raises: Exception. The given story does not exist. Exception. The story is already published. Exception. The user does not have enough rights to publish the story. """ def _are_nodes_valid_for_publishing(story_nodes): """Validates the story nodes before publishing. Args: story_nodes: list(dict(str, *)). The list of story nodes dicts. Raises: Exception: The story node doesn't contain any exploration id or the exploration id is invalid or isn't published yet. """ exploration_id_list = [] for node in story_nodes: if not node.exploration_id: raise Exception( 'Story node with id %s does not contain an ' 'exploration id.' % node.id) exploration_id_list.append(node.exploration_id) explorations = exp_fetchers.get_multiple_explorations_by_id( exploration_id_list, strict=False) for node in story_nodes: if not node.exploration_id in explorations: raise Exception( 'Exploration id %s doesn\'t exist.' % node.exploration_id) multiple_exploration_rights = ( rights_manager.get_multiple_exploration_rights_by_ids( exploration_id_list)) for exploration_rights in multiple_exploration_rights: if exploration_rights.is_private(): raise Exception( 'Exploration with id %s isn\'t published.' % exploration_rights.id) topic = topic_fetchers.get_topic_by_id(topic_id, strict=None) if topic is None: raise Exception('A topic with the given ID doesn\'t exist') user = user_services.UserActionsInfo(committer_id) if role_services.ACTION_CHANGE_STORY_STATUS not in user.actions: raise Exception( 'The user does not have enough rights to publish the story.') story = story_fetchers.get_story_by_id(story_id, strict=False) if story is None: raise Exception('A story with the given ID doesn\'t exist') for node in story.story_contents.nodes: if node.id == story.story_contents.initial_node_id: _are_nodes_valid_for_publishing([node]) topic.publish_story(story_id) change_list = [topic_domain.TopicChange({ 'cmd': topic_domain.CMD_PUBLISH_STORY, 'story_id': story_id })] _save_topic( committer_id, topic, 'Published story with id %s' % story_id, change_list) generate_topic_summary(topic.id)
def update_topic_and_subtopic_pages( committer_id, topic_id, change_list, commit_message): """Updates a topic and its subtopic pages. Commits changes. Args: committer_id: str. The id of the user who is performing the update action. topic_id: str. The topic id. change_list: list(TopicChange and SubtopicPageChange). These changes are applied in sequence to produce the resulting topic. commit_message: str or None. A description of changes made to the topic. Raises: ValueError. Current user does not have enough rights to edit a topic. """ if not commit_message: raise ValueError( 'Expected a commit message, received none.') old_topic = topic_fetchers.get_topic_by_id(topic_id) ( updated_topic, updated_subtopic_pages_dict, deleted_subtopic_ids, newly_created_subtopic_ids, updated_subtopic_pages_change_cmds_dict ) = apply_change_list(topic_id, change_list) if ( old_topic.url_fragment != updated_topic.url_fragment and does_topic_with_url_fragment_exist(updated_topic.url_fragment)): raise utils.ValidationError( 'Topic with URL Fragment \'%s\' already exists' % updated_topic.url_fragment) if ( old_topic.name != updated_topic.name and does_topic_with_name_exist(updated_topic.name)): raise utils.ValidationError( 'Topic with name \'%s\' already exists' % updated_topic.name) _save_topic( committer_id, updated_topic, commit_message, change_list ) # The following loop deletes those subtopic pages that are already in the # datastore, which are supposed to be deleted in the current changelist. for subtopic_id in deleted_subtopic_ids: if subtopic_id not in newly_created_subtopic_ids: subtopic_page_services.delete_subtopic_page( committer_id, topic_id, subtopic_id) for subtopic_page_id in updated_subtopic_pages_dict: subtopic_page = updated_subtopic_pages_dict[subtopic_page_id] subtopic_page_change_list = updated_subtopic_pages_change_cmds_dict[ subtopic_page_id] subtopic_id = subtopic_page.get_subtopic_id_from_subtopic_page_id() # The following condition prevents the creation of subtopic pages that # were deleted above. if subtopic_id not in deleted_subtopic_ids: subtopic_page_services.save_subtopic_page( committer_id, subtopic_page, commit_message, subtopic_page_change_list) generate_topic_summary(topic_id) if old_topic.name != updated_topic.name: opportunity_services.update_opportunities_with_new_topic_name( updated_topic.id, updated_topic.name)
def _save_story(committer_id, story, commit_message, change_list): """Validates a story and commits it to persistent storage. If successful, increments the version number of the incoming story domain object by 1. Args: committer_id: str. ID of the given committer. story: Story. The story domain object to be saved. commit_message: str. The commit message. change_list: list(StoryChange). List of changes applied to a story. Raises: ValidationError: An invalid exploration was referenced in the story. Exception: The story model and the incoming story domain object have different version numbers. """ if not change_list: raise Exception( 'Unexpected error: received an invalid change list when trying to ' 'save story %s: %s' % (story.id, change_list)) topic = topic_fetchers.get_topic_by_id( story.corresponding_topic_id, strict=False) if topic is None: raise utils.ValidationError( 'Expected story to only belong to a valid topic, but found no ' 'topic with ID: %s' % story.corresponding_topic_id) story_is_published = False story_is_present_in_topic = False for story_reference in topic.get_all_story_references(): if story_reference.story_id == story.id: story_is_present_in_topic = True story_is_published = story_reference.story_is_published if not story_is_present_in_topic: raise Exception( 'Expected story to belong to the topic %s, but it is ' 'neither a part of the canonical stories or the additional ' 'stories of the topic.' % story.corresponding_topic_id) story.validate() if story_is_published: exp_ids = [] for node in story.story_contents.nodes: if not node.exploration_id: raise Exception( 'Story node with id %s does not contain an ' 'exploration id.' % node.id) exp_ids.append(node.exploration_id) validate_explorations_for_story(exp_ids, True) # Story model cannot be None as story is passed as parameter here and that # is only possible if a story model with that story id exists. Also this is # a private function and so it cannot be called independently with any # story object. story_model = story_models.StoryModel.get(story.id) if story.version > story_model.version: raise Exception( 'Unexpected error: trying to update version %s of story ' 'from version %s. Please reload the page and try again.' % (story_model.version, story.version)) elif story.version < story_model.version: raise Exception( 'Trying to update version %s of story from version %s, ' 'which is too old. Please reload the page and try again.' % (story_model.version, story.version)) story_model.description = story.description story_model.title = story.title story_model.thumbnail_bg_color = story.thumbnail_bg_color story_model.thumbnail_filename = story.thumbnail_filename story_model.notes = story.notes story_model.language_code = story.language_code story_model.story_contents_schema_version = ( story.story_contents_schema_version) story_model.story_contents = story.story_contents.to_dict() story_model.corresponding_topic_id = story.corresponding_topic_id story_model.version = story.version change_dicts = [change.to_dict() for change in change_list] story_model.commit(committer_id, commit_message, change_dicts) memcache_services.delete(story_fetchers.get_story_memcache_key(story.id)) story.version += 1
def _save_story(committer_id, story, commit_message, change_list): """Validates a story and commits it to persistent storage. If successful, increments the version number of the incoming story domain object by 1. Args: committer_id: str. ID of the given committer. story: Story. The story domain object to be saved. commit_message: str. The commit message. change_list: list(StoryChange). List of changes applied to a story. Raises: ValidationError: An invalid exploration was referenced in the story. Exception: The story model and the incoming story domain object have different version numbers. """ if not change_list: raise Exception( 'Unexpected error: received an invalid change list when trying to ' 'save story %s: %s' % (story.id, change_list)) story.validate() # Validate that all explorations referenced by the story exist. exp_ids = [ node.exploration_id for node in story.story_contents.nodes if node.exploration_id is not None] # The first exp ID in the story to compare categories later on. sample_exp_id = exp_ids[0] if exp_ids else None # Strict = False, since the existence of explorations is checked below. exps_dict = ( exp_fetchers.get_multiple_explorations_by_id(exp_ids, strict=False)) for node in story.story_contents.nodes: if (node.exploration_id is not None) and ( node.exploration_id not in exps_dict): raise utils.ValidationError( 'Expected story to only reference valid explorations, ' 'but found an exploration with ID: %s (was it deleted?)' % node.exploration_id) if exps_dict: common_exp_category = exps_dict[sample_exp_id].category for exp_id in exps_dict: exp = exps_dict[exp_id] if exp.category != common_exp_category: raise utils.ValidationError( 'All explorations in a story should be of the ' 'same category. The explorations with ID %s and %s have' ' different categories.' % (sample_exp_id, exp_id)) if ( exp.language_code not in android_validation_constants.SUPPORTED_LANGUAGES): raise utils.ValidationError( 'Invalid language %s found for exploration with ID %s.' % (exp.language_code, exp_id)) if exp.param_specs or exp.param_changes: raise utils.ValidationError( 'Expected no exploration to have parameter values in' ' it. Invalid exploration: %s' % exp.id) for state_name in exp.states: state = exp.states[state_name] if not state.interaction.is_supported_on_android_app(): raise utils.ValidationError( 'Invalid interaction %s in exploration with ID: %s.' % (state.interaction.id, exp.id)) if not state.is_rte_content_supported_on_android(): raise utils.ValidationError( 'RTE content in state %s of exploration with ID %s is ' 'not supported on mobile.' % (state_name, exp.id)) # Story model cannot be None as story is passed as parameter here and that # is only possible if a story model with that story id exists. Also this is # a private function and so it cannot be called independently with any # story object. story_model = story_models.StoryModel.get(story.id) if story.version > story_model.version: raise Exception( 'Unexpected error: trying to update version %s of story ' 'from version %s. Please reload the page and try again.' % (story_model.version, story.version)) elif story.version < story_model.version: raise Exception( 'Trying to update version %s of story from version %s, ' 'which is too old. Please reload the page and try again.' % (story_model.version, story.version)) topic = topic_fetchers.get_topic_by_id( story.corresponding_topic_id, strict=False) if topic is None: raise utils.ValidationError( 'Expected story to only belong to a valid topic, but found an ' 'topic with ID: %s' % story.corresponding_topic_id) canonical_story_ids = topic.get_canonical_story_ids() additional_story_ids = topic.get_additional_story_ids() if story.id not in canonical_story_ids + additional_story_ids: raise Exception( 'Expected story to belong to the topic %s, but it is ' 'neither a part of the canonical stories or the additional stories ' 'of the topic.' % story.corresponding_topic_id) story_model.description = story.description story_model.title = story.title story_model.notes = story.notes story_model.language_code = story.language_code story_model.story_contents_schema_version = ( story.story_contents_schema_version) story_model.story_contents = story.story_contents.to_dict() story_model.corresponding_topic_id = story.corresponding_topic_id story_model.version = story.version change_dicts = [change.to_dict() for change in change_list] story_model.commit(committer_id, commit_message, change_dicts) memcache_services.delete(story_fetchers.get_story_memcache_key(story.id)) story.version += 1
def test_get_topic_by_id(self): expected_topic = self.topic.to_dict() topic = topic_fetchers.get_topic_by_id(self.TOPIC_ID) self.assertEqual(topic.to_dict(), expected_topic)
def validate_prerequisite_skills_in_story_contents(corresponding_topic_id, story_contents): """Validates the prerequisites skills in the story contents. Args: corresponding_topic_id: str. The corresponding topic id of the story. story_contents: StoryContents. The story contents. Raises: ValidationError. Expected prerequisite skills to have been acquired in previous nodes. ValidationError. Expected story to not contain loops. """ if len(story_contents.nodes) == 0: return # nodes_queue stores the pending nodes to visit in the story that # are unlocked, in a 'queue' form with a First In First Out # structure. nodes_queue = [] is_node_visited = [False] * len(story_contents.nodes) starting_node_index = story_contents.get_node_index( story_contents.initial_node_id) nodes_queue.append(story_contents.nodes[starting_node_index].id) # The user is assumed to have all the prerequisite skills of the # starting node before starting the story. Also, this list models # the skill IDs acquired by a learner as they progress through the # story. simulated_skill_ids = copy.deepcopy( story_contents.nodes[starting_node_index].prerequisite_skill_ids) # The following loop employs a Breadth First Search from the given # starting node and makes sure that the user has acquired all the # prerequisite skills required by the destination nodes 'unlocked' # by visiting a particular node by the time that node is finished. while len(nodes_queue) > 0: current_node_id = nodes_queue.pop() current_node_index = story_contents.get_node_index(current_node_id) is_node_visited[current_node_index] = True current_node = story_contents.nodes[current_node_index] for skill_id in current_node.acquired_skill_ids: simulated_skill_ids.append(skill_id) for node_id in current_node.destination_node_ids: node_index = story_contents.get_node_index(node_id) # The following condition checks whether the destination # node for a particular node, has already been visited, in # which case the story would have loops, which are not # allowed. if is_node_visited[node_index]: raise utils.ValidationError( 'Loops are not allowed in stories.') destination_node = story_contents.nodes[node_index] skill_ids_present_in_topic = (topic_fetchers.get_topic_by_id( corresponding_topic_id).get_all_skill_ids()) # Include only skill ids relevant to the topic for validation. topic_relevant_skill_ids = list( set(skill_ids_present_in_topic).intersection( set(destination_node.prerequisite_skill_ids))) if not (set(topic_relevant_skill_ids).issubset(simulated_skill_ids) ): raise utils.ValidationError( 'The skills with ids ' + ' '.join( set(topic_relevant_skill_ids) - set(simulated_skill_ids)) + ' were specified as prerequisites for Chapter %s,' ' but were not taught in any chapter before it.' % destination_node.title) nodes_queue.append(node_id)
def _save_story(committer_id, story, commit_message, change_list): """Validates a story and commits it to persistent storage. If successful, increments the version number of the incoming story domain object by 1. Args: committer_id: str. ID of the given committer. story: Story. The story domain object to be saved. commit_message: str. The commit message. change_list: list(StoryChange). List of changes applied to a story. Raises: ValidationError: An invalid exploration was referenced in the story. Exception: The story model and the incoming story domain object have different version numbers. """ if not change_list: raise Exception( 'Unexpected error: received an invalid change list when trying to ' 'save story %s: %s' % (story.id, change_list)) story.validate() # Validate that all explorations referenced by the story exist. exp_ids = [] for node in story.story_contents.nodes: if node.exploration_id is not None: exp_ids.append(node.exploration_id) exp_summaries = ( exp_fetchers.get_exploration_summaries_matching_ids(exp_ids)) exp_summaries_dict = { exp_id: exp_summaries[ind] for (ind, exp_id) in enumerate(exp_ids) } for node in story.story_contents.nodes: if (node.exploration_id is not None) and (not exp_summaries_dict[node.exploration_id]): raise utils.ValidationError( 'Expected story to only reference valid explorations, ' 'but found an exploration with ID: %s (was it deleted?)' % node.exploration_id) # Story model cannot be None as story is passed as parameter here and that # is only possible if a story model with that story id exists. Also this is # a private function and so it cannot be called independently with any # story object. story_model = story_models.StoryModel.get(story.id) if story.version > story_model.version: raise Exception( 'Unexpected error: trying to update version %s of story ' 'from version %s. Please reload the page and try again.' % (story_model.version, story.version)) elif story.version < story_model.version: raise Exception( 'Trying to update version %s of story from version %s, ' 'which is too old. Please reload the page and try again.' % (story_model.version, story.version)) topic = topic_fetchers.get_topic_by_id(story.corresponding_topic_id, strict=False) if topic is None: raise utils.ValidationError( 'Expected story to only belong to a valid topic, but found an ' 'topic with ID: %s' % story.corresponding_topic_id) canonical_story_ids = topic.get_canonical_story_ids() additional_story_ids = topic.get_additional_story_ids() if story.id not in canonical_story_ids + additional_story_ids: raise Exception( 'Expected story to belong to the topic %s, but it is ' 'neither a part of the canonical stories or the additional stories ' 'of the topic.' % story.corresponding_topic_id) story_model.description = story.description story_model.title = story.title story_model.notes = story.notes story_model.language_code = story.language_code story_model.story_contents_schema_version = ( story.story_contents_schema_version) story_model.story_contents = story.story_contents.to_dict() story_model.corresponding_topic_id = story.corresponding_topic_id story_model.version = story.version change_dicts = [change.to_dict() for change in change_list] story_model.commit(committer_id, commit_message, change_dicts) memcache_services.delete(story_fetchers.get_story_memcache_key(story.id)) story.version += 1
def get(self, topic_id): """Populates the data on the individual topic page.""" topic = topic_fetchers.get_topic_by_id(topic_id, strict=False) if topic is None: raise self.PageNotFoundException( Exception('The topic with the given id doesn\'t exist.')) skill_id_to_description_dict, deleted_skill_ids = ( skill_services.get_descriptions_of_skills( topic.get_all_skill_ids())) topics = topic_fetchers.get_all_topics() grouped_skill_summary_dicts = {} skill_id_to_rubrics_dict = {} for topic_object in topics: skill_id_to_rubrics_dict_local, deleted_skill_ids = ( skill_services.get_rubrics_of_skills( topic_object.get_all_skill_ids()) ) skill_id_to_rubrics_dict.update(skill_id_to_rubrics_dict_local) if deleted_skill_ids: deleted_skills_string = ', '.join(deleted_skill_ids) logging.exception( 'The deleted skills: %s are still present in topic with ' 'id %s' % (deleted_skills_string, topic_id) ) if feconf.CAN_SEND_EMAILS: email_manager.send_mail_to_admin( 'Deleted skills present in topic', 'The deleted skills: %s are still present in ' 'topic with id %s' % (deleted_skills_string, topic_id)) skill_summaries = skill_services.get_multi_skill_summaries( topic_object.get_all_skill_ids()) skill_summary_dicts = [ summary.to_dict() for summary in skill_summaries] grouped_skill_summary_dicts[topic_object.name] = skill_summary_dicts classroom_url_fragment = ( classroom_services.get_classroom_url_fragment_for_topic_id( topic_id)) skill_question_count_dict = {} for skill_id in topic.get_all_skill_ids(): skill_question_count_dict[skill_id] = ( question_services.get_total_question_count_for_skill_ids( [skill_id])) skill_creation_is_allowed = ( role_services.ACTION_CREATE_NEW_SKILL in self.user.actions) self.values.update({ 'classroom_url_fragment': classroom_url_fragment, 'topic_dict': topic.to_dict(), 'grouped_skill_summary_dicts': grouped_skill_summary_dicts, 'skill_question_count_dict': skill_question_count_dict, 'skill_id_to_description_dict': skill_id_to_description_dict, 'skill_id_to_rubrics_dict': skill_id_to_rubrics_dict, 'skill_creation_is_allowed': skill_creation_is_allowed }) self.render_json(self.values)
def publish_story(topic_id, story_id, committer_id): """Marks the given story as published. Args: topic_id: str. The id of the topic. story_id: str. The id of the given story. committer_id: str. ID of the committer. Raises: Exception. The given story does not exist. Exception. The story is already published. Exception. The user does not have enough rights to publish the story. """ def _are_nodes_valid_for_publishing(story_nodes): """Validates the story nodes before publishing. Args: story_nodes: list(dict(str, *)). The list of story nodes dicts. Raises: Exception. The story node doesn't contain any exploration id or the exploration id is invalid or isn't published yet. """ exploration_id_list = [] for node in story_nodes: if not node.exploration_id: raise Exception('Story node with id %s does not contain an ' 'exploration id.' % node.id) exploration_id_list.append(node.exploration_id) story_services.validate_explorations_for_story(exploration_id_list, True) topic = topic_fetchers.get_topic_by_id(topic_id, strict=None) if topic is None: raise Exception('A topic with the given ID doesn\'t exist') user = user_services.get_user_actions_info(committer_id) if role_services.ACTION_CHANGE_STORY_STATUS not in user.actions: raise Exception( 'The user does not have enough rights to publish the story.') story = story_fetchers.get_story_by_id(story_id, strict=False) if story is None: raise Exception('A story with the given ID doesn\'t exist') for node in story.story_contents.nodes: if node.id == story.story_contents.initial_node_id: _are_nodes_valid_for_publishing([node]) topic.publish_story(story_id) change_list = [ topic_domain.TopicChange({ 'cmd': topic_domain.CMD_PUBLISH_STORY, 'story_id': story_id }) ] _save_topic(committer_id, topic, 'Published story with id %s' % story_id, change_list) generate_topic_summary(topic.id) # Create exploration opportunities corresponding to the story and linked # explorations. linked_exp_ids = story.story_contents.get_all_linked_exp_ids() opportunity_services.add_new_exploration_opportunities( story_id, linked_exp_ids)
def _save_story( committer_id, story, commit_message, change_list, story_is_published ): """Validates a story and commits it to persistent storage. If successful, increments the version number of the incoming story domain object by 1. Args: committer_id: str. ID of the given committer. story: Story. The story domain object to be saved. commit_message: str. The commit message. change_list: list(StoryChange). List of changes applied to a story. story_is_published: bool. Whether the supplied story is published. Raises: ValidationError. An invalid exploration was referenced in the story. Exception. The story model and the incoming story domain object have different version numbers. """ if not change_list: raise Exception( 'Unexpected error: received an invalid change list when trying to ' 'save story %s: %s' % (story.id, change_list)) story.validate() corresponding_topic = ( topic_fetchers.get_topic_by_id(story.corresponding_topic_id)) validate_prerequisite_skills_in_story_contents( corresponding_topic.get_all_skill_ids(), story.story_contents) if story_is_published: exp_ids = [] for node in story.story_contents.nodes: if not node.exploration_id: raise Exception( 'Story node with id %s does not contain an ' 'exploration id.' % node.id) exp_ids.append(node.exploration_id) validate_explorations_for_story(exp_ids, True) # Story model cannot be None as story is passed as parameter here and that # is only possible if a story model with that story id exists. Also this is # a private function and so it cannot be called independently with any # story object. story_model = story_models.StoryModel.get(story.id) if story.version > story_model.version: raise Exception( 'Unexpected error: trying to update version %s of story ' 'from version %s. Please reload the page and try again.' % (story_model.version, story.version)) if story.version < story_model.version: raise Exception( 'Trying to update version %s of story from version %s, ' 'which is too old. Please reload the page and try again.' % (story_model.version, story.version)) story_model = populate_story_model_fields(story_model, story) change_dicts = [change.to_dict() for change in change_list] story_model.commit(committer_id, commit_message, change_dicts) caching_services.delete_multi( caching_services.CACHE_NAMESPACE_STORY, None, [story.id]) story.version += 1