Ticket #7145: 7145-concurrent-editing-r10684.patch
| File 7145-concurrent-editing-r10684.patch, 45.7 KB (added by rblank, 13 months ago) |
|---|
-
trac/htdocs/css/ticket.css
diff --git a/trac/htdocs/css/ticket.css b/trac/htdocs/css/ticket.css
a b div.comment ol { list-style: decimal } 116 116 } 117 117 #trac-comment-editor .wikitoolbar { margin-left: -1px } 118 118 #trac-add-comment :link, #trac-add-comment :visited { color: #b00 } 119 .trac-new { border-left: 0.31em solid #c0f0c0; padding-left: 0.31em; } 119 120 #changelog h3, #ticketchange h3 { 120 121 border-bottom: 1px solid #d7d7d7; 121 122 color: #999; -
trac/htdocs/js/threaded_comments.js
diff --git a/trac/htdocs/js/threaded_comments.js b/trac/htdocs/js/threaded_comments.js
a b jQuery(document).ready(function($){ 2 2 var comments = null; 3 3 var toggle = $('#trac-threaded-toggle'); 4 4 toggle.click(function() { 5 if ($(this).checked()) {6 if (!comments)7 comments = $("div.change");5 $(this).toggleClass('checked'); 6 if ($(this).hasClass('checked')) { 7 comments = $("div.change"); 8 8 comments.each(function() { 9 9 var children = $("a.follow-up", this).map(function() { 10 10 var cnum = $(this).attr("href").replace('#comment:', ''); -
trac/ticket/model.py
diff --git a/trac/ticket/model.py b/trac/ticket/model.py
a b class Ticket(object): 255 255 return self.id 256 256 257 257 def save_changes(self, author=None, comment=None, when=None, db=None, 258 cnum='' ):258 cnum='', replyto=None): 259 259 """ 260 260 Store ticket changes in the database. The ticket must already exist in 261 261 the database. Returns False if there were no changes to save, True … … class Ticket(object): 263 263 264 264 :since 0.13: the `db` parameter is no longer needed and will be removed 265 265 in version 0.14 266 :since 0.13: the `cnum` parameter is deprecated, and threading should 267 be controled with the `replyto` argument 266 268 """ 267 269 assert self.exists, "Cannot update a new ticket" 268 270 … … class Ticket(object): 296 298 pass 297 299 298 300 with self.env.db_transaction as db: 301 db("UPDATE ticket SET changetime=%s WHERE id=%s", 302 (when_ts, self.id)) 303 299 304 # find cnum if it isn't provided 300 comment_num = cnum 301 if not comment_num: 305 if not cnum: 302 306 num = 0 303 307 for ts, old in db(""" 304 308 SELECT DISTINCT tc1.time, COALESCE(tc2.oldvalue,'') … … class Ticket(object): 314 318 break 315 319 except ValueError: 316 320 num += 1 317 comment_num = str(num + 1) 321 cnum = str(num + 1) 322 if replyto: 323 cnum = '%s.%s' % (replyto, cnum) 318 324 319 325 # store fields 320 326 for name in self._old.keys(): … … class Ticket(object): 344 350 db("""INSERT INTO ticket_change 345 351 (ticket,time,author,field,oldvalue,newvalue) 346 352 VALUES (%s,%s,%s,'comment',%s,%s) 347 """, (self.id, when_ts, author, comment_num, comment)) 348 349 db("UPDATE ticket SET changetime=%s WHERE id=%s", 350 (when_ts, self.id)) 353 """, (self.id, when_ts, author, cnum, comment)) 351 354 352 355 old_values = self._old 353 356 self._old = {} … … class Ticket(object): 355 358 356 359 for listener in TicketSystem(self.env).change_listeners: 357 360 listener.ticket_changed(self, comment, author, old_values) 358 return True361 return int(cnum.rsplit('.', 1)[-1]) 359 362 360 363 def get_changelog(self, when=None, db=None): 361 364 """Return the changelog as a list of tuples of the form … … class Ticket(object): 420 423 for listener in TicketSystem(self.env).change_listeners: 421 424 listener.ticket_deleted(self) 422 425 423 def get_change(self, cnum , db=None):424 """Return a ticket change by its number .426 def get_change(self, cnum=None, cdate=None, db=None): 427 """Return a ticket change by its number or date. 425 428 426 429 :since 0.13: the `db` parameter is no longer needed and will be removed 427 430 in version 0.14 428 431 """ 429 row = self._find_change(cnum) 430 if row: 431 ts, author, comment = row 432 fields = {} 433 change = {'date': from_utimestamp(ts), 434 'author': author, 'fields': fields} 435 for field, author, old, new in self.env.db_query(""" 436 SELECT field, author, oldvalue, newvalue 437 FROM ticket_change WHERE ticket=%s AND time=%s 438 """, (self.id, ts)): 439 fields[field] = {'author': author, 'old': old, 'new': new} 432 if cdate is None: 433 row = self._find_change(cnum) 434 if not row: 435 return 436 cdate = from_utimestamp(row[0]) 437 ts = to_utimestamp(cdate) 438 fields = {} 439 change = {'date': cdate, 'fields': fields} 440 for field, author, old, new in self.env.db_query(""" 441 SELECT field, author, oldvalue, newvalue 442 FROM ticket_change WHERE ticket=%s AND time=%s 443 """, (self.id, ts)): 444 fields[field] = {'author': author, 'old': old, 'new': new} 445 if field == 'comment': 446 change['author'] = author 447 elif not field.startswith('_'): 448 change.setdefault('author', author) 449 if fields: 440 450 return change 441 451 442 def delete_change(self, cnum): 443 """Delete a ticket change.""" 444 row = self._find_change(cnum) 445 if not row: 446 return 447 ts = row[0] 452 def delete_change(self, cnum=None, cdate=None): 453 """Delete a ticket change identified by its number or date.""" 454 if cdate is None: 455 row = self._find_change(cnum) 456 if not row: 457 return 458 cdate = from_utimestamp(row[0]) 459 ts = to_utimestamp(cdate) 448 460 with self.env.db_transaction as db: 449 461 # Find modified fields and their previous value 450 462 fields = [(field, old, new) … … class Ticket(object): 545 557 546 558 self.values['changetime'] = when 547 559 548 def get_comment_history(self, cnum, db=None): 549 """Retrieve the edit history of comment `cnum`. 560 def get_comment_history(self, cnum=None, cdate=None, db=None): 561 """Retrieve the edit history of a comment identified by its number or 562 date. 550 563 551 564 :since 0.13: the `db` parameter is no longer needed and will be removed 552 565 in version 0.14 553 566 """ 554 row = self._find_change(cnum) 555 if row: 567 if cdate is None: 568 row = self._find_change(cnum) 569 if not row: 570 return 556 571 ts0, author0, last_comment = row 557 with self.env.db_query as db: 558 # Get all fields of the form "_comment%d" 559 rows = db("""SELECT field, author, oldvalue, newvalue 560 FROM ticket_change 561 WHERE ticket=%%s AND time=%%s AND field %s 562 """ % db.like(), 563 (self.id, ts0, db.like_escape('_comment') + '%')) 564 rows = sorted((int(field[8:]), author, old, new) 565 for field, author, old, new in rows) 566 history = [] 567 for rev, author, comment, ts in rows: 568 history.append((rev, from_utimestamp(long(ts0)), author0, 569 comment)) 570 ts0, author0 = ts, author 571 history.sort() 572 rev = history[-1][0] + 1 if history else 0 572 else: 573 ts0, author0, last_comment = to_utimestamp(cdate), None, None 574 with self.env.db_query as db: 575 # Get last comment and author if not available 576 if last_comment is None: 577 last_comment = '' 578 for author0, last_comment in db(""" 579 SELECT author, newvalue FROM ticket_change 580 WHERE ticket=%s AND time=%s AND field='comment' 581 """, (self.id, ts0)): 582 break 583 if author0 is None: 584 for author0, last_comment in db(""" 585 SELECT author, new FROM ticket_change 586 WHERE ticket=%%s AND time=%%s AND NOT field %s LIMIT 1 587 """ % db.like(), 588 (self.id, ts0, db.like_escape('_') + '%')): 589 break 590 else: 591 return 592 593 # Get all fields of the form "_comment%d" 594 rows = db("""SELECT field, author, oldvalue, newvalue 595 FROM ticket_change 596 WHERE ticket=%%s AND time=%%s AND field %s 597 """ % db.like(), 598 (self.id, ts0, db.like_escape('_comment') + '%')) 599 rows = sorted((int(field[8:]), author, old, new) 600 for field, author, old, new in rows) 601 history = [] 602 for rev, author, comment, ts in rows: 573 603 history.append((rev, from_utimestamp(long(ts0)), author0, 574 last_comment)) 575 return history 604 comment)) 605 ts0, author0 = ts, author 606 history.sort() 607 rev = history[-1][0] + 1 if history else 0 608 history.append((rev, from_utimestamp(long(ts0)), author0, 609 last_comment)) 610 return history 576 611 577 612 def _find_change(self, cnum): 578 613 """Find a comment by its number.""" -
trac/ticket/templates/ticket.html
diff --git a/trac/ticket/templates/ticket.html b/trac/ticket/templates/ticket.html
a b 39 39 .blur(function() { comment_focused = false; }); 40 40 $("#propertyform").autoSubmit({preview: '1'}, function(data, reply) { 41 41 var items = $(reply); 42 // Update ticket box 42 43 $("#ticket").replaceWith(items.filter('#ticket')); 43 var changes = $("#ticketchange-content").html(items.filter('ul.changes, div.comment')); 44 var show_preview = changes.children().length != 0; 44 // Unthread and update changelog 45 var threaded_toggle = $('#trac-threaded-toggle'); 46 if (threaded_toggle.checked()) 47 threaded_toggle.click(); 48 $("#changelog").replaceWith(items.filter("#changelog")); 49 // Show warning 50 var new_changes = $("#changelog .trac-new"); 51 $("#trac-edit-warning").toggle(new_changes.length != 0); 52 if (new_changes.length != 0) 53 $("#changelog").parent().show().removeClass("collapsed"); 54 // Update view time 55 $("#propertyform input[name='view_time']").replaceWith(items.filter("input[name='view_time']")); 56 // Update preview 57 var preview = $("#ticketchange").html(items.filter('#preview').children()); 58 var show_preview = preview.children().length != 0; 45 59 $("#ticketchange").toggle(show_preview); 60 // Collapse property form if comment editor has focus 46 61 if (show_preview && comment_focused) 47 62 $("#modify").parent().addClass("collapsed"); 48 63 }); … … 52 67 }); 53 68 /*]]>*/ 54 69 <py:if test="preview_mode"> 55 $("#changelog").parent().toggleClass("collapsed");56 70 $("#attachments").toggleClass("collapsed"); 57 71 $("#trac-add-comment").scrollToTop(); 58 72 </py:if> … … 117 131 py:with="alist = attachments; foldable = True"/> 118 132 </py:if> 119 133 120 <div py:if="ticket.exists and changes">134 <div style="${'display: none' if not changes else None}"> 121 135 <form id="trac-threaded-form" method="get" action="" style="display: none"> 122 136 <div> 123 137 <input id="trac-threaded-toggle" type="checkbox" /> … … 128 142 <h2 class="foldable">Change History</h2> 129 143 130 144 <div id="changelog"> 131 <py:for each="change in changes" 132 py:with="can_edit_comment = has_edit_comment or (authname and authname != 'anonymous' 133 and authname == change.author); 134 show_editor = can_edit_comment and str(change.cnum) == cnum_edit; 135 show_history = str(change.cnum) == cnum_hist; 136 max_version = max(change.comment_history); 137 comment_version = int(cversion or 0) if show_history else max_version; 138 show_buttons = not show_editor and comment_version == max_version"> 139 <div class="change${not show_buttons and ' trac-nobuttons' or ''}" 140 id="${'trac-change-%d' % change.cnum if 'cnum' in change else None}"> 141 <h3 class="change"> 142 <span class="threading" py:if="'cnum' in change" 143 py:with="change_replies = replies.get(str(change.cnum), [])"> 144 <span id="comment:$change.cnum" class="cnum">${commentref('comment:', change.cnum)}</span> 145 <py:if test="change_replies or 'replyto' in change"> 146 <py:if test="'replyto' in change"> 147 in reply to: ${commentref('↑ ', change.replyto)} 148 <py:if test="change_replies">; </py:if> 149 </py:if> 150 <py:if test="change_replies"> 151 <i18n:choose numeral="len(change_replies)"> 152 <span i18n:singular="">follow-up:</span> 153 <span i18n:plural="">follow-ups:</span> 154 </i18n:choose> 155 <py:for each="reply in change_replies"> 156 ${commentref('↓ ', reply, 'follow-up')} 157 </py:for></py:if> 158 </py:if> 159 </span> 160 <i18n:msg params="date, author">Changed ${pretty_dateinfo(change.date)} by ${authorinfo(change.author)}</i18n:msg> 161 </h3> 162 <py:if test="show_buttons"> 163 <form py:if="'cnum' in change and can_edit_comment" method="get" action="#comment:${change.cnum}"> 164 <div class="inlinebuttons"> 165 <input type="hidden" name="cnum_edit" value="${change.cnum}"/> 166 <input type="submit" value="${_('Edit')}" title="${_('Edit comment %(cnum)s', cnum=change.cnum)}"/> 167 </div> 168 </form> 169 <form py:if="'cnum' in change and can_append" id="reply-to-comment-${change.cnum}" 170 method="get" action="#comment"> 171 <div class="inlinebuttons"> 172 <input type="hidden" name="replyto" value="${change.cnum}"/> 173 <input type="submit" value="${_('Reply')}" title="${_('Reply to comment %(cnum)s', cnum=change.cnum)}"/> 174 </div> 175 </form> 176 </py:if> 145 <py:for each="change in changes"> 146 <div class="change${' trac-new' if change.date > start_time and 'attachment' not in change.fields else None}" 147 id="${'trac-change-%d-%d' % (change.cnum, to_utimestamp(change.date)) if 'cnum' in change else None}"> 177 148 <xi:include href="ticket_change.html"/> 178 <div py:if="not show_editor and len(change.comment_history) > 1" py:choose=""179 class="trac-lastedit ${'trac-shade' if comment_version != max_version else None}">180 <i18n:msg params="version, date, author" py:when="comment_version != max_version">181 Version ${comment_version}, edited ${pretty_dateinfo(change.comment_history[comment_version].date)}182 by ${authorinfo(change.comment_history[comment_version].author)}183 </i18n:msg>184 <i18n:msg params="date, author" py:otherwise="">185 Last edited ${pretty_dateinfo(change.comment_history[comment_version].date)}186 by ${authorinfo(change.comment_history[comment_version].author)}187 </i18n:msg>188 <py:if test="comment_version > 0">189 (<a href="${href.ticket(ticket.id, cnum_hist=change.cnum, cversion=comment_version - 1)190 }#comment:${change.cnum}">previous</a>)191 </py:if>192 <py:if test="comment_version < max_version">193 (<a href="${href.ticket(ticket.id, cnum_hist=change.cnum, cversion=comment_version + 1)194 }#comment:${change.cnum}">next</a>)195 </py:if>196 <py:if test="comment_version > 0">197 (<a href="${href.ticket(ticket.id, action='comment-diff', cnum=change.cnum,198 version=comment_version)}">diff</a>)199 </py:if>200 </div>201 149 </div> 202 150 </py:for> 203 151 </div> … … 209 157 action="${href.ticket(ticket.id) + '#trac-add-comment' if ticket.exists 210 158 else href.newticket() + '#ticket'}"> 211 159 <!--! Add comment --> 212 <div py:if="ticket.exists and can_append" class="field">160 <div py:if="ticket.exists and can_append" id="trac-add-comment" class="field"> 213 161 <div class="trac-nav"> 214 162 <a href="#content" title="View ticket fields and description">View</a> ↑ 215 163 </div> 216 <h2 id="trac-add-comment">164 <h2> 217 165 <a id="edit" onfocus="$('#comment').get(0).focus()">Add a comment</a> 218 166 </h2> 167 <div id="trac-edit-warning" class="warning system-message" 168 style="${'display: none' if start_time == ticket['changetime'] else None}"> 169 This ticket has been modified since you started editing. You should review the 170 <em class="trac-new">other modifications</em> which have been appended above. 171 You can nevertheless proceed and submit your changes if you wish so. 172 </div> 219 173 <!--! Comment field --> 220 174 <fieldset class="iefix"> 221 175 <label for="comment" i18n:msg="">You may use … … 225 179 <textarea id="comment" name="comment" class="wikitext trac-resizable" rows="10" cols="78"> 226 180 ${comment}</textarea> 227 181 </fieldset> 228 <div py:if="preview_mode and chrome.warnings" i18n:msg="" class="warning system-message">229 Warnings are shown at the <a href="#warning">top of the page</a>. The ticket validation230 may have failed.231 </div>232 182 </div> 233 183 234 184 <div> … … 363 313 </div> 364 314 </div> 365 315 316 <!--! Preview of ticket changes --> 317 <div py:if="ticket.exists and can_append" id="ticketchange" class="ticketdraft" 318 style="${'display: none' if not (change_preview.fields or change_preview.comment) 319 or cnum_edit is not None else None}"> 320 <xi:include href="ticket_change.html" py:with="change = change_preview"/> 321 </div> 322 366 323 <!--! Author or Reporter --> 367 324 <div py:if="authname == 'anonymous'" class="field"> 368 325 <fieldset py:choose=""> … … 385 342 </fieldset> 386 343 </div> 387 344 388 <!--! Preview of ticket changes -->389 <div py:if="ticket.exists and can_append" id="ticketchange" class="ticketdraft"390 style="${'display: none' if not (change_preview.fields or change_preview.comment)391 or cnum_edit is not None else None}">392 <h3 class="change" id="${'comment:%d' % change_preview.cnum if 'cnum' in change_preview else None}">393 <span class="threading" py:if="'replyto' in change_preview">394 in reply to: ${commentref('↑ ', change_preview.replyto)}395 </span>396 <i18n:msg params="author">Changed by ${authorinfo(change_preview.author)}</i18n:msg>397 </h3>398 <div id="ticketchange-content"><xi:include href="ticket_change.html" py:with="change = change_preview"/></div>399 </div>400 401 345 <!--! Attachment on creation checkbox --> 402 346 <p py:if="not ticket.exists and 'ATTACHMENT_CREATE' in perm(ticket.resource.child('attachment'))"> 403 347 <label> … … 411 355 </div> 412 356 <div class="buttons"> 413 357 <py:if test="ticket.exists"> 414 <input type="hidden" name="ts" value="${timestamp}" /> 358 <input type="hidden" name="start_time" value="${to_utimestamp(start_time)}" /> 359 <input type="hidden" name="view_time" value="${to_utimestamp(ticket['changetime'])}" /> 415 360 <input type="hidden" name="replyto" value="${replyto}" /> 416 <input type="hidden" name="cnum" value="${cnum}" />417 361 </py:if> 418 362 <input type="submit" name="preview" value="${_('Preview')}" accesskey="r" /> 419 363 <input type="submit" name="submit" value="${_('Submit changes') if ticket.exists else _('Create ticket')}" /> -
trac/ticket/templates/ticket_change.html
diff --git a/trac/ticket/templates/ticket_change.html b/trac/ticket/templates/ticket_change.html
a b Render a ticket comment. 3 3 4 4 Arguments: 5 5 - change: the change data 6 - show_editor=False: if True, show a comment editor 7 - edited_comment: the current value of the comment edito 8 - cnum_edit: the comment number being edited 6 - hide_buttons=False: hide all buttons (Edit, Reply) 7 - cnum_edit=None: the comment number being edited 8 - edited_comment: the current value of the comment editor 9 - cnum_hist=None: the comment number for which to show a historical content 10 - can_append=False: True if the user is allowed to append to tickets 11 - has_edit_comment=False: True if the user is allowed to edit all comments 9 12 --> 10 13 <html xmlns="http://www.w3.org/1999/xhtml" 11 14 xmlns:py="http://genshi.edgewall.org/" 12 15 xmlns:xi="http://www.w3.org/2001/XInclude" 13 16 xmlns:i18n="http://genshi.edgewall.org/i18n" 14 py:with="show_editor = value_of('show_editor', False)" py:strip=""> 17 py:with="cnum = change.get('cnum'); hide_buttons = value_of('hide_buttons', False); 18 cnum_edit = value_of('cnum_edit'); cnum_hist = value_of('cnum_hist'); 19 can_append = value_of('can_append', False); has_edit_comment = value_of('has_edit_comment', False); 20 can_edit_comment = has_edit_comment or (authname and authname != 'anonymous' 21 and authname == change.author); 22 show_editor = can_edit_comment and str(cnum) == cnum_edit; 23 show_history = str(cnum) == cnum_hist; 24 max_version = max(change.comment_history) if change.comment_history else 0; 25 comment_version = int(cversion or 0) if show_history else max_version; 26 show_buttons = not hide_buttons and not show_editor and comment_version == max_version" 27 py:strip=""> 28 <py:def function="commentref(prefix, cnum, cls=None)"> 29 <a href="#comment:$cnum" class="$cls">$prefix$cnum</a> 30 </py:def> 31 <h3 class="change"> 32 <span class="threading" 33 py:with="change_replies = replies.get(str(cnum), []) if 'cnum' in change else []"> 34 <span py:if="'cnum' in change" id="comment:$cnum" class="cnum">${commentref('comment:', cnum)}</span> 35 <py:if test="'replyto' in change"> 36 in reply to: ${commentref('↑ ', change.replyto)} 37 <py:if test="change_replies">; </py:if> 38 </py:if> 39 <py:if test="change_replies"> 40 <i18n:choose numeral="len(change_replies)"> 41 <span i18n:singular="">follow-up:</span> 42 <span i18n:plural="">follow-ups:</span> 43 </i18n:choose> 44 <py:for each="reply in change_replies"> 45 ${commentref('↓ ', reply, 'follow-up')} 46 </py:for> 47 </py:if> 48 </span> 49 <py:choose> 50 <py:when test="'date' in change"> 51 <i18n:msg params="date, author">Changed ${pretty_dateinfo(change.date)} by ${authorinfo(change.author)}</i18n:msg> 52 </py:when> 53 <py:otherwise> 54 <i18n:msg params="author">Changed by ${authorinfo(change.author)}</i18n:msg> 55 </py:otherwise> 56 </py:choose> 57 </h3> 58 <div py:if="show_buttons" class="trac-ticket-buttons"> 59 <form py:if="'cnum' in change and can_edit_comment" method="get" action="#comment:${cnum}"> 60 <div class="inlinebuttons"> 61 <input type="hidden" name="cnum_edit" value="${cnum}"/> 62 <input type="submit" value="${_('Edit')}" title="${_('Edit comment %(cnum)s', cnum=cnum)}"/> 63 </div> 64 </form> 65 <form py:if="'cnum' in change and can_append" id="reply-to-comment-${cnum}" 66 method="get" action="#comment"> 67 <div class="inlinebuttons"> 68 <input type="hidden" name="replyto" value="${cnum}"/> 69 <input type="submit" value="${_('Reply')}" title="${_('Reply to comment %(cnum)s', cnum=cnum)}"/> 70 </div> 71 </form> 72 </div> 15 73 <ul py:if="change.fields" class="changes"> 16 74 <li py:for="field_name, field in sorted(change.fields.iteritems(), key=lambda item: item[1].label.lower())"> 17 75 <strong>${field.label}</strong> … … Arguments: 36 94 </li> 37 95 </ul> 38 96 <form py:if="show_editor" id="trac-comment-editor" method="post" 39 action="${href.ticket(ticket.id) + '#comment:%d' % c hange.cnum}">97 action="${href.ticket(ticket.id) + '#comment:%d' % cnum}"> 40 98 <div> 41 99 <textarea name="edited_comment" class="wikitext trac-resizable" rows="10" cols="78"> 42 100 ${edited_comment if edited_comment is not None else change.comment}</textarea> 43 <input type="hidden" name="cnum_edit" value="${c hange.cnum}"/>101 <input type="hidden" name="cnum_edit" value="${cnum}"/> 44 102 </div> 45 103 <div class="buttons"> 46 104 <input type="submit" name="preview_comment" value="${_('Preview')}" 47 title="${_('Preview changes to comment %(cnum)s', cnum=c hange.cnum)}"/>105 title="${_('Preview changes to comment %(cnum)s', cnum=cnum)}"/> 48 106 <input type="submit" name="edit_comment" value="${_('Submit changes')}" 49 title="${_('Submit changes to comment %(cnum)s', cnum=c hange.cnum)}"/>107 title="${_('Submit changes to comment %(cnum)s', cnum=cnum)}"/> 50 108 <input type="submit" name="cancel_comment" value="${_('Cancel')}" 51 109 title="Cancel comment edit"/> 52 110 </div> 53 111 </form> 54 112 <py:choose> 55 <div py:when="str(c hange.cnum) == cnum_edit"113 <div py:when="str(cnum) == cnum_edit" 56 114 py:with="text = edited_comment if edited_comment is not None else change.comment" 57 115 class="comment searchable ticketdraft" style="${'display: none' if not text else None}" xml:space="preserve"> 58 116 ${wiki_to_html(context, text, escape_newlines=preserve_newlines)} … … Arguments: 64 122 ${wiki_to_html(context, change.comment, escape_newlines=preserve_newlines)} 65 123 </div> 66 124 </py:choose> 125 <div py:if="not show_editor and len(change.comment_history) > 1" py:choose="" 126 class="trac-lastedit ${'trac-shade' if comment_version != max_version else None}"> 127 <i18n:msg params="version, date, author" py:when="comment_version != max_version"> 128 Version ${comment_version}, edited ${pretty_dateinfo(change.comment_history[comment_version].date)} 129 by ${authorinfo(change.comment_history[comment_version].author)} 130 </i18n:msg> 131 <i18n:msg params="date, author" py:otherwise=""> 132 Last edited ${pretty_dateinfo(change.comment_history[comment_version].date)} 133 by ${authorinfo(change.comment_history[comment_version].author)} 134 </i18n:msg> 135 <py:if test="comment_version > 0"> 136 (<a href="${href.ticket(ticket.id, cnum_hist=cnum, cversion=comment_version - 1) 137 }#comment:${cnum}">previous</a>) 138 </py:if> 139 <py:if test="comment_version < max_version"> 140 (<a href="${href.ticket(ticket.id, cnum_hist=cnum, cversion=comment_version + 1) 141 }#comment:${cnum}">next</a>) 142 </py:if> 143 <py:if test="comment_version > 0"> 144 (<a href="${href.ticket(ticket.id, action='comment-diff', cnum=cnum, 145 version=comment_version)}">diff</a>) 146 </py:if> 147 </div> 67 148 </html> -
trac/ticket/templates/ticket_preview.html
diff --git a/trac/ticket/templates/ticket_preview.html b/trac/ticket/templates/ticket_preview.html
a b 1 1 <!--! 2 Render a ticket box preview and a ticket change preview, to be sent as a reply 3 to a ticket change auto-preview. 2 Render data relevant to automatic ticket preview. 4 3 --> 5 4 <html xmlns="http://www.w3.org/1999/xhtml" 6 5 xmlns:py="http://genshi.edgewall.org/" 7 6 xmlns:xi="http://www.w3.org/2001/XInclude" 8 7 xmlns:i18n="http://genshi.edgewall.org/i18n" 8 py:with="can_append = 'TICKET_APPEND' in perm(ticket.resource); 9 has_edit_comment = 'TICKET_EDIT_COMMENT' in perm(ticket.resource);" 9 10 py:strip=""> 10 <xi:include href="ticket_box.html" py:with="can_append = 'TICKET_APPEND' in perm(ticket.resource)"/> 11 <xi:include href="ticket_change.html" py:with="change = change_preview"/> 11 <xi:include href="ticket_box.html"/> 12 <div id="changelog"> 13 <div py:for="change in changes" 14 class="change${' trac-new' if change.date > start_time and 'attachment' not in change.fields else None}" 15 id="${'trac-change-%d-%d' % (change.cnum, to_utimestamp(change.date)) if 'cnum' in change else None}"> 16 <xi:include href="ticket_change.html" py:with="edited_comment = None; cnum_edit = 0"/> 17 </div> 18 </div> 19 <input type="hidden" name="view_time" value="${to_utimestamp(ticket['changetime'])}"/> 20 <div id="preview"><xi:include href="ticket_change.html" py:with="change = change_preview"/></div> 12 21 </html> -
trac/ticket/tests/model.py
diff --git a/trac/ticket/tests/model.py b/trac/ticket/tests/model.py
a b class TicketCommentTestCase(unittest.Tes 411 411 return from_utimestamp(ts) 412 412 413 413 def assertChange(self, ticket, cnum, date, author, **fields): 414 change = ticket.get_change(cnum )414 change = ticket.get_change(cnum=cnum) 415 415 self.assertEqual(dict(date=date, author=author, fields=fields), change) 416 416 417 417 … … class TicketCommentEditTestCase(TicketCo 567 567 ticket.modify_comment(self._find_change(ticket, 1), 568 568 'joe (%d)' % i, 569 569 'Comment 1 (%d)' % i, t[-1]) 570 history = ticket.get_comment_history(1) 570 history = ticket.get_comment_history(cnum=1) 571 self.assertEqual((0, t[0], 'jack', 'Comment 1'), history[0]) 572 for i in range(1, len(history)): 573 self.assertEqual((i, t[i], 'joe (%d)' % i, 574 'Comment 1 (%d)' % i), history[i]) 575 history = ticket.get_comment_history(cdate=self.t1) 571 576 self.assertEqual((0, t[0], 'jack', 'Comment 1'), history[0]) 572 577 for i in range(1, len(history)): 573 578 self.assertEqual((i, t[i], 'joe (%d)' % i, … … class TicketCommentDeleteTestCase(Ticket 602 607 ticket = Ticket(self.env, self.id) 603 608 self.assertEqual('a', ticket['keywords']) 604 609 self.assertEqual('change4', ticket['foo']) 605 ticket.delete_change( 4)610 ticket.delete_change(cnum=4) 606 611 self.assertEqual('a, b', ticket['keywords']) 607 612 self.assertEqual('change3', ticket['foo']) 608 self.assertEqual(None, ticket.get_change(4)) 609 self.assertNotEqual(None, ticket.get_change(3)) 613 self.assertEqual(None, ticket.get_change(cnum=4)) 614 self.assertNotEqual(None, ticket.get_change(cnum=3)) 615 self.assertEqual(self.t3, ticket.time_changed) 616 617 def test_delete_last_comment_by_date(self): 618 ticket = Ticket(self.env, self.id) 619 self.assertEqual('a', ticket['keywords']) 620 self.assertEqual('change4', ticket['foo']) 621 ticket.delete_change(cdate=self.t4) 622 self.assertEqual('a, b', ticket['keywords']) 623 self.assertEqual('change3', ticket['foo']) 624 self.assertEqual(None, ticket.get_change(cdate=self.t4)) 625 self.assertNotEqual(None, ticket.get_change(cdate=self.t3)) 610 626 self.assertEqual(self.t3, ticket.time_changed) 611 627 612 628 def test_delete_mid_comment(self): … … class TicketCommentDeleteTestCase(Ticket 615 631 comment=dict(author='joe', old='4', new='Comment 4'), 616 632 keywords=dict(author='joe', old='a, b', new='a'), 617 633 foo=dict(author='joe', old='change3', new='change4')) 618 ticket.delete_change(3) 619 self.assertEqual(None, ticket.get_change(3)) 634 ticket.delete_change(cnum=3) 635 self.assertEqual(None, ticket.get_change(cnum=3)) 636 self.assertEqual('a', ticket['keywords']) 637 self.assertChange(ticket, 4, self.t4, 'joe', 638 comment=dict(author='joe', old='4', new='Comment 4'), 639 keywords=dict(author='joe', old='a, b, c', new='a'), 640 foo=dict(author='joe', old='change2', new='change4')) 641 self.assertEqual(self.t4, ticket.time_changed) 642 643 def test_delete_mid_comment_by_date(self): 644 ticket = Ticket(self.env, self.id) 645 self.assertChange(ticket, 4, self.t4, 'joe', 646 comment=dict(author='joe', old='4', new='Comment 4'), 647 keywords=dict(author='joe', old='a, b', new='a'), 648 foo=dict(author='joe', old='change3', new='change4')) 649 ticket.delete_change(cdate=self.t3) 650 self.assertEqual(None, ticket.get_change(cdate=self.t3)) 620 651 self.assertEqual('a', ticket['keywords']) 621 652 self.assertChange(ticket, 4, self.t4, 'joe', 622 653 comment=dict(author='joe', old='4', new='Comment 4'), -
trac/ticket/web_ui.py
diff --git a/trac/ticket/web_ui.py b/trac/ticket/web_ui.py
a b class TicketModule(Component): 493 493 data.update({'action': None, 494 494 'reassign_owner': req.authname, 495 495 'resolve_resolution': None, 496 ' timestamp': str(ticket['changetime'])})496 'start_time': ticket['changetime']}) 497 497 elif req.method == 'POST': # 'Preview' or 'Submit' 498 498 if 'cancel_comment' in req.args: 499 499 req.redirect(req.href.ticket(ticket.id)) … … class TicketModule(Component): 556 556 req.args['preview'] = True 557 557 558 558 # Preview an existing ticket (after a Preview or a failed Save) 559 start_time = from_utimestamp(long(req.args.get('start_time', 0))) 559 560 data.update({ 560 'action': action, 561 'timestamp': req.args.get('ts'), 561 'action': action, 'start_time': start_time, 562 562 'reassign_owner': (req.args.get('reassign_choice') 563 563 or req.authname), 564 564 'resolve_resolution': req.args.get('resolve_choice'), … … class TicketModule(Component): 570 570 'reassign_owner': req.authname, 571 571 'resolve_resolution': None, 572 572 # Store a timestamp for detecting "mid air collisions" 573 ' timestamp': str(ticket['changetime'])})573 'start_time': ticket['changetime']}) 574 574 575 575 data.update({'comment': req.args.get('comment'), 576 576 'cnum_edit': req.args.get('cnum_edit'), … … class TicketModule(Component): 655 655 return 'ticket.html', data, None 656 656 657 657 def _prepare_data(self, req, ticket, absurls=False): 658 return {'ticket': ticket, 658 return {'ticket': ticket, 'to_utimestamp': to_utimestamp, 659 659 'context': web_context(req, ticket.resource, 660 660 absurls=absurls), 661 661 'preserve_newlines': self.must_preserve_newlines} … … class TicketModule(Component): 1141 1141 1142 1142 # Mid air collision? 1143 1143 if ticket.exists and (ticket._old or comment or force_collision_check): 1144 if req.args.get('ts') != str(ticket['changetime']): 1144 changetime = ticket['changetime'] 1145 if req.args.get('view_time') != str(to_utimestamp(changetime)): 1145 1146 add_warning(req, _("Sorry, can not save your changes. " 1146 1147 "This ticket has been modified by someone else " 1147 1148 "since you started")) … … class TicketModule(Component): 1187 1188 1188 1189 # Validate comment numbering 1189 1190 try: 1190 # comment index must be a number1191 int(req.args.get('cnum') or 0)1192 1191 # replyto must be 'description' or a number 1193 1192 replyto = req.args.get('replyto') 1194 1193 if replyto != 'description': … … class TicketModule(Component): 1239 1238 req.redirect(req.href.ticket(ticket.id)) 1240 1239 1241 1240 def _do_save(self, req, ticket, action): 1242 cnum = req.args.get('cnum')1243 replyto = req.args.get('replyto')1244 internal_cnum = cnum1245 if cnum and replyto: # record parent.child relationship1246 internal_cnum = '%s.%s' % (replyto, cnum)1247 1248 1241 # Save the action controllers we need to call side-effects for before 1249 1242 # we save the changes to the ticket. 1250 1243 controllers = list(self._get_action_controllers(req, ticket, action)) … … class TicketModule(Component): 1253 1246 1254 1247 fragment = '' 1255 1248 now = datetime.now(utc) 1256 if ticket.save_changes(get_reporter_id(req, 'author'), 1257 req.args.get('comment'), when=now, 1258 cnum=internal_cnum): 1259 fragment = '#comment:' + cnum if cnum else '' 1249 cnum = ticket.save_changes(get_reporter_id(req, 'author'), 1250 req.args.get('comment'), when=now, 1251 replyto=req.args.get('replyto')) 1252 if cnum: 1253 fragment = '#comment:%d' % cnum 1260 1254 try: 1261 1255 tn = TicketNotifyEmail(self.env) 1262 1256 tn.notify(ticket, newticket=False, modtime=now) … … class TicketModule(Component): 1549 1543 1550 1544 # Insert change preview 1551 1545 change_preview = { 1552 'date': datetime.now(utc), 1553 'author': author_id, 1554 'fields': field_changes, 1555 'preview': True, 1546 'author': author_id, 'fields': field_changes, 'preview': True, 1556 1547 'comment': req.args.get('comment', data.get('comment')), 1548 'comment_history': {}, 1557 1549 } 1558 1550 replyto = req.args.get('replyto') 1559 1551 if replyto: … … class TicketModule(Component): 1575 1567 ticket[user]) 1576 1568 data.update({ 1577 1569 'context': context, 1578 'fields': fields, 'changes': changes, 1579 'replies': replies, 'cnum': cnum + 1, 1570 'fields': fields, 'changes': changes, 'replies': replies, 1580 1571 'attachments': AttachmentModule(self.env).attachment_data(context), 1581 'action_controls': action_controls, 1582 'action': selected_action, 1572 'action_controls': action_controls, 'action': selected_action, 1583 1573 'change_preview': change_preview, 1584 1574 }) 1585 1575 -
tracopt/ticket/deleter.py
diff --git a/tracopt/ticket/deleter.py b/tracopt/ticket/deleter.py
a b from trac.core import Component, TracErr 19 19 from trac.ticket.model import Ticket 20 20 from trac.ticket.web_ui import TicketModule 21 21 from trac.util import get_reporter_id 22 from trac.util.datefmt import from_utimestamp 22 23 from trac.util.translation import _ 23 24 from trac.web.api import IRequestFilter, IRequestHandler, ITemplateStreamFilter 24 25 from trac.web.chrome import ITemplateProvider, add_notice, add_stylesheet … … class TicketDeleter(Component): 54 55 # ITemplateStreamFilter methods 55 56 56 57 def filter_stream(self, req, method, filename, stream, data): 57 if filename != 'ticket.html':58 if filename not in ('ticket.html', 'ticket_preview.html'): 58 59 return stream 59 60 ticket = data.get('ticket') 60 61 if not (ticket and ticket.exists … … class TicketDeleter(Component): 73 74 74 75 def delete_comment(): 75 76 for event in buffer: 76 cnum = event[1][1].get('id')[12:]77 cnum, cdate = event[1][1].get('id')[12:].split('-', 1) 77 78 return tag.form( 78 79 tag.div( 79 80 tag.input(type='hidden', name='action', 80 81 value='delete-comment'), 81 82 tag.input(type='hidden', name='cnum', value=cnum), 83 tag.input(type='hidden', name='cdate', value=cdate), 82 84 tag.input(type='submit', value=_('Delete'), 83 85 title=_('Delete comment %(num)s', 84 86 num=cnum)), … … class TicketDeleter(Component): 89 91 return stream | Transformer('//div[@class="description"]' 90 92 '/h3[@id="comment:description"]') \ 91 93 .after(delete_ticket).end() \ 92 .select('//div[ @class="change"]/@id') \94 .select('//div[starts-with(@class, "change")]/@id') \ 93 95 .copy(buffer).end() \ 94 .select('//div[@class="change" and @id]/h3[@class="change"]') \ 95 .after(delete_comment) 96 .select('//div[starts-with(@class, "change") and @id]' 97 '/div[@class="trac-ticket-buttons"]') \ 98 .prepend(delete_comment) 96 99 97 100 # IRequestFilter methods 98 101 … … class TicketDeleter(Component): 118 121 req.perm('ticket', id).require('TICKET_ADMIN') 119 122 ticket = Ticket(self.env, id) 120 123 action = req.args['action'] 124 cnum = req.args.get('cnum') 121 125 if req.method == 'POST': 122 126 if 'cancel' in req.args: 123 127 href = req.href.ticket(id) 124 128 if action == 'delete-comment': 125 href += '#comment:%s' % req.args.get('cnum')129 href += '#comment:%s' % cnum 126 130 req.redirect(href) 127 131 128 132 if action == 'delete': … … class TicketDeleter(Component): 132 136 req.redirect(req.href()) 133 137 134 138 elif action == 'delete-comment': 135 c num = int(req.args.get('cnum'))136 ticket.delete_change(c num)139 cdate = from_utimestamp(long(req.args.get('cdate'))) 140 ticket.delete_change(cdate=cdate) 137 141 add_notice(req, _('The ticket comment %(num)s on ticket ' 138 142 '#%(id)s has been deleted.', 139 143 num=cnum, id=ticket.id)) … … class TicketDeleter(Component): 143 147 data = tm._prepare_data(req, ticket) 144 148 tm._insert_ticket_data(req, ticket, data, 145 149 get_reporter_id(req, 'author'), {}) 146 data.update(action=action, del_cnum=None)150 data.update(action=action, cdate=None) 147 151 148 152 if action == 'delete-comment': 149 cnum = int(req.args.get('cnum'))150 data['del_cnum'] = cnum153 data['cdate'] = req.args.get('cdate') 154 cdate = from_utimestamp(long(data['cdate'])) 151 155 for change in data['changes']: 152 if change.get(' cnum') == cnum:156 if change.get('date') == cdate: 153 157 data['change'] = change 158 data['cnum'] = change.get('cnum') 154 159 break 155 160 else: 156 161 raise TracError(_('Comment %(num)s not found', num=cnum)) -
tracopt/ticket/templates/ticket_delete.html
diff --git a/tracopt/ticket/templates/ticket_delete.html b/tracopt/ticket/templates/ticket_delete.html
a b 9 9 <head> 10 10 <title py:choose="action"> 11 11 <py:when test="'delete'"><i18n:msg params="id">Delete Ticket #$ticket.id</i18n:msg></py:when> 12 <py:otherwise><i18n:msg params="num, id">Delete comment $ del_cnum on Ticket #$ticket.id</i18n:msg></py:otherwise>12 <py:otherwise><i18n:msg params="num, id">Delete comment $cnum on Ticket #$ticket.id</i18n:msg></py:otherwise> 13 13 </title> 14 14 </head> 15 15 … … 46 46 </py:when> 47 47 48 48 <py:otherwise> 49 <h1 i18n:msg="num, id">Delete comment $ del_cnum on Ticket #$ticket.id</h1>49 <h1 i18n:msg="num, id">Delete comment $cnum on Ticket #$ticket.id</h1> 50 50 51 51 <div id="changelog"> 52 52 <div class="change"> 53 <h3 class="change"> 54 <span class="threading" py:if="'cnum' in change" 55 py:with="change_replies = replies.get(str(change.cnum), [])"> 56 <span class="cnum">comment:$change.cnum</span> 57 </span> 58 <i18n:msg params="date, author">Changed ${pretty_dateinfo(change.date)} by ${authorinfo(change.author)}</i18n:msg> 59 </h3> 60 <xi:include href="ticket_change.html"/> 53 <xi:include href="ticket_change.html" py:with="hide_buttons = True"/> 61 54 </div> 62 55 </div> 63 56 64 57 <form id="edit" action="" method="post"> 65 58 <div> 66 59 <input type="hidden" name="action" value="delete-comment"/> 67 <input type="hidden" name="cnum" value="$del_cnum"/> 60 <input type="hidden" name="cnum" value="$cnum"/> 61 <input type="hidden" name="cdate" value="$cdate"/> 68 62 <p><strong>Are you sure you want to delete this ticket comment?</strong><br/> 69 63 This is an irreversible operation.</p> 70 64 </div>
