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])
Esempio n. 2
0
    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
        })
Esempio n. 3
0
    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)
Esempio n. 4
0
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
Esempio n. 5
0
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)
Esempio n. 6
0
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)
Esempio n. 7
0
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
Esempio n. 8
0
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
Esempio n. 9
0
 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)
Esempio n. 10
0
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)
Esempio n. 11
0
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
Esempio n. 12
0
    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)
Esempio n. 13
0
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)
Esempio n. 14
0
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