Esempio n. 1
0
class ReffedModel(models.Model):
    m2m_reffed_counter = RelationCounterField('m2m_reffed')
    reffed_key_counter = RelationCounterField('key_reffed')

    # These are here to ensure that RelationCounterField's smarts don't
    # over-increment/decrement other counters.
    m2m_reffed_counter_2 = RelationCounterField('m2m_reffed')
    reffed_key_counter_2 = RelationCounterField('key_reffed')
Esempio n. 2
0
class FileAttachmentHistory(models.Model):
    """Revision history for a single file attachment.

    This tracks multiple revisions of the same file attachment (for instance,
    when someone replaces a screenshot with an updated version).
    """

    display_position = models.IntegerField()
    latest_revision = RelationCounterField('file_attachments')

    def get_revision_to_id_map(self):
        """Return a map from revision number to FileAttachment ID."""
        results = {}

        for attachment in self.file_attachments.all():
            results[attachment.attachment_revision] = attachment.id

        return results

    @staticmethod
    def compute_next_display_position(review_request):
        """Compute the display position for a new FileAttachmentHistory."""
        # Right now, display_position is monotonically increasing for each
        # review request. In the future this might be extended to allow the
        # user to change the order of attachments on the page.
        max_position = (FileAttachmentHistory.objects.filter(
            review_request=review_request).aggregate(
                Max('display_position')).get('display_position__max')) or 0

        return max_position + 1

    class Meta:
        db_table = 'attachments_fileattachmenthistory'
        verbose_name = _('File Attachment History')
        verbose_name_plural = _('File Attachment Histories')
Esempio n. 3
0
class FileDiffCollectionMixin(models.Model):
    """A mixin for models that consist of a colleciton of FileDiffs."""

    file_count = RelationCounterField('files')

    def get_total_line_counts(self):
        """Return the total line counts of all child FileDiffs.

        Returns:
            dict:
            A dictionary with the following keys:

            * ``raw_insert_count``
            * ``raw_delete_count``
            * ``insert_count``
            * ``delete_count``
            * ``replace_count``
            * ``equal_count``
            * ``total_line_count``

            Each entry maps to the sum of that line count type for all child
            :py:class:`FileDiffs
            <reviewboard.diffviewer.models.filediff.FileDiff>`.
        """
        counts = {
            'raw_insert_count': 0,
            'raw_delete_count': 0,
            'insert_count': 0,
            'delete_count': 0,
            'replace_count': None,
            'equal_count': None,
            'total_line_count': None,
        }

        for filediff in self.files.all():
            for key, value in six.iteritems(filediff.get_line_counts()):
                if value is not None:
                    if counts[key] is None:
                        counts[key] = value
                    else:
                        counts[key] += value

        return dict(counts)

    class Meta:
        abstract = True
