Exemplo n.º 1
0
class BeamJobRunResultModel(base_models.BaseModel):
    """Represents the result of an Apache Beam job.

    IMPORTANT: Datastore entities have a size limit of ~1MiB (1,048,572 bytes,
    to be precise). To support jobs that may need to produce outputs that exceed
    this this limit, BeamJobRunResultModels are split into _unordered_ batches.
    Thus, to return the full results you must fetch all models with matching
    job_model_id values and concatenate their results.

    The IDs of BeamJobRunResultModel are inconsequential, and generated by using
    the urlsafe base64 encoding of a uuid() value: ^[A-Za-z0-9-_]{22}$.
    """

    # The ID of the BeamJobRunResultModel corresponding to the result.
    job_id = datastore_services.StringProperty(required=True, indexed=True)
    # The standard text output generated by the corresponding Apache Beam job.
    stdout = datastore_services.TextProperty(required=False, indexed=False)
    # The error output generated by the corresponding Apache Beam job.
    stderr = datastore_services.TextProperty(required=False, indexed=False)

    # We have ignored [override] here because the signature of this method
    # doesn't match with signature of super class's get_new_id() method.
    @classmethod
    def get_new_id(cls) -> str:  # type: ignore[override]
        """Generates an ID for a new BeamJobRunResultModel.

        Returns:
            str. The new ID.
        """
        return _get_new_model_id(cls)

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model doesn't contain any data directly corresponding to a user."""
        return base_models.DELETION_POLICY.NOT_APPLICABLE

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Model doesn't contain user data."""
        return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model doesn't contain any data directly corresponding to a user."""
        return dict(
            super(BeamJobRunResultModel, cls).get_export_policy(), **{
                'job_id': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'stdout': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'stderr': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            })
Exemplo n.º 2
0
class GeneralFeedbackThreadModel(base_models.BaseModel):
    """Threads for each entity.

    The id of instances of this class has the form
        [entity_type].[entity_id].[generated_string]
    """

    # We use the model id as a key in the Takeout dict.
    ID_IS_USED_AS_TAKEOUT_KEY = True

    # The type of entity the thread is linked to.
    entity_type = datastore_services.StringProperty(required=True,
                                                    indexed=True)
    # The ID of the entity the thread is linked to.
    entity_id = datastore_services.StringProperty(required=True, indexed=True)
    # ID of the user who started the thread. This may be None if the feedback
    # was given anonymously by a learner.
    original_author_id = datastore_services.StringProperty(indexed=True)
    # Latest status of the thread.
    status = datastore_services.StringProperty(
        default=STATUS_CHOICES_OPEN,
        choices=STATUS_CHOICES,
        required=True,
        indexed=True,
    )
    # Latest subject of the thread.
    subject = datastore_services.StringProperty(indexed=True, required=True)
    # Summary text of the thread.
    summary = datastore_services.TextProperty(indexed=False)
    # Specifies whether this thread has a related suggestion.
    has_suggestion = datastore_services.BooleanProperty(indexed=True,
                                                        default=False,
                                                        required=True)

    # Cached value of the number of messages in the thread.
    message_count = datastore_services.IntegerProperty(indexed=True, default=0)
    # Cached text of the last message in the thread with non-empty content, or
    # None if there is no such message.
    last_nonempty_message_text = datastore_services.TextProperty(indexed=False)
    # Cached ID for the user of the last message in the thread with non-empty
    # content, or None if the message was made anonymously or if there is no
    # such message.
    last_nonempty_message_author_id = (datastore_services.StringProperty(
        indexed=True))

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model contains data to pseudonymize corresponding to a user:
        original_author_id and last_nonempty_message_author_id fields.
        """
        return base_models.DELETION_POLICY.LOCALLY_PSEUDONYMIZE

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Model is exported as multiple instances per user since there
        are multiple feedback threads relevant to a particular user.
        """
        return base_models.MODEL_ASSOCIATION_TO_USER.MULTIPLE_INSTANCES_PER_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model contains data to export corresponding to a user."""
        return dict(
            super(cls, cls).get_export_policy(),
            **{
                'entity_type': base_models.EXPORT_POLICY.EXPORTED,
                'entity_id': base_models.EXPORT_POLICY.EXPORTED,
                # We do not export the original_author_id because we should not
                # export internal user ids.
                'original_author_id': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'status': base_models.EXPORT_POLICY.EXPORTED,
                'subject': base_models.EXPORT_POLICY.EXPORTED,
                'summary': base_models.EXPORT_POLICY.EXPORTED,
                'has_suggestion': base_models.EXPORT_POLICY.EXPORTED,
                'message_count': base_models.EXPORT_POLICY.EXPORTED,
                'last_nonempty_message_text':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'last_nonempty_message_author_id':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'last_updated': base_models.EXPORT_POLICY.EXPORTED
            })

    @classmethod
    def get_field_names_for_takeout(cls) -> Dict[str, str]:
        """Indicates that the last_updated variable is exported under the
        name "last_updated_msec" in Takeout.
        """
        return dict(
            super(cls, cls).get_field_names_for_takeout(),
            **{'last_updated': 'last_updated_msec'})

    @classmethod
    def has_reference_to_user_id(cls, user_id: str) -> bool:
        """Check whether GeneralFeedbackThreadModel exists for user.

        Args:
            user_id: str. The ID of the user whose data should be checked.

        Returns:
            bool. Whether any models refer to the given user ID.
        """
        return cls.query(
            datastore_services.any_of(
                cls.original_author_id == user_id,
                cls.last_nonempty_message_author_id == user_id)).get(
                    keys_only=True) is not None

    @classmethod
    def export_data(
            cls, user_id: str) -> Dict[str, Dict[str, Union[str, bool, None]]]:
        """Exports the data from GeneralFeedbackThreadModel
        into dict format for Takeout.

        Args:
            user_id: str. The ID of the user whose data should be exported.

        Returns:
            dict. Dictionary of the data from GeneralFeedbackThreadModel.
        """

        user_data = {}
        feedback_models: Sequence[GeneralFeedbackThreadModel] = (
            cls.get_all().filter(cls.original_author_id == user_id).fetch())

        for feedback_model in feedback_models:
            user_data[feedback_model.id] = {
                'entity_type':
                feedback_model.entity_type,
                'entity_id':
                feedback_model.entity_id,
                'status':
                feedback_model.status,
                'subject':
                feedback_model.subject,
                'has_suggestion':
                feedback_model.has_suggestion,
                'summary':
                feedback_model.summary,
                'message_count':
                feedback_model.message_count,
                'last_updated_msec':
                utils.get_time_in_millisecs(feedback_model.last_updated)
            }

        return user_data

    @classmethod
    def generate_new_thread_id(cls, entity_type: str, entity_id: str) -> str:
        """Generates a new thread ID which is unique.

        Args:
            entity_type: str. The type of the entity.
            entity_id: str. The ID of the entity.

        Returns:
            str. A thread ID that is different from the IDs of all
            the existing threads within the given entity.

        Raises:
            Exception. There were too many collisions with existing thread IDs
                when attempting to generate a new thread ID.
        """
        for _ in python_utils.RANGE(_MAX_RETRIES):
            thread_id = (
                '%s.%s.%s%s' %
                (entity_type, entity_id,
                 utils.base64_from_int(
                     int(utils.get_current_time_in_millisecs())),
                 utils.base64_from_int(utils.get_random_int(_RAND_RANGE))))
            if not cls.get_by_id(thread_id):
                return thread_id
        raise Exception(
            'New thread id generator is producing too many collisions.')

    @classmethod
    def create(cls, thread_id: str) -> 'GeneralFeedbackThreadModel':
        """Creates a new FeedbackThreadModel entry.

        Args:
            thread_id: str. Thread ID of the newly-created thread.

        Returns:
            GeneralFeedbackThreadModel. The newly created FeedbackThreadModel
            instance.

        Raises:
            Exception. A thread with the given thread ID exists already.
        """
        if cls.get_by_id(thread_id):
            raise Exception('Feedback thread ID conflict on create.')
        return cls(id=thread_id)

    @classmethod
    def get_threads(
        cls,
        entity_type: str,
        entity_id: str,
        limit: int = feconf.DEFAULT_QUERY_LIMIT
    ) -> Sequence['GeneralFeedbackThreadModel']:
        """Returns a list of threads associated with the entity, ordered
        by their "last updated" field. The number of entities fetched is
        limited by the `limit` argument to this method, whose default
        value is equal to the default query limit.

        Args:
            entity_type: str. The type of the entity.
            entity_id: str. The ID of the entity.
            limit: int. The maximum possible number of items in the returned
                list.

        Returns:
            list(GeneralFeedbackThreadModel). List of threads associated with
            the entity. Doesn't include deleted entries.
        """
        return cls.get_all().filter(cls.entity_type == entity_type).filter(
            cls.entity_id == entity_id).order(-cls.last_updated).fetch(limit)
Exemplo n.º 3
0
class GeneralFeedbackMessageModel(base_models.BaseModel):
    """Feedback messages. One or more of these messages make a thread.

    The id of instances of this class has the form [thread_id].[message_id]
    """

    # We use the model id as a key in the Takeout dict.
    ID_IS_USED_AS_TAKEOUT_KEY = True

    # ID corresponding to an entry of FeedbackThreadModel.
    thread_id = datastore_services.StringProperty(required=True, indexed=True)
    # 0-based sequential numerical ID. Sorting by this field will create the
    # thread in chronological order.
    message_id = datastore_services.IntegerProperty(required=True,
                                                    indexed=True)
    # ID of the user who posted this message. This may be None if the feedback
    # was given anonymously by a learner.
    author_id = datastore_services.StringProperty(indexed=True)
    # New thread status. Must exist in the first message of a thread. For the
    # rest of the thread, should exist only when the status changes.
    updated_status = (datastore_services.StringProperty(choices=STATUS_CHOICES,
                                                        indexed=True))
    # New thread subject. Must exist in the first message of a thread. For the
    # rest of the thread, should exist only when the subject changes.
    updated_subject = datastore_services.StringProperty(indexed=True)
    # Message text. Allowed not to exist (e.g. post only to update the status).
    text = datastore_services.TextProperty(indexed=False)
    # Whether the incoming message is received by email (as opposed to via
    # the web).
    received_via_email = datastore_services.BooleanProperty(default=False,
                                                            indexed=True,
                                                            required=True)

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model contains data to pseudonymize corresponding to a user:
        author_id field.
        """
        return base_models.DELETION_POLICY.LOCALLY_PSEUDONYMIZE

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Model is exported as multiple instances per user since there are
        multiple feedback messages relevant to a user.
        """
        return base_models.MODEL_ASSOCIATION_TO_USER.MULTIPLE_INSTANCES_PER_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model contains data to export corresponding to a user."""
        return dict(
            super(cls, cls).get_export_policy(),
            **{
                'thread_id': base_models.EXPORT_POLICY.EXPORTED,
                'message_id': base_models.EXPORT_POLICY.EXPORTED,
                # We do not export the author_id because we should not export
                # internal user ids.
                'author_id': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'updated_status': base_models.EXPORT_POLICY.EXPORTED,
                'updated_subject': base_models.EXPORT_POLICY.EXPORTED,
                'text': base_models.EXPORT_POLICY.EXPORTED,
                'received_via_email': base_models.EXPORT_POLICY.EXPORTED
            })

    @classmethod
    def has_reference_to_user_id(cls, user_id: str) -> bool:
        """Check whether GeneralFeedbackMessageModel exists for user.

        Args:
            user_id: str. The ID of the user whose data should be checked.

        Returns:
            bool. Whether any models refer to the given user ID.
        """
        return cls.query(cls.author_id == user_id).get(
            keys_only=True) is not None

    @classmethod
    def export_data(
            cls,
            user_id: str) -> Dict[str, Dict[str, Union[str, int, bool, None]]]:
        """Exports the data from GeneralFeedbackMessageModel
        into dict format for Takeout.

        Args:
            user_id: str. The ID of the user whose data should be exported.

        Returns:
            dict. Dictionary of the data from GeneralFeedbackMessageModel.
        """

        user_data = {}
        feedback_models: Sequence[GeneralFeedbackMessageModel] = (
            cls.get_all().filter(cls.author_id == user_id).fetch())

        for feedback_model in feedback_models:
            user_data[feedback_model.id] = {
                'thread_id': feedback_model.thread_id,
                'message_id': feedback_model.message_id,
                'updated_status': feedback_model.updated_status,
                'updated_subject': feedback_model.updated_subject,
                'text': feedback_model.text,
                'received_via_email': feedback_model.received_via_email
            }

        return user_data

    @classmethod
    def _generate_id(cls, thread_id: str, message_id: int) -> str:
        """Generates full message ID given the thread ID and message ID.

        Args:
            thread_id: str. Thread ID of the thread to which the message
                belongs.
            message_id: int. Message ID of the message.

        Returns:
            str. Full message ID.
        """
        return '.'.join([thread_id, python_utils.UNICODE(message_id)])

    @property
    def entity_id(self) -> str:
        """Returns the entity_id corresponding to this thread instance.

        Returns:
            str. The entity_id.
        """
        return self.id.split('.')[1]

    @property
    def entity_type(self) -> str:
        """Returns the entity_type corresponding to this thread instance.

        Returns:
            str. The entity_type.
        """
        return self.id.split('.')[0]

    @classmethod
    def create(
        cls,
        message_identifier: feedback_domain.FullyQualifiedMessageIdentifier
    ) -> 'GeneralFeedbackMessageModel':
        """Creates a new GeneralFeedbackMessageModel entry.

        Args:
            message_identifier: FullyQualifiedMessageIdentifier. The message
                identifier consists of the thread_id and its corresponding
                message_id.

        Returns:
            GeneralFeedbackMessageModel. Instance of the new
            GeneralFeedbackMessageModel entry.

        Raises:
            Exception. A message with the same ID already exists
                in the given thread.
        """

        return cls.create_multi([message_identifier])[0]

    @classmethod
    def create_multi(
        cls, message_identifiers: List[
            feedback_domain.FullyQualifiedMessageIdentifier]
    ) -> List['GeneralFeedbackMessageModel']:
        """Creates a new GeneralFeedbackMessageModel entry for each
        (thread_id, message_id) pair.

        Args:
            message_identifiers: list(FullyQualifiedMessageIdentifier). Each
                message identifier consists of the thread_id and its
                corresponding message_id.

        Returns:
            list(GeneralFeedbackMessageModel). Instances of the new
            GeneralFeedbackMessageModel entries.

        Raises:
            Exception. The number of thread_ids must be equal to the number of
                message_ids.
            Exception. A message with the same ID already exists
                in the given thread.
        """
        thread_ids = [
            message_identifier.thread_id
            for message_identifier in message_identifiers
        ]
        message_ids = [
            message_identifier.message_id
            for message_identifier in message_identifiers
        ]

        # Generate the new ids.
        instance_ids = [
            cls._generate_id(thread_id, message_id)
            for thread_id, message_id in python_utils.ZIP(
                thread_ids, message_ids)
        ]

        # Check if the new ids are valid.
        current_instances = cls.get_multi(instance_ids)
        conflict_ids = [
            current_instance.id for current_instance in current_instances
            if current_instance is not None
        ]
        if len(conflict_ids) > 0:
            raise Exception(
                'The following feedback message ID(s) conflicted on '
                'create: %s' % (' '.join(conflict_ids)))

        return [cls(id=instance_id) for instance_id in instance_ids]

    # We have ignored [override] here because the signature of this method
    # doesn't match with BaseModel.get().
    @classmethod
    def get(  # type: ignore[override]
            cls,
            thread_id: str,
            message_id: int,
            strict: bool = True) -> Optional['GeneralFeedbackMessageModel']:
        """Gets the GeneralFeedbackMessageModel entry for the given ID. Raises
        an error if no undeleted message with the given ID is found and
        strict == True.

        Args:
            thread_id: str. ID of the thread.
            message_id: int. ID of the message.
            strict: bool. Whether to raise an error if no FeedbackMessageModel
                entry is found for the given IDs.

        Returns:
            GeneralFeedbackMessageModel or None. If strict == False and no
            undeleted message with the given message_id exists in the
            datastore, then returns None. Otherwise, returns the
            GeneralFeedbackMessageModel instance that corresponds to the
            given ID.

        Raises:
            EntityNotFoundError. The value of strict is True and either
                (i) message ID is not valid
                (ii) message is marked as deleted.
                No error will be raised if strict == False.
        """
        instance_id = cls._generate_id(thread_id, message_id)
        return super(GeneralFeedbackMessageModel, cls).get(instance_id,
                                                           strict=strict)

    @classmethod
    def get_messages(
            cls, thread_id: str) -> Sequence['GeneralFeedbackMessageModel']:
        """Returns a list of messages in the given thread. The number of
        messages returned is capped by feconf.DEFAULT_QUERY_LIMIT.

        Args:
            thread_id: str. ID of the thread.

        Returns:
            list(GeneralFeedbackMessageModel). A list of messages in the
            given thread, up to a maximum of feconf.DEFAULT_QUERY_LIMIT
            messages.
        """
        return cls.get_all().filter(cls.thread_id == thread_id).fetch(
            feconf.DEFAULT_QUERY_LIMIT)

    @classmethod
    def get_most_recent_message(
            cls, thread_id: str) -> 'GeneralFeedbackMessageModel':
        """Returns the last message in the thread.

        Args:
            thread_id: str. ID of the thread.

        Returns:
            GeneralFeedbackMessageModel. Last message in the thread.
        """
        thread = GeneralFeedbackThreadModel.get_by_id(thread_id)
        message = cls.get(thread_id, thread.message_count - 1)
        # Ruling out the possibility of None for mypy type checking.
        assert message is not None
        return message

    @classmethod
    def get_message_count(cls, thread_id: str) -> int:
        """Returns the number of messages in the thread. Includes the
        deleted entries.

        Args:
            thread_id: str. ID of the thread.

        Returns:
            int. Number of messages in the thread.
        """
        return cls.get_message_counts([thread_id])[0]

    @classmethod
    def get_message_counts(cls, thread_ids: List[str]) -> List[int]:
        """Returns a list containing the number of messages in the threads.
        Includes the deleted entries.

        Args:
            thread_ids: list(str). ID of the threads.

        Returns:
            list(int). List of the message counts for the threads.
        """
        thread_models = GeneralFeedbackThreadModel.get_multi(thread_ids)
        assert None not in thread_models
        return [
            thread_model.message_count if thread_model else None
            for thread_model in thread_models
        ]

    # TODO(#13523): Change the return value of the function below from
    # tuple(list, str|None, bool) to a domain object.
    @classmethod
    def get_all_messages(
        cls, page_size: int, urlsafe_start_cursor: Optional[str]
    ) -> Tuple[Sequence['GeneralFeedbackMessageModel'], Optional[str], bool]:
        """Fetches a list of all the messages sorted by their last updated
        attribute.

        Args:
            page_size: int. The maximum number of messages to be returned.
            urlsafe_start_cursor: str or None. If provided, the list of
                returned messages starts from this datastore cursor.
                Otherwise, the returned messages start from the beginning
                of the full list of messages.

        Returns:
            3-tuple of (results, cursor, more). Where:
                results: List of query results.
                cursor: str or None. A query cursor pointing to the next
                    batch of results. If there are no more results, this might
                    be None.
                more: bool. If True, there are (probably) more results after
                    this batch. If False, there are no further results after
                    this batch.
        """
        return cls._fetch_page_sorted_by_last_updated(cls.query(), page_size,
                                                      urlsafe_start_cursor)
