Ticket #7145: 7145-concurrent-editing-2-r10647.patch
| File 7145-concurrent-editing-2-r10647.patch, 25.0 KB (added by rblank, 14 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 } 110 110 } 111 111 #trac-comment-editor .wikitoolbar { margin-left: -1px } 112 112 #trac-add-comment :link, #trac-add-comment :visited { color: #b00 } 113 #changelog .trac-new { border-left: 0.3em solid #c0f0c0; padding-left: 0.3em; } 113 114 #changelog h3, #ticketchange h3 { 114 115 border-bottom: 1px solid #d7d7d7; 115 116 color: #999; … … form .field .wikitoolbar { margin-left: 137 138 form .field div.trac-resizable { width: 100% } 138 139 139 140 #propertyform { margin-bottom: 2em; } 141 #trac-edit-warning .trac-new { background-color: #c0f0c0; } 140 142 #properties { white-space: nowrap; line-height: 160%; padding: .5em } 141 143 #properties table { border-spacing: 0; width: 100%; padding: 0 .5em } 142 144 #properties table th { -
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($){ 3 3 var toggle = $('#trac-threaded-toggle'); 4 4 toggle.click(function() { 5 5 if ($(this).checked()) { 6 if (!comments) 7 comments = $("div.change"); 6 comments = $("div.change"); 8 7 comments.each(function() { 9 8 var children = $("a.follow-up", this).map(function() { 10 9 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 new `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 -
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.attr('checked', false).click().attr('checked', false); 48 $("#changelog .trac-new").remove(); 49 var new_changes = items.filter("#new-changes").children(); 50 $("#changelog").append(new_changes); 51 // Show warning 52 $("#trac-edit-warning").toggle(new_changes.length != 0); 53 // Update view time 54 $("#propertyform input[name='view_time']").replaceWith(items.filter("input[name='view_time']")); 55 // Update preview 56 var preview = $("#ticketchange-content").html(items.filter('#preview').children()); 57 var show_preview = preview.children().length != 0; 45 58 $("#ticketchange").toggle(show_preview); 59 // Collapse property form if comment editor has focus 46 60 if (show_preview && comment_focused) 47 61 $("#modify").parent().addClass("collapsed"); 48 62 }); … … 52 66 }); 53 67 /*]]>*/ 54 68 <py:if test="preview_mode"> 55 $("#changelog").parent().toggleClass("collapsed");56 69 $("#attachments").toggleClass("collapsed"); 57 70 $("#trac-add-comment").scrollToTop(); 58 71 </py:if> … … 128 141 <h2 class="foldable">Change History</h2> 129 142 130 143 <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> 177 <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 </div> 202 </py:for> 144 <xi:include href="ticket_changes.html"/> 203 145 </div> 204 146 </div> 205 147 </py:if> … … 216 158 <h2 id="trac-add-comment"> 217 159 <a id="edit" onfocus="$('#comment').get(0).focus()">Add a comment</a> 218 160 </h2> 161 <div id="trac-edit-warning" class="warning system-message" 162 style="${'display: none' if start_time == ticket['changetime'] else None}"> 163 This ticket has been modified since you started editing. The 164 <span class="trac-new">other modifications</span> are highlighted. 165 </div> 219 166 <!--! Comment field --> 220 167 <fieldset class="iefix"> 221 168 <label for="comment" i18n:msg="">You may use … … 225 172 <textarea id="comment" name="comment" class="wikitext trac-resizable" rows="10" cols="78"> 226 173 ${comment}</textarea> 227 174 </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 175 </div> 233 176 234 177 <div> … … 411 354 </div> 412 355 <div class="buttons"> 413 356 <py:if test="ticket.exists"> 414 <input type="hidden" name="ts" value="${timestamp}" /> 357 <input type="hidden" name="start_time" value="${to_utimestamp(start_time)}" /> 358 <input type="hidden" name="view_time" value="${to_utimestamp(ticket['changetime'])}" /> 415 359 <input type="hidden" name="replyto" value="${replyto}" /> 416 <input type="hidden" name="cnum" value="${cnum}" />417 360 </py:if> 418 361 <input type="submit" name="preview" value="${_('Preview')}" accesskey="r" /> 419 362 <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. 4 4 Arguments: 5 5 - change: the change data 6 6 - show_editor=False: if True, show a comment editor 7 - edited_comment: the current value of the comment edito 7 - edited_comment: the current value of the comment editor 8 8 - cnum_edit: the comment number being edited 9 9 --> 10 10 <html xmlns="http://www.w3.org/1999/xhtml" -
new file trac/ticket/templates/ticket_changes.html
diff --git a/trac/ticket/templates/ticket_changes.html b/trac/ticket/templates/ticket_changes.html new file mode 100644
- + 1 <!--! 2 Render a list of ticket changes. 3 4 Arguments: 5 - changes: the list of changes 6 - edited_comment: the current value of the comment edito 7 - cnum_edit: the comment number being edited 8 - can_append: True if the user is allowed to append to tickets 9 - has_edit_comment: True if the user is allowed to edit comments 10 --> 11 <html xmlns="http://www.w3.org/1999/xhtml" 12 xmlns:py="http://genshi.edgewall.org/" 13 xmlns:xi="http://www.w3.org/2001/XInclude" 14 xmlns:i18n="http://genshi.edgewall.org/i18n" 15 py:strip=""> 16 <py:def function="commentref(prefix, cnum, cls=None)"> 17 <a href="#comment:$cnum" class="$cls">$prefix$cnum</a> 18 </py:def> 19 <py:for each="change in changes" 20 py:with="can_edit_comment = has_edit_comment or (authname and authname != 'anonymous' 21 and authname == change.author); 22 show_editor = can_edit_comment and str(change.cnum) == cnum_edit; 23 show_history = str(change.cnum) == cnum_hist; 24 max_version = max(change.comment_history); 25 comment_version = int(cversion or 0) if show_history else max_version; 26 show_buttons = not show_editor and comment_version == max_version"> 27 <div class="change${' trac-nobuttons' if not show_buttons else None}${ 28 ' trac-new' if change.date > start_time else None}" 29 id="${'trac-change-%d' % change.cnum if 'cnum' in change else None}"> 30 <h3 class="change"> 31 <span class="threading" py:if="'cnum' in change" 32 py:with="change_replies = replies.get(str(change.cnum), [])"> 33 <span id="comment:$change.cnum" class="cnum">${commentref('comment:', change.cnum)}</span> 34 <py:if test="change_replies or 'replyto' in change"> 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></py:if> 47 </py:if> 48 </span> 49 <i18n:msg params="date, author">Changed ${pretty_dateinfo(change.date)} by ${authorinfo(change.author)}</i18n:msg> 50 </h3> 51 <py:if test="show_buttons"> 52 <form py:if="'cnum' in change and can_edit_comment" method="get" action="#comment:${change.cnum}"> 53 <div class="inlinebuttons"> 54 <input type="hidden" name="cnum_edit" value="${change.cnum}"/> 55 <input type="submit" value="${_('Edit')}" title="${_('Edit comment %(cnum)s', cnum=change.cnum)}"/> 56 </div> 57 </form> 58 <form py:if="'cnum' in change and can_append" id="reply-to-comment-${change.cnum}" 59 method="get" action="#comment"> 60 <div class="inlinebuttons"> 61 <input type="hidden" name="replyto" value="${change.cnum}"/> 62 <input type="submit" value="${_('Reply')}" title="${_('Reply to comment %(cnum)s', cnum=change.cnum)}"/> 63 </div> 64 </form> 65 </py:if> 66 <xi:include href="ticket_change.html"/> 67 <div py:if="not show_editor and len(change.comment_history) > 1" py:choose="" 68 class="trac-lastedit ${'trac-shade' if comment_version != max_version else None}"> 69 <i18n:msg params="version, date, author" py:when="comment_version != max_version"> 70 Version ${comment_version}, edited ${pretty_dateinfo(change.comment_history[comment_version].date)} 71 by ${authorinfo(change.comment_history[comment_version].author)} 72 </i18n:msg> 73 <i18n:msg params="date, author" py:otherwise=""> 74 Last edited ${pretty_dateinfo(change.comment_history[comment_version].date)} 75 by ${authorinfo(change.comment_history[comment_version].author)} 76 </i18n:msg> 77 <py:if test="comment_version > 0"> 78 (<a href="${href.ticket(ticket.id, cnum_hist=change.cnum, cversion=comment_version - 1) 79 }#comment:${change.cnum}">previous</a>) 80 </py:if> 81 <py:if test="comment_version < max_version"> 82 (<a href="${href.ticket(ticket.id, cnum_hist=change.cnum, cversion=comment_version + 1) 83 }#comment:${change.cnum}">next</a>) 84 </py:if> 85 <py:if test="comment_version > 0"> 86 (<a href="${href.ticket(ticket.id, action='comment-diff', cnum=change.cnum, 87 version=comment_version)}">diff</a>) 88 </py:if> 89 </div> 90 </div> 91 </py:for> 92 </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 to a ticket change auto-preview. 6 6 xmlns:py="http://genshi.edgewall.org/" 7 7 xmlns:xi="http://www.w3.org/2001/XInclude" 8 8 xmlns:i18n="http://genshi.edgewall.org/i18n" 9 py:with="can_append = 'TICKET_APPEND' in perm(ticket.resource); 10 has_edit_comment = 'TICKET_EDIT_COMMENT' in perm(ticket.resource);" 9 11 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"/> 12 <xi:include href="ticket_box.html"/> 13 <div id="new-changes"> 14 <xi:include href="ticket_changes.html" 15 py:with="changes = [c for c in changes if c.date > start_time]; 16 edited_comment=None; cnum_edit=0"/> 17 </div> 18 <input type="hidden" name="view_time" value="${to_utimestamp(ticket['changetime'])}"/> 19 <div id="preview"><xi:include href="ticket_change.html" py:with="change = change_preview"/></div> 12 20 </html> -
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): 1575 1569 ticket[user]) 1576 1570 data.update({ 1577 1571 'context': context, 1578 'fields': fields, 'changes': changes, 1579 'replies': replies, 'cnum': cnum + 1, 1572 'fields': fields, 'changes': changes, 'replies': replies, 1580 1573 'attachments': AttachmentModule(self.env).attachment_data(context), 1581 1574 'action_controls': action_controls, 1582 1575 'action': selected_action, -
tracopt/ticket/deleter.py
diff --git a/tracopt/ticket/deleter.py b/tracopt/ticket/deleter.py
a b class TicketDeleter(Component): 54 54 # ITemplateStreamFilter methods 55 55 56 56 def filter_stream(self, req, method, filename, stream, data): 57 # TODO: Also handle ticket_preview.html 57 58 if filename != 'ticket.html': 58 59 return stream 59 60 ticket = data.get('ticket') … … class TicketDeleter(Component): 74 75 def delete_comment(): 75 76 for event in buffer: 76 77 cnum = event[1][1].get('id')[12:] 78 # FIXME: Identify comment by timestamp instead of cnum 77 79 return tag.form( 78 80 tag.div( 79 81 tag.input(type='hidden', name='action',
