| 1 | # |
|---|
| 2 | # Copyright (C) 2005-2006 Team5 |
|---|
| 3 | # Copyright (C) 2016-2021 Cinc |
|---|
| 4 | # |
|---|
| 5 | # All rights reserved. |
|---|
| 6 | # |
|---|
| 7 | # This software is licensed as described in the file COPYING.txt, which |
|---|
| 8 | # you should have received as part of this distribution. |
|---|
| 9 | # |
|---|
| 10 | # Author: Team5 |
|---|
| 11 | # |
|---|
| 12 | # Provides functionality for main page |
|---|
| 13 | # Works with peerreview_main.html |
|---|
| 14 | |
|---|
| 15 | import itertools |
|---|
| 16 | from trac.core import Component, implements |
|---|
| 17 | from trac.perm import IPermissionRequestor |
|---|
| 18 | from trac.resource import get_resource_url, IResourceManager, resource_exists, Resource, ResourceNotFound |
|---|
| 19 | from trac.util import as_int |
|---|
| 20 | from trac.util.datefmt import format_date, to_datetime, user_time |
|---|
| 21 | from trac.util.html import Markup, html as tag |
|---|
| 22 | from trac.util.translation import _ |
|---|
| 23 | from trac.versioncontrol.api import RepositoryManager |
|---|
| 24 | from trac.web.chrome import add_ctxtnav, add_stylesheet, Chrome,\ |
|---|
| 25 | INavigationContributor, ITemplateProvider, web_context |
|---|
| 26 | from trac.web.main import IRequestHandler |
|---|
| 27 | from trac.wiki.api import IWikiSyntaxProvider |
|---|
| 28 | from trac.wiki.formatter import format_to |
|---|
| 29 | from .model import ReviewCommentModel, ReviewDataModel, ReviewFileModel, PeerReviewModel, PeerReviewerModel |
|---|
| 30 | from .util import review_is_finished |
|---|
| 31 | |
|---|
| 32 | |
|---|
| 33 | def add_ctxt_nav_items(req): |
|---|
| 34 | add_ctxtnav(req, _("My Code Reviews"), req.href.peerreviewmain(), title=_("My Code Reviews")) |
|---|
| 35 | add_ctxtnav(req, _("Create a Code Review"), req.href.peerreviewnew(), title=_("Create a Code review")) |
|---|
| 36 | add_ctxtnav(req, _("Report"), req.href.peerreviewreport(), title=_("Show Codereview Reports")) |
|---|
| 37 | add_ctxtnav(req, _("Search Code Reviews"), req.href.peerreviewsearch(), _("Search Code Reviews")) |
|---|
| 38 | |
|---|
| 39 | |
|---|
| 40 | class PeerReviewMain(Component): |
|---|
| 41 | """Main component for code reviews providing basic features. Show overview page for code reviews. |
|---|
| 42 | |
|---|
| 43 | [[BR]] |
|---|
| 44 | === Permissions |
|---|
| 45 | There are three additional permissions for code reviews: |
|---|
| 46 | * CODE_REVIEW_VIEW |
|---|
| 47 | * CODE_REVIEW_DEV |
|---|
| 48 | * CODE_REVIEW_MGR |
|---|
| 49 | |
|---|
| 50 | === Wiki syntax |
|---|
| 51 | Two new trac links are available with this plugin: |
|---|
| 52 | * {{{review:<xxx>}}} with <xxx> being a review id |
|---|
| 53 | * {{{rfile:<xxx>}}} with <xxx> being a file id |
|---|
| 54 | |
|---|
| 55 | These links open the review page or file page. |
|---|
| 56 | """ |
|---|
| 57 | implements(INavigationContributor, IPermissionRequestor, IRequestHandler, |
|---|
| 58 | IResourceManager, ITemplateProvider, IWikiSyntaxProvider) |
|---|
| 59 | |
|---|
| 60 | # INavigationContributor methods |
|---|
| 61 | |
|---|
| 62 | def get_active_navigation_item(self, req): |
|---|
| 63 | return 'peerreviewmain' |
|---|
| 64 | |
|---|
| 65 | def get_navigation_items(self, req): |
|---|
| 66 | if 'CODE_REVIEW_DEV' in req.perm: |
|---|
| 67 | yield ('mainnav', 'peerreviewmain', |
|---|
| 68 | Markup('<a href="%s">Codereview</a>') % req.href.peerreviewmain()) |
|---|
| 69 | |
|---|
| 70 | # IPermissionRequestor methods |
|---|
| 71 | |
|---|
| 72 | def get_permission_actions(self): |
|---|
| 73 | return [ |
|---|
| 74 | ('CODE_REVIEW_VIEW',['PEERREVIEWFILE_VIEW', 'PEERREVIEW_VIEW']), # Allow viewing of realm so reports show |
|---|
| 75 | ('CODE_REVIEW_DEV', ['CODE_REVIEW_VIEW']), # results. |
|---|
| 76 | ('CODE_REVIEW_MGR', ['CODE_REVIEW_DEV', 'PEERREVIEWFILE_VIEW']) |
|---|
| 77 | ] |
|---|
| 78 | |
|---|
| 79 | # IRequestHandler methods |
|---|
| 80 | |
|---|
| 81 | def send_preview(self, req): |
|---|
| 82 | # Taken from WikiRender component in wiki/web_api.py |
|---|
| 83 | # Allow all POST requests (with a valid __FORM_TOKEN, ensuring that |
|---|
| 84 | # the client has at least some permission). Additionally, allow GET |
|---|
| 85 | # requests from TRAC_ADMIN for testing purposes. |
|---|
| 86 | if req.method != 'POST': |
|---|
| 87 | req.perm.require('TRAC_ADMIN') |
|---|
| 88 | realm = req.args.get('realm', 'wiki') |
|---|
| 89 | id = req.args.get('id') |
|---|
| 90 | version = as_int(req.args.get('version'), None) |
|---|
| 91 | text = req.args.get('text', '') |
|---|
| 92 | flavor = req.args.get('flavor') |
|---|
| 93 | options = {} |
|---|
| 94 | if 'escape_newlines' in req.args: |
|---|
| 95 | options['escape_newlines'] = bool(int(req.args['escape_newlines'] or 0)) |
|---|
| 96 | if 'shorten' in req.args: |
|---|
| 97 | options['shorten'] = bool(int(req.args['shorten'] or 0)) |
|---|
| 98 | resource = Resource(realm, id=id, version=version) |
|---|
| 99 | context = web_context(req, resource) |
|---|
| 100 | rendered = format_to(self.env, flavor, context, text, **options) |
|---|
| 101 | req.send(rendered.encode('utf-8')) |
|---|
| 102 | |
|---|
| 103 | |
|---|
| 104 | def match_request(self, req): |
|---|
| 105 | if req.path_info == '/peerreviewmain' or req.path_info == "/preview_render": |
|---|
| 106 | return True |
|---|
| 107 | elif req.path_info == '/peerReviewMain': |
|---|
| 108 | self.env.log.info("Legacy URL 'peerReviewMain' called from: %s", req.get_header('Referer')) |
|---|
| 109 | return True |
|---|
| 110 | return False |
|---|
| 111 | |
|---|
| 112 | def process_request(self, req): |
|---|
| 113 | req.perm.require('CODE_REVIEW_DEV') |
|---|
| 114 | |
|---|
| 115 | if req.path_info == "/preview_render": |
|---|
| 116 | self.send_preview(req) |
|---|
| 117 | |
|---|
| 118 | data = {} |
|---|
| 119 | # test whether this user is a manager or not |
|---|
| 120 | if 'CODE_REVIEW_MGR' in req.perm: |
|---|
| 121 | data['manager'] = True |
|---|
| 122 | else: |
|---|
| 123 | data['manager'] = False |
|---|
| 124 | |
|---|
| 125 | # User requests an update |
|---|
| 126 | data['allassigned'] = req.args.get('allassigned') |
|---|
| 127 | data['allcreated'] = req.args.get('allcreated') |
|---|
| 128 | |
|---|
| 129 | r_tmpl = PeerReviewModel(self.env) |
|---|
| 130 | r_tmpl.clear_props() |
|---|
| 131 | if data['allcreated']: |
|---|
| 132 | all_reviews = list(r_tmpl.list_matching_objects()) |
|---|
| 133 | else: |
|---|
| 134 | all_reviews = [rev for rev in r_tmpl.list_matching_objects() if rev['status'] != "closed"] |
|---|
| 135 | |
|---|
| 136 | # We need this for displaying information about comments |
|---|
| 137 | comments = ReviewCommentModel.comment_ids_by_file_id(self.env) |
|---|
| 138 | my_comment_data = ReviewDataModel.comments_for_owner(self.env, req.authname) |
|---|
| 139 | |
|---|
| 140 | # Add files |
|---|
| 141 | files = ReviewFileModel.file_dict_by_review(self.env) |
|---|
| 142 | |
|---|
| 143 | # fill the table of currently open reviews |
|---|
| 144 | myreviews = [] |
|---|
| 145 | assigned_to_me =[] |
|---|
| 146 | manager_reviews = [] |
|---|
| 147 | |
|---|
| 148 | for rev in all_reviews: |
|---|
| 149 | # Reviews created by me |
|---|
| 150 | if rev['owner'] == req.authname: |
|---|
| 151 | rev.date = user_time(req, format_date, to_datetime(rev['created'])) |
|---|
| 152 | if rev['closed']: |
|---|
| 153 | rev.finish_date = user_time(req, format_date, to_datetime(rev['closed'])) |
|---|
| 154 | else: |
|---|
| 155 | rev.finish_date = '' |
|---|
| 156 | rm = RepositoryManager(self.env) |
|---|
| 157 | for file in files[rev['review_id']]: |
|---|
| 158 | repos = rm.get_repository(file['repo']) |
|---|
| 159 | file['short_rev'] = repos.display_rev(file['revision']) |
|---|
| 160 | rev.rev_files = files[rev['review_id']] |
|---|
| 161 | # Prepare number of comments for a review |
|---|
| 162 | rev.num_comments = 0 |
|---|
| 163 | for f in rev.rev_files: |
|---|
| 164 | if f['file_id'] in comments: |
|---|
| 165 | rev.num_comments += len(comments[f['file_id']]) |
|---|
| 166 | rev.num_notread = rev.num_comments - len([c_id for c_id, r, t, dat in my_comment_data if t == 'read' |
|---|
| 167 | and r == rev['review_id']]) |
|---|
| 168 | myreviews.append(rev) |
|---|
| 169 | |
|---|
| 170 | r_tmpl = PeerReviewerModel(self.env) |
|---|
| 171 | r_tmpl.clear_props() |
|---|
| 172 | r_tmpl['reviewer'] = req.authname |
|---|
| 173 | |
|---|
| 174 | if data['allassigned']: |
|---|
| 175 | # Don't filter list here |
|---|
| 176 | reviewer = list(r_tmpl.list_matching_objects()) |
|---|
| 177 | else: |
|---|
| 178 | reviewer = [rev for rev in r_tmpl.list_matching_objects() if rev['status'] != "reviewed"] |
|---|
| 179 | |
|---|
| 180 | # All reviews assigned to me |
|---|
| 181 | for item in reviewer: |
|---|
| 182 | rev = PeerReviewModel(self.env, item['review_id']) |
|---|
| 183 | if not review_is_finished(self.env.config, rev): |
|---|
| 184 | rev.reviewer = item |
|---|
| 185 | rev.date = user_time(req, format_date, to_datetime(rev['created'])) |
|---|
| 186 | if rev['closed']: |
|---|
| 187 | rev.finish_date = user_time(req, format_date, to_datetime(rev['closed'])) |
|---|
| 188 | else: |
|---|
| 189 | rev.finish_date = '' |
|---|
| 190 | rev.rev_files = files[rev['review_id']] |
|---|
| 191 | # Prepare number of comments for a review |
|---|
| 192 | rev.num_comments = 0 |
|---|
| 193 | for f in rev.rev_files: |
|---|
| 194 | if f['file_id'] in comments: |
|---|
| 195 | rev.num_comments += len(comments[f['file_id']]) |
|---|
| 196 | rev.num_notread = rev.num_comments - len([c_id for c_id, r, t, dat in my_comment_data if t == 'read' |
|---|
| 197 | and r == rev['review_id']]) |
|---|
| 198 | assigned_to_me.append(rev) |
|---|
| 199 | |
|---|
| 200 | data['myreviews'] = myreviews |
|---|
| 201 | data['manager_reviews'] = manager_reviews |
|---|
| 202 | data['assigned_reviews'] = assigned_to_me |
|---|
| 203 | data['cycle'] = itertools.cycle |
|---|
| 204 | |
|---|
| 205 | add_stylesheet(req, 'common/css/browser.css') |
|---|
| 206 | add_stylesheet(req, 'hw/css/peerreview.css') |
|---|
| 207 | add_ctxt_nav_items(req) |
|---|
| 208 | |
|---|
| 209 | if hasattr(Chrome, 'jenv'): |
|---|
| 210 | return 'peerreview_main_jinja.html', data |
|---|
| 211 | else: |
|---|
| 212 | return 'peerreview_main.html', data, None |
|---|
| 213 | |
|---|
| 214 | # IResourceManager methods |
|---|
| 215 | |
|---|
| 216 | def get_resource_url(self, resource, href, **kwargs): |
|---|
| 217 | """Return the canonical URL for displaying the given resource. |
|---|
| 218 | |
|---|
| 219 | :param resource: a `Resource` |
|---|
| 220 | :param href: an `Href` used for creating the URL |
|---|
| 221 | |
|---|
| 222 | Note that if there's no special rule associated to this realm for |
|---|
| 223 | creating URLs (i.e. the standard convention of using realm/id applies), |
|---|
| 224 | then it's OK to not define this method. |
|---|
| 225 | """ |
|---|
| 226 | if resource.realm == 'peerreviewfile': |
|---|
| 227 | return href('peerreviewfile', resource.id) |
|---|
| 228 | elif resource.realm == 'peerreview': |
|---|
| 229 | return href.peerreviewview(resource.id) |
|---|
| 230 | |
|---|
| 231 | return href('peerreviewmain') |
|---|
| 232 | |
|---|
| 233 | def get_resource_realms(self): |
|---|
| 234 | yield 'peerreview' |
|---|
| 235 | yield 'peerreviewfile' |
|---|
| 236 | |
|---|
| 237 | def get_resource_description(self, resource, format=None, context=None, |
|---|
| 238 | **kwargs): |
|---|
| 239 | if resource.realm == 'peerreview': |
|---|
| 240 | if format == 'compact': |
|---|
| 241 | return 'review:%s' % resource.id # Will be used as id in reports when 'realm' is used |
|---|
| 242 | else: |
|---|
| 243 | return 'Review %s' % resource.id |
|---|
| 244 | elif resource.realm == 'peerreviewfile': |
|---|
| 245 | if format == 'compact': |
|---|
| 246 | return 'rfile:%s' % resource.id |
|---|
| 247 | else: |
|---|
| 248 | return 'ReviewFile %s' % resource.id |
|---|
| 249 | return "" |
|---|
| 250 | |
|---|
| 251 | def resource_exists(self, resource): |
|---|
| 252 | with self.env.db_query as db: |
|---|
| 253 | cursor = db.cursor() |
|---|
| 254 | if resource.realm == 'peerreview': |
|---|
| 255 | cursor.execute("SELECT * FROM peerreview WHERE review_id = %s", (resource.id,)) |
|---|
| 256 | if cursor.fetchone(): |
|---|
| 257 | return True |
|---|
| 258 | else: |
|---|
| 259 | return False |
|---|
| 260 | elif resource.realm == 'peerreviewfile': |
|---|
| 261 | # Only files associated with a review are real peerreviewfiles |
|---|
| 262 | cursor.execute("SELECT * FROM peerreviewfile WHERE file_id = %s AND review_id != 0", (resource.id,)) |
|---|
| 263 | if cursor.fetchone(): |
|---|
| 264 | return True |
|---|
| 265 | else: |
|---|
| 266 | return False |
|---|
| 267 | |
|---|
| 268 | raise ResourceNotFound('Resource %s not found.' % resource.realm) |
|---|
| 269 | |
|---|
| 270 | # ITemplateProvider methods |
|---|
| 271 | |
|---|
| 272 | def get_templates_dirs(self): |
|---|
| 273 | """Return the path of the directory containing the provided templates.""" |
|---|
| 274 | from pkg_resources import resource_filename |
|---|
| 275 | return [resource_filename(__name__, 'templates')] |
|---|
| 276 | |
|---|
| 277 | def get_htdocs_dirs(self): |
|---|
| 278 | from pkg_resources import resource_filename |
|---|
| 279 | return [('hw', resource_filename(__name__, 'htdocs'))] |
|---|
| 280 | |
|---|
| 281 | # IWikiSyntaxProvider |
|---|
| 282 | |
|---|
| 283 | def get_link_resolvers(self): |
|---|
| 284 | return [('review', self._format_review_link), |
|---|
| 285 | ('rfile', self._format_file_link)] |
|---|
| 286 | |
|---|
| 287 | def get_wiki_syntax(self): |
|---|
| 288 | return [] |
|---|
| 289 | |
|---|
| 290 | status_map = {'approved': tag.span(u" \u2713", class_='approved'), |
|---|
| 291 | 'disapproved': tag.span(u" \u2717", class_='disapproved')} |
|---|
| 292 | |
|---|
| 293 | def _format_review_link(self, formatter, ns, target, label): |
|---|
| 294 | res = Resource('peerreview', target) |
|---|
| 295 | if resource_exists(self.env, res): |
|---|
| 296 | review = PeerReviewModel(self.env, target) |
|---|
| 297 | if review['status'] == 'closed': |
|---|
| 298 | cls = 'peer-wiki closed' |
|---|
| 299 | else: |
|---|
| 300 | cls = 'peer-wiki' |
|---|
| 301 | |
|---|
| 302 | try: |
|---|
| 303 | span = self.status_map[review['status']] |
|---|
| 304 | except KeyError: |
|---|
| 305 | span = '' |
|---|
| 306 | |
|---|
| 307 | return tag.a([label, span], |
|---|
| 308 | href=get_resource_url(self.env, res, formatter.href), |
|---|
| 309 | title=_(u"Review #%s (%s)") % (target, review['status']), |
|---|
| 310 | class_=cls |
|---|
| 311 | ) |
|---|
| 312 | |
|---|
| 313 | return tag.span(label + '?', |
|---|
| 314 | title=_(u"Review #%s doesn't exist") % target, |
|---|
| 315 | class_='missing') |
|---|
| 316 | |
|---|
| 317 | def _format_file_link(self, formatter, ns, target, label): |
|---|
| 318 | def rfile_is_finished(config, rfile): |
|---|
| 319 | """A finished review may only be reopened by a manager or admisnistrator |
|---|
| 320 | |
|---|
| 321 | :param config: Trac config object |
|---|
| 322 | :param rfile: review file object |
|---|
| 323 | |
|---|
| 324 | :return True if review is in one of the terminal states |
|---|
| 325 | """ |
|---|
| 326 | finish_states = config.getlist("peerreview", "terminal_review_states") |
|---|
| 327 | return rfile['status'] in finish_states |
|---|
| 328 | |
|---|
| 329 | res = Resource('peerreviewfile', target) |
|---|
| 330 | if resource_exists(self.env, res): |
|---|
| 331 | rfile = ReviewFileModel(self.env, target) |
|---|
| 332 | if rfile['status'] == 'closed': |
|---|
| 333 | cls = 'peer-wiki closed' |
|---|
| 334 | else: |
|---|
| 335 | cls = 'peer-wiki' |
|---|
| 336 | |
|---|
| 337 | try: |
|---|
| 338 | span = self.status_map[rfile['status']] |
|---|
| 339 | except KeyError: |
|---|
| 340 | span = '' |
|---|
| 341 | |
|---|
| 342 | return tag.a([label, span], |
|---|
| 343 | href=get_resource_url(self.env, res, formatter.href), |
|---|
| 344 | title=_(u"File #%s (%s)") % (target, rfile['status']), |
|---|
| 345 | class_=cls |
|---|
| 346 | ) |
|---|
| 347 | |
|---|
| 348 | return tag.span(label + '?', |
|---|
| 349 | title=_(u"File #%s doesn't exist") % target, |
|---|
| 350 | class_='missing') |
|---|