Exemplo n.º 4
0
class JobModel(base_models.BaseModel):
    """Class representing a datastore entity for a long-running job."""

    # The job type.
    job_type = datastore_services.StringProperty(indexed=True)
    # The time at which the job was queued, in milliseconds since the epoch.
    time_queued_msec = datastore_services.FloatProperty(indexed=True)
    # The time at which the job was started, in milliseconds since the epoch.
    # This is never set if the job was canceled before it was started.
    time_started_msec = datastore_services.FloatProperty(indexed=True)
    # The time at which the job was completed, failed or canceled, in
    # milliseconds since the epoch.
    time_finished_msec = datastore_services.FloatProperty(indexed=True)
    # The current status code for the job.
    status_code = datastore_services.StringProperty(
        indexed=True,
        default=STATUS_CODE_NEW,
        choices=[
            STATUS_CODE_NEW, STATUS_CODE_QUEUED, STATUS_CODE_STARTED,
            STATUS_CODE_COMPLETED, STATUS_CODE_FAILED, STATUS_CODE_CANCELED
        ])
    # Any metadata for the job, such as the root pipeline id for mapreduce
    # jobs.
    metadata = datastore_services.JsonProperty(indexed=False)
    # The output of the job. This is only populated if the job has status code
    # STATUS_CODE_COMPLETED, and is None otherwise. If populated, this is
    # expected to be a list of strings.
    output = datastore_services.JsonProperty(indexed=False)
    # The error message, if applicable. Only populated if the job has status
    # code STATUS_CODE_FAILED or STATUS_CODE_CANCELED; None otherwise.
    error = datastore_services.TextProperty(indexed=False)
    # Whether the datastore models associated with this job have been cleaned
    # up (i.e., deleted).
    has_been_cleaned_up = (datastore_services.BooleanProperty(default=False,
                                                              indexed=True))
    # Store additional params passed with job.
    additional_job_params = datastore_services.JsonProperty(default=None)

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model doesn't contain any data directly corresponding to a user."""
        return base_models.DELETION_POLICY.NOT_APPLICABLE

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Model does not contain user data."""
        return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model doesn't contain any data directly corresponding to a user."""
        return dict(
            super(cls, cls).get_export_policy(), **{
                'job_type': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'time_queued_msec': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'time_started_msec': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'time_finished_msec': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'status_code': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'metadata': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'output': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'error': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'has_been_cleaned_up':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'additional_job_params':
                base_models.EXPORT_POLICY.NOT_APPLICABLE
            })

    @property
    def is_cancelable(self) -> bool:
        """Checks if the job is cancelable.

        Returns:
            bool. Whether the job's status_code is 'queued' or 'started'.
        """
        # Whether the job is currently in 'queued' or 'started' status.
        return self.status_code in [STATUS_CODE_QUEUED, STATUS_CODE_STARTED]

    @classmethod
    def get_all_unfinished_jobs(cls, limit: int) -> Sequence['JobModel']:
        """Gets at most `limit` unfinished jobs.

        Args:
            limit: int. A limit on the number of jobs to return.

        Returns:
            list(JobModel) or None. A list of at most `limit` number
            of unfinished jobs.
        """
        return cls.query().filter(
            JobModel.status_code.IN([
                STATUS_CODE_QUEUED, STATUS_CODE_STARTED
            ])).order(-cls.time_queued_msec).fetch(limit)

    @classmethod
    def get_unfinished_jobs(cls, job_type: str) -> datastore_services.Query:
        """Gets jobs that are unfinished.

        Args:
            job_type: str. The type of jobs that may be unfinished.

        Returns:
            list(JobModel) or None. A list of all jobs that belong
            to the given job_type.
        """
        return cls.query().filter(cls.job_type == job_type).filter(
            JobModel.status_code.IN([STATUS_CODE_QUEUED, STATUS_CODE_STARTED]))

    @classmethod
    def do_unfinished_jobs_exist(cls, job_type: str) -> bool:
        """Checks if unfinished jobs exist.

        Args:
            job_type: str. Type of job for which to check.

        Returns:
            bool. True if unfinished jobs exist, otherwise false.
        """
        return bool(cls.get_unfinished_jobs(job_type).count(limit=1))
