source: peerreviewplugin/trunk/codereview/peerReviewView.py

Last change on this file was 18288, checked in by Cinc-th, 2 years ago

PeerReviewPlugin: improvements to ticket creation:

  • You may specify a list of review states for which the ticket creation section is shown.
  • A ticket template may be provided as a wiki page CodeReview/TicketTemplate
  • Ticket creation section is foldable now
  • Added documentation to plugin
File size: 20.1 KB
Line 
1#
2# Copyright (C) 2005-2006 Team5
3# Copyright (C) 2016-2021 Cinc
4# All rights reserved.
5#
6# This software is licensed as described in the file COPYING.txt, which
7# you should have received as part of this distribution.
8#
9# Author: Team5
10#
11
12# Provides functionality for view code review page
13# Works with peerreview_view.html
14
15import itertools
16import re
17from codereview.changeset import get_changeset_data
18from codereview.model import Comment, get_users, \
19    PeerReviewerModel, PeerReviewModel, ReviewCommentModel, ReviewDataModel, ReviewFileModel
20from codereview.peerReviewMain import add_ctxt_nav_items
21from codereview.tracgenericworkflow.api import IWorkflowOperationProvider, IWorkflowTransitionListener, \
22    ResourceWorkflowSystem
23from codereview.util import get_changeset_html, get_files_for_review_id, review_is_finished, review_is_locked
24from string import Template
25from trac.config import BoolOption, ListOption
26from trac.core import Component, implements, TracError
27from trac.mimeview.api import Mimeview
28from trac.resource import Resource
29from trac.util.datefmt import format_date, to_datetime, user_time
30from trac.util.html import html as tag
31from trac.util.text import CRLF, obfuscate_email_address
32from trac.util.translation import _
33from trac.versioncontrol.api import RepositoryManager
34from trac.web.chrome import add_link, add_script, add_stylesheet, Chrome, INavigationContributor, web_context
35from trac.web.main import IRequestHandler
36from trac.wiki.model import WikiPage
37from trac.wiki.formatter import format_to_html
38
39
40class PeerReviewView(Component):
41    """Displays a summary page for a review.
42
43    === Configuration
44
45    [[TracIni(peerreview)]]
46
47    === Ticket template
48
49    If the review status is in the list defined by {{{show_ticket}}}
50    you may create a ticket
51    prefilled with review details from within the review page. This can be
52    used to inform team members about pending work or a succesful review.
53
54    The ticket description is defined in the wiki page {{{CodeReview/TicketTemplate}}}.
55    If this page doesn't exists a default description is used. The following
56    variables can be used in the ticket template:
57
58    || **${review_id}** || will be replaced with the review id ||
59    || **${review_name}** || name of the current review ||
60    || **${review_notes}** || notes of the current review ||
61    || **${review_files}** || a table with files belonging to the review ||
62
63    The inserted file table is a wiki table with two columns like this:
64    {{{
65    || path/to/file || number of comments ||
66    || ... || ... ||
67    }}}
68
69    You may use the following wiki formatting for a file table with custom headers:
70    {{{
71    ||= File path =||= Comments =||
72    ${review_files}
73    }}}
74    """
75    implements(IRequestHandler, INavigationContributor, IWorkflowOperationProvider, IWorkflowTransitionListener)
76
77    ListOption("peerreview", "terminal_review_states", ['closed', 'approved', 'disapproved'],
78               doc="Ending states for a review. Only an administrator may force a review to leave these states. "
79                   "Reviews in one of these states may not be modified.")
80
81    ListOption("peerreview", "reviewer_locked_states", ['reviewed'],
82               doc="A reviewer may no longer comment on reviews in one of the given states. The review owner still "
83                   "may comment. Used to lock a review against modification after all reviewing persons have "
84                   "finished their task.")
85
86    show_ticket = ListOption("peerreview", "show_ticket", ['reviewed', 'in-review', 'approved', 'disapproved'],
87                             doc="A ticket may be created from within the review page with information about "
88                                 "a review. A ticket preview "
89                                 "and a button for filling the '''New Ticket''' page with data will be shown "
90                                 "on the view page of a review if the review status is in this list "
91                                 "of states. [[BR]][[BR]]"
92                                 "You must be the review author or a manager to use this feature.")
93
94    peerreview_view_re = re.compile(r'/peerreviewview/([0-9]+)$')
95
96    # used to build the url inside a workflow. Must be changed, when the depth of the url of this page changes.
97    workflow_base_href = '..'  # account for trailing review id in url
98
99    # IWorkflowOperationProvider methods
100
101    def get_implemented_operations(self):
102        yield 'set_review_owner'
103
104    def get_operation_control(self, req, action, operation, res_wf_state, resource):
105        """Get markup for workflow operation 'set_review_owner."""
106
107        id_ = 'action_%s_operation_%s' % (action, operation)
108
109        rws = ResourceWorkflowSystem(self.env)
110        this_action = rws.actions[resource.realm][action]  # We need the full action data for custom label
111
112        if operation == 'set_review_owner':
113            self.log.debug("Creating control for setting review owner.")
114            review = PeerReviewModel(self.env, resource.id)
115
116            if not (Chrome(self.env).show_email_addresses
117                    or 'EMAIL_VIEW' in req.perm(resource)):
118                format_user = obfuscate_email_address
119            else:
120                format_user = lambda address: address
121            current_owner = format_user(review['owner'])
122
123            self.log.debug("Current owner is %s." % current_owner)
124
125            selected_owner = req.args.get(id_, req.authname)
126
127            hint = "The owner will be changed from %s" % current_owner
128
129            owners = get_users(self.env)
130            if not owners:
131                owner = req.args.get(id_, req.authname)
132                control = tag(u'%s ' % this_action['name'],
133                              tag.input(type='text', id=id_,
134                                        name=id_, value=owner))
135            elif len(owners) == 1:
136                owner = tag.input(type='hidden', id=id_, name=id_,
137                                  value=owners[0])
138                formatted_owner = format_user(owners[0])
139                control = tag(u'%s ' % this_action['name'],
140                              tag(formatted_owner, owner))
141                if res_wf_state['owner'] != owners[0]:
142                    hint = "The owner will be changed from %s to %s" % (current_owner, formatted_owner)
143            else:
144                control = tag(u'%s ' % this_action['name'], tag.select(
145                        [tag.option(format_user(x), value=x,
146                                    selected=(x == selected_owner or None))
147                         for x in owners],
148                        id=id_, name=id_))
149
150            return control, hint
151
152        return None, ''
153
154    def perform_operation(self, req, action, operation, old_state, new_state, res_wf_state, resource):
155        """Perform 'set_review_owner' operation on reviews."""
156
157        self.log.debug("---> Performing operation %s while transitioning from %s to %s."
158                       % (operation, old_state, new_state))
159        new_owner = req.args.get('action_%s_operation_%s' % (action, operation), None)
160        review = PeerReviewModel(self.env, int(resource.id))
161        if review:
162            review['owner'] = new_owner
163            review.save_changes(author=req.authname)
164
165    # IWorkflowTransitionListener
166
167    def object_transition(self, res_wf_state, resource, action, old_state, new_state):
168        if resource.realm == 'peerreviewer':
169            reviewer = PeerReviewerModel(self.env, resource.id)
170            reviewer['status'] = new_state
171            # This is for updating the PeerReviewer object. The info is used for displaying the review state to the
172            # review author. Note that this change is recorded in 'peerreviewer_change' by this.
173            # The change is also recorded in the generic change table 'resourceworkflow_change' because of the
174            # Resource object 'resource'.
175            reviewer.save_changes(author=res_wf_state.authname,
176                                  comment="State change to %s  for %s from object_transition() of %s"
177                                                                        % (new_state, reviewer, resource))
178        elif resource.realm == 'peerreview':
179            review = PeerReviewModel(self.env, resource.id)
180            review.change_status(new_state, res_wf_state.authname)
181
182    # INavigationContributor
183
184    def get_active_navigation_item(self, req):
185        return 'peerreviewmain'
186
187    def get_navigation_items(self, req):
188        return []
189
190    # IRequestHandler methods
191    def match_request(self, req):
192        match = self.peerreview_view_re.match(req.path_info)
193        if match:
194            req.args['Review'] = match.group(1)
195            return True
196        # Deprecated legacy URL.
197        if req.path_info == '/peerReviewView':
198            self.env.log.info("Legacy URL 'peerReviewView' called from: %s", req.get_header('Referer'))
199            return True
200
201    def process_request(self, req):
202        req.perm.require('CODE_REVIEW_VIEW')
203
204        review_id = req.args.get('Review')
205        if review_id is None or not review_id.isdigit():
206            raise TracError(u"Invalid review ID supplied - unable to load page.")
207
208        if req.method == 'POST':
209            req.perm.require('CODE_REVIEW_DEV')
210            if req.args.get('resubmit'):
211                req.redirect(req.href.peerreviewnew(resubmit=review_id))
212            elif req.args.get('followup'):
213                req.redirect(req.href.peerreviewnew(resubmit=review_id, followup=1))
214            elif req.args.get('modify'):
215                req.redirect(req.href.peerreviewnew(resubmit=review_id, modify=1))
216
217        # Add display_rev() function to files so we can properly display the revision
218        # during template processing
219        rm = RepositoryManager(self.env)
220        files = self.get_files_for_review_id(req, review_id, True)
221        for file in files:
222            repos = rm.get_repository(file['repo'])
223            file['display_rev'] = repos.display_rev
224
225        # If this is not a changeset review 'reponame' and 'changeset' are empty strings.
226        reponame, changeset = get_changeset_data(self.env, review_id)
227        repos = rm.get_repository(reponame)
228        short_rev = repos.display_rev(changeset) if repos else changeset
229        data = {'review_files': files,
230                'users': get_users(self.env),
231                'show_ticket': self.show_ticket,
232                'cycle': itertools.cycle,
233                'review': self.get_review_by_id(req, review_id),
234                'reviewer': list(PeerReviewerModel.select_by_review_id(self.env, review_id)),
235                'repo': reponame,
236                'display_rev': repos.display_rev if repos else lambda x: x,
237                'changeset': changeset,
238                'changeset_html': get_changeset_html(self.env, req, short_rev, repos)
239                }
240
241        # check to see if the user is a manager of this page or not
242        if 'CODE_REVIEW_MGR' in req.perm:
243            data['manager'] = True
244
245        review = data['review']
246
247        # A finished reviews can't be changed anymore except by a manager
248        data['is_finished'] = review_is_finished(self.env.config, review)
249        # A user can't change his voting for a reviewed review
250        data['review_locked'] = review_is_locked(self.env.config, review, req.authname)
251        # Used to indicate that a review is done. 'review_locked' is not suitable because it is false for the
252        # author of a review even when the review is done.
253        data['review_done'] = review_is_locked(self.env.config, review)
254        data['finished_states_str'] = ', '.join(self.env.config.getlist("peerreview", "terminal_review_states"))
255
256        # Add data for parent review if any
257        self.add_parent_data(req, review, data)
258
259        self.add_ticket_data(req, data)  # This updates dict 'data'
260
261        # Actions for a reviewer. Each reviewer marks his progress on a review. The author
262        # can see this progress in the user list. The possible actions are defined in trac.ini
263        # as a workflow in [peerreviewer-resource_workflow]
264        wflow = self.prepare_workflow_for_reviewer(req, review, data)
265        if wflow:
266            data['reviewer_workflow'] = wflow
267        # Actions for the author of a review. The author may approve, disapprove or close a review.
268        # The possible actions are defined in trac.ini as a workflow in [peerreview-resource_workflow]
269        data['workflow'] = self.prepare_workflow_for_author(req, review, data)
270
271        # For downloading in docx format
272        self.add_docx_export_link(req, review_id)
273
274        add_stylesheet(req, 'common/css/code.css')
275        add_stylesheet(req, 'common/css/browser.css')
276        add_stylesheet(req, 'common/css/ticket.css')
277        add_stylesheet(req, 'hw/css/peerreview.css')
278        Chrome(self.env).add_jquery_ui(req)  # For user icons
279        add_ctxt_nav_items(req)
280        if hasattr(Chrome, 'jenv'):
281            return 'peerreview_view_jinja.html', data
282        else:
283            add_script(req, 'common/js/folding.js')
284            return 'peerreview_view.html', data, None
285
286    def add_parent_data(self, req, review, data):
287        """Add inforamtion about parent review to dict 'data'. Do nothing if not parent."""
288        def get_parent_file(curfile, par_files):
289            fid = u"%s%s%s" % (curfile['path'], curfile['line_start'], curfile['line_end'])
290            for parfile in par_files:
291                tmp = u"%s%s%s" % (parfile['path'], parfile['line_start'], parfile['line_end'])
292                if tmp == fid:
293                    return parfile
294            return None
295
296        if review['parent_id'] != 0:
297            data['parent_review'] = self.get_review_by_id(req, review['parent_id'])
298
299            # Add display_rev() function to files so we can properly display the revision
300            # during template processing
301            rm = RepositoryManager(self.env)
302            files = self.get_files_for_review_id(req, review['parent_id'], False)
303            for file in files:
304                repos = rm.get_repository(file['repo'])
305                file['display_rev'] = repos.display_rev
306            data['parent_files'] = files
307
308            # Map files to parent files. Key is current file id, value is parent file object
309            file_map = {}
310            for rfile in data['review_files']:
311                file_map[rfile['file_id']] = get_parent_file(rfile, data['parent_files'])
312            data['file_map'] = file_map
313
314    def prepare_workflow_for_reviewer(self, req, review, data):
315        """
316        :param req: Request object
317        :param review: PeerReviewModel object representing the current review
318        :return: a workflow suitable for directly inserting into the page. or None if we are not a reviewer.
319        """
320        # Actions for a reviewer. Each reviewer marks his progress on a review. The author
321        # can see this progress in the user list. The possible actions are defined in trac.ini
322        # as a workflow in [peerreviewer-resource_workflow]
323        realm = 'peerreviewer'
324        for reviewer in data['reviewer']:
325            if reviewer['reviewer'] == req.authname:
326                # Todo: 'canivote' should be obsolete by now
327                if not data['is_finished'] and not data['review_done']:  # even author isn't allowed to change
328                    data['canivote'] = True
329                res = Resource(realm, str(reviewer['reviewer_id']))  # id must be a string
330                wf_data = {'redirect': req.href.peerreviewview(review['review_id']),
331                           'legend': _("Set review progress"),
332                           'help': _("Setting the current state helps the review author to track progress.")}
333                return ResourceWorkflowSystem(self.env).get_workflow_markup(req, self.workflow_base_href,
334                                                                            realm, res, wf_data)
335        return
336
337    def prepare_workflow_for_author(self, req, review, data):
338        """
339        :param req: Request object
340        :param review: PeerReviewModel object representing the current review
341        :return: a workflow suitable for directly inserting into the page.
342        """
343        # Actions for the author of a review. The author may approve, disapprove or close a review.
344        # The possible actions are defined in trac.ini as a workflow in [peerreview-resource_workflow]
345        realm = 'peerreview'
346        res = Resource(realm, str(review['review_id']))  # Must be a string (?)
347
348        wf_data = {'redirect': req.href.peerreviewview(review['review_id']),
349                   'legend': _("Set state of review (author or manager only)"),
350                   'help': _("Closing a review means it is marked as obsolete and <strong>all associated data will be unavailable</strong>.")}
351        return ResourceWorkflowSystem(self.env).get_workflow_markup(req, self.workflow_base_href, realm, res, wf_data)
352
353    def add_docx_export_link(self, req, review_id):
354        """Ad a download link for docx format if conversion is available.
355
356        Note that python-docx must be installed for the link to show up.
357        """
358        conversions = Mimeview(self.env).get_supported_conversions('text/x-trac-peerreview')
359        for key, name, ext, mime_in, mime_out, q, c in conversions:
360            conversion_href = req.href("peerreview", format=key, reviewid=review_id)
361            add_link(req, 'alternate', conversion_href, name, mime_out)
362
363
364    def get_files_for_review_id(self, req, review_id, comments=False):
365        """Get all file objects belonging to the given review id. Provide the number of comments if asked for.
366
367        :param review_id: id of review as an int
368        :param req: Request object
369        :param comments: if True add information about comments as attributes to the file objects
370        :return: list of ReviewFileModels
371        """
372        return get_files_for_review_id(self.env, req, review_id, comments)
373
374        rev_files = list(ReviewFileModel.select_by_review(self.env, review_id))
375        if comments:
376            for file_ in rev_files:
377                file_.num_comments = len(list(ReviewCommentModel.select_by_file_id(self.env, file_['file_id'])))
378                my_comment_data = ReviewDataModel.comments_for_file_and_owner(self.env, file_['file_id'], req.authname)
379                file_.num_notread = file_.num_comments - len([c_id for c_id, t, dat in my_comment_data if t == 'read'])
380        return rev_files
381
382    def get_review_by_id(self, req, review_id):
383        """Get a PeerReviewModel for the given review id and prepare some additional data used by the template"""
384        review = PeerReviewModel(self.env, review_id)
385        review.html_notes = format_to_html(self.env, web_context(req), review['notes'])
386        review.date = user_time(req, format_date, to_datetime(review['created']))
387        if review['closed']:
388            review.finish_date = user_time(req, format_date, to_datetime(review['closed']))
389        else:
390            review.finish_date = ''
391        return review
392
393    desc = u"""
394Review [review:${review_id} ${review_name}].
395=== Review
396
397||= Name =|| ${review_name} ||
398||= ID =|| [review:${review_id}] ||
399
400=== Review Notes
401
402${review_notes}
403
404=== Files
405||= File name =||= Comments =||
406${review_files}
407"""
408
409    def add_ticket_data(self, req, data):
410        """Create the ticket description for tickets created from the review page"""
411        review = data['review']
412
413        wikipage = WikiPage(self.env, 'CodeReview/TicketTemplate')
414        if wikipage.exists:
415            tmpl = Template(wikipage.text)
416        else:
417            tmpl = Template(self.desc)
418
419        try:
420            file_table = ''
421            for f in data['review_files']:
422                file_table += u"||[rfile:%s %s]|| %s ||%s" % \
423                       (f['file_id'], f['path'], f.num_comments, CRLF)
424        except KeyError:
425            pass
426
427        txt = tmpl.substitute(review_name=review['name'], review_id=review['review_id'],
428                              review_notes=review['notes'] if review['notes'] else '',
429                              review_files=file_table)
430
431        data['ticket_desc_wiki'] = self.create_preview(req, txt)
432        data['ticket_desc'] = txt
433        data['ticket_summary'] = u'Review "%s"' % review['name']
434
435    def create_preview(self, req, text):
436        resource = Resource('peerreview')
437        context = web_context(req, resource)
438        return format_to_html(self.env, context, text)
Note: See TracBrowser for help on using the repository browser.