def update_story(committer_id, story_id, change_list, commit_message): """Updates a story. Commits changes. # NOTE: This function should not be called on its own. Access it # through `topic_services.update_story_and_topic_summary`. Args: committer_id: str. The id of the user who is performing the update action. story_id: str. The story id. change_list: list(StoryChange). These changes are applied in sequence to produce the resulting story. commit_message: str or None. A description of changes made to the story. Raises: ValidationError. Exploration is already linked to a different story. """ if not commit_message: raise ValueError('Expected a commit message but received none.') old_story = story_fetchers.get_story_by_id(story_id) new_story, exp_ids_removed_from_story, exp_ids_added_to_story = ( apply_change_list(story_id, change_list)) story_is_published = _is_story_published_and_present_in_topic(new_story) exploration_context_models_to_be_deleted = ( exp_models.ExplorationContextModel.get_multi( exp_ids_removed_from_story)) exploration_context_models_to_be_deleted = [ model for model in exploration_context_models_to_be_deleted if model is not None ] exploration_context_models_collisions_list = ( exp_models.ExplorationContextModel.get_multi(exp_ids_added_to_story)) for context_model in exploration_context_models_collisions_list: if context_model is not None and context_model.story_id != story_id: raise utils.ValidationError( 'The exploration with ID %s is already linked to story ' 'with ID %s' % (context_model.id, context_model.story_id)) if (old_story.url_fragment != new_story.url_fragment and does_story_exist_with_url_fragment(new_story.url_fragment)): raise utils.ValidationError( 'Story Url Fragment is not unique across the site.') _save_story(committer_id, new_story, commit_message, change_list, story_is_published) create_story_summary(new_story.id) if story_is_published and _is_topic_published(new_story): opportunity_services.update_exploration_opportunities( old_story, new_story) suggestion_services.auto_reject_translation_suggestions_for_exp_ids( exp_ids_removed_from_story) exp_models.ExplorationContextModel.delete_multi( exploration_context_models_to_be_deleted) new_exploration_context_models = [ exp_models.ExplorationContextModel(id=exp_id, story_id=story_id) for exp_id in exp_ids_added_to_story ] exp_models.ExplorationContextModel.update_timestamps_multi( new_exploration_context_models) exp_models.ExplorationContextModel.put_multi( new_exploration_context_models)
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 post(self, story_id, node_id): if not constants.ENABLE_NEW_STRUCTURE_VIEWER_UPDATES: raise self.PageNotFoundException try: story_fetchers.get_node_index_by_story_id_and_node_id( story_id, node_id) except Exception as e: raise self.PageNotFoundException(e) story = story_fetchers.get_story_by_id(story_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 = [ node for node in story.story_contents.get_ordered_nodes() ] next_exp_ids = [] next_node_id = None if not node_id in completed_node_ids: story_services.record_completed_node_in_story_context( self.user_id, story_id, node_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 ] for node in ordered_nodes: if node.id not in completed_node_ids: next_exp_ids = [node.exploration_id] next_node_id = node.id break 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_nodes) == len(ordered_nodes) learner_at_review_point_in_story = ( len(exp_summaries) != 0 and (len(completed_nodes) & 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 return self.render_json({ 'summaries': exp_summaries, 'ready_for_review_test': ready_for_review_test, 'next_node_id': next_node_id })
def apply_change_list(story_id, change_list): """Applies a changelist to a story and returns the result. Args: story_id: str. ID of the given story. change_list: list(StoryChange). A change list to be applied to the given story. Returns: Story, list(str), list(str). The resulting story domain object, the exploration IDs removed from story and the exploration IDs added to the story. """ story = story_fetchers.get_story_by_id(story_id) exp_ids_in_old_story = story.story_contents.get_all_linked_exp_ids() try: for change in change_list: if not isinstance(change, story_domain.StoryChange): raise Exception('Expected change to be of type StoryChange') if change.cmd == story_domain.CMD_ADD_STORY_NODE: story.add_node(change.node_id, change.title) elif change.cmd == story_domain.CMD_DELETE_STORY_NODE: story.delete_node(change.node_id) elif (change.cmd == story_domain.CMD_UPDATE_STORY_NODE_OUTLINE_STATUS): if change.new_value: story.mark_node_outline_as_finalized(change.node_id) else: story.mark_node_outline_as_unfinalized(change.node_id) elif change.cmd == story_domain.CMD_UPDATE_STORY_NODE_PROPERTY: if (change.property_name == story_domain.STORY_NODE_PROPERTY_OUTLINE): story.update_node_outline(change.node_id, change.new_value) elif (change.property_name == story_domain.STORY_NODE_PROPERTY_TITLE): story.update_node_title(change.node_id, change.new_value) elif (change.property_name == story_domain.STORY_NODE_PROPERTY_DESCRIPTION): story.update_node_description(change.node_id, change.new_value) elif (change.property_name == story_domain.STORY_NODE_PROPERTY_THUMBNAIL_FILENAME): story.update_node_thumbnail_filename( change.node_id, change.new_value) elif (change.property_name == story_domain.STORY_NODE_PROPERTY_THUMBNAIL_BG_COLOR): story.update_node_thumbnail_bg_color( change.node_id, change.new_value) elif (change.property_name == story_domain.STORY_NODE_PROPERTY_ACQUIRED_SKILL_IDS): story.update_node_acquired_skill_ids( change.node_id, change.new_value) elif (change.property_name == story_domain.STORY_NODE_PROPERTY_PREREQUISITE_SKILL_IDS): story.update_node_prerequisite_skill_ids( change.node_id, change.new_value) elif (change.property_name == story_domain.STORY_NODE_PROPERTY_DESTINATION_NODE_IDS): story.update_node_destination_node_ids( change.node_id, change.new_value) elif (change.property_name == story_domain.STORY_NODE_PROPERTY_EXPLORATION_ID): story.update_node_exploration_id(change.node_id, change.new_value) elif change.cmd == story_domain.CMD_UPDATE_STORY_PROPERTY: if (change.property_name == story_domain.STORY_PROPERTY_TITLE): story.update_title(change.new_value) elif (change.property_name == story_domain.STORY_PROPERTY_THUMBNAIL_FILENAME): story.update_thumbnail_filename(change.new_value) elif (change.property_name == story_domain.STORY_PROPERTY_THUMBNAIL_BG_COLOR): story.update_thumbnail_bg_color(change.new_value) elif (change.property_name == story_domain.STORY_PROPERTY_DESCRIPTION): story.update_description(change.new_value) elif (change.property_name == story_domain.STORY_PROPERTY_NOTES ): story.update_notes(change.new_value) elif (change.property_name == story_domain.STORY_PROPERTY_LANGUAGE_CODE): story.update_language_code(change.new_value) elif (change.property_name == story_domain.STORY_PROPERTY_URL_FRAGMENT): story.update_url_fragment(change.new_value) elif (change.property_name == story_domain.STORY_PROPERTY_META_TAG_CONTENT): story.update_meta_tag_content(change.new_value) elif change.cmd == story_domain.CMD_UPDATE_STORY_CONTENTS_PROPERTY: if (change.property_name == story_domain.INITIAL_NODE_ID): story.update_initial_node(change.new_value) if change.property_name == story_domain.NODE: story.rearrange_node_in_story(change.old_value, change.new_value) elif (change.cmd == story_domain.CMD_MIGRATE_SCHEMA_TO_LATEST_VERSION): # Loading the story model from the datastore into a # Story domain object automatically converts it to use the # latest schema version. As a result, simply resaving the # story is sufficient to apply the schema migration. continue exp_ids_in_modified_story = ( story.story_contents.get_all_linked_exp_ids()) exp_ids_removed_from_story = list( set(exp_ids_in_old_story).difference(exp_ids_in_modified_story)) exp_ids_added_to_story = list( set(exp_ids_in_modified_story).difference(exp_ids_in_old_story)) return story, exp_ids_removed_from_story, exp_ids_added_to_story except Exception as e: logging.error('%s %s %s %s' % (e.__class__.__name__, e, story_id, change_list)) python_utils.reraise_exception()
def test_migration_job_converts_old_story(self): """Tests that the schema conversion functions work correctly and an old story is converted to new version. """ # Generate story with old(v1) story contents data. self.save_new_story_with_story_contents_schema_v1( self.STORY_ID, 'image.svg', '#F8BF74', self.albert_id, 'A title', 'A description', 'A note', self.TOPIC_ID) topic_services.add_canonical_story( self.albert_id, self.TOPIC_ID, self.STORY_ID) story_model = ( story_models.StoryModel.get(self.STORY_ID)) self.assertEqual(story_model.story_contents_schema_version, 1) self.assertEqual( story_model.story_contents, { 'initial_node_id': 'node_1', 'next_node_id': 'node_2', 'nodes': [{ 'acquired_skill_ids': [], 'destination_node_ids': [], 'exploration_id': None, 'id': 'node_1', 'outline': ( '<p>Value</p><oppia-noninteractive-math raw_l' 'atex-with-value="&quot;+,-,-,+&quot' ';"></oppia-noninteractive-math>'), 'outline_is_finalized': False, 'prerequisite_skill_ids': [], 'title': 'Chapter 1' }] }) story = story_fetchers.get_story_by_id(self.STORY_ID) self.assertEqual(story.story_contents_schema_version, 4) self.assertEqual( story.story_contents.to_dict(), self.MIGRATED_STORY_CONTENTS_DICT) # Start migration job. job_id = ( story_jobs_one_off.StoryMigrationOneOffJob.create_new()) story_jobs_one_off.StoryMigrationOneOffJob.enqueue(job_id) self.process_and_flush_pending_mapreduce_tasks() # Verify the story migrates correctly. updated_story = ( story_models.StoryModel.get(self.STORY_ID)) self.assertEqual( updated_story.story_contents_schema_version, feconf.CURRENT_STORY_CONTENTS_SCHEMA_VERSION) updated_story = ( story_fetchers.get_story_by_id(self.STORY_ID)) self.assertEqual( updated_story.story_contents_schema_version, feconf.CURRENT_STORY_CONTENTS_SCHEMA_VERSION) self.assertEqual( updated_story.story_contents.to_dict(), self.MIGRATED_STORY_CONTENTS_DICT) output = story_jobs_one_off.StoryMigrationOneOffJob.get_output(job_id) expected = [[u'story_migrated', [u'1 stories successfully migrated.']]] self.assertEqual(expected, [ast.literal_eval(x) for x in output])
def test_get_story_by_id_with_valid_ids_returns_correct_dict(self) -> None: expected_story = self.story.to_dict() story = story_fetchers.get_story_by_id(self.story_id) self.assertEqual(story.to_dict(), expected_story)
def test_update_story_node_properties(self): changelist = [ story_domain.StoryChange({ 'cmd': story_domain.CMD_ADD_STORY_NODE, 'node_id': self.NODE_ID_2, 'title': 'Title 2' }), story_domain.StoryChange({ 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY, 'property_name': (story_domain.STORY_NODE_PROPERTY_DESTINATION_NODE_IDS), 'node_id': self.NODE_ID_2, 'old_value': [], 'new_value': [self.NODE_ID_1] }), story_domain.StoryChange({ 'cmd': story_domain.CMD_UPDATE_STORY_NODE_OUTLINE_STATUS, 'node_id': self.NODE_ID_2, 'old_value': False, 'new_value': True }), story_domain.StoryChange({ 'cmd': story_domain.CMD_UPDATE_STORY_CONTENTS_PROPERTY, 'property_name': (story_domain.INITIAL_NODE_ID), 'old_value': self.NODE_ID_1, 'new_value': self.NODE_ID_2 }) ] story_services.update_story(self.USER_ID, self.STORY_ID, changelist, 'Added story node.') story = story_fetchers.get_story_by_id(self.STORY_ID) self.assertEqual(story.story_contents.nodes[1].destination_node_ids, [self.NODE_ID_1]) self.assertEqual(story.story_contents.nodes[1].outline_is_finalized, True) self.assertEqual(story.story_contents.nodes[1].title, 'Title 2') self.assertEqual(story.story_contents.initial_node_id, self.NODE_ID_2) self.assertEqual(story.story_contents.next_node_id, 'node_3') self.assertEqual(story.version, 3) story_summary = story_fetchers.get_story_summary_by_id(self.STORY_ID) self.assertEqual(story_summary.node_count, 2) changelist = [ story_domain.StoryChange({ 'cmd': story_domain.CMD_DELETE_STORY_NODE, 'node_id': self.NODE_ID_1 }), story_domain.StoryChange({ 'cmd': story_domain.CMD_UPDATE_STORY_NODE_OUTLINE_STATUS, 'node_id': self.NODE_ID_2, 'old_value': True, 'new_value': False }), story_domain.StoryChange({ 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY, 'property_name': (story_domain.STORY_NODE_PROPERTY_TITLE), 'node_id': self.NODE_ID_2, 'old_value': 'Title 2', 'new_value': 'Modified title 2' }), ] story_services.update_story(self.USER_ID, self.STORY_ID, changelist, 'Removed a story node.') story_summary = story_fetchers.get_story_summary_by_id(self.STORY_ID) story = story_fetchers.get_story_by_id(self.STORY_ID) self.assertEqual(story_summary.node_count, 1) self.assertEqual(story.story_contents.nodes[0].title, 'Modified title 2') self.assertEqual(story.story_contents.nodes[0].destination_node_ids, []) self.assertEqual(story.story_contents.nodes[0].outline_is_finalized, False)
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 test_get_story_by_id(self): expected_story = self.story.to_dict() story = story_fetchers.get_story_by_id(self.STORY_ID) self.assertEqual(story.to_dict(), expected_story)
def apply_change_list(story_id, change_list): """Applies a changelist to a story and returns the result. Args: story_id: str. ID of the given story. change_list: list(StoryChange). A change list to be applied to the given story. Returns: Story. The resulting story domain object. """ story = story_fetchers.get_story_by_id(story_id) try: for change in change_list: if not isinstance(change, story_domain.StoryChange): raise Exception('Expected change to be of type StoryChange') if change.cmd == story_domain.CMD_ADD_STORY_NODE: story.add_node(change.node_id, change.title) elif change.cmd == story_domain.CMD_DELETE_STORY_NODE: story.delete_node(change.node_id) elif (change.cmd == story_domain.CMD_UPDATE_STORY_NODE_OUTLINE_STATUS): if change.new_value: story.mark_node_outline_as_finalized(change.node_id) else: story.mark_node_outline_as_unfinalized(change.node_id) elif change.cmd == story_domain.CMD_UPDATE_STORY_NODE_PROPERTY: if (change.property_name == story_domain.STORY_NODE_PROPERTY_OUTLINE): story.update_node_outline(change.node_id, change.new_value) elif (change.property_name == story_domain.STORY_NODE_PROPERTY_TITLE): story.update_node_title(change.node_id, change.new_value) elif (change.property_name == story_domain.STORY_NODE_PROPERTY_ACQUIRED_SKILL_IDS): story.update_node_acquired_skill_ids( change.node_id, change.new_value) elif (change.property_name == story_domain.STORY_NODE_PROPERTY_PREREQUISITE_SKILL_IDS): story.update_node_prerequisite_skill_ids( change.node_id, change.new_value) elif (change.property_name == story_domain.STORY_NODE_PROPERTY_DESTINATION_NODE_IDS): story.update_node_destination_node_ids( change.node_id, change.new_value) elif (change.property_name == story_domain.STORY_NODE_PROPERTY_EXPLORATION_ID): story.update_node_exploration_id( change.node_id, change.new_value) elif change.cmd == story_domain.CMD_UPDATE_STORY_PROPERTY: if (change.property_name == story_domain.STORY_PROPERTY_TITLE): story.update_title(change.new_value) elif (change.property_name == story_domain.STORY_PROPERTY_DESCRIPTION): story.update_description(change.new_value) elif (change.property_name == story_domain.STORY_PROPERTY_NOTES): story.update_notes(change.new_value) elif (change.property_name == story_domain.STORY_PROPERTY_LANGUAGE_CODE): story.update_language_code(change.new_value) elif change.cmd == story_domain.CMD_UPDATE_STORY_CONTENTS_PROPERTY: if (change.property_name == story_domain.INITIAL_NODE_ID): story.update_initial_node(change.new_value) elif ( change.cmd == story_domain.CMD_MIGRATE_SCHEMA_TO_LATEST_VERSION): # Loading the story model from the datastore into a # Story domain object automatically converts it to use the # latest schema version. As a result, simply resaving the # story is sufficient to apply the schema migration. continue return story except Exception as e: logging.error( '%s %s %s %s' % ( e.__class__.__name__, e, story_id, change_list) ) raise
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 })