Exemplo n.º 5
0
class SentEmailModel(base_models.BaseModel):
    """Records the content and metadata of an email sent from Oppia.

    This model is read-only; entries cannot be modified once created. The
    id/key of instances of this class has the form '[intent].[random hash]'.
    """

    # TODO(sll): Implement functionality to get all emails sent to a particular
    # user with a given intent within a given time period.

    # The user ID of the email recipient.
    recipient_id = (datastore_services.StringProperty(required=True,
                                                      indexed=True))
    # The email address of the recipient.
    recipient_email = datastore_services.StringProperty(required=True)
    # The user ID of the email sender. For site-generated emails this is equal
    # to SYSTEM_COMMITTER_ID.
    sender_id = datastore_services.StringProperty(required=True, indexed=True)
    # The email address used to send the notification. This should be either
    # the noreply address or the system address.
    sender_email = datastore_services.StringProperty(required=True)
    # The intent of the email.
    intent = datastore_services.StringProperty(
        required=True,
        indexed=True,
        choices=[
            feconf.EMAIL_INTENT_SIGNUP, feconf.EMAIL_INTENT_MARKETING,
            feconf.EMAIL_INTENT_DAILY_BATCH,
            feconf.EMAIL_INTENT_EDITOR_ROLE_NOTIFICATION,
            feconf.EMAIL_INTENT_FEEDBACK_MESSAGE_NOTIFICATION,
            feconf.EMAIL_INTENT_SUBSCRIPTION_NOTIFICATION,
            feconf.EMAIL_INTENT_SUGGESTION_NOTIFICATION,
            feconf.EMAIL_INTENT_UNPUBLISH_EXPLORATION,
            feconf.EMAIL_INTENT_DELETE_EXPLORATION,
            feconf.EMAIL_INTENT_REPORT_BAD_CONTENT,
            feconf.EMAIL_INTENT_QUERY_STATUS_NOTIFICATION,
            feconf.EMAIL_INTENT_ONBOARD_REVIEWER,
            feconf.EMAIL_INTENT_REMOVE_REVIEWER,
            feconf.EMAIL_INTENT_ADDRESS_CONTRIBUTOR_DASHBOARD_SUGGESTIONS,
            feconf.EMAIL_INTENT_REVIEW_CREATOR_DASHBOARD_SUGGESTIONS,
            feconf.EMAIL_INTENT_REVIEW_CONTRIBUTOR_DASHBOARD_SUGGESTIONS,
            feconf.EMAIL_INTENT_ADD_CONTRIBUTOR_DASHBOARD_REVIEWERS,
            feconf.EMAIL_INTENT_VOICEOVER_APPLICATION_UPDATES,
            feconf.EMAIL_INTENT_ACCOUNT_DELETED, feconf.BULK_EMAIL_INTENT_TEST
        ])
    # The subject line of the email.
    subject = datastore_services.TextProperty(required=True)
    # The HTML content of the email body.
    html_body = datastore_services.TextProperty(required=True)
    # The datetime the email was sent, in UTC.
    sent_datetime = (datastore_services.DateTimeProperty(required=True,
                                                         indexed=True))
    # The hash of the recipient id, email subject and message body.
    email_hash = datastore_services.StringProperty(indexed=True)

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model contains data corresponding to a user: recipient_id,
        recipient_email, sender_id, and sender_email, but this isn't deleted
        because this model is needed for auditing purposes.
        """
        return base_models.DELETION_POLICY.KEEP

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Users already have access to this data since emails were sent
        to them.
        """
        return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model contains data corresponding to a user, but this isn't exported
        because users already have access to noteworthy details of this data
        (since emails were sent to them).
        """
        return dict(
            super(cls, cls).get_export_policy(), **{
                'recipient_id': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'recipient_email': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'sender_id': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'sender_email': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'intent': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'subject': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'html_body': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'sent_datetime': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'email_hash': base_models.EXPORT_POLICY.NOT_APPLICABLE
            })

    @classmethod
    def has_reference_to_user_id(cls, user_id: str) -> bool:
        """Check whether SentEmailModel exists for user.

        Args:
            user_id: str. The ID of the user whose data should be checked.

        Returns:
            bool. Whether any models refer to the given user ID.
        """
        return cls.query(
            datastore_services.any_of(
                cls.recipient_id == user_id,
                cls.sender_id == user_id,
            )).get(keys_only=True) is not None

    @classmethod
    def _generate_id(cls, intent: str) -> str:
        """Generates an ID for a new SentEmailModel instance.

        Args:
            intent: str. The intent string, i.e. the purpose of the email.
                Valid intent strings are defined in feconf.py.

        Returns:
            str. The newly-generated ID for the SentEmailModel instance.

        Raises:
            Exception. The id generator for SentEmailModel is producing
                too many collisions.
        """
        id_prefix = '%s.' % intent

        for _ in python_utils.RANGE(base_models.MAX_RETRIES):
            new_id = '%s.%s' % (id_prefix,
                                utils.convert_to_hash(
                                    python_utils.UNICODE(
                                        utils.get_random_int(
                                            base_models.RAND_RANGE)),
                                    base_models.ID_LENGTH))
            if not cls.get_by_id(new_id):
                return new_id

        raise Exception(
            'The id generator for SentEmailModel is producing too many '
            'collisions.')

    @classmethod
    def create(cls, recipient_id: str, recipient_email: str, sender_id: str,
               sender_email: str, intent: str, subject: str, html_body: str,
               sent_datetime: datetime.datetime) -> None:
        """Creates a new SentEmailModel entry.

        Args:
            recipient_id: str. The user ID of the email recipient.
            recipient_email: str. The email address of the recipient.
            sender_id: str. The user ID of the email sender.
            sender_email: str. The email address used to send the notification.
            intent: str. The intent string, i.e. the purpose of the email.
            subject: str. The subject line of the email.
            html_body: str. The HTML content of the email body.
            sent_datetime: datetime.datetime. The datetime the email was sent,
                in UTC.
        """
        instance_id = cls._generate_id(intent)
        email_model_instance = cls(id=instance_id,
                                   recipient_id=recipient_id,
                                   recipient_email=recipient_email,
                                   sender_id=sender_id,
                                   sender_email=sender_email,
                                   intent=intent,
                                   subject=subject,
                                   html_body=html_body,
                                   sent_datetime=sent_datetime)

        email_model_instance.update_timestamps()
        email_model_instance.put()

    def _pre_put_hook(self) -> None:
        """Operations to perform just before the model is `put` into storage."""
        super(SentEmailModel, self)._pre_put_hook()
        self.email_hash = self._generate_hash(self.recipient_id, self.subject,
                                              self.html_body)

    @classmethod
    def get_by_hash(
        cls,
        email_hash: str,
        sent_datetime_lower_bound: Optional[datetime.datetime] = None
    ) -> List['SentEmailModel']:
        """Returns all messages with a given email_hash.

        This also takes an optional sent_datetime_lower_bound argument,
        which is a datetime instance. If this is given, only
        SentEmailModel instances sent after sent_datetime_lower_bound
        should be returned.

        Args:
            email_hash: str. The hash value of the email.
            sent_datetime_lower_bound: datetime.datetime. The lower bound on
                sent_datetime of the email to be searched.

        Returns:
            list(SentEmailModel). A list of emails which have the given hash
            value and sent more recently than sent_datetime_lower_bound.

        Raises:
            Exception. The sent_datetime_lower_bound is not a valid
                datetime.datetime.
        """

        if sent_datetime_lower_bound is not None:
            if not isinstance(sent_datetime_lower_bound, datetime.datetime):
                raise Exception('Expected datetime, received %s of type %s' %
                                (sent_datetime_lower_bound,
                                 type(sent_datetime_lower_bound)))

        query = cls.query().filter(cls.email_hash == email_hash)

        if sent_datetime_lower_bound is not None:
            query = query.filter(cls.sent_datetime > sent_datetime_lower_bound)

        messages = cast(List[SentEmailModel], query.fetch())

        return messages

    @classmethod
    def _generate_hash(cls, recipient_id: str, email_subject: str,
                       email_body: str) -> str:
        """Generate hash for a given recipient_id, email_subject and cleaned
        email_body.

        Args:
            recipient_id: str. The user ID of the email recipient.
            email_subject: str. The subject line of the email.
            email_body: str. The HTML content of the email body.

        Returns:
            str. The generated hash value of the given email.
        """
        hash_value = utils.convert_to_hash(
            recipient_id + email_subject + email_body, 100)

        return hash_value

    @classmethod
    def check_duplicate_message(cls, recipient_id: str, email_subject: str,
                                email_body: str) -> bool:
        """Check for a given recipient_id, email_subject and cleaned
        email_body, whether a similar message has been sent in the last
        DUPLICATE_EMAIL_INTERVAL_MINS.

        Args:
            recipient_id: str. The user ID of the email recipient.
            email_subject: str. The subject line of the email.
            email_body: str. The HTML content of the email body.

        Returns:
            bool. Whether a similar message has been sent to the same recipient
            in the last DUPLICATE_EMAIL_INTERVAL_MINS.
        """

        email_hash = cls._generate_hash(recipient_id, email_subject,
                                        email_body)

        datetime_now = datetime.datetime.utcnow()
        time_interval = datetime.timedelta(
            minutes=feconf.DUPLICATE_EMAIL_INTERVAL_MINS)

        sent_datetime_lower_bound = datetime_now - time_interval

        messages = cls.get_by_hash(
            email_hash, sent_datetime_lower_bound=sent_datetime_lower_bound)

        for message in messages:
            if (message.recipient_id == recipient_id
                    and message.subject == email_subject
                    and message.html_body == email_body):
                return True

        return False
Exemplo n.º 6
0
class BulkEmailModel(base_models.BaseModel):
    """Records the content of an email sent from Oppia to multiple users.

    This model is read-only; entries cannot be modified once created. The
    id/key of instances of this model is randomly generated string of
    length 12.
    """

    # The user IDs of the email recipients.
    recipient_ids = datastore_services.JsonProperty(default=[],
                                                    compressed=True)
    # The user ID of the email sender. For site-generated emails this is equal
    # to SYSTEM_COMMITTER_ID.
    sender_id = datastore_services.StringProperty(required=True, indexed=True)
    # The email address used to send the notification.
    sender_email = datastore_services.StringProperty(required=True)
    # The intent of the email.
    intent = datastore_services.StringProperty(
        required=True,
        indexed=True,
        choices=[
            feconf.BULK_EMAIL_INTENT_MARKETING,
            feconf.BULK_EMAIL_INTENT_IMPROVE_EXPLORATION,
            feconf.BULK_EMAIL_INTENT_CREATE_EXPLORATION,
            feconf.BULK_EMAIL_INTENT_CREATOR_REENGAGEMENT,
            feconf.BULK_EMAIL_INTENT_LEARNER_REENGAGEMENT,
            feconf.BULK_EMAIL_INTENT_ML_JOB_FAILURE
        ])
    # The subject line of the email.
    subject = datastore_services.TextProperty(required=True)
    # The HTML content of the email body.
    html_body = datastore_services.TextProperty(required=True)
    # The datetime the email was sent, in UTC.
    sent_datetime = (datastore_services.DateTimeProperty(required=True,
                                                         indexed=True))

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model contains data corresponding to a user: recipient_ids,
        sender_id, and sender_email, but this isn't deleted because this model
        is needed for auditing purposes.
        """
        return base_models.DELETION_POLICY.KEEP

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Users already have access to this data since the emails were sent
        to them.
        """
        return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model contains data corresponding to a user, but this isn't exported
        because users already have access to noteworthy details of this data
        (since emails were sent to them).
        """
        return dict(
            super(cls, cls).get_export_policy(), **{
                'recipient_ids': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'sender_id': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'sender_email': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'intent': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'subject': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'html_body': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'sent_datetime': base_models.EXPORT_POLICY.NOT_APPLICABLE
            })

    @classmethod
    def has_reference_to_user_id(cls, user_id: str) -> bool:
        """Check whether BulkEmailModel exists for user. Since recipient_ids
        can't be indexed it also can't be checked by this method, we can allow
        this because the deletion policy for this model is keep , thus even the
        deleted user's id will remain here.

        Args:
            user_id: str. The ID of the user whose data should be checked.

        Returns:
            bool. Whether any models refer to the given user ID.
        """
        return (cls.query(cls.sender_id == user_id).get(keys_only=True)
                is not None)

    @classmethod
    def create(cls, instance_id: str, recipient_ids: List[str], sender_id: str,
               sender_email: str, intent: str, subject: str, html_body: str,
               sent_datetime: datetime.datetime) -> None:
        """Creates a new BulkEmailModel entry.

        Args:
            instance_id: str. The ID of the instance.
            recipient_ids: list(str). The user IDs of the email recipients.
            sender_id: str. The user ID of the email sender.
            sender_email: str. The email address used to send the notification.
            intent: str. The intent string, i.e. the purpose of the email.
            subject: str. The subject line of the email.
            html_body: str. The HTML content of the email body.
            sent_datetime: datetime.datetime. The date and time the email
                was sent, in UTC.
        """
        email_model_instance = cls(id=instance_id,
                                   recipient_ids=recipient_ids,
                                   sender_id=sender_id,
                                   sender_email=sender_email,
                                   intent=intent,
                                   subject=subject,
                                   html_body=html_body,
                                   sent_datetime=sent_datetime)
        email_model_instance.update_timestamps()
        email_model_instance.put()
