| [13497] | 1 | # |
|---|
| 2 | # Copyright (C) 2005-2006 Team5 |
|---|
| [18050] | 3 | # Copyright (C) 2016-2021 Cinc |
|---|
| [15448] | 4 | # |
|---|
| [13497] | 5 | # All rights reserved. |
|---|
| 6 | # |
|---|
| [15228] | 7 | # This software is licensed as described in the file COPYING.txt, which |
|---|
| [13497] | 8 | # you should have received as part of this distribution. |
|---|
| 9 | # |
|---|
| [717] | 10 | # Author: Team5 |
|---|
| 11 | # |
|---|
| 12 | |
|---|
| [15242] | 13 | import json |
|---|
| [17441] | 14 | from codereview.model import ReviewCommentModel, PeerReviewModel, ReviewDataModel, ReviewFileModel |
|---|
| 15 | from codereview.util import get_review_for_file, not_allowed_to_comment, review_is_finished, review_is_locked |
|---|
| [18269] | 16 | from functools import partial |
|---|
| [18244] | 17 | try: |
|---|
| 18 | from genshi.template.markup import MarkupTemplate |
|---|
| 19 | except ImportError: |
|---|
| 20 | pass # We are Trac 1.4 and use Jinja2 |
|---|
| [717] | 21 | from trac.core import * |
|---|
| [18248] | 22 | from trac.util.datefmt import format_date, to_datetime, user_time |
|---|
| [18249] | 23 | from trac.util.html import Markup |
|---|
| [18244] | 24 | from trac.web.chrome import Chrome, web_context |
|---|
| [717] | 25 | from trac.web.main import IRequestHandler |
|---|
| [15518] | 26 | from trac.wiki import format_to_html |
|---|
| [717] | 27 | |
|---|
| [15517] | 28 | |
|---|
| [15242] | 29 | def writeJSONResponse(rq, data, httperror=200): |
|---|
| [18263] | 30 | writeResponse(rq, json.dumps(data), httperror, content_type='application/json; charset=utf-8') |
|---|
| [15242] | 31 | |
|---|
| 32 | |
|---|
| [18263] | 33 | def writeResponse(req, data, httperror=200, content_type='text/plain; charset=utf-8'): |
|---|
| [17448] | 34 | data = data.encode('utf-8') |
|---|
| [15242] | 35 | req.send_response(httperror) |
|---|
| [18263] | 36 | req.send_header('Content-Type', content_type) |
|---|
| [15242] | 37 | req.send_header('Content-Length', len(data)) |
|---|
| 38 | req.end_headers() |
|---|
| 39 | req.write(data) |
|---|
| 40 | |
|---|
| [17448] | 41 | |
|---|
| [15228] | 42 | class PeerReviewCommentHandler(Component): |
|---|
| [18280] | 43 | """Handling of comments for reviews. This component is responsible for creating comments and |
|---|
| 44 | building comment trees for display. |
|---|
| 45 | """ |
|---|
| [15228] | 46 | implements(IRequestHandler) |
|---|
| 47 | |
|---|
| [717] | 48 | # IRequestHandler methods |
|---|
| [17448] | 49 | |
|---|
| [717] | 50 | def match_request(self, req): |
|---|
| [18263] | 51 | if req.path_info == '/peercomment': |
|---|
| 52 | return True |
|---|
| [717] | 53 | return req.path_info == '/peerReviewCommentCallback' |
|---|
| 54 | |
|---|
| [17448] | 55 | # This page should never be called directly. It should only be called |
|---|
| 56 | # by JavaScript HTTPRequest calls. |
|---|
| [717] | 57 | def process_request(self, req): |
|---|
| [17448] | 58 | data = {'invalid': 0} |
|---|
| [717] | 59 | |
|---|
| [18265] | 60 | if not 'CODE_REVIEW_VIEW' in req.perm: |
|---|
| [18264] | 61 | writeResponse(req, "", 403) |
|---|
| [3488] | 62 | |
|---|
| [15242] | 63 | if req.method == 'POST': |
|---|
| [18265] | 64 | if not 'CODE_REVIEW_DEV' in req.perm: |
|---|
| 65 | writeResponse(req, "", 403) |
|---|
| [15242] | 66 | if req.args.get('addcomment'): |
|---|
| [18263] | 67 | fileid = req.args.get('fileid') |
|---|
| [15517] | 68 | # This shouldn't happen but still... |
|---|
| [18263] | 69 | review = get_review_for_file(self.env, fileid) |
|---|
| [15517] | 70 | if not_allowed_to_comment(self.env, review, req.perm, req.authname): |
|---|
| 71 | writeResponse(req, "", 403) |
|---|
| 72 | return |
|---|
| [15253] | 73 | # We shouldn't end here but in case just drop out. |
|---|
| [15242] | 74 | if self.review_is_closed(req): |
|---|
| 75 | data['invalid'] = 'closed' |
|---|
| [18247] | 76 | if hasattr(Chrome, 'jenv'): |
|---|
| [18281] | 77 | return 'peerreview_comment_jinja.html', data |
|---|
| [18247] | 78 | else: |
|---|
| [18281] | 79 | return 'peerreview_comment.html', data, None |
|---|
| [18246] | 80 | |
|---|
| [18263] | 81 | rfile = ReviewFileModel(self.env, fileid) |
|---|
| [18274] | 82 | data['path'] = rfile['path'].lstrip('/') # Trac path doesn't start with '/'. Db path does. |
|---|
| [15249] | 83 | txt = req.args.get('comment') |
|---|
| [17441] | 84 | comment = ReviewCommentModel(self.env) |
|---|
| 85 | comment['file_id'] = data['fileid'] = req.args.get('fileid') |
|---|
| 86 | comment['parent_id'] = data['parentid'] = req.args.get('parentid') |
|---|
| 87 | comment['comment'] = txt |
|---|
| 88 | comment['line_num'] = data['line'] = req.args.get('line') |
|---|
| 89 | comment['author'] = req.authname |
|---|
| [15249] | 90 | if txt and txt.strip(): |
|---|
| 91 | comment.insert() |
|---|
| [15242] | 92 | writeJSONResponse(req, data) |
|---|
| [15253] | 93 | return |
|---|
| [15448] | 94 | elif req.args.get('markread'): |
|---|
| 95 | data['fileid'] = req.args.get('fileid') |
|---|
| 96 | data['line'] = req.args.get('line') |
|---|
| [18263] | 97 | rfile = ReviewFileModel(self.env, data['fileid']) |
|---|
| 98 | data['path'] = rfile['path'] |
|---|
| [15448] | 99 | if req.args.get('markread') == 'read': |
|---|
| 100 | rev_dat = ReviewDataModel(self.env) |
|---|
| 101 | rev_dat['file_id'] = data['fileid'] |
|---|
| 102 | rev_dat['comment_id'] = req.args.get('commentid') |
|---|
| 103 | rev_dat['review_id'] = req.args.get('reviewid') |
|---|
| 104 | rev_dat['owner'] = req.authname |
|---|
| 105 | rev_dat['type'] = 'read' |
|---|
| 106 | rev_dat['data'] = 'read' |
|---|
| 107 | rev_dat.insert() |
|---|
| 108 | else: |
|---|
| 109 | rev_dat = ReviewDataModel(self.env) |
|---|
| 110 | rev_dat['file_id'] = data['fileid'] |
|---|
| 111 | rev_dat['comment_id'] = req.args.get('commentid') |
|---|
| 112 | rev_dat['owner'] = req.authname |
|---|
| 113 | for rev in rev_dat.list_matching_objects(): |
|---|
| 114 | rev.delete() |
|---|
| 115 | writeJSONResponse(req, data) |
|---|
| 116 | return |
|---|
| [15242] | 117 | |
|---|
| [18263] | 118 | if req.args.get('action') == 'commenttree': |
|---|
| 119 | self.get_comment_tree(req, data) |
|---|
| 120 | data['path'] = req.args.get('path', '') |
|---|
| 121 | elif req.args.get('action') == 'addcommentdlg': |
|---|
| 122 | data['create_add_comment_dlg'] = True |
|---|
| 123 | data['form_token'] = req.form_token |
|---|
| [18281] | 124 | else: |
|---|
| 125 | data['invalid'] = 'error' # We shouldn't end here |
|---|
| [18263] | 126 | |
|---|
| [18247] | 127 | if hasattr(Chrome, 'jenv'): |
|---|
| [18281] | 128 | return 'peerreview_comment_jinja.html', data |
|---|
| [18247] | 129 | else: |
|---|
| [18281] | 130 | return 'peerreview_comment.html', data, None |
|---|
| [717] | 131 | |
|---|
| [18281] | 132 | |
|---|
| [15208] | 133 | def review_is_closed(self, req): |
|---|
| [15242] | 134 | fileid = req.args.get('IDFile') |
|---|
| 135 | if not fileid: |
|---|
| 136 | fileid = req.args.get('fileid') |
|---|
| [17448] | 137 | review = get_review_for_file(self.env, fileid) |
|---|
| [15294] | 138 | if review['status'] == 'closed': |
|---|
| [15208] | 139 | return True |
|---|
| 140 | return False |
|---|
| 141 | |
|---|
| [15330] | 142 | # Returns a comment tree for the requested line number |
|---|
| 143 | # in the requested file |
|---|
| [15288] | 144 | def get_comment_tree(self, req, data): |
|---|
| [18263] | 145 | fileid = req.args.get('IDFile') or req.args.get('fileid') |
|---|
| 146 | linenum = req.args.get('LineNum') or req.args.get('line') |
|---|
| [15249] | 147 | |
|---|
| [15288] | 148 | if not fileid or not linenum: |
|---|
| [18281] | 149 | data['invalid'] = 'valueerror' |
|---|
| [717] | 150 | return |
|---|
| [15249] | 151 | |
|---|
| [18049] | 152 | with self.env.db_query as db: |
|---|
| [18280] | 153 | comments = ReviewCommentModel.create_comment_tree(self.env, fileid, linenum) |
|---|
| [15448] | 154 | my_comment_data = ReviewDataModel.comments_for_file_and_owner(self.env, fileid, req.authname) |
|---|
| 155 | data['read_comments'] = [c_id for c_id, t, dat in my_comment_data if t == 'read'] |
|---|
| 156 | |
|---|
| [15511] | 157 | rfile = ReviewFileModel(self.env, fileid) |
|---|
| 158 | review = PeerReviewModel(self.env, rfile['review_id']) |
|---|
| [15253] | 159 | data['review'] = review |
|---|
| [15312] | 160 | # A finished review can't be changed anymore except by a manager |
|---|
| [15328] | 161 | data['is_finished'] = review_is_finished(self.env.config, review) |
|---|
| [15511] | 162 | # A user can't change his voting for a reviewed review |
|---|
| [15328] | 163 | data['review_locked'] = review_is_locked(self.env.config, review, req.authname) |
|---|
| [15517] | 164 | data['not_allowed'] = not_allowed_to_comment(self.env, review, req.perm, req.authname) |
|---|
| [15253] | 165 | |
|---|
| [15288] | 166 | comment_html = "" |
|---|
| [18252] | 167 | keys = sorted(comments.keys()) |
|---|
| [717] | 168 | for key in keys: |
|---|
| [18280] | 169 | if comments[key].parent_id == -1: |
|---|
| 170 | comment_html += self.build_comment_html(req, comments[key], 0, linenum, fileid, data) |
|---|
| [15288] | 171 | comment_html = comment_html.strip() |
|---|
| 172 | if not comment_html: |
|---|
| 173 | comment_html = "No Comments on this Line" |
|---|
| 174 | data['lineNum'] = linenum |
|---|
| 175 | data['fileID'] = fileid |
|---|
| 176 | data['commentHTML'] = Markup(comment_html) |
|---|
| [717] | 177 | |
|---|
| [15250] | 178 | comment_template = u""" |
|---|
| 179 | <table xmlns:py="http://genshi.edgewall.org/" |
|---|
| [18266] | 180 | style="width:450px" |
|---|
| [18280] | 181 | py:attrs="{'class': 'comment-table'} if comment.comment_id in read_comments else {'class': 'comment-table comment-notread'}" |
|---|
| 182 | id="c-${comment.comment_id}" data-child-of="$comment.parent_id"> |
|---|
| [15252] | 183 | <tbody> |
|---|
| [15448] | 184 | <tr> |
|---|
| 185 | <td style="width:${width}px"></td> |
|---|
| [18266] | 186 | <td colspan="3" style="width:${450-width}px" |
|---|
| [15448] | 187 | class="border-col"></td> |
|---|
| [15250] | 188 | </tr> |
|---|
| 189 | <tr> |
|---|
| [15448] | 190 | <td style="width:${width}px"></td> |
|---|
| [18280] | 191 | <td colspan="2" class="comment-author">Author: ${authorinfo(comment.author)} |
|---|
| 192 | <a py:if="comment.comment_id not in read_comments" |
|---|
| 193 | href="javascript:markCommentRead($line, $fileid, $comment.comment_ic, ${review['review_id']})">Mark read</a> |
|---|
| 194 | <a py:if="comment.comment_ic in read_comments" |
|---|
| 195 | href="javascript:markCommentNotread($line, $fileid, $comment.comment_id, ${review['review_id']})">Mark unread</a> |
|---|
| [15448] | 196 | </td> |
|---|
| 197 | <td style="width:100px" class="comment-date">$date</td> |
|---|
| [15250] | 198 | </tr> |
|---|
| 199 | <tr> |
|---|
| [15448] | 200 | <td style="width:${width}px"></td> |
|---|
| [18280] | 201 | <td valign="top" style="width:${factor}px" id="${comment.comment_id}TreeButton"> |
|---|
| 202 | <img py:if="childrenHTML" src="${href.chrome('hw/images/minus.gif')}" id="${comment.comment_id}collapse" |
|---|
| 203 | onclick="collapseComments(${comment.comment_id});" style="cursor: pointer;" /> |
|---|
| [15461] | 204 | <img py:if="childrenHTML" src="${href.chrome('hw/images/plus.gif')}" style="display: none;cursor:pointer;" |
|---|
| [18280] | 205 | id="${comment.comment_id}expand" |
|---|
| 206 | onclick="expandComments(${comment.comment_id});" /> |
|---|
| [15250] | 207 | </td> |
|---|
| [15448] | 208 | <td colspan="2"> |
|---|
| 209 | <div class="comment-text"> |
|---|
| [15256] | 210 | $text |
|---|
| [15448] | 211 | </div> |
|---|
| [15250] | 212 | </td> |
|---|
| 213 | </tr> |
|---|
| 214 | <tr> |
|---|
| [15448] | 215 | <td></td> |
|---|
| 216 | <td></td> |
|---|
| [18281] | 217 | <td></td> |
|---|
| [15448] | 218 | <td class="comment-reply"> |
|---|
| [18280] | 219 | <a py:if="not is_locked" href="javascript:addComment($line, $fileid, $comment.comment_id)">Reply</a> |
|---|
| [15250] | 220 | </td> |
|---|
| 221 | </tr> |
|---|
| [15252] | 222 | </tbody> |
|---|
| [15250] | 223 | </table> |
|---|
| 224 | """ |
|---|
| [18244] | 225 | comment_template_jinja = u""" |
|---|
| [18266] | 226 | <table style="width:450px" |
|---|
| [18280] | 227 | class="${'comment-table' if comment.comment_id in read_comments else 'comment-table comment-notread'}" |
|---|
| 228 | id="c-${comment.comment_id}" data-child-of="${comment.parent_id}"> |
|---|
| [18244] | 229 | <tbody> |
|---|
| 230 | <tr> |
|---|
| 231 | <td style="width:${width}px"></td> |
|---|
| [18266] | 232 | <td colspan="3" style="width:${450-width}px" |
|---|
| [18244] | 233 | class="border-col"></td> |
|---|
| 234 | </tr> |
|---|
| 235 | <tr> |
|---|
| 236 | <td style="width:${width}px"></td> |
|---|
| [18280] | 237 | <td colspan="2" class="comment-author">Author: ${authorinfo(comment.author)} |
|---|
| 238 | # if comment.comment_id not in read_comments: |
|---|
| 239 | <a href="javascript:markCommentRead(${line}, ${fileid}, ${comment.comment_id}, ${review['review_id']})">Mark read</a> |
|---|
| [18244] | 240 | # else: |
|---|
| [18280] | 241 | <a href="javascript:markCommentNotread(${line}, ${fileid}, ${comment.comment_id}, ${review['review_id']})">Mark unread</a> |
|---|
| [18244] | 242 | # endif |
|---|
| 243 | </td> |
|---|
| 244 | <td style="width:100px" class="comment-date">${date}</td> |
|---|
| 245 | </tr> |
|---|
| 246 | <tr> |
|---|
| 247 | <td style="width:${width}px"></td> |
|---|
| [18280] | 248 | <td valign="top" style="width:${factor}px" id="${comment.comment_id}TreeButton"> |
|---|
| [18244] | 249 | # if childrenHTML: |
|---|
| [18280] | 250 | <img src="${href.chrome('hw/images/minus.gif')}" id="${comment.comment_id}collapse" |
|---|
| 251 | onclick="collapseComments(${comment.comment_id});" style="cursor: pointer;" /> |
|---|
| [18244] | 252 | <img src="${href.chrome('hw/images/plus.gif')}" style="display: none;cursor:pointer;" |
|---|
| [18280] | 253 | id="${comment.comment_id}expand" |
|---|
| 254 | onclick="expandComments(${comment.comment_id});" /> |
|---|
| [18244] | 255 | # endif |
|---|
| 256 | </td> |
|---|
| 257 | <td colspan="2"> |
|---|
| 258 | <div class="comment-text"> |
|---|
| 259 | ${text} |
|---|
| 260 | </div> |
|---|
| 261 | </td> |
|---|
| 262 | </tr> |
|---|
| 263 | <tr> |
|---|
| 264 | <td></td> |
|---|
| 265 | <td></td> |
|---|
| [18281] | 266 | <td></td> |
|---|
| [18244] | 267 | <td class="comment-reply"> |
|---|
| 268 | # if not is_locked: |
|---|
| [18280] | 269 | <a href="javascript:addComment(${line}, ${fileid}, ${comment.comment_id})">Reply</a> |
|---|
| [18244] | 270 | # endif |
|---|
| 271 | </td> |
|---|
| 272 | </tr> |
|---|
| 273 | </tbody> |
|---|
| 274 | </table> |
|---|
| 275 | """ |
|---|
| [15250] | 276 | |
|---|
| [17448] | 277 | # Recursively builds the comment html to send back. |
|---|
| [18280] | 278 | def build_comment_html(self, req, comment, indent, linenum, fileid, data): |
|---|
| [18263] | 279 | if indent > 50: |
|---|
| [717] | 280 | return "" |
|---|
| 281 | |
|---|
| [15252] | 282 | children_html = "" |
|---|
| [18280] | 283 | keys = sorted(comment.children.keys()) |
|---|
| [717] | 284 | for key in keys: |
|---|
| [18280] | 285 | child = comment.children[key] |
|---|
| 286 | children_html += self.build_comment_html(req, child, indent + 1, linenum, fileid, data) |
|---|
| [15228] | 287 | |
|---|
| [717] | 288 | factor = 15 |
|---|
| [18263] | 289 | width = 5 + indent * factor |
|---|
| [15228] | 290 | |
|---|
| [18269] | 291 | chrome = Chrome(self.env) |
|---|
| [18050] | 292 | context = web_context(req) |
|---|
| [15250] | 293 | tdata = {'width': width, |
|---|
| [18280] | 294 | 'text': format_to_html(self.env, context, comment.comment, |
|---|
| [15256] | 295 | escape_newlines=True), |
|---|
| [15250] | 296 | 'comment': comment, |
|---|
| [18280] | 297 | 'date': user_time(req, format_date, to_datetime(comment.created)), |
|---|
| [15250] | 298 | 'factor': factor, |
|---|
| [15252] | 299 | 'childrenHTML': children_html != '' or False, |
|---|
| [17448] | 300 | 'href': req.href, |
|---|
| [15252] | 301 | 'line': linenum, |
|---|
| [17448] | 302 | 'fileid': fileid, |
|---|
| 303 | 'callback': req.href.peerReviewCommentCallback(), # this is for attachments |
|---|
| [15312] | 304 | 'review': data['review'], |
|---|
| [15517] | 305 | 'is_locked': data['is_finished'] or data['review_locked'] or data.get('not_allowed', False), |
|---|
| [18269] | 306 | 'read_comments': data['read_comments'], |
|---|
| 307 | 'authorinfo': partial(chrome.authorinfo, req) |
|---|
| [15250] | 308 | } |
|---|
| [15249] | 309 | |
|---|
| [18244] | 310 | if hasattr(Chrome, 'jenv'): |
|---|
| 311 | template = chrome.jenv.from_string(self.comment_template_jinja) |
|---|
| [18247] | 312 | return chrome.render_template_string(template, tdata, True) + children_html |
|---|
| [18244] | 313 | else: |
|---|
| 314 | tbl = MarkupTemplate(self.comment_template, lookup='lenient') |
|---|
| 315 | return tbl.generate(**tdata).render(encoding=None) + children_html |
|---|