~launchpad-pqm/launchpad/devel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).

"""Base class view for branch merge proposal listings."""

__metaclass__ = type

__all__ = [
    'ActiveReviewsView',
    'BranchActiveReviewsView',
    'BranchMergeProposalListingItem',
    'BranchMergeProposalListingView',
    'PersonActiveReviewsView',
    'PersonProductActiveReviewsView',
    ]

from operator import attrgetter

from lazr.delegates import delegates
from lazr.enum import (
    EnumeratedType,
    Item,
    use_template,
    )
from zope.component import getUtility
from zope.interface import (
    implements,
    Interface,
    )
from zope.schema import Choice

from lp import _
from lp.app.browser.launchpadform import (
    custom_widget,
    LaunchpadFormView,
    )
from lp.app.widgets.itemswidgets import LaunchpadDropdownWidget
from lp.code.enums import (
    BranchMergeProposalStatus,
    CodeReviewVote,
    )
from lp.code.interfaces.branchcollection import (
    IAllBranches,
    IBranchCollection,
    )
from lp.code.interfaces.branchmergeproposal import (
    BRANCH_MERGE_PROPOSAL_FINAL_STATES,
    IBranchMergeProposal,
    IBranchMergeProposalGetter,
    IBranchMergeProposalListingBatchNavigator,
    )
from lp.code.interfaces.hasbranches import IHasMergeProposals
from lp.services.config import config
from lp.services.propertycache import (
    cachedproperty,
    get_property_cache,
    )
from lp.services.webapp.authorization import check_permission
from lp.services.webapp.batching import TableBatchNavigator


class BranchMergeProposalListingItem:
    """A branch merge proposal that knows summary values for comments."""

    delegates(IBranchMergeProposal, 'context')

    def __init__(self, branch_merge_proposal, summary, proposal_reviewer,
                 vote_references=None):
        self.context = branch_merge_proposal
        self.summary = summary
        self.proposal_reviewer = proposal_reviewer
        if vote_references is None:
            vote_references = []
        self.vote_references = vote_references

    @property
    def vote_summary_items(self):
        """A generator of votes.

        This is iterated over in TAL, and provides a items that are dict's for
        simple TAL traversal.

        The dicts contain the name and title of the enumerated vote type, the
        count of those votes and the reviewers whose latest review is of that
        type.
        """
        for vote in CodeReviewVote.items:
            vote_count = self.summary.get(vote, 0)
            if vote_count > 0:
                reviewers = []
                for ref in self.vote_references:
                    if ref.comment is not None and ref.comment.vote == vote:
                        reviewers.append(ref.reviewer.unique_displayname)
                yield {'name': vote.name, 'title': vote.title,
                       'count': vote_count,
                       'reviewers': ', '.join(sorted(reviewers))}

    @property
    def vote_type_count(self):
        """The number of vote types used on this proposal."""
        # The dict has one entry for comments and one for each type of vote.
        return len(self.summary) - 1

    @property
    def comment_count(self):
        """The number of comments (that aren't votes)."""
        return self.summary['comment_count']

    @property
    def has_no_activity(self):
        """True if no votes and no comments."""
        return self.comment_count == 0 and self.vote_type_count == 0

    @property
    def reviewer_vote(self):
        """A vote from the specified reviewer."""
        return self.context.getUsersVoteReference(self.proposal_reviewer)

    @property
    def sort_key(self):
        """The value to order by.

        This defaults to date_review_requested, but there are occasions where
        this is not set if the proposal went directly from work in progress to
        approved.  In this case the date_reviewed is used.

        The value is always not None as proposals in needs review state will
        always have date_review_requested set, and approved proposals will
        always have date_reviewed set.  These are the only two states that are
        shown in the active reviews page, so they can always be sorted on.
        """
        if self.context.date_review_requested is not None:
            return self.context.date_review_requested
        elif self.context.date_reviewed is not None:
            return self.context.date_reviewed
        else:
            return self.context.date_created


class BranchMergeProposalListingBatchNavigator(TableBatchNavigator):
    """Batch up the branch listings."""
    implements(IBranchMergeProposalListingBatchNavigator)

    def __init__(self, view):
        TableBatchNavigator.__init__(
            self, view.getVisibleProposalsForUser(), view.request,
            columns_to_show=view.extra_columns,
            size=config.launchpad.branchlisting_batch_size)
        self.view = view

    @cachedproperty
    def _proposals_for_current_batch(self):
        return list(self.currentBatch())

    @cachedproperty
    def _vote_summaries(self):
        """A dict of proposals to counts of votes and comments."""
        utility = getUtility(IBranchMergeProposalGetter)
        return utility.getVoteSummariesForProposals(
            self._proposals_for_current_batch)

    def _createItem(self, proposal):
        """Create the listing item for the proposal."""
        summary = self._vote_summaries[proposal]
        return BranchMergeProposalListingItem(proposal, summary,
            proposal_reviewer=self.view.getUserFromContext())

    @cachedproperty
    def proposals(self):
        """Return a list of BranchListingItems."""
        proposals = self._proposals_for_current_batch
        return [self._createItem(proposal) for proposal in proposals]

    @property
    def table_class(self):
        if self.has_multiple_pages:
            return "listing"
        else:
            return "listing sortable"