Exemplo n.º 7
0
class CollectionSummaryModel(base_models.BaseModel):
    """Summary model for an Oppia collection.

    This should be used whenever the content blob of the collection is not
    needed (e.g. search results, etc).

    A CollectionSummaryModel instance stores the following information:

        id, title, category, objective, language_code, tags, ratings,
        last_updated, created_on, status (private, public),
        community_owned, owner_ids, editor_ids,
        viewer_ids, version.

    The key of each instance is the collection id.
    """

    # What this collection is called.
    title = datastore_services.StringProperty(required=True)
    # The category this collection belongs to.
    category = datastore_services.StringProperty(required=True, indexed=True)
    # The objective of this collection.
    objective = datastore_services.TextProperty(required=True, indexed=False)
    # The ISO 639-1 code for the language this collection is written in.
    language_code = (
        datastore_services.StringProperty(required=True, indexed=True))
    # Tags associated with this collection.
    tags = datastore_services.StringProperty(repeated=True, indexed=True)

    # Aggregate user-assigned ratings of the collection.
    ratings = datastore_services.JsonProperty(default=None, indexed=False)

    # Time when the collection model was last updated (not to be
    # confused with last_updated, which is the time when the
    # collection *summary* model was last updated).
    collection_model_last_updated = (
        datastore_services.DateTimeProperty(indexed=True))
    # Time when the collection model was created (not to be confused
    # with created_on, which is the time when the collection *summary*
    # model was created).
    collection_model_created_on = (
        datastore_services.DateTimeProperty(indexed=True))

    # The publication status of this collection.
    status = datastore_services.StringProperty(
        default=constants.ACTIVITY_STATUS_PRIVATE, indexed=True,
        choices=[
            constants.ACTIVITY_STATUS_PRIVATE,
            constants.ACTIVITY_STATUS_PUBLIC
        ]
    )

    # Whether this collection is owned by the community.
    community_owned = (
        datastore_services.BooleanProperty(required=True, indexed=True))

    # The user_ids of owners of this collection.
    owner_ids = datastore_services.StringProperty(indexed=True, repeated=True)
    # The user_ids of users who are allowed to edit this collection.
    editor_ids = datastore_services.StringProperty(indexed=True, repeated=True)
    # The user_ids of users who are allowed to view this collection.
    viewer_ids = datastore_services.StringProperty(indexed=True, repeated=True)
    # The user_ids of users who have contributed (humans who have made a
    # positive (not just a revert) change to the collection's content).
    # NOTE TO DEVELOPERS: contributor_ids and contributors_summary need to be
    # synchronized, meaning that the keys in contributors_summary need be
    # equal to the contributor_ids list.
    contributor_ids = (
        datastore_services.StringProperty(indexed=True, repeated=True))
    # A dict representing the contributors of non-trivial commits to this
    # collection. Each key of this dict is a user_id, and the corresponding
    # value is the number of non-trivial commits that the user has made.
    contributors_summary = (
        datastore_services.JsonProperty(default={}, indexed=False))
    # The version number of the collection after this commit. Only populated
    # for commits to an collection (as opposed to its rights, etc.).
    version = datastore_services.IntegerProperty()
    # The number of nodes(explorations) that are within this collection.
    node_count = datastore_services.IntegerProperty()

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model contains data to pseudonymize or delete corresponding
        to a user: viewer_ids, editor_ids, owner_ids, contributor_ids,
        and contributors_summary fields.
        """
        return (
            base_models.DELETION_POLICY.PSEUDONYMIZE_IF_PUBLIC_DELETE_IF_PRIVATE
        )

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Model data has already been exported as a part of the
        CollectionRightsModel, and thus does not need an export_data
        function.
        """
        return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model contains data corresponding to a user, but this isn't exported
        because noteworthy details that belong to this model have already been
        exported as a part of the CollectionRightsModel.
        """
        return dict(super(cls, cls).get_export_policy(), **{
            'title': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'category': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'objective': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'language_code': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'tags': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'ratings': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'collection_model_last_updated':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'collection_model_created_on':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'status': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'community_owned': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'owner_ids': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'editor_ids': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'viewer_ids': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'contributor_ids': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'contributors_summary': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'version': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'node_count': base_models.EXPORT_POLICY.NOT_APPLICABLE
        })

    @classmethod
    def has_reference_to_user_id(cls, user_id: str) -> bool:
        """Check whether CollectionSummaryModel references user.

        Args:
            user_id: str. The ID of the user whose data should be checked.

        Returns:
            bool. Whether any models refer to the given user ID.
        """
        return cls.query(datastore_services.any_of(
            cls.owner_ids == user_id,
            cls.editor_ids == user_id,
            cls.viewer_ids == user_id,
            cls.contributor_ids == user_id)).get(keys_only=True) is not None

    @classmethod
    def get_non_private(cls) -> Sequence[CollectionSummaryModel]:
        """Returns an iterable with non-private collection summary models.

        Returns:
            iterable. An iterable with non-private collection summary models.
        """
        return cls.get_all().filter(
            cls.status != constants.ACTIVITY_STATUS_PRIVATE
        ).fetch(feconf.DEFAULT_QUERY_LIMIT)

    @classmethod
    def get_private_at_least_viewable(
        cls, user_id: str
    ) -> Sequence[CollectionSummaryModel]:
        """Returns an iterable with private collection summary models that are
        at least viewable by the given user.

        Args:
            user_id: str. The id of the given user.

        Returns:
            iterable. An iterable with private collection summary models that
            are at least viewable by the given user.
        """
        return cls.get_all().filter(
            cls.status == constants.ACTIVITY_STATUS_PRIVATE
        ).filter(
            datastore_services.any_of(
                cls.owner_ids == user_id,
                cls.editor_ids == user_id,
                cls.viewer_ids == user_id
            )
        ).fetch(feconf.DEFAULT_QUERY_LIMIT)

    @classmethod
    def get_at_least_editable(
        cls, user_id: str
    ) -> Sequence[CollectionSummaryModel]:
        """Returns an iterable with collection summary models that are at least
        editable by the given user.

        Args:
            user_id: str. The id of the given user.

        Returns:
            iterable. An iterable with collection summary models that are at
            least viewable by the given user.
        """
        return CollectionSummaryModel.get_all().filter(
            datastore_services.any_of(
                CollectionSummaryModel.owner_ids == user_id,
                CollectionSummaryModel.editor_ids == user_id
            )
        ).fetch(feconf.DEFAULT_QUERY_LIMIT)
Exemplo n.º 8
0
class BlogPostModel(base_models.BaseModel):
    """Model to store blog post data.

    The id of instances of this class is in the form of random hash of
    12 chars.
    """

    # We use the model id as a key in the Takeout dict.
    ID_IS_USED_AS_TAKEOUT_KEY: Literal[True] = True

    # The ID of the user the blog post is authored by.
    author_id = datastore_services.StringProperty(indexed=True, required=True)
    # Title of the blog post.
    title = datastore_services.StringProperty(indexed=True, required=True)
    # Content of the blog post.
    content = datastore_services.TextProperty(indexed=False, required=True)
    # The unique url fragment of the blog post. If the user directly enters the
    # blog post's url in the editor or the homepage, the blogPostModel will be
    # queried using the url fragment to retrieve data for populating the editor
    # dashboard / blog post page.
    url_fragment = (datastore_services.StringProperty(indexed=True,
                                                      required=True))
    # Tags associated with the blog post.
    tags = datastore_services.StringProperty(indexed=True, repeated=True)
    # The thumbnail filename of the blog post. It's value will be None until
    # a thumbnail is added to the blog post. It can be None only when blog
    # post is a draft.
    thumbnail_filename = datastore_services.StringProperty(indexed=True)
    # Time when the blog post model was last published. Value will be None
    # if the blog has never been published.
    published_on = (datastore_services.DateTimeProperty(indexed=True))

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model contains data to pseudonymize corresponding to a user:
        author_id field.
        """
        return base_models.DELETION_POLICY.LOCALLY_PSEUDONYMIZE

    @classmethod
    def has_reference_to_user_id(cls, user_id: str) -> bool:
        """Check whether BlogPostModel references user.

        Args:
            user_id: str. The ID of the user whose data should be checked.

        Returns:
            bool. Whether any models refer to the given user ID.
        """
        return cls.query(cls.author_id == user_id).get(
            keys_only=True) is not None

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Model is exported as multiple instances per user since there can
        be multiple blog post models relevant to a user.
        """
        return base_models.MODEL_ASSOCIATION_TO_USER.MULTIPLE_INSTANCES_PER_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model contains data corresponding to a user to export."""
        return dict(
            super(BlogPostModel, cls).get_export_policy(),
            **{
                # We do not export the author_id because we should not
                # export internal user ids.
                'author_id': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'title': base_models.EXPORT_POLICY.EXPORTED,
                'content': base_models.EXPORT_POLICY.EXPORTED,
                'url_fragment': base_models.EXPORT_POLICY.EXPORTED,
                'tags': base_models.EXPORT_POLICY.EXPORTED,
                'thumbnail_filename': base_models.EXPORT_POLICY.EXPORTED,
                'published_on': base_models.EXPORT_POLICY.EXPORTED,
            })

    @classmethod
    def generate_new_blog_post_id(cls) -> str:
        """Generates a new blog post ID which is unique and is in the form of
        random hash of 12 chars.

        Returns:
            str. A blog post ID that is different from the IDs of all
            the existing blog posts.

        Raises:
            Exception. There were too many collisions with existing blog post
                IDs when attempting to generate a new blog post ID.
        """
        for _ in range(base_models.MAX_RETRIES):
            blog_post_id = utils.convert_to_hash(
                str(utils.get_random_int(base_models.RAND_RANGE)),
                base_models.ID_LENGTH)
            if not cls.get_by_id(blog_post_id):
                return blog_post_id
        raise Exception(
            'New blog post id generator is producing too many collisions.')

    @classmethod
    def create(cls, blog_post_id: str, author_id: str) -> BlogPostModel:
        """Creates a new BlogPostModel entry.

        Args:
            blog_post_id: str. Blog Post ID of the newly-created blog post.
            author_id: str. User ID of the author.

        Returns:
            BlogPostModel. The newly created BlogPostModel instance.

        Raises:
            Exception. A blog post with the given blog post ID exists already.
        """
        if cls.get_by_id(blog_post_id):
            raise Exception(
                'A blog post with the given blog post ID exists already.')

        entity = cls(id=blog_post_id,
                     author_id=author_id,
                     content='',
                     title='',
                     published_on=None,
                     url_fragment='',
                     tags=[],
                     thumbnail_filename=None)
        entity.update_timestamps()
        entity.put()

        return entity

    @classmethod
    def get_by_url_fragment(cls, url_fragment: str) -> Optional[BlogPostModel]:
        """Gets BlogPostModel by url_fragment. Returns None if the blog post
        with the given url_fragment doesn't exist.

        Args:
            url_fragment: str. The url fragment of the blog post.

        Returns:
            BlogPostModel | None. The blog post model of the Blog or None if not
            found.
        """
        return BlogPostModel.query(
            datastore_services.all_of(cls.url_fragment == url_fragment,
                                      cls.deleted == False)  # pylint: disable=singleton-comparison
        ).get()

    @classmethod
    def export_data(cls, user_id: str) -> Dict[str, BlogPostModelDataDict]:
        """Exports the data from BlogPostModel into dict format for Takeout.

        Args:
            user_id: str. The ID of the user whose data should be exported.

        Returns:
            dict. Dictionary of the data from BlogPostModel.
        """
        user_data: Dict[str, BlogPostModelDataDict] = {}
        blog_post_models: Sequence[BlogPostModel] = cls.get_all().filter(
            cls.author_id == user_id).fetch()

        for blog_post_model in blog_post_models:
            user_data[blog_post_model.id] = {
                'title':
                blog_post_model.title,
                'content':
                blog_post_model.content,
                'url_fragment':
                blog_post_model.url_fragment,
                'tags':
                blog_post_model.tags,
                'thumbnail_filename':
                blog_post_model.thumbnail_filename,
                'published_on':
                utils.get_time_in_millisecs(blog_post_model.published_on),
            }

        return user_data
