| 1 | # |
|---|
| 2 | # Copyright (C) 2005-2006 Team5 |
|---|
| 3 | # Copyright (C) 2016 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 | |
|---|
| 13 | |
|---|
| 14 | import os |
|---|
| 15 | import shutil |
|---|
| 16 | import sys |
|---|
| 17 | import time |
|---|
| 18 | import unicodedata |
|---|
| 19 | import urllib |
|---|
| 20 | import json |
|---|
| 21 | from genshi.template.markup import MarkupTemplate |
|---|
| 22 | from trac import util |
|---|
| 23 | from trac.core import * |
|---|
| 24 | from trac.mimeview import Context |
|---|
| 25 | from trac.util import Markup |
|---|
| 26 | from trac.web.main import IRequestHandler |
|---|
| 27 | from trac.wiki import format_to_html |
|---|
| 28 | from dbBackend import * |
|---|
| 29 | from model import Comment, PeerReviewModel, ReviewDataModel, ReviewFileModel |
|---|
| 30 | from util import get_review_for_file, not_allowed_to_comment, review_is_finished, review_is_locked |
|---|
| 31 | |
|---|
| 32 | |
|---|
| 33 | def writeJSONResponse(rq, data, httperror=200): |
|---|
| 34 | writeResponse(rq, json.dumps(data), httperror) |
|---|
| 35 | |
|---|
| 36 | |
|---|
| 37 | def writeResponse(req, data, httperror=200): |
|---|
| 38 | data=data.encode('utf-8') |
|---|
| 39 | req.send_response(httperror) |
|---|
| 40 | req.send_header('Content-Type', 'text/plain; charset=utf-8') |
|---|
| 41 | req.send_header('Content-Length', len(data)) |
|---|
| 42 | req.end_headers() |
|---|
| 43 | req.write(data) |
|---|
| 44 | |
|---|
| 45 | class PeerReviewCommentHandler(Component): |
|---|
| 46 | implements(IRequestHandler) |
|---|
| 47 | |
|---|
| 48 | # IRequestHandler methods |
|---|
| 49 | def match_request(self, req): |
|---|
| 50 | return req.path_info == '/peerReviewCommentCallback' |
|---|
| 51 | |
|---|
| 52 | #This page should never be called directly. It should only be called |
|---|
| 53 | #by JavaScript HTTPRequest calls. |
|---|
| 54 | def process_request(self, req): |
|---|
| 55 | req.perm.require('CODE_REVIEW_DEV') |
|---|
| 56 | |
|---|
| 57 | data = {} |
|---|
| 58 | |
|---|
| 59 | if not (req.perm.has_permission('CODE_REVIEW_MGR') or |
|---|
| 60 | req.perm.has_permission('CODE_REVIEW_DEV')): |
|---|
| 61 | |
|---|
| 62 | data['invalid'] = 4 |
|---|
| 63 | return 'peerReviewCommentCallback.html', data, None |
|---|
| 64 | |
|---|
| 65 | data['invalid'] = 0 |
|---|
| 66 | data['trac.href.peerReviewCommentCallback'] = self.env.href.peerReviewCommentCallback() |
|---|
| 67 | |
|---|
| 68 | if req.method == 'POST': |
|---|
| 69 | if req.args.get('addcomment'): |
|---|
| 70 | # This shouldn't happen but still... |
|---|
| 71 | review = get_review_for_file(self.env, req.args.get('fileid')) |
|---|
| 72 | if not_allowed_to_comment(self.env, review, req.perm, req.authname): |
|---|
| 73 | writeResponse(req, "", 403) |
|---|
| 74 | return |
|---|
| 75 | # We shouldn't end here but in case just drop out. |
|---|
| 76 | if self.review_is_closed(req): |
|---|
| 77 | data['invalid'] = 'closed' |
|---|
| 78 | return 'peerReviewCommentCallback.html', data, None |
|---|
| 79 | txt = req.args.get('comment') |
|---|
| 80 | comment = Comment(self.env) |
|---|
| 81 | comment.file_id = data['fileid'] = req.args.get('fileid') |
|---|
| 82 | comment.parent_id = data['parentid'] = req.args.get('parentid') |
|---|
| 83 | comment.comment = txt |
|---|
| 84 | comment.line_num = data['line'] = req.args.get('line') |
|---|
| 85 | comment.author = req.authname |
|---|
| 86 | if txt and txt.strip(): |
|---|
| 87 | comment.insert() |
|---|
| 88 | writeJSONResponse(req, data) |
|---|
| 89 | return |
|---|
| 90 | elif req.args.get('markread'): |
|---|
| 91 | data['fileid'] = req.args.get('fileid') |
|---|
| 92 | data['line'] = req.args.get('line') |
|---|
| 93 | if req.args.get('markread') == 'read': |
|---|
| 94 | rev_dat = ReviewDataModel(self.env) |
|---|
| 95 | rev_dat['file_id'] = data['fileid'] |
|---|
| 96 | rev_dat['comment_id'] = req.args.get('commentid') |
|---|
| 97 | rev_dat['review_id'] = req.args.get('reviewid') |
|---|
| 98 | rev_dat['owner'] = req.authname |
|---|
| 99 | rev_dat['type'] = 'read' |
|---|
| 100 | rev_dat['data'] = 'read' |
|---|
| 101 | rev_dat.insert() |
|---|
| 102 | else: |
|---|
| 103 | rev_dat = ReviewDataModel(self.env) |
|---|
| 104 | rev_dat['file_id'] = data['fileid'] |
|---|
| 105 | rev_dat['comment_id'] = req.args.get('commentid') |
|---|
| 106 | rev_dat['owner'] = req.authname |
|---|
| 107 | for rev in rev_dat.list_matching_objects(): |
|---|
| 108 | rev.delete() |
|---|
| 109 | writeJSONResponse(req, data) |
|---|
| 110 | return |
|---|
| 111 | |
|---|
| 112 | actionType = req.args.get('actionType') |
|---|
| 113 | |
|---|
| 114 | if actionType == 'getCommentTree': |
|---|
| 115 | self.get_comment_tree(req, data) |
|---|
| 116 | |
|---|
| 117 | elif actionType == 'getCommentFile': |
|---|
| 118 | self.getCommentFile(req, data) |
|---|
| 119 | |
|---|
| 120 | else: |
|---|
| 121 | data['invalid'] = 5 |
|---|
| 122 | |
|---|
| 123 | return 'peerReviewCommentCallback.html', data, None |
|---|
| 124 | |
|---|
| 125 | def review_is_closed(self, req): |
|---|
| 126 | fileid = req.args.get('IDFile') |
|---|
| 127 | if not fileid: |
|---|
| 128 | fileid = req.args.get('fileid') |
|---|
| 129 | r_file = ReviewFileModel(self.env, fileid) |
|---|
| 130 | review = PeerReviewModel(self.env, r_file['review_id']) |
|---|
| 131 | if review['status'] == 'closed': |
|---|
| 132 | return True |
|---|
| 133 | return False |
|---|
| 134 | |
|---|
| 135 | # Used to send a file that is attached to a comment |
|---|
| 136 | def getCommentFile(self, req, data): |
|---|
| 137 | data['invalid'] = 6 |
|---|
| 138 | short_path = req.args.get('fileName') |
|---|
| 139 | fileid = req.args.get('IDFile') |
|---|
| 140 | if not fileid or not short_path: |
|---|
| 141 | return |
|---|
| 142 | |
|---|
| 143 | short_path = urllib.unquote(short_path) |
|---|
| 144 | self.path = os.path.join(self.env.path, 'attachments', 'CodeReview', |
|---|
| 145 | urllib.quote(fileid)) |
|---|
| 146 | self.path = os.path.normpath(self.path) |
|---|
| 147 | attachments_dir = os.path.join(os.path.normpath(self.env.path), |
|---|
| 148 | 'attachments') |
|---|
| 149 | commonprefix = os.path.commonprefix([attachments_dir, self.path]) |
|---|
| 150 | assert commonprefix == attachments_dir |
|---|
| 151 | full_path = os.path.join(self.path, short_path) |
|---|
| 152 | req.send_header('Content-Disposition', 'attachment; filename=' + short_path) |
|---|
| 153 | req.send_file(full_path) |
|---|
| 154 | |
|---|
| 155 | #Creates a comment based on the values from the request |
|---|
| 156 | def createComment(self, req, data): |
|---|
| 157 | data['invalid'] = 5 |
|---|
| 158 | struct = ReviewCommentStruct(None) |
|---|
| 159 | struct.IDParent = req.args.get('IDParent') |
|---|
| 160 | struct.IDFile = req.args.get('IDFile') |
|---|
| 161 | struct.LineNum = req.args.get('LineNum') |
|---|
| 162 | struct.Author = util.get_reporter_id(req) |
|---|
| 163 | struct.Text = req.args.get('Text') |
|---|
| 164 | struct.DateCreate = int(time.time()) |
|---|
| 165 | |
|---|
| 166 | if struct.IDFile is None or struct.LineNum is None or \ |
|---|
| 167 | struct.Author is None or struct.Text is None: |
|---|
| 168 | return |
|---|
| 169 | |
|---|
| 170 | if struct.IDFile == "" or struct.LineNum == "" or struct.Author == "": |
|---|
| 171 | return |
|---|
| 172 | |
|---|
| 173 | if struct.Text == "": |
|---|
| 174 | return |
|---|
| 175 | |
|---|
| 176 | if struct.IDParent is None or struct.IDParent == "": |
|---|
| 177 | struct.IDParent = "-1" |
|---|
| 178 | |
|---|
| 179 | # If there was a file uploaded with the comment, place it in the correct spot |
|---|
| 180 | # The basic parts of this code were taken from the file upload portion of |
|---|
| 181 | # the trac wiki code |
|---|
| 182 | |
|---|
| 183 | if 'FileUp' in req.args: |
|---|
| 184 | upload = req.args['FileUp'] |
|---|
| 185 | if upload and upload.filename: |
|---|
| 186 | self.path = \ |
|---|
| 187 | os.path.join(self.env.path, 'attachments', |
|---|
| 188 | 'CodeReview', urllib.quote(struct.IDFile)) |
|---|
| 189 | self.path = os.path.normpath(self.path) |
|---|
| 190 | if hasattr(upload.file, 'fileno'): |
|---|
| 191 | size = os.fstat(upload.file.fileno())[6] |
|---|
| 192 | else: |
|---|
| 193 | size = upload.file.len |
|---|
| 194 | if size != 0: |
|---|
| 195 | filename = urllib.unquote(upload.filename) |
|---|
| 196 | filename = filename.replace('\\', '/').replace(':', '/') |
|---|
| 197 | filename = os.path.basename(filename) |
|---|
| 198 | if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3): |
|---|
| 199 | filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') |
|---|
| 200 | attachments_dir = os.path.join(os.path.normpath(self.env.path), 'attachments') |
|---|
| 201 | commonprefix = os.path.commonprefix([attachments_dir, self.path]) |
|---|
| 202 | assert commonprefix == attachments_dir |
|---|
| 203 | if not os.access(self.path, os.F_OK): |
|---|
| 204 | os.makedirs(self.path) |
|---|
| 205 | path, targetfile = util.create_unique_file(os.path.join(self.path, filename)) |
|---|
| 206 | try: |
|---|
| 207 | shutil.copyfileobj(upload.file, targetfile) |
|---|
| 208 | struct.AttachmentPath = os.path.basename(path) |
|---|
| 209 | finally: |
|---|
| 210 | targetfile.close() |
|---|
| 211 | struct.save(self.env.get_db_cnx()) |
|---|
| 212 | |
|---|
| 213 | # Returns a comment tree for the requested line number |
|---|
| 214 | # in the requested file |
|---|
| 215 | def get_comment_tree(self, req, data): |
|---|
| 216 | fileid = req.args.get('IDFile') |
|---|
| 217 | linenum = req.args.get('LineNum') |
|---|
| 218 | |
|---|
| 219 | if not fileid or not linenum: |
|---|
| 220 | data['invalid'] = 1 |
|---|
| 221 | return |
|---|
| 222 | |
|---|
| 223 | db = self.env.get_read_db() |
|---|
| 224 | dbBack = dbBackend(db) |
|---|
| 225 | comments = dbBack.getCommentsByFileIDAndLine(fileid, linenum) |
|---|
| 226 | |
|---|
| 227 | my_comment_data = ReviewDataModel.comments_for_file_and_owner(self.env, fileid, req.authname) |
|---|
| 228 | data['read_comments'] = [c_id for c_id, t, dat in my_comment_data if t == 'read'] |
|---|
| 229 | |
|---|
| 230 | rfile = ReviewFileModel(self.env, fileid) |
|---|
| 231 | review = PeerReviewModel(self.env, rfile['review_id']) |
|---|
| 232 | data['review'] = review |
|---|
| 233 | data['context'] = Context.from_request(req) |
|---|
| 234 | # A finished review can't be changed anymore except by a manager |
|---|
| 235 | data['is_finished'] = review_is_finished(self.env.config, review) |
|---|
| 236 | # A user can't change his voting for a reviewed review |
|---|
| 237 | data['review_locked'] = review_is_locked(self.env.config, review, req.authname) |
|---|
| 238 | data['not_allowed'] = not_allowed_to_comment(self.env, review, req.perm, req.authname) |
|---|
| 239 | |
|---|
| 240 | comment_html = "" |
|---|
| 241 | first = True |
|---|
| 242 | keys = comments.keys() |
|---|
| 243 | keys.sort() |
|---|
| 244 | for key in keys: |
|---|
| 245 | if comments[key].IDParent not in comments: |
|---|
| 246 | comment_html += self.build_comment_html(comments[key], 0, linenum, fileid, first, data) |
|---|
| 247 | first = False |
|---|
| 248 | comment_html = comment_html.strip() |
|---|
| 249 | if not comment_html: |
|---|
| 250 | comment_html = "No Comments on this Line" |
|---|
| 251 | data['lineNum'] = linenum |
|---|
| 252 | data['fileID'] = fileid |
|---|
| 253 | data['commentHTML'] = Markup(comment_html) |
|---|
| 254 | |
|---|
| 255 | |
|---|
| 256 | comment_template = u""" |
|---|
| 257 | <table xmlns:py="http://genshi.edgewall.org/" |
|---|
| 258 | style="width:400px" |
|---|
| 259 | py:attrs="{'class': 'comment-table'} if comment.IDComment in read_comments else {'class': 'comment-table comment-notread'}" |
|---|
| 260 | id="${comment.IDParent}:${comment.IDComment}" data-child-of="$comment.IDParent"> |
|---|
| 261 | <tbody> |
|---|
| 262 | <tr> |
|---|
| 263 | <td style="width:${width}px"></td> |
|---|
| 264 | <td colspan="3" style="width:${400-width}px" |
|---|
| 265 | class="border-col"></td> |
|---|
| 266 | </tr> |
|---|
| 267 | <tr> |
|---|
| 268 | <td style="width:${width}px"></td> |
|---|
| 269 | <td colspan="2" class="comment-author">Author: $comment.Author |
|---|
| 270 | <a py:if="comment.IDComment not in read_comments" |
|---|
| 271 | href="javascript:markCommentRead($line, $fileid, $comment.IDComment, ${review['review_id']})">Mark read</a> |
|---|
| 272 | <a py:if="comment.IDComment in read_comments" |
|---|
| 273 | href="javascript:markCommentNotread($line, $fileid, $comment.IDComment, ${review['review_id']})">Mark unread</a> |
|---|
| 274 | </td> |
|---|
| 275 | <td style="width:100px" class="comment-date">$date</td> |
|---|
| 276 | </tr> |
|---|
| 277 | <tr> |
|---|
| 278 | <td style="width:${width}px"></td> |
|---|
| 279 | <td valign="top" style="width:${factor}px" id="${comment.IDComment}TreeButton"> |
|---|
| 280 | <img py:if="childrenHTML" src="${href.chrome('hw/images/minus.gif')}" id="${comment.IDComment}collapse" |
|---|
| 281 | onclick="collapseComments($comment.IDComment);" style="cursor: pointer;" /> |
|---|
| 282 | <img py:if="childrenHTML" src="${href.chrome('hw/images/plus.gif')}" style="display: none;cursor:pointer;" |
|---|
| 283 | id="${comment.IDComment}expand" |
|---|
| 284 | onclick="expandComments($comment.IDComment);" /> |
|---|
| 285 | </td> |
|---|
| 286 | <td colspan="2"> |
|---|
| 287 | <div class="comment-text"> |
|---|
| 288 | $text |
|---|
| 289 | </div> |
|---|
| 290 | </td> |
|---|
| 291 | </tr> |
|---|
| 292 | <tr> |
|---|
| 293 | <td></td> |
|---|
| 294 | <td></td> |
|---|
| 295 | <td> |
|---|
| 296 | <!--! Attachment --> |
|---|
| 297 | <a py:if="comment.AttachmentPath" border="0" alt="Code Attachment" |
|---|
| 298 | href="${callback}?actionType=getCommentFile&fileName=${comment.AttachmentPath}&IDFile=$fileid"> |
|---|
| 299 | <img src="${href.chrome('hw/images/paper_clip.gif')}" /> $comment.AttachmentPath |
|---|
| 300 | </a> |
|---|
| 301 | </td> |
|---|
| 302 | <td class="comment-reply"> |
|---|
| 303 | <a py:if="not is_locked" href="javascript:addComment($line, $fileid, $comment.IDComment)">Reply</a> |
|---|
| 304 | </td> |
|---|
| 305 | </tr> |
|---|
| 306 | </tbody> |
|---|
| 307 | </table> |
|---|
| 308 | """ |
|---|
| 309 | |
|---|
| 310 | #Recursively builds the comment html to send back. |
|---|
| 311 | def build_comment_html(self, comment, nodesIn, linenum, fiileid, first, data): |
|---|
| 312 | if nodesIn > 50: |
|---|
| 313 | return "" |
|---|
| 314 | |
|---|
| 315 | children_html = "" |
|---|
| 316 | keys = comment.Children.keys() |
|---|
| 317 | keys.sort() |
|---|
| 318 | for key in keys: |
|---|
| 319 | child = comment.Children[key] |
|---|
| 320 | children_html += self.build_comment_html(child, nodesIn + 1, linenum, fiileid, False, data) |
|---|
| 321 | |
|---|
| 322 | factor = 15 |
|---|
| 323 | width = 5 + nodesIn * factor |
|---|
| 324 | |
|---|
| 325 | tdata = {'width': width, |
|---|
| 326 | 'text': format_to_html(self.env, data['context'], comment.Text, |
|---|
| 327 | escape_newlines=True), |
|---|
| 328 | 'comment': comment, |
|---|
| 329 | 'first': first, |
|---|
| 330 | 'date': util.format_date(comment.DateCreate), |
|---|
| 331 | 'factor': factor, |
|---|
| 332 | 'childrenHTML': children_html != '' or False, |
|---|
| 333 | 'href': self.env.href, |
|---|
| 334 | 'line': linenum, |
|---|
| 335 | 'fileid': fiileid, |
|---|
| 336 | 'callback': self.env.href.peerReviewCommentCallback(), |
|---|
| 337 | 'review': data['review'], |
|---|
| 338 | 'is_locked': data['is_finished'] or data['review_locked'] or data.get('not_allowed', False), |
|---|
| 339 | 'read_comments': data['read_comments'] |
|---|
| 340 | } |
|---|
| 341 | |
|---|
| 342 | tbl = MarkupTemplate(self.comment_template, lookup='lenient') |
|---|
| 343 | return tbl.generate(**tdata).render(encoding=None) + children_html |
|---|