Esempio n. 4
0
class ReviewRequest(BaseReviewRequestDetails):
    """A review request.

    This is one of the primary models in Review Board. Most everything
    is associated with a review request.

    The ReviewRequest model contains detailed information on a review
    request. Some fields are user-modifiable, while some are used for
    internal state.
    """
    PENDING_REVIEW = "P"
    SUBMITTED = "S"
    DISCARDED = "D"

    STATUSES = (
        (PENDING_REVIEW, _('Pending Review')),
        (SUBMITTED, _('Submitted')),
        (DISCARDED, _('Discarded')),
    )

    ISSUE_COUNTER_FIELDS = {
        BaseComment.OPEN: 'issue_open_count',
        BaseComment.RESOLVED: 'issue_resolved_count',
        BaseComment.DROPPED: 'issue_dropped_count',
        BaseComment.VERIFYING_RESOLVED: 'issue_verifying_count',
        BaseComment.VERIFYING_DROPPED: 'issue_verifying_count',
    }

    summary = models.CharField(
        _("summary"), max_length=BaseReviewRequestDetails.MAX_SUMMARY_LENGTH)

    submitter = models.ForeignKey(User,
                                  verbose_name=_("submitter"),
                                  related_name="review_requests")
    time_added = models.DateTimeField(_("time added"), default=timezone.now)
    last_updated = ModificationTimestampField(_("last updated"))
    status = models.CharField(_("status"),
                              max_length=1,
                              choices=STATUSES,
                              db_index=True)
    public = models.BooleanField(_("public"), default=False)
    changenum = models.PositiveIntegerField(_("change number"),
                                            blank=True,
                                            null=True,
                                            db_index=True)
    repository = models.ForeignKey(Repository,
                                   related_name="review_requests",
                                   verbose_name=_("repository"),
                                   null=True,
                                   blank=True)
    email_message_id = models.CharField(_("e-mail message ID"),
                                        max_length=255,
                                        blank=True,
                                        null=True)
    time_emailed = models.DateTimeField(_("time e-mailed"),
                                        null=True,
                                        default=None,
                                        blank=True)

    diffset_history = models.ForeignKey(DiffSetHistory,
                                        related_name="review_request",
                                        verbose_name=_('diff set history'),
                                        blank=True)
    target_groups = models.ManyToManyField(Group,
                                           related_name="review_requests",
                                           verbose_name=_("target groups"),
                                           blank=True)
    target_people = models.ManyToManyField(
        User,
        verbose_name=_("target people"),
        related_name="directed_review_requests",
        blank=True)
    screenshots = models.ManyToManyField(Screenshot,
                                         related_name="review_request",
                                         verbose_name=_("screenshots"),
                                         blank=True)
    inactive_screenshots = models.ManyToManyField(
        Screenshot,
        verbose_name=_("inactive screenshots"),
        help_text=_("A list of screenshots that used to be but are no "
                    "longer associated with this review request."),
        related_name="inactive_review_request",
        blank=True)

    file_attachments = models.ManyToManyField(
        FileAttachment,
        related_name="review_request",
        verbose_name=_("file attachments"),
        blank=True)
    inactive_file_attachments = models.ManyToManyField(
        FileAttachment,
        verbose_name=_("inactive file attachments"),
        help_text=_("A list of file attachments that used to be but are no "
                    "longer associated with this review request."),
        related_name="inactive_review_request",
        blank=True)
    file_attachment_histories = models.ManyToManyField(
        FileAttachmentHistory,
        related_name='review_request',
        verbose_name=_('file attachment histories'),
        blank=True)

    changedescs = models.ManyToManyField(ChangeDescription,
                                         verbose_name=_("change descriptions"),
                                         related_name="review_request",
                                         blank=True)

    depends_on = models.ManyToManyField('ReviewRequest',
                                        blank=True,
                                        null=True,
                                        verbose_name=_('Dependencies'),
                                        related_name='blocks')

    # Review-related information

    # The timestamp representing the last public activity of a review.
    # This includes publishing reviews and manipulating issues.
    last_review_activity_timestamp = models.DateTimeField(
        _("last review activity timestamp"),
        db_column='last_review_timestamp',
        null=True,
        default=None,
        blank=True)
    shipit_count = CounterField(_("ship-it count"), default=0)

    issue_open_count = CounterField(_('open issue count'),
                                    initializer=_initialize_issue_counts)

    issue_resolved_count = CounterField(_('resolved issue count'),
                                        initializer=_initialize_issue_counts)

    issue_dropped_count = CounterField(_('dropped issue count'),
                                       initializer=_initialize_issue_counts)

    issue_verifying_count = CounterField(_('verifying issue count'),
                                         initializer=_initialize_issue_counts)

    screenshots_count = RelationCounterField(
        'screenshots', verbose_name=_('screenshots count'))

    inactive_screenshots_count = RelationCounterField(
        'inactive_screenshots', verbose_name=_('inactive screenshots count'))

    file_attachments_count = RelationCounterField(
        'file_attachments', verbose_name=_('file attachments count'))

    inactive_file_attachments_count = RelationCounterField(
        'inactive_file_attachments',
        verbose_name=_('inactive file attachments count'))

    local_site = models.ForeignKey(LocalSite,
                                   blank=True,
                                   null=True,
                                   related_name='review_requests')
    local_id = models.IntegerField('site-local ID', blank=True, null=True)

    # Set this up with the ReviewRequestManager
    objects = ReviewRequestManager()

    @staticmethod
    def status_to_string(status):
        """Return a string representation of a review request status.

        Args:
            status (unicode):
                A single-character string representing the status.

        Returns:
            unicode:
            A longer string representation of the status suitable for use in
            the API.
        """
        if status == ReviewRequest.PENDING_REVIEW:
            return 'pending'
        elif status == ReviewRequest.SUBMITTED:
            return 'submitted'
        elif status == ReviewRequest.DISCARDED:
            return 'discarded'
        elif status is None:
            return 'all'
        else:
            raise ValueError('Invalid status "%s"' % status)

    @staticmethod
    def string_to_status(status):
        """Return a review request status from an API string.

        Args:
            status (unicode):
                A string from the API representing the status.

        Returns:
            unicode:
            A single-character string representing the status, suitable for
            storage in the ``status`` field.
        """
        if status == 'pending':
            return ReviewRequest.PENDING_REVIEW
        elif status == 'submitted':
            return ReviewRequest.SUBMITTED
        elif status == 'discarded':
            return ReviewRequest.DISCARDED
        elif status == 'all':
            return None
        else:
            raise ValueError('Invalid status string "%s"' % status)

    def get_commit(self):
        if self.commit_id is not None:
            return self.commit_id
        elif self.changenum is not None:
            self.commit_id = six.text_type(self.changenum)

            # Update the state in the database, but don't save this
            # model, or we can end up with some state (if we haven't
            # properly loaded everything yet). This affects docs.db
            # generation, and may cause problems in the wild.
            ReviewRequest.objects.filter(pk=self.pk).update(
                commit_id=six.text_type(self.changenum))

            return self.commit_id

        return None

    def set_commit(self, commit_id):
        try:
            self.changenum = int(commit_id)
        except (TypeError, ValueError):
            pass

        self.commit_id = commit_id

    commit = property(get_commit, set_commit)

    @property
    def approved(self):
        """Returns whether or not a review request is approved by reviewers.

        On a default installation, a review request is approved if it has
        at least one Ship It!, and doesn't have any open issues.

        Extensions may customize approval by providing their own
        ReviewRequestApprovalHook.
        """
        if not hasattr(self, '_approved'):
            self._calculate_approval()

        return self._approved

    @property
    def approval_failure(self):
        """Returns the error indicating why a review request isn't approved.

        If ``approved`` is ``False``, this will provide the text describing
        why it wasn't approved.

        Extensions may customize approval by providing their own
        ReviewRequestApprovalHook.
        """
        if not hasattr(self, '_approval_failure'):
            self._calculate_approval()

        return self._approval_failure

    @property
    def owner(self):
        """The owner of a review request.

        This is an alias for :py:attr:`submitter`. It provides compatibilty
        with :py:attr:`ReviewRequestDraft.owner
        <reviewboard.reviews.models.review_request_draft.ReviewRequestDraft.owner>`,
        for functions working with either method, and for review request
        fields, but it cannot be used for queries.
        """
        return self.submitter

    @owner.setter
    def owner(self, new_owner):
        self.submitter = new_owner

    def get_participants(self):
        """Returns a list of users who have discussed this review request."""
        # See the comment in Review.get_participants for this list
        # comprehension.
        return [
            u for review in self.reviews.all() for u in review.participants
        ]

    participants = property(get_participants)

    def get_new_reviews(self, user):
        """Returns all new reviews since last viewing this review request.

        This will factor in the time the user last visited the review request,
        and find any reviews that have been added or updated since.
        """
        if user.is_authenticated():
            # If this ReviewRequest was queried using with_counts=True,
            # then we should know the new review count and can use this to
            # decide whether we have anything at all to show.
            if hasattr(self, "new_review_count") and self.new_review_count > 0:
                query = self.visits.filter(user=user)

                try:
                    visit = query[0]

                    return self.reviews.filter(
                        public=True,
                        timestamp__gt=visit.timestamp).exclude(user=user)
                except IndexError:
                    # This visit doesn't exist, so bail.
                    pass

        return self.reviews.get_empty_query_set()

    def get_display_id(self):
        """Returns the ID that should be exposed to the user."""
        if self.local_site_id:
            return self.local_id
        else:
            return self.id

    display_id = property(get_display_id)

    def get_public_reviews(self):
        """Returns all public top-level reviews for this review request."""
        return self.reviews.filter(public=True, base_reply_to__isnull=True)

    def is_accessible_by(self,
                         user,
                         local_site=None,
                         request=None,
                         silent=False):
        """Returns whether or not the user can read this review request.

        This performs several checks to ensure that the user has access.
        This user has access if:

        * The review request is public or the user can modify it (either
          by being an owner or having special permissions).

        * The repository is public or the user has access to it (either by
          being explicitly on the allowed users list, or by being a member
          of a review group on that list).

        * The user is listed as a requested reviewer or the user has access
          to one or more groups listed as requested reviewers (either by
          being a member of an invite-only group, or the group being public).
        """
        # Users always have access to their own review requests.
        if self.submitter == user:
            return True

        if not self.public and not self.is_mutable_by(user):
            if not silent:
                logging.warning(
                    'Review Request pk=%d (display_id=%d) is not '
                    'accessible by user %s because it has not yet '
                    'been published.',
                    self.pk,
                    self.display_id,
                    user,
                    request=request)

            return False

        if self.repository and not self.repository.is_accessible_by(user):
            if not silent:
                logging.warning(
                    'Review Request pk=%d (display_id=%d) is not '
                    'accessible by user %s because its repository '
                    'is not accessible by that user.',
                    self.pk,
                    self.display_id,
                    user,
                    request=request)

            return False

        if local_site and not local_site.is_accessible_by(user):
            if not silent:
                logging.warning(
                    'Review Request pk=%d (display_id=%d) is not '
                    'accessible by user %s because its local_site '
                    'is not accessible by that user.',
                    self.pk,
                    self.display_id,
                    user,
                    request=request)

            return False

        if (user.is_authenticated()
                and self.target_people.filter(pk=user.pk).count() > 0):
            return True

        groups = list(self.target_groups.all())

        if not groups:
            return True

        # We specifically iterate over these instead of making it part
        # of the query in order to keep the logic in Group, and to allow
        # for future expansion (extensions, more advanced policy)
        #
        # We're looking for at least one group that the user has access
        # to. If they can access any of the groups, then they have access
        # to the review request.
        for group in groups:
            if group.is_accessible_by(user, silent=silent):
                return True

        if not silent:
            logging.warning(
                'Review Request pk=%d (display_id=%d) is not '
                'accessible by user %s because they are not '
                'directly listed as a reviewer, and none of '
                'the target groups are accessible by that user.',
                self.pk,
                self.display_id,
                user,
                request=request)

        return False

    def is_mutable_by(self, user):
        """Return whether the user can modify this review request.

        Args:
            user (django.contrib.auth.models.User):
                The user to check.

        Returns:
            bool:
            Whether the user can modify this review request.
        """
        return ((self.submitter == user or user.has_perm(
            'reviews.can_edit_reviewrequest', self.local_site))
                and not is_site_read_only_for(user))

    def is_status_mutable_by(self, user):
        """Return whether the user can modify this review request's status.

        Args:
            user (django.contrib.auth.models.User):
                The user to check.

        Returns:
            bool:
            Whether the user can modify this review request's status.
        """
        return ((self.submitter == user or user.has_perm(
            'reviews.can_change_status', self.local_site))
                and not is_site_read_only_for(user))

    def is_deletable_by(self, user):
        """Return whether the user can delete this review request.

        Args:
            user (django.contrib.auth.models.User):
                The user to check.

        Returns:
            bool:
            Whether the user can delete this review request.
        """
        return (user.has_perm('reviews.delete_reviewrequest')
                and not is_site_read_only_for(user))

    def get_draft(self, user=None):
        """Returns the draft of the review request.

        If a user is specified, than the draft will be returned only if owned
        by the user. Otherwise, None will be returned.
        """
        if not user:
            return get_object_or_none(self.draft)
        elif user.is_authenticated():
            return get_object_or_none(self.draft,
                                      review_request__submitter=user)

        return None

    def get_pending_review(self, user):
        """Returns the pending review owned by the specified user, if any.

        This will return an actual review, not a reply to a review.
        """
        from reviewboard.reviews.models.review import Review

        return Review.objects.get_pending_review(self, user)

    def get_last_activity(self, diffsets=None, reviews=None):
        """Returns the last public activity information on the review request.

        This will return the last object updated, along with the timestamp
        of that object. It can be used to judge whether something on a
        review request has been made public more recently.
        """
        timestamp = self.last_updated
        updated_object = self

        # Check if the diff was updated along with this.
        if not diffsets and self.repository_id:
            latest_diffset = self.get_latest_diffset()
            diffsets = []

            if latest_diffset:
                diffsets.append(latest_diffset)

        if diffsets:
            for diffset in diffsets:
                if diffset.timestamp >= timestamp:
                    timestamp = diffset.timestamp
                    updated_object = diffset

        # Check for the latest review or reply.
        if not reviews:
            try:
                reviews = [self.reviews.filter(public=True).latest()]
            except ObjectDoesNotExist:
                reviews = []

        for review in reviews:
            if review.public and review.timestamp >= timestamp:
                timestamp = review.timestamp
                updated_object = review

        return timestamp, updated_object

    def changeset_is_pending(self, commit_id):
        """Returns whether the associated changeset is pending commit.

        For repositories that support it, this will return whether the
        associated changeset is pending commit. This requires server-side
        knowledge of the change.
        """
        cache_key = make_cache_key('commit-id-is-pending-%d-%s' %
                                   (self.pk, commit_id))

        cached_values = cache.get(cache_key)
        if cached_values:
            return cached_values

        is_pending = False

        scmtool = self.repository.get_scmtool()
        if (scmtool.supports_pending_changesets and commit_id is not None):
            changeset = scmtool.get_changeset(commit_id, allow_empty=True)

            if changeset:
                is_pending = changeset.pending

                new_commit_id = six.text_type(changeset.changenum)

                if commit_id != new_commit_id:
                    self.commit_id = new_commit_id
                    self.save(update_fields=['commit_id'])
                    commit_id = new_commit_id

                    draft = self.get_draft()
                    if draft:
                        draft.commit_id = new_commit_id
                        draft.save(update_fields=['commit_id'])

                # If the changeset is pending, we cache for only one minute to
                # speed things up a little bit when navigating through
                # different pages. If the changeset is no longer pending, cache
                # for the full default time.
                if is_pending:
                    cache.set(cache_key, (is_pending, commit_id), 60)
                else:
                    cache.set(cache_key, (is_pending, commit_id))

        return is_pending, commit_id

    def get_absolute_url(self):
        if self.local_site:
            local_site_name = self.local_site.name
        else:
            local_site_name = None

        return local_site_reverse(
            'review-request-detail',
            local_site_name=local_site_name,
            kwargs={'review_request_id': self.display_id})

    def get_diffsets(self):
        """Returns a list of all diffsets on this review request.

        This will also fetch all associated FileDiffs, as well as a count
        of the number of files (stored in DiffSet.file_count).
        """
        if not self.repository_id:
            return []

        if not hasattr(self, '_diffsets'):
            self._diffsets = list(
                DiffSet.objects.filter(
                    history__pk=self.diffset_history_id).annotate(
                        file_count=Count('files')).prefetch_related('files'))

        return self._diffsets

    def get_latest_diffset(self):
        """Returns the latest diffset for this review request."""
        try:
            return DiffSet.objects.filter(
                history=self.diffset_history_id).latest()
        except DiffSet.DoesNotExist:
            return None

    def get_close_info(self):
        """Return metadata of the most recent closing of a review request.

        This is a helper which is used to gather the data which is rendered in
        the close description boxes on various pages.

        Returns:
            dict:
            A dictionary with the following keys:

            ``'close_description'`` (:py:class:`unicode`):
                Description of review request upon closing.

            ``'is_rich_text'`` (:py:class:`bool`):
                Boolean whether description is rich text.

            ``'timestamp'`` (:py:class:`datetime.datetime`):
                Time of review requests last closing.
        """
        # We're fetching all entries instead of just public ones because
        # another query may have already prefetched the list of
        # changedescs. In this case, a new filter() would result in more
        # queries.
        #
        # Realistically, there will only ever be at most a single
        # non-public change description (the current draft), so we
        # wouldn't be saving much of anything with a filter.
        changedescs = list(self.changedescs.all())
        latest_changedesc = None
        timestamp = None

        for changedesc in changedescs:
            if changedesc.public:
                latest_changedesc = changedesc
                break

        close_description = ''
        is_rich_text = False

        if latest_changedesc and 'status' in latest_changedesc.fields_changed:
            status = latest_changedesc.fields_changed['status']['new'][0]

            if status in (ReviewRequest.DISCARDED, ReviewRequest.SUBMITTED):
                close_description = latest_changedesc.text
                is_rich_text = latest_changedesc.rich_text
                timestamp = latest_changedesc.timestamp

        return {
            'close_description': close_description,
            'is_rich_text': is_rich_text,
            'timestamp': timestamp
        }

    def get_close_description(self):
        """Return metadata of the most recent closing of a review request.

        This is a helper which is used to gather the data which is rendered in
        the close description boxes on various pages.

        .. deprecated:: 3.0
           Use :py:meth:`get_close_info` instead

        Returns:
            tuple:
            A 2-tuple of:

            * The close description (:py:class:`unicode`)
            * Whether or not the close description is rich text
              (:py:class:`bool`)
        """
        warnings.warn(
            'ReviewRequest.get_close_description() is deprecated. '
            'Use ReviewRequest.get_close_info().', DeprecationWarning)

        close_info = self.get_close_info()
        return (close_info['close_description'], close_info['is_rich_text'])

    def get_blocks(self):
        """Returns the list of review request this one blocks.

        The returned value will be cached for future lookups.
        """
        if not hasattr(self, '_blocks'):
            self._blocks = list(self.blocks.all())

        return self._blocks

    def save(self, update_counts=False, old_submitter=None, **kwargs):
        if update_counts or self.id is None:
            self._update_counts(old_submitter)

        if self.status != self.PENDING_REVIEW:
            # If this is not a pending review request now, delete any
            # and all ReviewRequestVisit objects.
            self.visits.all().delete()

        super(ReviewRequest, self).save(**kwargs)

    def delete(self, **kwargs):
        from reviewboard.accounts.models import Profile, LocalSiteProfile

        profile, profile_is_new = \
            Profile.objects.get_or_create(user=self.submitter)

        if profile_is_new:
            profile.save()

        local_site = self.local_site
        site_profile, site_profile_is_new = \
            LocalSiteProfile.objects.get_or_create(user=self.submitter,
                                                   profile=profile,
                                                   local_site=local_site)

        site_profile.decrement_total_outgoing_request_count()

        if self.status == self.PENDING_REVIEW:
            site_profile.decrement_pending_outgoing_request_count()

            if self.public:
                self._decrement_reviewer_counts()

        super(ReviewRequest, self).delete(**kwargs)

    def can_publish(self):
        return not self.public or get_object_or_none(self.draft) is not None

    def close(self,
              close_type=None,
              user=None,
              description=None,
              rich_text=False,
              **kwargs):
        """Closes the review request.

        Args:
            close_type (unicode):
                How the close occurs. This should be one of
                :py:attr:`SUBMITTED` or :py:attr:`DISCARDED`.

            user (django.contrib.auth.models.User):
                The user who is closing the review request.

            description (unicode):
                An optional description that indicates why the review request
                was closed.

            rich_text (bool):
                Indicates whether or not that the description is rich text.

        Raises:
            ValueError:
                The provided close type is not a valid value.

            PermissionError:
                The user does not have permission to close the review request.

            TypeError:
                Keyword arguments were supplied to the function.

        .. versionchanged:: 3.0
           The ``type`` argument is deprecated: ``close_type`` should be used
           instead.

           This method raises :py:exc:`ValueError` instead of
           :py:exc:`AttributeError` when the ``close_type`` has an incorrect
           value.
        """
        if close_type is None:
            try:
                close_type = kwargs.pop('type')
            except KeyError:
                raise AttributeError('close_type must be provided')

            warnings.warn(
                'The "type" argument was deprecated in Review Board 3.0 and '
                'will be removed in a future version. Use "close_type" '
                'instead.')

        if kwargs:
            raise TypeError('close() does not accept keyword arguments.')

        if (user and not self.is_mutable_by(user) and not user.has_perm(
                "reviews.can_change_status", self.local_site)):
            raise PermissionError

        if close_type not in [self.SUBMITTED, self.DISCARDED]:
            raise ValueError("%s is not a valid close type" % type)

        review_request_closing.send(sender=type(self),
                                    user=user,
                                    review_request=self,
                                    close_type=close_type,
                                    type=deprecated_signal_argument(
                                        signal_name='review_request_closing',
                                        old_name='type',
                                        new_name='close_type',
                                        value=close_type),
                                    description=description,
                                    rich_text=rich_text)

        draft = get_object_or_none(self.draft)

        if self.status != close_type:
            if (draft is not None and not self.public
                    and close_type == self.DISCARDED):
                # Copy over the draft information if this is a private discard.
                draft.copy_fields_to_request(self)

            # TODO: Use the user's default for rich_text.
            changedesc = ChangeDescription(public=True,
                                           text=description or "",
                                           rich_text=rich_text or False,
                                           user=user or self.submitter)

            status_field = get_review_request_field('status')(self)
            status_field.record_change_entry(changedesc, self.status,
                                             close_type)
            changedesc.save()

            self.changedescs.add(changedesc)

            if close_type == self.SUBMITTED:
                if not self.public:
                    raise PublishError("The draft must be public first.")
            else:
                self.commit_id = None

            self.status = close_type
            self.save(update_counts=True)

            review_request_closed.send(sender=type(self),
                                       user=user,
                                       review_request=self,
                                       close_type=close_type,
                                       type=deprecated_signal_argument(
                                           signal_name='review_request_closed',
                                           old_name='type',
                                           new_name='close_type',
                                           value=close_type),
                                       description=description,
                                       rich_text=rich_text)
        else:
            # Update submission description.
            changedesc = self.changedescs.filter(public=True).latest()
            changedesc.timestamp = timezone.now()
            changedesc.text = description or ""
            changedesc.rich_text = rich_text
            changedesc.save()

            # Needed to renew last-update.
            self.save()

        # Delete the associated draft review request.
        if draft is not None:
            draft.delete()

    def reopen(self, user=None):
        """Reopens the review request for review."""
        from reviewboard.reviews.models.review_request_draft import \
            ReviewRequestDraft

        if (user and not self.is_mutable_by(user) and not user.has_perm(
                "reviews.can_change_status", self.local_site)):
            raise PermissionError

        old_status = self.status
        old_public = self.public

        if old_status != self.PENDING_REVIEW:
            # The reopening signal is only fired when actually making a status
            # change since the main consumers (extensions) probably only care
            # about changes.
            review_request_reopening.send(sender=self.__class__,
                                          user=user,
                                          review_request=self)

            changedesc = ChangeDescription(user=user or self.submitter)
            status_field = get_review_request_field('status')(self)
            status_field.record_change_entry(changedesc, old_status,
                                             self.PENDING_REVIEW)

            if old_status == self.DISCARDED:
                # A draft is needed if reopening a discarded review request.
                self.public = False
                changedesc.save()
                draft = ReviewRequestDraft.create(self)
                draft.changedesc = changedesc
                draft.save()
            else:
                changedesc.public = True
                changedesc.save()
                self.changedescs.add(changedesc)

            self.status = self.PENDING_REVIEW
            self.save(update_counts=True)

        review_request_reopened.send(sender=self.__class__,
                                     user=user,
                                     review_request=self,
                                     old_status=old_status,
                                     old_public=old_public)

    def publish(self, user, trivial=False, validate_fields=True):
        """Publishes the current draft attached to this review request.

        The review request will be mark as public, and signals will be
        emitted for any listeners.
        """
        if not self.is_mutable_by(user):
            raise PermissionError

        draft = get_object_or_none(self.draft)
        old_submitter = self.submitter

        review_request_publishing.send(sender=self.__class__,
                                       user=user,
                                       review_request_draft=draft)

        # Decrement the counts on everything. we lose them.
        # We'll increment the resulting set during ReviewRequest.save.
        # This should be done before the draft is published.
        # Once the draft is published, the target people
        # and groups will be updated with new values.
        # Decrement should not happen while publishing
        # a new request or a discarded request
        if self.public:
            self._decrement_reviewer_counts()

        if draft is not None:
            # This will in turn save the review request, so we'll be done.
            try:
                changes = draft.publish(self,
                                        send_notification=False,
                                        user=user,
                                        validate_fields=validate_fields)
            except Exception:
                # The draft failed to publish, for one reason or another.
                # Check if we need to re-increment those counters we
                # previously decremented.
                if self.public:
                    self._increment_reviewer_counts()

                raise

            draft.delete()
        else:
            changes = None

        if not self.public and self.changedescs.count() == 0:
            # This is a brand new review request that we're publishing
            # for the first time. Set the creation timestamp to now.
            self.time_added = timezone.now()

        self.public = True
        self.save(update_counts=True, old_submitter=old_submitter)

        review_request_published.send(sender=self.__class__,
                                      user=user,
                                      review_request=self,
                                      trivial=trivial,
                                      changedesc=changes)

    def determine_user_for_changedesc(self, changedesc):
        """Determine the user associated with the change description.

        Args:
            changedesc (reviewboard.changedescs.models.ChangeDescription):
                The change description.

        Returns:
            django.contrib.auth.models.User:
            The user associated with the change description.
        """
        if 'submitter' in changedesc.fields_changed:
            entry = changedesc.fields_changed['submitter']['old'][0]
            return User.objects.get(pk=entry[2])

        user_pk = None

        changes = (self.changedescs.filter(
            pk__lt=changedesc.pk).order_by('-pk'))

        for changedesc in changes:
            if 'submitter' in changedesc.fields_changed:
                user_pk = changedesc.fields_changed['submitter']['new'][0][2]
                break

        if user_pk:
            return User.objects.get(pk=user_pk)

        return self.submitter

    def _update_counts(self, old_submitter):
        from reviewboard.accounts.models import Profile, LocalSiteProfile

        submitter_changed = (old_submitter is not None
                             and old_submitter != self.submitter)

        profile, profile_is_new = \
            Profile.objects.get_or_create(user=self.submitter)

        if profile_is_new:
            profile.save()

        local_site = self.local_site
        site_profile, site_profile_is_new = \
            LocalSiteProfile.objects.get_or_create(
                user=self.submitter,
                profile=profile,
                local_site=local_site)

        if site_profile_is_new:
            site_profile.save()

        if self.id is None:
            # This hasn't been created yet. Bump up the outgoing request
            # count for the user.
            site_profile.increment_total_outgoing_request_count()
            old_status = None
            old_public = False
        else:
            # We need to see if the status has changed, so that means
            # finding out what's in the database.
            r = ReviewRequest.objects.get(pk=self.id)
            old_status = r.status
            old_public = r.public

            if submitter_changed:
                if not site_profile_is_new:
                    site_profile.increment_total_outgoing_request_count()

                    if self.status == self.PENDING_REVIEW:
                        site_profile.increment_pending_outgoing_request_count()

                try:
                    old_profile = LocalSiteProfile.objects.get(
                        user=old_submitter, local_site=local_site)
                    old_profile.decrement_total_outgoing_request_count()

                    if old_status == self.PENDING_REVIEW:
                        old_profile.decrement_pending_outgoing_request_count()
                except LocalSiteProfile.DoesNotExist:
                    pass

        if self.status == self.PENDING_REVIEW:
            if old_status != self.status and not submitter_changed:
                site_profile.increment_pending_outgoing_request_count()

            if self.public and self.id is not None:
                self._increment_reviewer_counts()
        elif old_status == self.PENDING_REVIEW:
            if old_status != self.status and not submitter_changed:
                site_profile.decrement_pending_outgoing_request_count()

            if old_public:
                self._decrement_reviewer_counts()

    def _increment_reviewer_counts(self):
        from reviewboard.accounts.models import LocalSiteProfile

        groups = self.target_groups.all()
        people = self.target_people.all()

        Group.incoming_request_count.increment(groups)
        LocalSiteProfile.direct_incoming_request_count.increment(
            LocalSiteProfile.objects.filter(user__in=people,
                                            local_site=self.local_site))
        LocalSiteProfile.total_incoming_request_count.increment(
            LocalSiteProfile.objects.filter(
                Q(local_site=self.local_site)
                & Q(Q(user__review_groups__in=groups) | Q(user__in=people))))
        LocalSiteProfile.starred_public_request_count.increment(
            LocalSiteProfile.objects.filter(
                profile__starred_review_requests=self,
                local_site=self.local_site))

    def _decrement_reviewer_counts(self):
        from reviewboard.accounts.models import LocalSiteProfile

        groups = self.target_groups.all()
        people = self.target_people.all()

        Group.incoming_request_count.decrement(groups)
        LocalSiteProfile.direct_incoming_request_count.decrement(
            LocalSiteProfile.objects.filter(user__in=people,
                                            local_site=self.local_site))
        LocalSiteProfile.total_incoming_request_count.decrement(
            LocalSiteProfile.objects.filter(
                Q(local_site=self.local_site)
                & Q(Q(user__review_groups__in=groups) | Q(user__in=people))))
        LocalSiteProfile.starred_public_request_count.decrement(
            LocalSiteProfile.objects.filter(
                profile__starred_review_requests=self,
                local_site=self.local_site))

    def _calculate_approval(self):
        """Calculates the approval information for the review request."""
        from reviewboard.extensions.hooks import ReviewRequestApprovalHook

        approved = True
        failure = None

        if self.shipit_count == 0:
            approved = False
            failure = 'The review request has not been marked "Ship It!"'
        elif self.issue_open_count > 0:
            approved = False
            failure = 'The review request has open issues.'
        elif self.issue_verifying_count > 0:
            approved = False
            failure = 'The review request has unverified issues.'

        for hook in ReviewRequestApprovalHook.hooks:
            try:
                result = hook.is_approved(self, approved, failure)

                if isinstance(result, tuple):
                    approved, failure = result
                elif isinstance(result, bool):
                    approved = result
                else:
                    raise ValueError('%r returned an invalid value %r from '
                                     'is_approved' % (hook, result))

                if approved:
                    failure = None
            except Exception as e:
                extension = hook.extension
                logging.error(
                    'Error when running ReviewRequestApprovalHook.'
                    'is_approved function in extension: "%s": %s',
                    extension.id,
                    e,
                    exc_info=1)

        self._approval_failure = failure
        self._approved = approved

    def get_review_request(self):
        """Returns this review request.

        This is provided so that consumers can be passed either a
        ReviewRequest or a ReviewRequestDraft and retrieve the actual
        ReviewRequest regardless of the object.
        """
        return self

    class Meta:
        app_label = 'reviews'
        db_table = 'reviews_reviewrequest'
        ordering = ['-last_updated', 'submitter', 'summary']
        unique_together = (('commit_id', 'repository'),
                           ('changenum', 'repository'), ('local_site',
                                                         'local_id'))
        permissions = (
            ("can_change_status", "Can change status"),
            ("can_submit_as_another_user", "Can submit as another user"),
            ("can_edit_reviewrequest", "Can edit review request"),
        )
        verbose_name = _('Review Request')
        verbose_name_plural = _('Review Requests')