Exemplo n.º 9
0
class MachineTranslationModel(base_models.BaseModel):
    """Model for storing machine generated translations for the purpose of
    preventing duplicate generation. Machine translations are used for reference
    purpose only and therefore are context agnostic. Model instances are mapped
    by a deterministic key generated from the source and target language codes,
    followed by a SHA-1 hash of the untranslated source text formated as
    follows:

        [source_language_code].[target_language_code].[hashed_source_text]

    See MachineTranslationModel._generate_id() below for details.
    The same origin text, source_language_code, and target_language_code always
    maps to the same key and therefore always returns the same translated_text.
    """

    # The untranslated source text.
    source_text = datastore_services.TextProperty(required=True, indexed=False)
    # A SHA-1 hash of the source text. This can be used to index the datastore
    # by source text.
    hashed_source_text = datastore_services.StringProperty(
        required=True, indexed=True)
    # The language code for the source text language. Must be different from
    # target_language_code.
    source_language_code = datastore_services.StringProperty(
        required=True, indexed=True)
    # The language code for the target translation language. Must be different
    # from source_language_code.
    target_language_code = datastore_services.StringProperty(
        required=True, indexed=True)
    # The machine generated translation of the source text into the target
    # language.
    translated_text = datastore_services.TextProperty(
        required=True, indexed=False)

    @classmethod
    def create(
            cls,
            source_language_code: str,
            target_language_code: str,
            source_text: str,
            translated_text: str
    ) -> Optional[str]:
        """Creates a new MachineTranslationModel instance and returns its ID.

        Args:
            source_language_code: str. The language code for the source text
                language. Must be different from target_language_code.
            target_language_code: str. The language code for the target
                translation language. Must be different from
                source_language_code.
            source_text: str. The untranslated source text.
            translated_text: str. The machine generated translation of the
                source text into the target language.

        Returns:
            str|None. The id of the newly created
            MachineTranslationModel instance, or None if the inputs are
            invalid.
        """
        if source_language_code is target_language_code:
            return None
        # SHA-1 always produces a 40 digit hash. 50 is chosen here to prevent
        # convert_to_hash from truncating the hash.
        hashed_source_text = utils.convert_to_hash(source_text, 50)
        entity_id = cls._generate_id(
            source_language_code, target_language_code, hashed_source_text)
        translation_entity = cls(
            id=entity_id,
            hashed_source_text=hashed_source_text,
            source_language_code=source_language_code,
            target_language_code=target_language_code,
            source_text=source_text,
            translated_text=translated_text)
        translation_entity.put()
        return entity_id

    @staticmethod
    def _generate_id(
            source_language_code: str,
            target_language_code: str,
            hashed_source_text: str
    ) -> str:
        """Generates a valid, deterministic key for a MachineTranslationModel
        instance.

        Args:
            source_language_code: str. The language code for the source text
                language. Must be different from target_language_code.
            target_language_code: str. The language code for the target
                translation language. Must be different from
                source_language_code.
            hashed_source_text: str. An SHA-1 hash of the untranslated source
                text.

        Returns:
            str. The deterministically generated identifier for this entity of
            the form:

            [source_language_code].[target_language_code].[hashed_source_text]
        """
        return (
            '%s.%s.%s' % (
                source_language_code, target_language_code, hashed_source_text)
        )

    @classmethod
    def get_machine_translation(
        cls,
        source_language_code: str,
        target_language_code: str,
        source_text: str
    ) -> Optional[MachineTranslationModel]:
        """Gets MachineTranslationModel by language codes and source text.

        Args:
            source_language_code: str. The language code for the source text
                language. Must be different from target_language_code.
            target_language_code: str. The language code for the target
                translation language. Must be different from
                source_language_code.
            source_text: str. The untranslated source text.

        Returns:
            MachineTranslationModel|None. The MachineTranslationModel
            instance corresponding to the given inputs, if such a translation
            exists, or None if no translation is found.
        """
        hashed_source_text = utils.convert_to_hash(source_text, 50)
        instance_id = cls._generate_id(
            source_language_code, target_language_code, hashed_source_text)
        return cls.get(instance_id, strict=False)

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model is not associated with users."""
        return base_models.DELETION_POLICY.NOT_APPLICABLE

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Model is not associated with users."""
        return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model is not associated with users."""
        return dict(super(cls, cls).get_export_policy(), **{
            'source_text': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'hashed_source_text': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'source_language_code': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'target_language_code': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'translated_text': base_models.EXPORT_POLICY.NOT_APPLICABLE
        })
Exemplo n.º 10
0
class ExplorationModel(base_models.VersionedModel):
    """Versioned storage model for an Oppia exploration.

    This class should only be imported by the exploration services file
    and the exploration model test file.
    """

    SNAPSHOT_METADATA_CLASS = ExplorationSnapshotMetadataModel
    SNAPSHOT_CONTENT_CLASS = ExplorationSnapshotContentModel
    COMMIT_LOG_ENTRY_CLASS = ExplorationCommitLogEntryModel
    ALLOW_REVERT = True

    # What this exploration is called.
    title = datastore_services.StringProperty(required=True)
    # The category this exploration belongs to.
    category = datastore_services.StringProperty(required=True, indexed=True)
    # The objective of this exploration.
    objective = datastore_services.TextProperty(default='', indexed=False)
    # The ISO 639-1 code for the language this exploration is written in.
    language_code = datastore_services.StringProperty(
        default=constants.DEFAULT_LANGUAGE_CODE, indexed=True)
    # Tags (topics, skills, concepts, etc.) associated with this
    # exploration.
    tags = datastore_services.StringProperty(repeated=True, indexed=True)
    # A blurb for this exploration.
    blurb = datastore_services.TextProperty(default='', indexed=False)
    # 'Author notes' for this exploration.
    author_notes = datastore_services.TextProperty(default='', indexed=False)

    # The version of the states blob schema.
    states_schema_version = datastore_services.IntegerProperty(required=True,
                                                               default=0,
                                                               indexed=True)
    # The name of the initial state of this exploration.
    init_state_name = (datastore_services.StringProperty(required=True,
                                                         indexed=True))
    # A dict representing the states of this exploration. This dict should
    # not be empty.
    states = datastore_services.JsonProperty(default={}, indexed=False)
    # The dict of parameter specifications associated with this exploration.
    # Each specification is a dict whose keys are param names and whose values
    # are each dicts with a single key, 'obj_type', whose value is a string.
    param_specs = datastore_services.JsonProperty(default={}, indexed=False)
    # The list of parameter changes to be performed once at the start of a
    # reader's encounter with an exploration.
    param_changes = (datastore_services.JsonProperty(repeated=True,
                                                     indexed=False))
    # A boolean indicating whether automatic text-to-speech is enabled in
    # this exploration.
    auto_tts_enabled = (datastore_services.BooleanProperty(default=True,
                                                           indexed=True))
    # A boolean indicating whether correctness feedback is enabled in this
    # exploration.
    correctness_feedback_enabled = datastore_services.BooleanProperty(
        default=False, indexed=True)

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model doesn't contain any data directly corresponding to a user."""
        return base_models.DELETION_POLICY.NOT_APPLICABLE

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Model does not contain user data."""
        return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model doesn't contain any data directly corresponding to a user."""
        return dict(
            super(cls, cls).get_export_policy(), **{
                'title':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'category':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'objective':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'language_code':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'tags':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'blurb':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'author_notes':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'states_schema_version':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'init_state_name':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'states':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'param_specs':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'param_changes':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'auto_tts_enabled':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'correctness_feedback_enabled':
                base_models.EXPORT_POLICY.NOT_APPLICABLE
            })

    @classmethod
    def get_exploration_count(cls) -> int:
        """Returns the total number of explorations."""
        return cls.get_all().count()

    # We expect Mapping because we want to allow models that inherit
    # from BaseModel as the values, if we used Dict this wouldn't be allowed.
    def _prepare_additional_models(
            self) -> Mapping[str, base_models.BaseModel]:
        """Prepares additional models needed for the commit process.

        Returns:
            dict(str, BaseModel). Additional models needed for
            the commit process. Contains the ExplorationRightsModel.
        """
        return {'rights_model': ExplorationRightsModel.get_by_id(self.id)}

    # TODO(#13523): Change 'commit_cmds' to TypedDict/Domain Object
    # to remove Any used below.
    def compute_models_to_commit(
        self,
        committer_id: str,
        commit_type: str,
        commit_message: str,
        commit_cmds: List[Dict[str, Any]],
        # We expect Mapping because we want to allow models that inherit
        # from BaseModel as the values, if we used Dict this wouldn't
        # be allowed.
        additional_models: Mapping[str, base_models.BaseModel]
    ) -> base_models.ModelsToPutDict:
        """Record the event to the commit log after the model commit.

        Note that this extends the superclass method.

        Args:
            committer_id: str. The user_id of the user who committed the
                change.
            commit_type: str. The type of commit. Possible values are in
                core.storage.base_models.COMMIT_TYPE_CHOICES.
            commit_message: str. The commit description message.
            commit_cmds: list(dict). A list of commands, describing changes
                made in this model, which should give sufficient information to
                reconstruct the commit. Each dict always contains:
                    cmd: str. Unique command.
                and then additional arguments for that command.
            additional_models: dict(str, BaseModel). Additional models that are
                needed for the commit process.

        Returns:
            ModelsToPutDict. A dict of models that should be put into
            the datastore.
        """
        models_to_put = super().compute_models_to_commit(
            committer_id, commit_type, commit_message, commit_cmds,
            additional_models)

        # The cast is needed because the additional_models is list of BaseModels
        # and we want to hint the mypy that this is ExplorationRightsModel.
        exploration_rights_model = cast(ExplorationRightsModel,
                                        additional_models['rights_model'])
        exploration_commit_log = ExplorationCommitLogEntryModel.create(
            self.id, self.version, committer_id, commit_type, commit_message,
            commit_cmds, exploration_rights_model.status,
            exploration_rights_model.community_owned)
        exploration_commit_log.exploration_id = self.id
        return {
            'snapshot_metadata_model':
            models_to_put['snapshot_metadata_model'],
            'snapshot_content_model': models_to_put['snapshot_content_model'],
            'commit_log_model': exploration_commit_log,
            'versioned_model': models_to_put['versioned_model'],
        }

    # We have ignored [override] here because the signature of this method
    # doesn't match with BaseModel.delete_multi().
    @classmethod
    def delete_multi(  # type: ignore[override]
            cls,
            entity_ids: List[str],
            committer_id: str,
            commit_message: str,
            force_deletion: bool = False) -> None:
        """Deletes the given cls instances with the given entity_ids.

        Note that this extends the superclass method.

        Args:
            entity_ids: list(str). Ids of entities to delete.
            committer_id: str. The user_id of the user who committed the change.
            commit_message: str. The commit description message.
            force_deletion: bool. If True these models are deleted completely
                from storage, otherwise there are only marked as deleted.
                Default is False.
        """
        super(ExplorationModel,
              cls).delete_multi(entity_ids,
                                committer_id,
                                commit_message,
                                force_deletion=force_deletion)

        if not force_deletion:
            commit_log_models = []
            exp_rights_models = ExplorationRightsModel.get_multi(
                entity_ids, include_deleted=True)
            versioned_models = cls.get_multi(entity_ids, include_deleted=True)

            versioned_and_exp_rights_models = zip(versioned_models,
                                                  exp_rights_models)
            for model, rights_model in versioned_and_exp_rights_models:
                # Ruling out the possibility of None for mypy type checking.
                assert model is not None
                assert rights_model is not None
                exploration_commit_log = ExplorationCommitLogEntryModel.create(
                    model.id, model.version, committer_id,
                    feconf.COMMIT_TYPE_DELETE, commit_message,
                    [{
                        'cmd': cls.CMD_DELETE_COMMIT
                    }], rights_model.status, rights_model.community_owned)
                exploration_commit_log.exploration_id = model.id
                commit_log_models.append(exploration_commit_log)
            ExplorationCommitLogEntryModel.update_timestamps_multi(
                commit_log_models)
            datastore_services.put_multi(commit_log_models)

    # TODO(#13523): Change snapshot of this model to TypedDict/Domain Object
    # to remove Any used below.
    @staticmethod
    def convert_to_valid_dict(snapshot_dict: Dict[str, Any]) -> Dict[str, Any]:
        """Replace invalid fields and values in the ExplorationModel dict.
        Some old ExplorationModels can contain fields
        and field values that are no longer supported and would cause
        an exception when we try to reconstitute a ExplorationModel from
        them. We need to remove or replace these fields and values.

        Args:
            snapshot_dict: dict. The content of the model. Some fields and field
                values might no longer exist in the ExplorationModel
                schema.

        Returns:
            dict. The content of the model. Only valid fields and values are
            present.
        """

        if 'skill_tags' in snapshot_dict:
            del snapshot_dict['skill_tags']
        if 'default_skin' in snapshot_dict:
            del snapshot_dict['default_skin']
        if 'skin_customizations' in snapshot_dict:
            del snapshot_dict['skin_customizations']

        return snapshot_dict

    # TODO(#13523): Change 'snapshot_dict' to TypedDict/Domain Object
    # to remove Any used below.
    def _reconstitute(self, snapshot_dict: Dict[str, Any]) -> ExplorationModel:
        """Populates the model instance with the snapshot.
        Some old ExplorationSnapshotContentModels can contain fields
        and field values that are no longer supported and would cause
        an exception when we try to reconstitute a ExplorationModel from
        them. We need to remove or replace these fields and values.

        Args:
            snapshot_dict: dict(str, *). The snapshot with the model
                property values.

        Returns:
            VersionedModel. The instance of the VersionedModel class populated
            with the snapshot.
        """

        self.populate(**ExplorationModel.convert_to_valid_dict(snapshot_dict))
        return self
