| 1 | # |
|---|
| 2 | # Copyright (C) 2005-2006 Team5 |
|---|
| 3 | # All rights reserved. |
|---|
| 4 | # |
|---|
| 5 | # This software is licensed as described in the file COPYING.txt, which |
|---|
| 6 | # you should have received as part of this distribution. |
|---|
| 7 | # |
|---|
| 8 | # Author: Team5 |
|---|
| 9 | # |
|---|
| 10 | |
|---|
| 11 | # Provides functionality for view code review page |
|---|
| 12 | # Works with peerReviewView.html |
|---|
| 13 | |
|---|
| 14 | import itertools |
|---|
| 15 | from string import Template |
|---|
| 16 | from trac.config import BoolOption, ListOption |
|---|
| 17 | from trac.core import Component, implements, TracError |
|---|
| 18 | from trac.mimeview import Context |
|---|
| 19 | from trac.mimeview.api import Mimeview |
|---|
| 20 | from trac.resource import Resource |
|---|
| 21 | from trac.util import format_date |
|---|
| 22 | from trac.util.html import html as tag |
|---|
| 23 | from trac.util.text import CRLF, obfuscate_email_address |
|---|
| 24 | from trac.web.chrome import add_link, add_stylesheet, Chrome, INavigationContributor |
|---|
| 25 | from trac.web.main import IRequestHandler |
|---|
| 26 | from trac.wiki.formatter import format_to, format_to_html |
|---|
| 27 | from model import Comment, get_users, \ |
|---|
| 28 | PeerReviewerModel, PeerReviewModel, ReviewFileModel, ReviewDataModel |
|---|
| 29 | from peerReviewMain import add_ctxt_nav_items, web_context_compat |
|---|
| 30 | from tracgenericworkflow.api import IWorkflowOperationProvider, IWorkflowTransitionListener, ResourceWorkflowSystem |
|---|
| 31 | from util import review_is_finished, review_is_locked |
|---|
| 32 | |
|---|
| 33 | try: |
|---|
| 34 | from trac.web.chrome import web_context |
|---|
| 35 | except ImportError: |
|---|
| 36 | web_context = web_context_compat |
|---|
| 37 | |
|---|
| 38 | |
|---|
| 39 | class PeerReviewView(Component): |
|---|
| 40 | """Displays a summary page for a review. |
|---|
| 41 | |
|---|
| 42 | === The following configuration options may be set: |
|---|
| 43 | |
|---|
| 44 | [[TracIni(peerreview)]] |
|---|
| 45 | """ |
|---|
| 46 | implements(IRequestHandler, INavigationContributor, IWorkflowOperationProvider, IWorkflowTransitionListener) |
|---|
| 47 | |
|---|
| 48 | ListOption("peerreview", "terminal_review_states", ['closed', 'approved', 'disapproved'], |
|---|
| 49 | doc="Ending states for a review. Only an administrator may force a review to leave these states. " |
|---|
| 50 | "Reviews in one of these states may not be modified.") |
|---|
| 51 | |
|---|
| 52 | ListOption("peerreview", "reviewer_locked_states", ['reviewed'], |
|---|
| 53 | doc="A reviewer may no longer comment on reviews in one of the given states. The review owner still " |
|---|
| 54 | "may comment. Used to lock a review against modification after all reviewing persons have " |
|---|
| 55 | "finished their task.") |
|---|
| 56 | |
|---|
| 57 | show_ticket = BoolOption("peerreview", "show_ticket", False, |
|---|
| 58 | doc="A ticket may be created with information about " |
|---|
| 59 | "a review. If set to {{{True}}} a ticket preview on " |
|---|
| 60 | "the view page of a review will be shown and a button " |
|---|
| 61 | "for filling the '''New Ticket''' page with data. " |
|---|
| 62 | "The review must have status ''reviewed''. Only the " |
|---|
| 63 | "author or a manager have the necessary permisisons.") |
|---|
| 64 | |
|---|
| 65 | # IWorkflowOperationProvider methods |
|---|
| 66 | |
|---|
| 67 | def get_implemented_operations(self): |
|---|
| 68 | yield 'set_review_owner' |
|---|
| 69 | |
|---|
| 70 | def get_operation_control(self, req, action, operation, res_wf_state, resource): |
|---|
| 71 | """Get markup for workflow operation 'set_review_owner.""" |
|---|
| 72 | |
|---|
| 73 | id_ = 'action_%s_operation_%s' % (action, operation) |
|---|
| 74 | |
|---|
| 75 | rws = ResourceWorkflowSystem(self.env) |
|---|
| 76 | this_action = rws.actions[resource.realm][action] # We need the full action data for custom label |
|---|
| 77 | |
|---|
| 78 | if operation == 'set_review_owner': |
|---|
| 79 | self.log.debug("Creating control for setting review owner.") |
|---|
| 80 | review = PeerReviewModel(self.env, resource.id) |
|---|
| 81 | |
|---|
| 82 | if not (Chrome(self.env).show_email_addresses |
|---|
| 83 | or 'EMAIL_VIEW' in req.perm(resource)): |
|---|
| 84 | format_user = obfuscate_email_address |
|---|
| 85 | else: |
|---|
| 86 | format_user = lambda address: address |
|---|
| 87 | current_owner = format_user(review['owner']) |
|---|
| 88 | |
|---|
| 89 | self.log.debug("Current owner is %s." % current_owner) |
|---|
| 90 | |
|---|
| 91 | selected_owner = req.args.get(id_, req.authname) |
|---|
| 92 | |
|---|
| 93 | hint = "The owner will be changed from %s" % current_owner |
|---|
| 94 | |
|---|
| 95 | owners = get_users(self.env) |
|---|
| 96 | if not owners: |
|---|
| 97 | owner = req.args.get(id_, req.authname) |
|---|
| 98 | control = tag(u'%s ' % this_action['name'], |
|---|
| 99 | tag.input(type='text', id=id_, |
|---|
| 100 | name=id_, value=owner)) |
|---|
| 101 | elif len(owners) == 1: |
|---|
| 102 | owner = tag.input(type='hidden', id=id_, name=id_, |
|---|
| 103 | value=owners[0]) |
|---|
| 104 | formatted_owner = format_user(owners[0]) |
|---|
| 105 | control = tag(u'%s ' % this_action['name'], |
|---|
| 106 | tag(formatted_owner, owner)) |
|---|
| 107 | if res_wf_state['owner'] != owners[0]: |
|---|
| 108 | hint = "The owner will be changed from %s to %s" % (current_owner, formatted_owner) |
|---|
| 109 | else: |
|---|
| 110 | control = tag(u'%s ' % this_action['name'], tag.select( |
|---|
| 111 | [tag.option(format_user(x), value=x, |
|---|
| 112 | selected=(x == selected_owner or None)) |
|---|
| 113 | for x in owners], |
|---|
| 114 | id=id_, name=id_)) |
|---|
| 115 | |
|---|
| 116 | return control, hint |
|---|
| 117 | |
|---|
| 118 | return None, '' |
|---|
| 119 | |
|---|
| 120 | def perform_operation(self, req, action, operation, old_state, new_state, res_wf_state, resource): |
|---|
| 121 | """Perform 'set_review_owner' operation on reviews.""" |
|---|
| 122 | |
|---|
| 123 | self.log.debug("---> Performing operation %s while transitioning from %s to %s." |
|---|
| 124 | % (operation, old_state, new_state)) |
|---|
| 125 | new_owner = req.args.get('action_%s_operation_%s' % (action, operation), None) |
|---|
| 126 | review = PeerReviewModel(self.env, int(resource.id)) |
|---|
| 127 | if review: |
|---|
| 128 | review['owner'] = new_owner |
|---|
| 129 | review.save_changes(author=req.authname) |
|---|
| 130 | |
|---|
| 131 | # IWorkflowTransitionListener |
|---|
| 132 | |
|---|
| 133 | def object_transition(self, res_wf_state, resource, action, old_state, new_state): |
|---|
| 134 | if resource.realm == 'peerreviewer': |
|---|
| 135 | reviewer = PeerReviewerModel(self.env, resource.id) |
|---|
| 136 | reviewer['status'] = new_state |
|---|
| 137 | # This is for updating the PeerReviewer object. The info is used for displaying the review state to the |
|---|
| 138 | # review author. Note that this change is recorded in 'peerreviewer_change' by this. |
|---|
| 139 | # The change is also recorded in the generic change table 'resourceworkflow_change' because of the |
|---|
| 140 | # Resource object 'resource'. |
|---|
| 141 | reviewer.save_changes(author=res_wf_state.authname, |
|---|
| 142 | comment="State change to %s for %s from object_transition() of %s" |
|---|
| 143 | % (new_state, reviewer, resource)) |
|---|
| 144 | elif resource.realm == 'peerreview': |
|---|
| 145 | review = PeerReviewModel(self.env, resource.id) |
|---|
| 146 | review.change_status(new_state, res_wf_state.authname) |
|---|
| 147 | |
|---|
| 148 | # INavigationContributor |
|---|
| 149 | |
|---|
| 150 | def get_active_navigation_item(self, req): |
|---|
| 151 | return 'peerReviewMain' |
|---|
| 152 | |
|---|
| 153 | def get_navigation_items(self, req): |
|---|
| 154 | return [] |
|---|
| 155 | |
|---|
| 156 | # IRequestHandler methods |
|---|
| 157 | def match_request(self, req): |
|---|
| 158 | return req.path_info == '/peerReviewView' |
|---|
| 159 | |
|---|
| 160 | def process_request(self, req): |
|---|
| 161 | def get_review_by_id(review_id): |
|---|
| 162 | """Get a PeerReviewModel for the given review id and prepare some additional data used by the template""" |
|---|
| 163 | review = PeerReviewModel(self.env, review_id) |
|---|
| 164 | review.html_notes = format_to_html(self.env, Context.from_request(req), review['notes']) |
|---|
| 165 | review.date = format_date(review['created']) |
|---|
| 166 | if review['closed']: |
|---|
| 167 | review.finish_date = format_date(review['closed']) |
|---|
| 168 | else: |
|---|
| 169 | review.finish_date = '' |
|---|
| 170 | return review |
|---|
| 171 | def get_files_for_review_id(review_id, comments=False): |
|---|
| 172 | """Get all files belonging to the given review id. Provide the number of comments if asked for.""" |
|---|
| 173 | rfm = ReviewFileModel(self.env) |
|---|
| 174 | rfm.clear_props() |
|---|
| 175 | rfm['review_id'] = review_id |
|---|
| 176 | rev_files = list(rfm.list_matching_objects()) |
|---|
| 177 | if comments: |
|---|
| 178 | for f in rev_files: |
|---|
| 179 | f.num_comments = len(Comment.select_by_file_id(self.env, f['file_id'])) |
|---|
| 180 | my_comment_data = ReviewDataModel.comments_for_file_and_owner(self.env, f['file_id'], req.authname) |
|---|
| 181 | f.num_notread = f.num_comments - len([c_id for c_id, t, dat in my_comment_data if t == 'read']) |
|---|
| 182 | return rev_files |
|---|
| 183 | |
|---|
| 184 | req.perm.require('CODE_REVIEW_DEV') |
|---|
| 185 | |
|---|
| 186 | data = {} |
|---|
| 187 | # check to see if the user is a manager of this page or not |
|---|
| 188 | if 'CODE_REVIEW_MGR' in req.perm: |
|---|
| 189 | data['manager'] = True |
|---|
| 190 | |
|---|
| 191 | # review_id argument checking |
|---|
| 192 | review_id = req.args.get('Review') |
|---|
| 193 | if review_id is None or not review_id.isdigit(): |
|---|
| 194 | raise TracError(u"Invalid review ID supplied - unable to load page.") |
|---|
| 195 | |
|---|
| 196 | if req.method == 'POST': |
|---|
| 197 | if req.args.get('resubmit'): |
|---|
| 198 | req.redirect(self.env.href.peerReviewNew(resubmit=review_id)) |
|---|
| 199 | elif req.args.get('followup'): |
|---|
| 200 | req.redirect(self.env.href.peerReviewNew(resubmit=review_id, followup=1)) |
|---|
| 201 | elif req.args.get('modify'): |
|---|
| 202 | req.redirect(self.env.href.peerReviewNew(resubmit=review_id, modify=1)) |
|---|
| 203 | |
|---|
| 204 | data['review_files'] = get_files_for_review_id(review_id, True) |
|---|
| 205 | data['users'] = get_users(self.env) |
|---|
| 206 | data['show_ticket'] = self.show_ticket |
|---|
| 207 | |
|---|
| 208 | review = get_review_by_id(review_id) |
|---|
| 209 | data['review'] = review |
|---|
| 210 | |
|---|
| 211 | # A finished review can't be changed anymore except by a manager |
|---|
| 212 | data['is_finished'] = review_is_finished(self.env.config, review) |
|---|
| 213 | # A user can't change his voting for a reviewed review |
|---|
| 214 | data['review_locked'] = review_is_locked(self.env.config, review, req.authname) |
|---|
| 215 | # Used to indicate that a review is done. 'review_locked' is not suitable because it is false for the |
|---|
| 216 | # author of a review even when the review is done. |
|---|
| 217 | data['review_done'] = review_is_locked(self.env.config, review) |
|---|
| 218 | data['finished_states_str'] = ', '.join(self.env.config.getlist("peerreview", "terminal_review_states")) |
|---|
| 219 | # Parent review if any |
|---|
| 220 | if review['parent_id'] != 0: |
|---|
| 221 | data['parent_review'] = get_review_by_id(review['parent_id']) |
|---|
| 222 | par_files = get_files_for_review_id(review['parent_id'], False) |
|---|
| 223 | data['parent_files'] = par_files |
|---|
| 224 | |
|---|
| 225 | # Map files to parent files. Key is current file id, value is parent file object |
|---|
| 226 | |
|---|
| 227 | def get_parent_file(rfile, par_files): |
|---|
| 228 | fid = u"%s%s%s" % (rfile['path'], rfile['line_start'], rfile['line_end']) |
|---|
| 229 | for f in par_files: |
|---|
| 230 | tmp = u"%s%s%s" % (f['path'], f['line_start'], f['line_end']) |
|---|
| 231 | if tmp == fid: |
|---|
| 232 | return f |
|---|
| 233 | return None |
|---|
| 234 | file_map = {} |
|---|
| 235 | for f in data['review_files']: |
|---|
| 236 | file_map[f['file_id']] = get_parent_file(f, par_files) |
|---|
| 237 | |
|---|
| 238 | data['file_map'] = file_map |
|---|
| 239 | |
|---|
| 240 | self.create_ticket_data(req, data) |
|---|
| 241 | url = '.' |
|---|
| 242 | # Actions for a reviewer. Each reviewer marks his progress on a review. The author |
|---|
| 243 | # can see this progress in the user list. The possible actions are defined in trac.ini |
|---|
| 244 | # as a workflow in [peerreviewer-resource_workflow] |
|---|
| 245 | realm = 'peerreviewer' |
|---|
| 246 | res = None |
|---|
| 247 | |
|---|
| 248 | rm = PeerReviewerModel(self.env) |
|---|
| 249 | rm.clear_props() |
|---|
| 250 | rm['review_id'] = review_id |
|---|
| 251 | reviewers = list(rm.list_matching_objects()) |
|---|
| 252 | data['reviewer'] = reviewers |
|---|
| 253 | |
|---|
| 254 | for reviewer in reviewers: |
|---|
| 255 | if reviewer['reviewer'] == req.authname: |
|---|
| 256 | res = Resource(realm, str(reviewer['reviewer_id'])) # id must be a string |
|---|
| 257 | if not data['is_finished'] and not data['review_done']: # even author isn't allowed to change |
|---|
| 258 | data['canivote'] = True |
|---|
| 259 | break |
|---|
| 260 | if res: |
|---|
| 261 | data['reviewer_workflow'] = ResourceWorkflowSystem(self.env).\ |
|---|
| 262 | get_workflow_markup(req, url, realm, res, {'redirect': req.href.peerReviewView(Review=review_id)}) |
|---|
| 263 | |
|---|
| 264 | # Actions for the author of a review. The author may approve, disapprove or close a review. |
|---|
| 265 | # The possible actions are defined in trac.ini as a workflow in [peerreview-resource_workflow] |
|---|
| 266 | realm = 'peerreview' |
|---|
| 267 | res = Resource(realm, str(review['review_id'])) # Must be a string |
|---|
| 268 | data['workflow'] = ResourceWorkflowSystem(self.env).\ |
|---|
| 269 | get_workflow_markup(req, url, realm, res, {'redirect': req.href.peerReviewView(Review=review_id)}) |
|---|
| 270 | |
|---|
| 271 | data['cycle'] = itertools.cycle |
|---|
| 272 | |
|---|
| 273 | add_stylesheet(req, 'common/css/code.css') |
|---|
| 274 | add_stylesheet(req, 'common/css/browser.css') |
|---|
| 275 | add_stylesheet(req, 'common/css/ticket.css') |
|---|
| 276 | add_stylesheet(req, 'hw/css/peerreview.css') |
|---|
| 277 | add_ctxt_nav_items(req) |
|---|
| 278 | |
|---|
| 279 | # For downloading in docx format |
|---|
| 280 | conversions = Mimeview(self.env).get_supported_conversions('text/x-trac-peerreview') |
|---|
| 281 | for key, name, ext, mime_in, mime_out, q, c in conversions: |
|---|
| 282 | conversion_href = req.href("peerreview", format=key, reviewid=review_id) |
|---|
| 283 | add_link(req, 'alternate', conversion_href, name, mime_out) |
|---|
| 284 | |
|---|
| 285 | return 'peerReviewView.html', data, None |
|---|
| 286 | |
|---|
| 287 | desc = u""" |
|---|
| 288 | Review [/peerReviewView?Review=${review_id} ${review_name}] is finished. |
|---|
| 289 | === Review |
|---|
| 290 | |
|---|
| 291 | ||= Name =|| ${review_name} || |
|---|
| 292 | ||= ID =|| ${review_id} || |
|---|
| 293 | [[br]] |
|---|
| 294 | **Review Notes:** |
|---|
| 295 | ${review_notes} |
|---|
| 296 | |
|---|
| 297 | === Files |
|---|
| 298 | ||= File name =||= Comments =|| |
|---|
| 299 | """ |
|---|
| 300 | |
|---|
| 301 | def create_ticket_data(self, req, data): |
|---|
| 302 | """Create the ticket description for tickets created from the review page""" |
|---|
| 303 | txt = u"" |
|---|
| 304 | review = data['review'] |
|---|
| 305 | tmpl = Template(self.desc) |
|---|
| 306 | txt = tmpl.substitute(review_name=review['name'], review_id=review['review_id'], |
|---|
| 307 | review_notes="") #review['notes']) |
|---|
| 308 | |
|---|
| 309 | try: |
|---|
| 310 | for f in data['review_files']: |
|---|
| 311 | txt += u"||[/peerReviewPerform?IDFile=%s %s]|| %s ||%s" % \ |
|---|
| 312 | (f['file_id'], f['path'], f.num_comments, CRLF) |
|---|
| 313 | except KeyError: |
|---|
| 314 | pass |
|---|
| 315 | |
|---|
| 316 | data['ticket_desc_wiki'] = self.create_preview(req, txt) |
|---|
| 317 | data['ticket_desc'] = txt |
|---|
| 318 | data['ticket_summary'] = u'Problems with Review "%s"'% review['name'] |
|---|
| 319 | |
|---|
| 320 | def create_preview(self, req, text): |
|---|
| 321 | resource = Resource('peerreview') |
|---|
| 322 | context = web_context(req, resource) |
|---|
| 323 | return format_to_html(self.env, context, text) |
|---|