Esempio n. 5
0
class ReviewRequestDraft(BaseReviewRequestDetails):
    """A draft of a review request.

    When a review request is being modified, a special draft copy of it is
    created containing all the details of the review request. This copy can
    be modified and eventually saved or discarded. When saved, the new
    details are copied back over to the originating ReviewRequest.
    """
    summary = models.CharField(
        _("summary"),
        max_length=BaseReviewRequestDetails.MAX_SUMMARY_LENGTH)

    owner = models.ForeignKey(
        User,
        verbose_name=_('owner'),
        null=True,
        related_name='draft')
    review_request = models.ForeignKey(
        ReviewRequest,
        related_name="draft",
        verbose_name=_("review request"),
        unique=True)
    last_updated = ModificationTimestampField(
        _("last updated"))
    diffset = models.ForeignKey(
        DiffSet,
        verbose_name=_('diff set'),
        blank=True,
        null=True,
        related_name='review_request_draft')
    changedesc = models.ForeignKey(
        ChangeDescription,
        verbose_name=_('change description'),
        blank=True,
        null=True)
    target_groups = models.ManyToManyField(
        Group,
        related_name="drafts",
        verbose_name=_("target groups"),
        blank=True)
    target_people = models.ManyToManyField(
        User,
        verbose_name=_("target people"),
        related_name="directed_drafts",
        blank=True)
    screenshots = models.ManyToManyField(
        Screenshot,
        related_name="drafts",
        verbose_name=_("screenshots"),
        blank=True)
    inactive_screenshots = models.ManyToManyField(
        Screenshot,
        verbose_name=_("inactive screenshots"),
        related_name="inactive_drafts",
        blank=True)

    file_attachments = models.ManyToManyField(
        FileAttachment,
        related_name="drafts",
        verbose_name=_("file attachments"),
        blank=True)
    inactive_file_attachments = models.ManyToManyField(
        FileAttachment,
        verbose_name=_("inactive files"),
        related_name="inactive_drafts",
        blank=True)

    submitter = property(lambda self: self.owner or
                         self.review_request.owner)
    repository = property(lambda self: self.review_request.repository)
    local_site = property(lambda self: self.review_request.local_site)

    depends_on = models.ManyToManyField('ReviewRequest',
                                        blank=True, null=True,
                                        verbose_name=_('Dependencies'),
                                        related_name='draft_blocks')

    screenshots_count = RelationCounterField(
        'screenshots',
        verbose_name=_('screenshots count'))

    inactive_screenshots_count = RelationCounterField(
        'inactive_screenshots',
        verbose_name=_('inactive screenshots count'))

    file_attachments_count = RelationCounterField(
        'file_attachments',
        verbose_name=_('file attachments count'))

    inactive_file_attachments_count = RelationCounterField(
        'inactive_file_attachments',
        verbose_name=_('inactive file attachments count'))

    # Set this up with a ConcurrencyManager to help prevent race conditions.
    objects = ConcurrencyManager()

    commit = property(lambda self: self.commit_id,
                      lambda self, value: setattr(self, 'commit_id', value))

    def get_latest_diffset(self):
        """Returns the diffset for this draft."""
        return self.diffset

    def is_accessible_by(self, user):
        """Returns whether or not the user can access this draft."""
        return self.is_mutable_by(user)

    def is_mutable_by(self, user):
        """Returns whether or not the user can modify this draft."""
        return self.review_request.is_mutable_by(user)

    @staticmethod
    def create(review_request):
        """Creates a draft based on a review request.

        This will copy over all the details of the review request that
        we care about. If a draft already exists for the review request,
        the draft will be returned.
        """
        draft, draft_is_new = \
            ReviewRequestDraft.objects.get_or_create(
                review_request=review_request,
                defaults={
                    'summary': review_request.summary,
                    'description': review_request.description,
                    'testing_done': review_request.testing_done,
                    'bugs_closed': review_request.bugs_closed,
                    'branch': review_request.branch,
                    'description_rich_text':
                        review_request.description_rich_text,
                    'testing_done_rich_text':
                        review_request.testing_done_rich_text,
                    'rich_text': review_request.rich_text,
                    'commit_id': review_request.commit_id,
                })

        if draft.changedesc is None and review_request.public:
            draft.changedesc = ChangeDescription.objects.create()
        if draft_is_new:
            draft.target_groups = review_request.target_groups.all()
            draft.target_people = review_request.target_people.all()
            draft.depends_on = review_request.depends_on.all()
            draft.extra_data = copy.deepcopy(review_request.extra_data)
            draft.save()

            if review_request.screenshots_count > 0:
                review_request.screenshots.update(draft_caption=F('caption'))
                draft.screenshots = review_request.screenshots.all()

            if review_request.inactive_screenshots_count > 0:
                review_request.inactive_screenshots.update(
                    draft_caption=F('caption'))
                draft.inactive_screenshots = \
                    review_request.inactive_screenshots.all()

            if review_request.file_attachments_count > 0:
                review_request.file_attachments.update(
                    draft_caption=F('caption'))
                draft.file_attachments = review_request.file_attachments.all()

            if review_request.inactive_file_attachments_count > 0:
                review_request.inactive_file_attachments.update(
                    draft_caption=F('caption'))
                draft.inactive_file_attachments = \
                    review_request.inactive_file_attachments.all()

        return draft

    def publish(self, review_request=None, user=None, trivial=False,
                send_notification=True, validate_fields=True, timestamp=None):

        """Publish this draft.

        This is an internal method. Programmatic publishes should use
        :py:meth:`reviewboard.reviews.models.review_request.ReviewRequest.publish`
        instead.

        This updates and returns the draft's ChangeDescription, which
        contains the changed fields. This is used by the e-mail template
        to tell people what's new and interesting.

        The keys that may be saved in ``fields_changed`` in the
        ChangeDescription are:

        *  ``submitter``
        *  ``summary``
        *  ``description``
        *  ``testing_done``
        *  ``bugs_closed``
        *  ``depends_on``
        *  ``branch``
        *  ``target_groups``
        *  ``target_people``
        *  ``screenshots``
        *  ``screenshot_captions``
        *  ``diff``
        *  Any custom field IDs

        Each field in 'fields_changed' represents a changed field. This will
        save fields in the standard formats as defined by the
        'ChangeDescription' documentation, with the exception of the
        'screenshot_captions' and 'diff' fields.

        For the 'screenshot_captions' field, the value will be a dictionary
        of screenshot ID/dict pairs with the following fields:

        * ``old``: The old value of the field
        * ``new``: The new value of the field

        For the ``diff`` field, there is only ever an ``added`` field,
        containing the ID of the new diffset.

        Args:
            review_request (reviewboard.reviews.models.review_request.
                            ReviewRequest, optional):
                The review request associated with this diff. If not provided,
                it will be looked up.

            user (django.contrib.auth.models.User, optional):
                The user publishing the draft. If not provided, this defaults
                to the review request submitter.

            trivial (bool, optional):
                Whether or not this is a trivial publish.

                Trivial publishes do not result in e-mail notifications.

            send_notification (bool, optional):
                Whether or not this will emit the
                :py:data:`reviewboard.reviews.signals.review_request_published`
                signal.

                This parameter is intended for internal use **only**.

            validate_fields (bool, optional):
                Whether or not the fields should be validated.

                This should only be ``False`` in the case of programmatic
                publishes, e.g., from close as submitted hooks.

            timestamp (datetime.datetime, optional):
                The datetime that should be used for all timestamps for objects
                published
                (:py:class:`~reviewboard.diffviewer.models.diff_set.DiffSet`,
                :py:class:`~reviewboard.changedescs.models.ChangeDescription`)
                over the course of the method.

        Returns:
            reviewboard.changedescs.models.ChangeDescription:
            The change description that results from this publish (if any).

            If this is an initial publish, there will be no change description
            (and this function will return ``None``).
        """
        if timestamp is None:
            timestamp = timezone.now()

        if not review_request:
            review_request = self.review_request

        if not self.changedesc and review_request.public:
            self.changedesc = ChangeDescription()

        if not user:
            if self.changedesc:
                user = self.changedesc.get_user(self)
            else:
                user = review_request.submitter

        self.copy_fields_to_request(review_request)

        # If no changes were made, raise exception and do not save
        if self.changedesc and not self.changedesc.has_modified_fields():
            raise NotModifiedError()

        if validate_fields:
            if not (self.target_groups.exists() or
                    self.target_people.exists()):
                raise PublishError(
                    ugettext('There must be at least one reviewer before this '
                             'review request can be published.'))

            if not review_request.summary.strip():
                raise PublishError(
                    ugettext('The draft must have a summary.'))

            if not review_request.description.strip():
                raise PublishError(
                    ugettext('The draft must have a description.'))

        if self.diffset:
            self.diffset.history = review_request.diffset_history
            self.diffset.timestamp = timestamp
            self.diffset.save(update_fields=('history', 'timestamp'))

        if self.changedesc:
            self.changedesc.user = user
            self.changedesc.timestamp = timestamp
            self.changedesc.public = True
            self.changedesc.save()
            review_request.changedescs.add(self.changedesc)

        review_request.description_rich_text = self.description_rich_text
        review_request.testing_done_rich_text = self.testing_done_rich_text
        review_request.rich_text = self.rich_text
        review_request.save()

        if send_notification:
            review_request_published.send(sender=type(review_request),
                                          user=user,
                                          review_request=review_request,
                                          trivial=trivial,
                                          changedesc=self.changedesc)

        return self.changedesc

    def update_from_commit_id(self, commit_id):
        """Update the data from a server-side changeset.

        If the commit ID refers to a pending changeset on an SCM which stores
        such things server-side (like Perforce), the details like the summary
        and description will be updated with the latest information.

        If the change number is the commit ID of a change which exists on the
        server, the summary and description will be set from the commit's
        message, and the diff will be fetched from the SCM.

        Args:
            commit_id (unicode):
                The commit ID or changeset ID that the draft will update
                from.
        """
        scmtool = self.repository.get_scmtool()
        changeset = None

        if scmtool.supports_pending_changesets:
            changeset = scmtool.get_changeset(commit_id, allow_empty=True)

        if changeset and changeset.pending:
            self.update_from_pending_change(commit_id, changeset)
        elif self.repository.supports_post_commit:
            self.update_from_committed_change(commit_id)
        else:
            if changeset:
                raise InvalidChangeNumberError()
            else:
                raise NotImplementedError()

    def update_from_pending_change(self, commit_id, changeset):
        """Update the data from a server-side pending changeset.

        This will fetch the metadata from the server and update the fields on
        the draft.

        Args:
            commit_id (unicode):
                The changeset ID that the draft will update from.

            changeset (reviewboard.scmtools.core.ChangeSet):
                The changeset information to update from.
        """
        if not changeset:
            raise InvalidChangeNumberError()

        # If the SCM supports changesets, they should always include a number,
        # summary and description, parsed from the changeset description. Some
        # specialized systems may support the other fields, but we don't want
        # to clobber the user-entered values if they don't.
        self.commit = commit_id
        description = changeset.description
        testing_done = changeset.testing_done

        self.summary = changeset.summary
        self.description = description
        self.description_rich_text = False

        if testing_done:
            self.testing_done = testing_done
            self.testing_done_rich_text = False

        if changeset.branch:
            self.branch = changeset.branch

        if changeset.bugs_closed:
            self.bugs_closed = ','.join(changeset.bugs_closed)

    def update_from_committed_change(self, commit_id):
        """Update from a committed change present on the server.

        Fetches the commit message and diff from the repository and sets the
        relevant fields.

        Args:
            commit_id (unicode):
                The commit ID to update from.
        """
        commit = self.repository.get_change(commit_id)
        summary, message = commit.split_message()
        message = message.strip()

        self.commit = commit_id
        self.summary = summary.strip()

        self.description = message
        self.description_rich_text = False

        self.diffset = DiffSet.objects.create_from_data(
            repository=self.repository,
            diff_file_name='diff',
            diff_file_contents=commit.diff,
            parent_diff_file_name=None,
            parent_diff_file_contents=None,
            diffset_history=None,
            basedir='/',
            request=None,
            base_commit_id=commit.parent,
            check_existence=False)

        # Compute a suitable revision for the diffset.
        self.diffset.update_revision_from_history(
            self.review_request.diffset_history)
        self.diffset.save(update_fields=('revision',))

    def copy_fields_to_request(self, review_request):
        """Copies the draft information to the review request and updates the
        draft's change description.
        """
        def update_list(a, b, name, record_changes=True, name_field=None):
            aset = set([x.id for x in a.all()])
            bset = set([x.id for x in b.all()])

            if aset.symmetric_difference(bset):
                if record_changes and self.changedesc:
                    self.changedesc.record_field_change(name, a.all(), b.all(),
                                                        name_field)

                a.clear()
                for item in b.all():
                    a.add(item)

        for field_cls in get_review_request_fields():
            field = field_cls(review_request)

            if field.can_record_change_entry:
                old_value = field.load_value(review_request)
                new_value = field.load_value(self)

                if field.has_value_changed(old_value, new_value):
                    field.propagate_data(self)

                    if self.changedesc:
                        field.record_change_entry(self.changedesc,
                                                  old_value, new_value)

        # Screenshots are a bit special.  The list of associated screenshots
        # can change, but so can captions within each screenshot.
        if (review_request.screenshots_count > 0 or
            self.screenshots_count > 0):
            screenshots = list(self.screenshots.all())
            caption_changes = {}

            for s in review_request.screenshots.all():
                if s in screenshots and s.caption != s.draft_caption:
                    caption_changes[s.id] = {
                        'old': (s.caption,),
                        'new': (s.draft_caption,),
                    }

                    s.caption = s.draft_caption
                    s.save(update_fields=['caption'])

            # Now scan through again and set the caption correctly for
            # newly-added screenshots by copying the draft_caption over. We
            # don't need to include this in the changedescs here because it's a
            # new screenshot, and update_list will record the newly-added item.
            for s in screenshots:
                if s.caption != s.draft_caption:
                    s.caption = s.draft_caption
                    s.save(update_fields=['caption'])

            if caption_changes and self.changedesc:
                self.changedesc.fields_changed['screenshot_captions'] = \
                    caption_changes

            update_list(review_request.screenshots,
                        self.screenshots,
                        name='screenshots',
                        name_field="caption")

        if (review_request.inactive_screenshots_count > 0 or
            self.inactive_screenshots_count > 0):
            # There's no change notification required for this field.
            review_request.inactive_screenshots = \
                self.inactive_screenshots.all()

        # Files are treated like screenshots. The list of files can
        # change, but so can captions within each file.
        if (review_request.file_attachments_count > 0 or
            self.file_attachments_count > 0):
            files = list(self.file_attachments.all())
            caption_changes = {}

            for f in review_request.file_attachments.all():
                if f in files and f.caption != f.draft_caption:
                    caption_changes[f.id] = {
                        'old': (f.caption,),
                        'new': (f.draft_caption,),
                    }

                    f.caption = f.draft_caption
                    f.save(update_fields=['caption'])

            # Now scan through again and set the caption correctly for
            # newly-added files by copying the draft_caption over. We don't
            # need to include this in the changedescs here because it's a new
            # screenshot, and update_list will record the newly-added item.
            for f in files:
                if f.caption != f.draft_caption:
                    f.caption = f.draft_caption
                    f.save(update_fields=['caption'])

            if caption_changes and self.changedesc:
                self.changedesc.fields_changed['file_captions'] = \
                    caption_changes

            update_list(review_request.file_attachments,
                        self.file_attachments,
                        name='files',
                        name_field="display_name")

        if (review_request.inactive_file_attachments_count > 0 or
            self.inactive_file_attachments_count > 0):
            # There's no change notification required for this field.
            review_request.inactive_file_attachments = \
                self.inactive_file_attachments.all()

    def get_review_request(self):
        """Returns the associated review request."""
        return self.review_request

    class Meta:
        app_label = 'reviews'
        db_table = 'reviews_reviewrequestdraft'
        ordering = ['-last_updated']
        verbose_name = _('Review Request Draft')
        verbose_name_plural = _('Review Request Drafts')