Exemplo n.º 11
0
class TopicModel(base_models.VersionedModel):
    """Model for storing Topics.

    This class should only be imported by the topic services file
    and the topic model test file.
    """

    SNAPSHOT_METADATA_CLASS = TopicSnapshotMetadataModel
    SNAPSHOT_CONTENT_CLASS = TopicSnapshotContentModel
    COMMIT_LOG_ENTRY_CLASS = TopicCommitLogEntryModel
    ALLOW_REVERT = False

    # The name of the topic.
    name = datastore_services.StringProperty(required=True, indexed=True)
    # The canonical name of the topic, created by making `name` lowercase.
    canonical_name = (
        datastore_services.StringProperty(required=True, indexed=True))
    # The abbreviated name of the topic.
    abbreviated_name = (
        datastore_services.StringProperty(indexed=True, default=''))
    # The thumbnail filename of the topic.
    thumbnail_filename = datastore_services.StringProperty(indexed=True)
    # The thumbnail background color of the topic.
    thumbnail_bg_color = datastore_services.StringProperty(indexed=True)
    # The thumbnail size in bytes of the topic.
    thumbnail_size_in_bytes = (
        datastore_services.IntegerProperty(indexed=True))
    # The description of the topic.
    description = datastore_services.TextProperty(indexed=False)
    # This consists of the list of objects referencing canonical stories that
    # are part of this topic.
    canonical_story_references = (
        datastore_services.JsonProperty(repeated=True, indexed=False))
    # This consists of the list of objects referencing additional stories that
    # are part of this topic.
    additional_story_references = (
        datastore_services.JsonProperty(repeated=True, indexed=False))
    # The schema version for the story reference object on each of the above 2
    # lists.
    story_reference_schema_version = datastore_services.IntegerProperty(
        required=True, indexed=True)
    # This consists of the list of uncategorized skill ids that are not part of
    # any subtopic.
    uncategorized_skill_ids = (
        datastore_services.StringProperty(repeated=True, indexed=True))
    # The list of subtopics that are part of the topic.
    subtopics = datastore_services.JsonProperty(repeated=True, indexed=False)
    # The schema version of the subtopic dict.
    subtopic_schema_version = (
        datastore_services.IntegerProperty(required=True, indexed=True))
    # The id for the next subtopic.
    next_subtopic_id = datastore_services.IntegerProperty(required=True)
    # The ISO 639-1 code for the language this topic is written in.
    language_code = (
        datastore_services.StringProperty(required=True, indexed=True))
    # The url fragment of the topic.
    url_fragment = (
        datastore_services.StringProperty(required=True, indexed=True))
    # Whether to show practice tab in the Topic viewer page.
    practice_tab_is_displayed = datastore_services.BooleanProperty(
        required=True, default=False)
    # The content of the meta tag in the Topic viewer page.
    meta_tag_content = datastore_services.StringProperty(
        indexed=True, default='')
    # The page title fragment used in the Topic viewer web page.
    # For example, if the full Topic viewer web page title is
    # 'Learn Fractions | Add, Subtract, Multiply and Divide | Oppia'
    # the page title fragment field represents the middle value 'Add, Subtract,
    # Multiply and Divide'.
    page_title_fragment_for_web = datastore_services.StringProperty(
        indexed=True, default='')

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model doesn't contain any data directly corresponding to a user."""
        return base_models.DELETION_POLICY.NOT_APPLICABLE

    # TODO(#13523): Change 'commit_cmds' to TypedDict/Domain Object
    # to remove Any used below.
    def _trusted_commit(
            self,
            committer_id: str,
            commit_type: str,
            commit_message: str,
            commit_cmds: List[Dict[str, Any]]
    ) -> None:
        """Record the event to the commit log after the model commit.

        Note that this extends the superclass method.

        Args:
            committer_id: str. The user_id of the user who committed the
                change.
            commit_type: str. The type of commit. Possible values are in
                core.storage.base_models.COMMIT_TYPE_CHOICES.
            commit_message: str. The commit description message.
            commit_cmds: list(dict). A list of commands, describing changes
                made in this model, which should give sufficient information to
                reconstruct the commit. Each dict always contains:
                    cmd: str. Unique command.
                and then additional arguments for that command.
        """
        super(TopicModel, self)._trusted_commit(
            committer_id, commit_type, commit_message, commit_cmds)

        topic_rights = TopicRightsModel.get_by_id(self.id)
        if topic_rights.topic_is_published:
            status = constants.ACTIVITY_STATUS_PUBLIC
        else:
            status = constants.ACTIVITY_STATUS_PRIVATE

        topic_commit_log_entry = TopicCommitLogEntryModel.create(
            self.id, self.version, committer_id, commit_type,
            commit_message, commit_cmds, status, False
        )
        topic_commit_log_entry.topic_id = self.id
        topic_commit_log_entry.update_timestamps()
        topic_commit_log_entry.put()

    @classmethod
    def get_by_name(cls, topic_name: str) -> Optional['TopicModel']:
        """Gets TopicModel by topic_name. Returns None if the topic with
        name topic_name doesn't exist.

        Args:
            topic_name: str. The name of the topic.

        Returns:
            TopicModel|None. The topic model of the topic or None if not
            found.
        """
        return cls.get_all().filter(
            cls.canonical_name == topic_name.lower()
        ).get()

    @classmethod
    def get_by_url_fragment(cls, url_fragment: str) -> Optional['TopicModel']:
        """Gets TopicModel by url_fragment. Returns None if the topic with
        name url_fragment doesn't exist.

        Args:
            url_fragment: str. The url fragment of the topic.

        Returns:
            TopicModel|None. The topic model of the topic or None if not
            found.
        """
        # TODO(#10210): Make fetching by URL fragment faster.
        return cls.get_all().filter(cls.url_fragment == url_fragment).get()

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Model does not contain user data."""
        return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model doesn't contain any data directly corresponding to a user."""
        return dict(super(cls, cls).get_export_policy(), **{
            'name': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'canonical_name': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'abbreviated_name': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'thumbnail_filename': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'thumbnail_bg_color': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'thumbnail_size_in_bytes': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'description': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'canonical_story_references':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'additional_story_references':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'story_reference_schema_version':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'uncategorized_skill_ids': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'subtopics': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'subtopic_schema_version': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'next_subtopic_id': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'language_code': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'meta_tag_content': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'page_title_fragment_for_web': (
                base_models.EXPORT_POLICY.NOT_APPLICABLE),
            'practice_tab_is_displayed':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'url_fragment': base_models.EXPORT_POLICY.NOT_APPLICABLE,
        })
Exemplo n.º 12
0
class TopicSummaryModel(base_models.BaseModel):
    """Summary model for an Oppia Topic.

    This should be used whenever the content blob of the topic is not
    needed (e.g. search results, etc).

    A TopicSummaryModel instance stores the following information:

        id, description, language_code, last_updated, created_on, version,
        url_fragment.

    The key of each instance is the topic id.
    """

    # The name of the topic.
    name = datastore_services.StringProperty(required=True, indexed=True)
    # The canonical name of the topic, created by making `name` lowercase.
    canonical_name = (
        datastore_services.StringProperty(required=True, indexed=True))
    # The ISO 639-1 code for the language this topic is written in.
    language_code = (
        datastore_services.StringProperty(required=True, indexed=True))
    # The description of the topic.
    description = datastore_services.TextProperty(indexed=False)
    # The url fragment of the topic.
    url_fragment = (
        datastore_services.StringProperty(required=True, indexed=True))

    # Time when the topic model was last updated (not to be
    # confused with last_updated, which is the time when the
    # topic *summary* model was last updated).
    topic_model_last_updated = (
        datastore_services.DateTimeProperty(required=True, indexed=True))
    # Time when the topic model was created (not to be confused
    # with created_on, which is the time when the topic *summary*
    # model was created).
    topic_model_created_on = (
        datastore_services.DateTimeProperty(required=True, indexed=True))
    # The number of canonical stories that are part of this topic.
    canonical_story_count = (
        datastore_services.IntegerProperty(required=True, indexed=True))
    # The number of additional stories that are part of this topic.
    additional_story_count = (
        datastore_services.IntegerProperty(required=True, indexed=True))
    # The total number of skills in the topic (including those that are
    # uncategorized).
    total_skill_count = (
        datastore_services.IntegerProperty(required=True, indexed=True))
    # The total number of published chapters in the topic.
    total_published_node_count = (
        datastore_services.IntegerProperty(required=True, indexed=True))
    # The number of skills that are not part of any subtopic.
    uncategorized_skill_count = (
        datastore_services.IntegerProperty(required=True, indexed=True))
    # The number of subtopics of the topic.
    subtopic_count = (
        datastore_services.IntegerProperty(required=True, indexed=True))
    # The thumbnail filename of the topic.
    thumbnail_filename = datastore_services.StringProperty(indexed=True)
    # The thumbnail background color of the topic.
    thumbnail_bg_color = datastore_services.StringProperty(indexed=True)
    version = datastore_services.IntegerProperty(required=True)

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model doesn't contain any data directly corresponding to a user."""
        return base_models.DELETION_POLICY.NOT_APPLICABLE

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Model does not contain user data."""
        return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model doesn't contain any data directly corresponding to a user."""
        return dict(super(cls, cls).get_export_policy(), **{
            'name': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'canonical_name': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'language_code': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'description': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'topic_model_last_updated':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'topic_model_created_on': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'canonical_story_count': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'additional_story_count': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'total_skill_count': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'total_published_node_count':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'uncategorized_skill_count':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'subtopic_count': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'thumbnail_filename': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'thumbnail_bg_color': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'version': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'url_fragment': base_models.EXPORT_POLICY.NOT_APPLICABLE
        })