class FilterableStatusValues(EnumeratedType):
    """Selectable values for filtering the merge proposal listings."""
    use_template(BranchMergeProposalStatus)

    sort_order = (
        'ALL', 'WORK_IN_PROGRESS', 'NEEDS_REVIEW', 'CODE_APPROVED',
        'REJECTED', 'MERGED', 'MERGE_FAILED', 'QUEUED', 'SUPERSEDED')

    ALL = Item("Any status")


class BranchMergeProposalFilterSchema(Interface):
    """Schema for generating the filter widget for listing views."""

    # Stats and status attributes
    status = Choice(
        title=_('Status'), vocabulary=FilterableStatusValues,
        default=FilterableStatusValues.ALL,)


class BranchMergeProposalListingView(LaunchpadFormView):
    """A base class for views of branch merge proposal listings."""

    schema = BranchMergeProposalFilterSchema
    field_names = ['status']
    custom_widget('status', LaunchpadDropdownWidget)

    extra_columns = []
    _queue_status = None

    @property
    def page_title(self):
        return "Merge Proposals for %s" % self.context.displayname
    label = page_title

    @property
    def initial_values(self):
        return {
            'status': FilterableStatusValues.ALL,
            }

    @cachedproperty
    def status_value(self):
        """The effective value of the status widget."""
        widget = self.widgets['status']
        if widget.hasValidInput():
            return widget.getInputValue()
        else:
            return FilterableStatusValues.ALL

    @cachedproperty
    def status_filter(self):
        """Return the status values to filter on."""
        if self.status_value == FilterableStatusValues.ALL:
            return BranchMergeProposalStatus.items
        else:
            return (BranchMergeProposalStatus.items[self.status_value.name], )

    @property
    def proposals(self):
        """The batch navigator for the proposals."""
        return BranchMergeProposalListingBatchNavigator(self)

    def getUserFromContext(self):
        """Get the relevant user from the context."""
        return None

    def getVisibleProposalsForUser(self):
        """Branch merge proposals that are visible by the logged in user."""
        has_proposals = IHasMergeProposals(self.context)
        return has_proposals.getMergeProposals(self.status_filter, self.user)

    @cachedproperty
    def proposal_count(self):
        """Return the number of proposals that will be returned."""
        return self.getVisibleProposalsForUser().count()

    @property
    def no_proposal_message(self):
        """Shown when there is no table to show."""
        if self.status_value == FilterableStatusValues.ALL:
            return "%s has no merge proposals." % self.context.displayname
        else:
            return "%s has no merge proposals with status: %s" % (
                self.context.displayname, self.status_value.title)