Esempio n. 6
0
class DiffSet(models.Model):
    """A revisioned collection of FileDiffs."""

    _FINALIZED_COMMIT_SERIES_KEY = '__finalized_commit_series'

    name = models.CharField(_('name'), max_length=256)
    revision = models.IntegerField(_("revision"))
    timestamp = models.DateTimeField(_("timestamp"), default=timezone.now)
    basedir = models.CharField(_('base directory'),
                               max_length=256,
                               blank=True,
                               default='')
    history = models.ForeignKey('DiffSetHistory',
                                null=True,
                                related_name="diffsets",
                                verbose_name=_("diff set history"))
    repository = models.ForeignKey(Repository,
                                   related_name="diffsets",
                                   verbose_name=_("repository"))
    diffcompat = models.IntegerField(
        _('differ compatibility version'),
        default=0,
        help_text=_("The diff generator compatibility version to use. "
                    "This can and should be ignored."))

    base_commit_id = models.CharField(
        _('commit ID'),
        max_length=64,
        blank=True,
        null=True,
        db_index=True,
        help_text=_('The ID/revision this change is built upon.'))

    commit_count = RelationCounterField('commits')

    extra_data = JSONField(null=True)

    objects = DiffSetManager()

    @property
    def is_commit_series_finalized(self):
        """Whether the commit series represented by this DiffSet is finalized.

        When a commit series is finalized, no more :py:class:`DiffCommits
        <reviewboard.diffviewer.models.diffcommit.DiffCommit>` can be added to
        it.
        """
        return (self.extra_data and self.extra_data.get(
            self._FINALIZED_COMMIT_SERIES_KEY, False))

    def finalize_commit_series(self,
                               cumulative_diff,
                               validation_info,
                               parent_diff=None,
                               request=None,
                               validate=True,
                               save=False):
        """Finalize the commit series represented by this DiffSet.

        Args:
            cumulative_diff (bytes):
                The cumulative diff of the entire commit series.

            validation_info (dict):
                The parsed validation information.

            parent_diff (bytes, optional):
                The parent diff of the cumulative diff, if any.

            request (django.http.HttpRequest, optional):
                The HTTP request from the client, if any.

            validate (bool, optional):
                Whether or not the cumulative diff (and optional parent diff)
                should be validated, up to and including file existence checks.

            save (bool, optional):
                Whether to save the model after finalization. Defaults to
                ``False``.

                If ``True``, only the :py:attr:`extra_data` field will be
                updated.

                If ``False``, the caller must save this model.

        Returns:
            list of reviewboard.diffviewer.models.filediff.FileDiff:
            The list of created FileDiffs.

        Raises:
            django.core.exceptions.ValidationError:
                The commit series failed validation.
        """
        if validate:
            if self.is_commit_series_finalized:
                raise ValidationError(
                    ugettext('This diff is already finalized.'),
                    code='invalid')

            if not self.files.exists():
                raise ValidationError(
                    ugettext('Cannot finalize an empty commit series.'),
                    code='invalid')

            commits = {
                commit.commit_id: commit
                for commit in self.commits.all()
            }

            missing_commit_ids = set()

            for commit_id, info in six.iteritems(validation_info):
                if (commit_id not in commits
                        or commits[commit_id].parent_id != info['parent_id']):
                    missing_commit_ids.add(commit_id)

            if missing_commit_ids:
                raise ValidationError(
                    ugettext('The following commits are specified in '
                             'validation_info but do not exist: %s') %
                    ', '.join(missing_commit_ids),
                    code='validation_info')

            for commit_id, commit in six.iteritems(commits):
                if (commit_id not in validation_info
                        or validation_info[commit_id]['parent_id'] !=
                        commit.parent_id):
                    missing_commit_ids.add(commit_id)

            if missing_commit_ids:
                raise ValidationError(
                    ugettext('The following commits exist but are not '
                             'present in validation_info: %s') %
                    ', '.join(missing_commit_ids),
                    code='validation_info')

        filediffs = create_filediffs(
            get_file_exists=self.repository.get_file_exists,
            diff_file_contents=cumulative_diff,
            parent_diff_file_contents=parent_diff,
            repository=self.repository,
            request=request,
            basedir=self.basedir,
            base_commit_id=self.base_commit_id,
            diffset=self,
            check_existence=validate)

        if self.extra_data is None:
            self.extra_data = {}

        self.extra_data[self._FINALIZED_COMMIT_SERIES_KEY] = True

        if save:
            self.save(update_fields=('extra_data', ))

        return filediffs

    def get_total_line_counts(self):
        """Return the total line counts of all child FileDiffs.

        Returns:
            dict:
            A dictionary with the following keys:

            * ``raw_insert_count``
            * ``raw_delete_count``
            * ``insert_count``
            * ``delete_count``
            * ``replace_count``
            * ``equal_count``
            * ``total_line_count``

            Each entry maps to the sum of that line count type for all child
            :py:class:`FileDiffs
            <reviewboard.diffviewer.models.filediff.FileDiff>`.
        """
        return get_total_line_counts(self.files.all())

    @property
    def per_commit_files(self):
        """The files limited to per-commit diffs.

        This will cache the results for future lookups. If the set of all files
        has already been fetched with :py:meth:`~django.db.models.query.
        QuerySet.prefetch_related`, no queries will be performed.
        """
        if not hasattr(self, '_per_commit_files'):
            # This is a giant hack because Django 1.6.x does not support
            # Prefetch() statements. In Django 1.8+ we can replace any use of:
            #
            #     # Django == 1.6
            #     ds = DiffSet.objects.prefetch_related('files')
            #     for d in ds:
            #         # Do something with d.per_commit_files
            #
            # with:
            #
            #     # Django >= 1.8
            #     ds = DiffSet.objects.prefetch_related(
            #         Prefetch('files',
            #                  queryset=File.objects.filter(
            #                      commit_id__isnull=False),
            #                  to_attr='per_commit_files')
            #     for d in ds:
            #         # Do something with d.per_commit_files
            if (hasattr(self, '_prefetched_objects_cache')
                    and 'files' in self._prefetched_objects_cache):
                self._per_commit_files = [
                    f for f in self.files.all() if f.commit_id is not None
                ]
            else:
                self._per_commit_files = list(
                    self.files.filter(commit_id__isnull=False))

        return self._per_commit_files

    @property
    def cumulative_files(self):
        """The files limited to the cumulative diff.

        This will cache the results for future lookups. If the set of all files
        has been already been fetched with :py:meth:`~django.db.models.query.
        QuerySet.prefetch_related`, no queries will be incurred.
        """
        # See per_commit_files for why we are doing this hack.
        if not hasattr(self, '_cumulative_files'):
            if (hasattr(self, '_prefetched_objects_cache')
                    and 'files' in self._prefetched_objects_cache):
                self._cumulative_files = [
                    f for f in self.files.all() if f.commit_id is None
                ]
            else:
                self._cumulative_files = list(
                    self.files.filter(commit_id__isnull=True))

        return self._cumulative_files

    def update_revision_from_history(self, diffset_history):
        """Update the revision of this diffset based on a diffset history.

        This will determine the appropriate revision to use for the diffset,
        based on how many other diffsets there are in the history. If there
        aren't any, the revision will be set to 1.

        Args:
            diffset_history (reviewboard.diffviewer.models.diffset_history.
                             DiffSetHistory):
                The diffset history used to compute the new revision.

        Raises:
            ValueError:
                The revision already has a valid value set, and cannot be
                updated.
        """
        if self.revision not in (0, None):
            raise ValueError('The diffset already has a valid revision set.')

        # Default this to revision 1. We'll use this if the DiffSetHistory
        # isn't saved yet (which may happen when creating a new review request)
        # or if there aren't yet any diffsets.
        self.revision = 1

        if diffset_history.pk:
            try:
                latest_diffset = \
                    diffset_history.diffsets.only('revision').latest()
                self.revision = latest_diffset.revision + 1
            except DiffSet.DoesNotExist:
                # Stay at revision 1.
                pass

    def save(self, **kwargs):
        """Save this diffset.

        This will set an initial revision of 1 if this is the first diffset
        in the history, and will set it to on more than the most recent
        diffset otherwise.

        Args:
            **kwargs (dict):
                Extra arguments for the save call.
        """
        if self.history is not None:
            if self.revision == 0:
                self.update_revision_from_history(self.history)

            self.history.last_diff_updated = self.timestamp
            self.history.save()

        super(DiffSet, self).save(**kwargs)

    def __str__(self):
        """Return a human-readable representation of the DiffSet.

        Returns:
            unicode:
            A human-readable representation of the DiffSet.
        """
        return "[%s] %s r%s" % (self.id, self.name, self.revision)

    class Meta:
        app_label = 'diffviewer'
        db_table = 'diffviewer_diffset'
        get_latest_by = 'revision'
        ordering = ['revision', 'timestamp']
        verbose_name = _('Diff Set')
        verbose_name_plural = _('Diff Sets')