Exemplo n.º 13
0
class StoryModel(base_models.VersionedModel):
    """Model for storing stories.

    This class should only be imported by the story services file
    and the story model test file.
    """

    SNAPSHOT_METADATA_CLASS = StorySnapshotMetadataModel
    SNAPSHOT_CONTENT_CLASS = StorySnapshotContentModel
    COMMIT_LOG_ENTRY_CLASS = StoryCommitLogEntryModel
    ALLOW_REVERT = False

    # The title of the story.
    title = datastore_services.StringProperty(required=True, indexed=True)
    # The thumbnail filename of the story.
    thumbnail_filename = datastore_services.StringProperty(indexed=True)
    # The thumbnail background color of the story.
    thumbnail_bg_color = datastore_services.StringProperty(indexed=True)
    # The thumbnail size of the story.
    thumbnail_size_in_bytes = datastore_services.IntegerProperty(indexed=True)
    # A high-level description of the story.
    description = datastore_services.TextProperty(indexed=False)
    # A set of notes, that describe the characters, main storyline, and setting.
    notes = datastore_services.TextProperty(indexed=False)
    # The ISO 639-1 code for the language this story is written in.
    language_code = (
        datastore_services.StringProperty(required=True, indexed=True))
    # The story contents dict specifying the list of story nodes and the
    # connection between them. Modelled by class StoryContents
    # (see story_domain.py for its current schema).
    story_contents = datastore_services.JsonProperty(default={}, indexed=False)
    # The schema version for the story_contents.
    story_contents_schema_version = (
        datastore_services.IntegerProperty(required=True, indexed=True))
    # The topic id to which the story belongs.
    corresponding_topic_id = (
        datastore_services.StringProperty(indexed=True, required=True))
    # The url fragment for the story.
    url_fragment = (
        datastore_services.StringProperty(required=True, indexed=True))
    # The content of the meta tag in the Story viewer page.
    meta_tag_content = datastore_services.StringProperty(
        indexed=True, default='')

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model doesn't contain any data directly corresponding to a user."""
        return base_models.DELETION_POLICY.NOT_APPLICABLE

    # TODO(#13523): Change 'commit_cmds' to TypedDict/Domain Object
    # to remove Any used below.
    def compute_models_to_commit(
        self,
        committer_id: str,
        commit_type: str,
        commit_message: str,
        commit_cmds: List[Dict[str, Any]],
        # We expect Mapping because we want to allow models that inherit
        # from BaseModel as the values, if we used Dict this wouldn't
        # be allowed.
        additional_models: Mapping[str, base_models.BaseModel]
    ) -> base_models.ModelsToPutDict:
        """Record the event to the commit log after the model commit.

        Note that this extends the superclass method.

        Args:
            committer_id: str. The user_id of the user who committed the
                change.
            commit_type: str. The type of commit. Possible values are in
                core.storage.base_models.COMMIT_TYPE_CHOICES.
            commit_message: str. The commit description message.
            commit_cmds: list(dict). A list of commands, describing changes
                made in this model, which should give sufficient information to
                reconstruct the commit. Each dict always contains:
                    cmd: str. Unique command.
                and then additional arguments for that command.
            additional_models: dict(str, BaseModel). Additional models that are
                needed for the commit process.

        Returns:
            ModelsToPutDict. A dict of models that should be put into
            the datastore.
        """
        models_to_put = super().compute_models_to_commit(
            committer_id,
            commit_type,
            commit_message,
            commit_cmds,
            additional_models
        )

        story_commit_log_entry = StoryCommitLogEntryModel.create(
            self.id, self.version, committer_id, commit_type, commit_message,
            commit_cmds, constants.ACTIVITY_STATUS_PUBLIC, False
        )
        story_commit_log_entry.story_id = self.id
        return {
            'snapshot_metadata_model': models_to_put['snapshot_metadata_model'],
            'snapshot_content_model': models_to_put['snapshot_content_model'],
            'commit_log_model': story_commit_log_entry,
            'versioned_model': models_to_put['versioned_model'],
        }

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Model does not contain user data."""
        return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model doesn't contain any data directly corresponding to a user."""
        return dict(super(cls, cls).get_export_policy(), **{
            'title': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'thumbnail_filename': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'thumbnail_bg_color': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'thumbnail_size_in_bytes': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'description': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'notes': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'language_code': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'story_contents': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'story_contents_schema_version':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'corresponding_topic_id': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'url_fragment': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'meta_tag_content': base_models.EXPORT_POLICY.NOT_APPLICABLE
        })

    @classmethod
    def get_by_url_fragment(cls, url_fragment: str) -> Optional[StoryModel]:
        """Gets StoryModel by url_fragment. Returns None if the story with
        name url_fragment doesn't exist.

        Args:
            url_fragment: str. The url fragment of the story.

        Returns:
            StoryModel|None. The story model of the story or None if not
            found.
        """
        return cls.get_all().filter(cls.url_fragment == url_fragment).get()
Exemplo n.º 14
0
class StorySummaryModel(base_models.BaseModel):
    """Summary model for an Oppia Story.

    This should be used whenever the content blob of the story is not
    needed (e.g. search results, etc).

    A StorySummaryModel instance stores the following information:

        id, description, language_code, last_updated, created_on, version.

    The key of each instance is the story id.
    """

    # The title of the story.
    title = datastore_services.StringProperty(required=True, indexed=True)
    # The ISO 639-1 code for the language this story is written in.
    language_code = (
        datastore_services.StringProperty(required=True, indexed=True))
    # A high-level description of the story.
    description = datastore_services.TextProperty(required=True, indexed=False)
    # Time when the story model was last updated (not to be
    # confused with last_updated, which is the time when the
    # story *summary* model was last updated).
    story_model_last_updated = (
        datastore_services.DateTimeProperty(required=True, indexed=True))
    # Time when the story model was created (not to be confused
    # with created_on, which is the time when the story *summary*
    # model was created).
    story_model_created_on = (
        datastore_services.DateTimeProperty(required=True, indexed=True))
    # The titles of the nodes in the story, in the same order as present there.
    node_titles = (
        datastore_services.StringProperty(repeated=True, indexed=True))
    # The thumbnail filename of the story.
    thumbnail_filename = datastore_services.StringProperty(indexed=True)
    # The thumbnail background color of the story.
    thumbnail_bg_color = datastore_services.StringProperty(indexed=True)
    version = datastore_services.IntegerProperty(required=True)
    # The url fragment for the story.
    url_fragment = (
        datastore_services.StringProperty(required=True, indexed=True))

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model doesn't contain any data directly corresponding to a user."""
        return base_models.DELETION_POLICY.NOT_APPLICABLE

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Model does not contain user data."""
        return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model doesn't contain any data directly corresponding to a user."""
        return dict(super(cls, cls).get_export_policy(), **{
            'title': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'language_code': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'description': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'story_model_last_updated':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'story_model_created_on': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'node_titles': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'thumbnail_filename': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'thumbnail_bg_color': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'version': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'url_fragment': base_models.EXPORT_POLICY.NOT_APPLICABLE
        })
Exemplo n.º 15
0
class QuestionSummaryModel(base_models.BaseModel):
    """Summary model for an Oppia question.

    This should be used whenever the content blob of the question is not
    needed (e.g. in search results, etc).

    A QuestionSummaryModel instance stores the following information:

    question_model_last_updated, question_model_created_on,
    question_state_data.

    The key of each instance is the question id.
    """

    # Time when the question model was last updated (not to be
    # confused with last_updated, which is the time when the
    # question *summary* model was last updated).
    question_model_last_updated = datastore_services.DateTimeProperty(
        indexed=True, required=True)
    # Time when the question model was created (not to be confused
    # with created_on, which is the time when the question *summary*
    # model was created).
    question_model_created_on = datastore_services.DateTimeProperty(
        indexed=True, required=True)
    # The html content for the question.
    question_content = (datastore_services.TextProperty(indexed=False,
                                                        required=True))
    # The ID of the interaction.
    interaction_id = (datastore_services.StringProperty(indexed=True,
                                                        required=True))
    # The misconception ids addressed in the question. This includes
    # tagged misconceptions ids as well as inapplicable misconception
    # ids in the question.
    misconception_ids = (datastore_services.StringProperty(indexed=True,
                                                           repeated=True))

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model doesn't contain any data directly corresponding to a user."""
        return base_models.DELETION_POLICY.NOT_APPLICABLE

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Model data has already been exported as a part of the QuestionModel
        export_data function, and thus a new export_data function does not
        need to be defined here.
        """
        return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model contains data corresponding to a user, but this isn't exported
        because because noteworthy details that belong to this model have
        already been exported as a part of the QuestionModel export_data
        function.
        """
        return dict(
            super(cls, cls).get_export_policy(), **{
                'question_model_last_updated':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'question_model_created_on':
                base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'question_content': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'interaction_id': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'misconception_ids': base_models.EXPORT_POLICY.NOT_APPLICABLE
            })