class ActiveReviewsView(BranchMergeProposalListingView):
    """Branch merge proposals for a context that are needing review."""

    show_diffs = False

    # The grouping classifications.
    APPROVED = 'approved'
    TO_DO = 'to_do'
    ARE_DOING = 'are_doing'
    CAN_DO = 'can_do'
    MINE = 'mine'
    OTHER = 'other'
    WIP = 'wip'

    def getProposals(self):
        """Get the proposals for the view."""
        collection = IBranchCollection(self.context)
        collection = collection.visibleByUser(self.user)
        proposals = collection.getMergeProposals(
            [BranchMergeProposalStatus.CODE_APPROVED,
             BranchMergeProposalStatus.NEEDS_REVIEW, ],
            eager_load=True)
        return proposals

    def _getReviewGroup(self, proposal, votes, reviewer):
        """One of APPROVED, MINE, TO_DO, CAN_DO, ARE_DOING, OTHER or WIP.

        These groupings define the different tables that the user is able
        to see.

        Proposals with a status of CODE_APPROVED or WORK_IN_PROGRESS are the
        groups APPROVED or WIP respectively.

        If the source branch is owned by the reviewer, or the proposal was
        registered by the reviewer, then the group is MINE.

        If the reviewer is a team, there is no MINE, nor can a team vote, so
        there is no ARE_DOING.  Since a team can't really have TO_DOs, they
        are explicitly checked for, so all possibles are CAN_DO.

        If there is a pending vote reference for the reviewer, then the group
        is TO_DO as the reviewer is expected to review.  If there is a vote
        reference where it is not pending, this means that the reviewer has
        reviewed, so the group is ARE_DOING.  If there is a pending review
        requested of a team that the reviewer is in, then the review becomes a
        CAN_DO.  All others are OTHER.
        """
        bmp_status = BranchMergeProposalStatus
        if proposal.queue_status == bmp_status.CODE_APPROVED:
            return self.APPROVED
        if proposal.queue_status == bmp_status.WORK_IN_PROGRESS:
            return self.WIP

        if (reviewer is not None and
            (proposal.source_branch.owner == reviewer or
             (reviewer.inTeam(proposal.source_branch.owner) and
              proposal.registrant == reviewer))):
            return self.MINE

        result = self.OTHER

        for vote in votes:
            if reviewer is not None:
                if vote.reviewer == reviewer and not reviewer.is_team:
                    if vote.comment is None:
                        return self.TO_DO
                    else:
                        return self.ARE_DOING
                # Since team reviews are always pending, and we've eliminated
                # the case where the reviewer is ther person, then if
                # the reviewer is in the reviewer team, it is a can do.
                if reviewer.inTeam(vote.reviewer):
                    result = self.CAN_DO
        return result

    def _getReviewer(self):
        """The user whose point of view are the groupings are for."""
        return self.user

    def initialize(self):
        # Work out the review groups
        self.review_groups = {}
        self.getter = getUtility(IBranchMergeProposalGetter)
        reviewer = self._getReviewer()
        # Listify so it works well being passed into getting the votes and
        # summaries.
        proposals = list(self.getProposals())
        all_votes = self.getter.getVotesForProposals(proposals)
        vote_summaries = self.getter.getVoteSummariesForProposals(proposals)
        for proposal in proposals:
            proposal_votes = all_votes[proposal]
            review_group = self._getReviewGroup(
                proposal, proposal_votes, reviewer)
            self.review_groups.setdefault(review_group, []).append(
                BranchMergeProposalListingItem(
                    proposal, vote_summaries[proposal], None, proposal_votes))
            if proposal.preview_diff is not None:
                self.show_diffs = True
        # Sort each collection...
        for group in self.review_groups.values():
            group.sort(key=attrgetter('sort_key'))
        get_property_cache(self).proposal_count = len(proposals)

    @cachedproperty
    def headings(self):
        """Return a dict of headings for the groups."""
        reviewer = self._getReviewer()
        headings = {
            self.APPROVED: 'Approved reviews ready to land',
            self.TO_DO: 'Reviews I have to do',
            self.ARE_DOING: 'Reviews I am doing',
            self.CAN_DO: 'Requested reviews I can do',
            self.MINE: 'Reviews I am waiting on',
            self.OTHER: 'Other reviews I am not actively reviewing',
            self.WIP: 'Work in progress'}
        if reviewer is None:
            # If there is no reviewer, then there will be no TO_DO, ARE_DOING,
            # CAN_DO or MINE, and we are not in a person context.
            headings[self.OTHER] = 'Reviews requested or in progress'
        elif self.user is not None and self.user.inTeam(reviewer):
            # The user is either looking at their own person review page, or a
            # reviews for a team that they are a member of.  The default
            # headings are good.
            pass
        elif reviewer.is_team:
            # Looking at a person team page.
            name = reviewer.displayname
            headings[self.CAN_DO] = 'Reviews %s can do' % name
            headings[self.OTHER] = (
                'Reviews %s is not actively reviewing' % name)
        else:
            # A user is looking at someone elses personal review page.
            name = reviewer.displayname
            headings[self.TO_DO] = 'Reviews %s has to do' % name
            headings[self.ARE_DOING] = 'Reviews %s is doing' % name
            headings[self.CAN_DO] = 'Reviews %s can do' % name
            headings[self.MINE] = 'Reviews %s is waiting on' % name
            headings[self.OTHER] = (
                'Reviews %s is not actively reviewing' % name)
        return headings

    @property
    def heading(self):
        return "Active code reviews for %s" % self.context.displayname

    page_title = heading

    @property
    def no_proposal_message(self):
        """Shown when there is no table to show."""
        return "%s has no active code reviews." % self.context.displayname


class BranchActiveReviewsView(ActiveReviewsView):
    """Branch merge proposals for a branch that are needing review."""

    def getProposals(self):
        """See `ActiveReviewsView`."""
        non_final = tuple(
            set(BranchMergeProposalStatus.items) -
            set(BRANCH_MERGE_PROPOSAL_FINAL_STATES))
        candidates = self.context.getMergeProposals(
            status=non_final, eager_load=True, visible_by_user=self.user)
        return [proposal for proposal in candidates
                if check_permission('launchpad.View', proposal)]


class PersonActiveReviewsView(ActiveReviewsView):
    """Branch merge proposals for the person that are needing review."""

    def _getReviewer(self):
        return self.context

    def _getCollection(self):
        return getUtility(IAllBranches)

    def getProposals(self):
        """See `ActiveReviewsView`."""
        collection = self._getCollection().visibleByUser(self.user)
        proposals = collection.getMergeProposalsForPerson(
            self._getReviewer(),
            [BranchMergeProposalStatus.CODE_APPROVED,
             BranchMergeProposalStatus.NEEDS_REVIEW],
            eager_load=True)

        return proposals


class PersonProductActiveReviewsView(PersonActiveReviewsView):
    """Active reviews for a person in a product."""

    def _getReviewer(self):
        return self.context.person

    def _getCollection(self):
        return getUtility(IAllBranches).inProduct(self.context.product)

    @property
    def heading(self):
        return "Active code reviews of %s for %s" % (
            self.context.product.displayname, self.context.person.displayname)

    page_title = heading

    @property
    def no_proposal_message(self):
        """Shown when there is no table to show."""
        return "%s has no active code reviews for %s." % (
            self.context.person.displayname, self.context.product.displayname)