Esempio n. 7
0
class BadKeyRefModel(models.Model):
    key = models.ForeignKey(ReffedModel, related_name='bad_key_reffed')
    counter = RelationCounterField('key')
    counter_2 = RelationCounterField('key')
Esempio n. 8
0
class M2MRefModel(models.Model):
    m2m = models.ManyToManyField(ReffedModel, related_name='m2m_reffed')
    counter = RelationCounterField('m2m')
    counter_2 = RelationCounterField('m2m')
Esempio n. 9
0
class DiffSet(FileDiffCollectionMixin, models.Model):
    """A revisioned collection of FileDiffs."""

    name = models.CharField(_('name'), max_length=256)
    revision = models.IntegerField(_("revision"))
    timestamp = models.DateTimeField(_("timestamp"), default=timezone.now)
    basedir = models.CharField(_('base directory'),
                               max_length=256,
                               blank=True,
                               default='')
    history = models.ForeignKey('DiffSetHistory',
                                null=True,
                                related_name="diffsets",
                                verbose_name=_("diff set history"))
    repository = models.ForeignKey(Repository,
                                   related_name="diffsets",
                                   verbose_name=_("repository"))
    diffcompat = models.IntegerField(
        _('differ compatibility version'),
        default=0,
        help_text=_("The diff generator compatibility version to use. "
                    "This can and should be ignored."))

    base_commit_id = models.CharField(
        _('commit ID'),
        max_length=64,
        blank=True,
        null=True,
        db_index=True,
        help_text=_('The ID/revision this change is built upon.'))

    commit_count = RelationCounterField('commits')

    extra_data = JSONField(null=True)

    objects = DiffSetManager()

    def update_revision_from_history(self, diffset_history):
        """Update the revision of this diffset based on a diffset history.

        This will determine the appropriate revision to use for the diffset,
        based on how many other diffsets there are in the history. If there
        aren't any, the revision will be set to 1.

        Args:
            diffset_history (reviewboard.diffviewer.models.diffset_history.
                             DiffSetHistory):
                The diffset history used to compute the new revision.

        Raises:
            ValueError:
                The revision already has a valid value set, and cannot be
                updated.
        """
        if self.revision not in (0, None):
            raise ValueError('The diffset already has a valid revision set.')

        # Default this to revision 1. We'll use this if the DiffSetHistory
        # isn't saved yet (which may happen when creating a new review request)
        # or if there aren't yet any diffsets.
        self.revision = 1

        if diffset_history.pk:
            try:
                latest_diffset = \
                    diffset_history.diffsets.only('revision').latest()
                self.revision = latest_diffset.revision + 1
            except DiffSet.DoesNotExist:
                # Stay at revision 1.
                pass

    def save(self, **kwargs):
        """Save this diffset.

        This will set an initial revision of 1 if this is the first diffset
        in the history, and will set it to on more than the most recent
        diffset otherwise.

        Args:
            **kwargs (dict):
                Extra arguments for the save call.
        """
        if self.history is not None:
            if self.revision == 0:
                self.update_revision_from_history(self.history)

            self.history.last_diff_updated = self.timestamp
            self.history.save()

        super(DiffSet, self).save(**kwargs)

    def __str__(self):
        """Return a human-readable representation of the DiffSet.

        Returns:
            unicode:
            A human-readable representation of the DiffSet.
        """
        return "[%s] %s r%s" % (self.id, self.name, self.revision)

    class Meta:
        app_label = 'diffviewer'
        db_table = 'diffviewer_diffset'
        get_latest_by = 'revision'
        ordering = ['revision', 'timestamp']
        verbose_name = _('Diff Set')
        verbose_name_plural = _('Diff Sets')