Exemplo n.º 16
0
class GeneralVoiceoverApplicationModel(base_models.BaseModel):
    """A general model for voiceover application of an entity.

    The ID of the voiceover application will be a random hashed value.
    """

    # We use the model id as a key in the Takeout dict.
    ID_IS_USED_AS_TAKEOUT_KEY = True

    # The type of entity to which the user will be assigned as a voice artist
    # once the application will get approved.
    target_type = datastore_services.StringProperty(required=True,
                                                    indexed=True)
    # The ID of the entity to which the application belongs.
    target_id = datastore_services.StringProperty(required=True, indexed=True)
    # The language code for the voiceover audio.
    language_code = (datastore_services.StringProperty(required=True,
                                                       indexed=True))
    # The status of the application. One of: accepted, rejected, in-review.
    status = datastore_services.StringProperty(required=True,
                                               indexed=True,
                                               choices=STATUS_CHOICES)
    # The HTML content written in the given language_code.
    # This will typically be a snapshot of the content of the initial card of
    # the target.
    content = datastore_services.TextProperty(required=True)
    # The filename of the voiceover audio. The filename will have
    # datetime-randomId(length 6)-language_code.mp3 pattern.
    filename = datastore_services.StringProperty(required=True, indexed=True)
    # The ID of the author of the voiceover application.
    author_id = datastore_services.StringProperty(required=True, indexed=True)
    # The ID of the reviewer who accepted/rejected the voiceover application.
    final_reviewer_id = datastore_services.StringProperty(indexed=True)
    # The plain text message submitted by the reviewer while rejecting the
    # application.
    rejection_message = datastore_services.TextProperty()

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model contains data to pseudonymize corresponding to a user:
        author_id, and final_reviewer_id fields.
        """
        return base_models.DELETION_POLICY.LOCALLY_PSEUDONYMIZE

    @classmethod
    def has_reference_to_user_id(cls, user_id: str) -> bool:
        """Check whether GeneralVoiceoverApplicationModel exists for the user.

        Args:
            user_id: str. The ID of the user whose data should be checked.

        Returns:
            bool. Whether any models refer to the given user ID.
        """
        return cls.query(
            datastore_services.any_of(cls.author_id == user_id,
                                      cls.final_reviewer_id == user_id)).get(
                                          keys_only=True) is not None

    @classmethod
    def get_user_voiceover_applications(
        cls,
        author_id: str,
        status: Optional[str] = None
    ) -> List['GeneralVoiceoverApplicationModel']:
        """Returns a list of voiceover application submitted by the given user.

        Args:
            author_id: str. The id of the user created the voiceover
                application.
            status: str|None. The status of the voiceover application.
                If the status is None, the query will fetch all the
                voiceover applications.

        Returns:
            list(GeneralVoiceoverApplicationModel). The list of voiceover
            applications submitted by the given user.
        """
        if status in STATUS_CHOICES:
            voiceover_application_query = cls.query(
                datastore_services.all_of(cls.author_id == author_id,
                                          cls.status == status))
        else:
            voiceover_application_query = cls.query(cls.author_id == author_id)

        return cast(List[GeneralVoiceoverApplicationModel],
                    voiceover_application_query.fetch())

    @classmethod
    def get_reviewable_voiceover_applications(
            cls, user_id: str) -> List['GeneralVoiceoverApplicationModel']:
        """Returns a list of voiceover application which a given user can
        review.

        Args:
            user_id: str. The id of the user trying to make this query.
                As a user cannot review their own voiceover application, so the
                voiceover application created by the user will be excluded.

        Returns:
            list(GeneralVoiceoverApplicationModel). The list of voiceover
            applications which the given user can review.
        """
        return cast(
            List[GeneralVoiceoverApplicationModel],
            cls.query(
                datastore_services.all_of(
                    cls.author_id != user_id,
                    cls.status == STATUS_IN_REVIEW)).fetch())

    @classmethod
    def get_voiceover_applications(
            cls, target_type: str, target_id: str,
            language_code: str) -> List['GeneralVoiceoverApplicationModel']:
        """Returns a list of voiceover applications submitted for a give entity
        in a given language.

        Args:
            target_type: str. The type of entity.
            target_id: str. The ID of the targeted entity.
            language_code: str. The code of the language in which the voiceover
                application is submitted.

        Returns:
            list(GeneralVoiceoverApplicationModel). The list of voiceover
            application which is submitted to a give entity in a given language.
        """
        return cast(
            List[GeneralVoiceoverApplicationModel],
            cls.query(
                datastore_services.all_of(
                    cls.target_type == target_type, cls.target_id == target_id,
                    cls.language_code == language_code)).fetch())

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Model is exported as multiple instances per user since there are
        multiple voiceover applications relevant to a user.
        """
        return base_models.MODEL_ASSOCIATION_TO_USER.MULTIPLE_INSTANCES_PER_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model contains data to export corresponding to a user."""
        return dict(
            super(cls, cls).get_export_policy(),
            **{
                'target_type': base_models.EXPORT_POLICY.EXPORTED,
                'target_id': base_models.EXPORT_POLICY.EXPORTED,
                'language_code': base_models.EXPORT_POLICY.EXPORTED,
                'status': base_models.EXPORT_POLICY.EXPORTED,
                'content': base_models.EXPORT_POLICY.EXPORTED,
                'filename': base_models.EXPORT_POLICY.EXPORTED,
                # The author_id and final_reviewer_id are not exported in order to
                # keep internal ids private.
                'author_id': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'final_reviewer_id': base_models.EXPORT_POLICY.NOT_APPLICABLE,
                'rejection_message': base_models.EXPORT_POLICY.EXPORTED
            })

    @classmethod
    def export_data(cls, user_id: str) -> Dict[str, Dict[str, Optional[str]]]:
        """(Takeout) Exports the data from GeneralVoiceoverApplicationModel
        into dict format.

        Args:
            user_id: str. The ID of the user whose data should be exported.

        Returns:
            dict. Dictionary of the data from GeneralVoiceoverApplicationModel.
        """
        user_data = {}

        voiceover_models = cast(List[GeneralVoiceoverApplicationModel],
                                cls.query(cls.author_id == user_id).fetch())

        for voiceover_model in voiceover_models:
            user_data[voiceover_model.id] = {
                'target_type': voiceover_model.target_type,
                'target_id': voiceover_model.target_id,
                'language_code': voiceover_model.language_code,
                'status': voiceover_model.status,
                'content': voiceover_model.content,
                'filename': voiceover_model.filename,
                'rejection_message': voiceover_model.rejection_message
            }
        return user_data
Exemplo n.º 17
0
class CollectionModel(base_models.VersionedModel):
    """Versioned storage model for an Oppia collection.

    This class should only be imported by the collection services file
    and the collection model test file.
    """

    SNAPSHOT_METADATA_CLASS = CollectionSnapshotMetadataModel
    SNAPSHOT_CONTENT_CLASS = CollectionSnapshotContentModel
    COMMIT_LOG_ENTRY_CLASS = CollectionCommitLogEntryModel
    ALLOW_REVERT = True

    # What this collection is called.
    title = datastore_services.StringProperty(required=True)
    # The category this collection belongs to.
    category = datastore_services.StringProperty(required=True, indexed=True)
    # The objective of this collection.
    objective = datastore_services.TextProperty(default='', indexed=False)
    # The language code of this collection.
    language_code = datastore_services.StringProperty(
        default=constants.DEFAULT_LANGUAGE_CODE, indexed=True)
    # Tags associated with this collection.
    tags = datastore_services.StringProperty(repeated=True, indexed=True)

    # The version of all property blob schemas.
    schema_version = datastore_services.IntegerProperty(
        required=True, default=1, indexed=True)

    # A dict representing the contents of a collection. Currently, this
    # contains the list of nodes. This dict should contain collection data
    # whose structure might need to be changed in the future.
    collection_contents = (
        datastore_services.JsonProperty(default={}, indexed=False))

    @staticmethod
    def get_deletion_policy() -> base_models.DELETION_POLICY:
        """Model doesn't contain any data directly corresponding to a user."""
        return base_models.DELETION_POLICY.NOT_APPLICABLE

    @staticmethod
    def get_model_association_to_user(
    ) -> base_models.MODEL_ASSOCIATION_TO_USER:
        """Model does not contain user data."""
        return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER

    @classmethod
    def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
        """Model doesn't contain any data directly corresponding to a user."""
        return dict(super(cls, cls).get_export_policy(), **{
            'title': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'category': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'objective': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'language_code': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'tags': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'schema_version': base_models.EXPORT_POLICY.NOT_APPLICABLE,
            'collection_contents': base_models.EXPORT_POLICY.NOT_APPLICABLE
        })

    @classmethod
    def get_collection_count(cls) -> int:
        """Returns the total number of collections."""
        return cls.get_all().count()

    # TODO(#13523): Change 'model_dict' to domain object/TypedDict to
    # remove Any from type-annotation below.
    @staticmethod
    def convert_to_valid_dict(model_dict: Dict[str, Any]) -> Dict[str, Any]:
        """Replace invalid fields and values in the CollectionModel dict.

        Some old CollectionModels can contain fields
        and field values that are no longer supported and would cause
        an exception when we try to reconstitute a CollectionModel from
        them. We need to remove or replace these fields and values.

        Args:
            model_dict: dict. The content of the model. Some fields and field
                values might no longer exist in the CollectionModel
                schema.

        Returns:
            dict. The content of the model. Only valid fields and values are
            present.
        """

        # The nodes field is moved to collection_contents dict. We
        # need to move the values from nodes field to collection_contents dict
        # and delete nodes.
        if 'nodes' in model_dict and model_dict['nodes']:
            model_dict['collection_contents']['nodes'] = (
                copy.deepcopy(model_dict['nodes']))
            del model_dict['nodes']

        return model_dict

    # TODO(#13523): Change 'snapshot_dict' to domain object/TypedDict to
    # remove Any from type-annotation below.
    def _reconstitute(self, snapshot_dict: Dict[str, Any]) -> CollectionModel:
        """Populates the model instance with the snapshot.

        Some old CollectionModels can contain fields
        and field values that are no longer supported and would cause
        an exception when we try to reconstitute a CollectionModel from
        them. We need to remove or replace these fields and values.

        Args:
            snapshot_dict: dict(str, *). The snapshot with the model
                property values.

        Returns:
            VersionedModel. The instance of the VersionedModel class populated
            with the the snapshot.
        """
        self.populate(
            **CollectionModel.convert_to_valid_dict(snapshot_dict))
        return self

    # We expect Mapping because we want to allow models that inherit
    # from BaseModel as the values, if we used Dict this wouldn't be allowed.
    def _prepare_additional_models(self) -> Mapping[str, base_models.BaseModel]:
        """Prepares additional models needed for the commit process.

        Returns:
            dict(str, BaseModel). Additional models needed for
            the commit process. Contains the CollectionRightsModel.
        """
        return {
            'rights_model': CollectionRightsModel.get_by_id(self.id)
        }

    # TODO(#13523): Change 'commit_cmds' to domain object/TypedDict to
    # remove Any from type-annotation below.
    def compute_models_to_commit(
        self,
        committer_id: str,
        commit_type: str,
        commit_message: str,
        commit_cmds: List[Dict[str, Any]],
        # We expect Mapping because we want to allow models that inherit
        # from BaseModel as the values, if we used Dict this wouldn't
        # be allowed.
        additional_models: Mapping[str, base_models.BaseModel]
    ) -> base_models.ModelsToPutDict:
        """Record the event to the commit log after the model commit.

        Note that this extends the superclass method.

        Args:
            committer_id: str. The user_id of the user who committed the
                change.
            commit_type: str. The type of commit. Possible values are in
                core.storage.base_models.COMMIT_TYPE_CHOICES.
            commit_message: str. The commit description message.
            commit_cmds: list(dict). A list of commands, describing changes
                made in this model, which should give sufficient information to
                reconstruct the commit. Each dict always contains:
                    cmd: str. Unique command.
                and then additional arguments for that command.
            additional_models: dict(str, BaseModel). Additional models that are
                needed for the commit process.

        Returns:
            ModelsToPutDict. A dict of models that should be put into
            the datastore.
        """
        models_to_put = super().compute_models_to_commit(
            committer_id,
            commit_type,
            commit_message,
            commit_cmds,
            additional_models
        )

        # The cast is needed because the additional_models is list of BaseModels
        # and we want to hint the mypy that this is CollectionRightsModel.
        collection_rights_model = cast(
            CollectionRightsModel, additional_models['rights_model']
        )
        collection_commit_log = CollectionCommitLogEntryModel.create(
            self.id,
            self.version,
            committer_id,
            commit_type,
            commit_message,
            commit_cmds,
            collection_rights_model.status,
            collection_rights_model.community_owned
        )
        collection_commit_log.collection_id = self.id
        return {
            'snapshot_metadata_model': models_to_put['snapshot_metadata_model'],
            'snapshot_content_model': models_to_put['snapshot_content_model'],
            'commit_log_model': collection_commit_log,
            'versioned_model': models_to_put['versioned_model'],
        }

    # We have ignored [override] here because the signature of this method
    # doesn't match with BaseModel.delete_multi().
    # https://mypy.readthedocs.io/en/stable/error_code_list.html#check-validity-of-overrides-override
    @classmethod
    def delete_multi( # type: ignore[override]
            cls,
            entity_ids: List[str],
            committer_id: str,
            commit_message: str,
            force_deletion: bool = False
    ) -> None:
        """Deletes the given cls instances with the given entity_ids.

        Note that this extends the superclass method.

        Args:
            entity_ids: list(str). Ids of entities to delete.
            committer_id: str. The user_id of the user who committed the change.
            commit_message: str. The commit description message.
            force_deletion: bool. If True these models are deleted completely
                from storage, otherwise there are only marked as deleted.
                Default is False.
        """
        super(CollectionModel, cls).delete_multi(
            entity_ids, committer_id,
            commit_message, force_deletion=force_deletion)

        if not force_deletion:
            commit_log_models = []
            collection_rights_models = CollectionRightsModel.get_multi(
                entity_ids, include_deleted=True)
            versioned_models = cls.get_multi(entity_ids, include_deleted=True)
            for model, rights_model in zip(
                versioned_models, collection_rights_models):
                # Ruling out the possibility of None for mypy type checking.
                assert model is not None
                assert rights_model is not None
                collection_commit_log = CollectionCommitLogEntryModel.create(
                    model.id, model.version, committer_id,
                    feconf.COMMIT_TYPE_DELETE,
                    commit_message, [{'cmd': cls.CMD_DELETE_COMMIT}],
                    rights_model.status, rights_model.community_owned
                )
                collection_commit_log.collection_id = model.id
                commit_log_models.append(collection_commit_log)
            CollectionCommitLogEntryModel.update_timestamps_multi(
                commit_log_models)
            datastore_services.put_multi(commit_log_models)