Edgewall Software

Ticket #7145: 7145-concurrent-editing-r10684.patch

File 7145-concurrent-editing-r10684.patch, 45.7 KB (added by rblank, 13 months ago)

Updated patch for current trunk.

  • 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 } 
    116116} 
    117117#trac-comment-editor .wikitoolbar { margin-left: -1px } 
    118118#trac-add-comment :link, #trac-add-comment :visited { color: #b00 } 
     119.trac-new { border-left: 0.31em solid #c0f0c0; padding-left: 0.31em; } 
    119120#changelog h3, #ticketchange h3 { 
    120121 border-bottom: 1px solid #d7d7d7; 
    121122 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($){ 
    22  var comments = null; 
    33  var toggle = $('#trac-threaded-toggle'); 
    44  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"); 
    88      comments.each(function() { 
    99        var children = $("a.follow-up", this).map(function() { 
    1010          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): 
    255255        return self.id 
    256256 
    257257    def save_changes(self, author=None, comment=None, when=None, db=None, 
    258                      cnum=''): 
     258                     cnum='', replyto=None): 
    259259        """ 
    260260        Store ticket changes in the database. The ticket must already exist in 
    261261        the database.  Returns False if there were no changes to save, True 
    class Ticket(object): 
    263263 
    264264        :since 0.13: the `db` parameter is no longer needed and will be removed 
    265265        in version 0.14 
     266        :since 0.13: the `cnum` parameter is deprecated, and threading should 
     267        be controled with the `replyto` argument 
    266268        """ 
    267269        assert self.exists, "Cannot update a new ticket" 
    268270 
    class Ticket(object): 
    296298                    pass 
    297299 
    298300        with self.env.db_transaction as db: 
     301            db("UPDATE ticket SET changetime=%s WHERE id=%s", 
     302               (when_ts, self.id)) 
     303             
    299304            # find cnum if it isn't provided 
    300             comment_num = cnum 
    301             if not comment_num: 
     305            if not cnum: 
    302306                num = 0 
    303307                for ts, old in db(""" 
    304308                        SELECT DISTINCT tc1.time, COALESCE(tc2.oldvalue,'') 
    class Ticket(object): 
    314318                        break 
    315319                    except ValueError: 
    316320                        num += 1 
    317                 comment_num = str(num + 1) 
     321                cnum = str(num + 1) 
     322                if replyto: 
     323                    cnum = '%s.%s' % (replyto, cnum) 
    318324 
    319325            # store fields 
    320326            for name in self._old.keys(): 
    class Ticket(object): 
    344350            db("""INSERT INTO ticket_change 
    345351                    (ticket,time,author,field,oldvalue,newvalue) 
    346352                  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)) 
    351354 
    352355        old_values = self._old 
    353356        self._old = {} 
    class Ticket(object): 
    355358 
    356359        for listener in TicketSystem(self.env).change_listeners: 
    357360            listener.ticket_changed(self, comment, author, old_values) 
    358         return True 
     361        return int(cnum.rsplit('.', 1)[-1]) 
    359362 
    360363    def get_changelog(self, when=None, db=None): 
    361364        """Return the changelog as a list of tuples of the form 
    class Ticket(object): 
    420423        for listener in TicketSystem(self.env).change_listeners: 
    421424            listener.ticket_deleted(self) 
    422425 
    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. 
    425428 
    426429        :since 0.13: the `db` parameter is no longer needed and will be removed 
    427430        in version 0.14 
    428431        """ 
    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: 
    440450            return change 
    441451 
    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) 
    448460        with self.env.db_transaction as db: 
    449461            # Find modified fields and their previous value 
    450462            fields = [(field, old, new) 
    class Ticket(object): 
    545557 
    546558        self.values['changetime'] = when 
    547559 
    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. 
    550563 
    551564        :since 0.13: the `db` parameter is no longer needed and will be removed 
    552565        in version 0.14 
    553566        """ 
    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 
    556571            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: 
    573603                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 
    576611 
    577612    def _find_change(self, cnum): 
    578613        """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  
    3939                     .blur(function() { comment_focused = false; }); 
    4040        $("#propertyform").autoSubmit({preview: '1'}, function(data, reply) { 
    4141          var items = $(reply); 
     42          // Update ticket box 
    4243          $("#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; 
    4559          $("#ticketchange").toggle(show_preview); 
     60          // Collapse property form if comment editor has focus 
    4661          if (show_preview && comment_focused) 
    4762            $("#modify").parent().addClass("collapsed"); 
    4863        }); 
     
    5267        }); 
    5368        /*]]>*/ 
    5469        <py:if test="preview_mode"> 
    55         $("#changelog").parent().toggleClass("collapsed"); 
    5670        $("#attachments").toggleClass("collapsed"); 
    5771        $("#trac-add-comment").scrollToTop(); 
    5872        </py:if> 
     
    117131                      py:with="alist = attachments; foldable = True"/> 
    118132        </py:if> 
    119133 
    120         <div py:if="ticket.exists and changes"> 
     134        <div style="${'display: none' if not changes else None}"> 
    121135          <form id="trac-threaded-form" method="get" action="" style="display: none"> 
    122136            <div> 
    123137              <input id="trac-threaded-toggle" type="checkbox" /> 
     
    128142          <h2 class="foldable">Change History</h2> 
    129143 
    130144          <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('&uarr;&nbsp;', 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('&darr;&nbsp;', 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}"> 
    177148                <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 &lt; 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> 
    201149              </div> 
    202150            </py:for> 
    203151          </div> 
     
    209157            action="${href.ticket(ticket.id) + '#trac-add-comment' if ticket.exists 
    210158                      else href.newticket() + '#ticket'}"> 
    211159        <!--! 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"> 
    213161          <div class="trac-nav"> 
    214162            <a href="#content" title="View ticket fields and description">View</a> &uarr; 
    215163          </div> 
    216           <h2 id="trac-add-comment"> 
     164          <h2> 
    217165            <a id="edit" onfocus="$('#comment').get(0).focus()">Add a comment</a> 
    218166          </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> 
    219173          <!--! Comment field --> 
    220174          <fieldset class="iefix"> 
    221175            <label for="comment" i18n:msg="">You may use 
     
    225179            <textarea id="comment" name="comment" class="wikitext trac-resizable" rows="10" cols="78"> 
    226180${comment}</textarea> 
    227181          </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 validation 
    230             may have failed. 
    231           </div> 
    232182        </div> 
    233183 
    234184        <div> 
     
    363313          </div> 
    364314        </div> 
    365315 
     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 
    366323        <!--! Author or Reporter --> 
    367324        <div py:if="authname == 'anonymous'" class="field"> 
    368325          <fieldset py:choose=""> 
     
    385342          </fieldset> 
    386343        </div> 
    387344 
    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('&uarr;&nbsp;', 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  
    401345        <!--! Attachment on creation checkbox --> 
    402346        <p py:if="not ticket.exists and 'ATTACHMENT_CREATE' in perm(ticket.resource.child('attachment'))"> 
    403347          <label> 
     
    411355        </div> 
    412356        <div class="buttons"> 
    413357          <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'])}" /> 
    415360            <input type="hidden" name="replyto" value="${replyto}" /> 
    416             <input type="hidden" name="cnum" value="${cnum}" /> 
    417361          </py:if> 
    418362          <input type="submit" name="preview" value="${_('Preview')}" accesskey="r" />&nbsp; 
    419363          <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. 
    33 
    44Arguments: 
    55 - 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 
    912--> 
    1013<html xmlns="http://www.w3.org/1999/xhtml" 
    1114      xmlns:py="http://genshi.edgewall.org/" 
    1215      xmlns:xi="http://www.w3.org/2001/XInclude" 
    1316      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('&uarr;&nbsp;', 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('&darr;&nbsp;', 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> 
    1573  <ul py:if="change.fields" class="changes"> 
    1674    <li py:for="field_name, field in sorted(change.fields.iteritems(), key=lambda item: item[1].label.lower())"> 
    1775      <strong>${field.label}</strong> 
    Arguments: 
    3694    </li> 
    3795  </ul> 
    3896  <form py:if="show_editor" id="trac-comment-editor" method="post" 
    39         action="${href.ticket(ticket.id) + '#comment:%d' % change.cnum}"> 
     97        action="${href.ticket(ticket.id) + '#comment:%d' % cnum}"> 
    4098    <div> 
    4199      <textarea name="edited_comment" class="wikitext trac-resizable" rows="10" cols="78"> 
    42100${edited_comment if edited_comment is not None else change.comment}</textarea> 
    43       <input type="hidden" name="cnum_edit" value="${change.cnum}"/> 
     101      <input type="hidden" name="cnum_edit" value="${cnum}"/> 
    44102    </div> 
    45103    <div class="buttons"> 
    46104      <input type="submit" name="preview_comment" value="${_('Preview')}" 
    47              title="${_('Preview changes to comment %(cnum)s', cnum=change.cnum)}"/> 
     105             title="${_('Preview changes to comment %(cnum)s', cnum=cnum)}"/> 
    48106      <input type="submit" name="edit_comment" value="${_('Submit changes')}" 
    49              title="${_('Submit changes to comment %(cnum)s', cnum=change.cnum)}"/> 
     107             title="${_('Submit changes to comment %(cnum)s', cnum=cnum)}"/> 
    50108      <input type="submit" name="cancel_comment" value="${_('Cancel')}" 
    51109             title="Cancel comment edit"/> 
    52110    </div> 
    53111  </form> 
    54112  <py:choose> 
    55     <div py:when="str(change.cnum) == cnum_edit" 
     113    <div py:when="str(cnum) == cnum_edit" 
    56114         py:with="text = edited_comment if edited_comment is not None else change.comment" 
    57115         class="comment searchable ticketdraft" style="${'display: none' if not text else None}" xml:space="preserve"> 
    58116      ${wiki_to_html(context, text, escape_newlines=preserve_newlines)} 
    Arguments: 
    64122      ${wiki_to_html(context, change.comment, escape_newlines=preserve_newlines)} 
    65123    </div> 
    66124  </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 &lt; 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> 
    67148</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  
    11<!--! 
    2 Render a ticket box preview and a ticket change preview, to be sent as a reply 
    3 to a ticket change auto-preview. 
     2Render data relevant to automatic ticket preview. 
    43--> 
    54<html xmlns="http://www.w3.org/1999/xhtml" 
    65      xmlns:py="http://genshi.edgewall.org/" 
    76      xmlns:xi="http://www.w3.org/2001/XInclude" 
    87      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);" 
    910      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> 
    1221</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 
    411411        return from_utimestamp(ts) 
    412412     
    413413    def assertChange(self, ticket, cnum, date, author, **fields): 
    414         change = ticket.get_change(cnum) 
     414        change = ticket.get_change(cnum=cnum) 
    415415        self.assertEqual(dict(date=date, author=author, fields=fields), change) 
    416416     
    417417 
    class TicketCommentEditTestCase(TicketCo 
    567567            ticket.modify_comment(self._find_change(ticket, 1), 
    568568                                  'joe (%d)' % i, 
    569569                                  '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) 
    571576        self.assertEqual((0, t[0], 'jack', 'Comment 1'), history[0]) 
    572577        for i in range(1, len(history)): 
    573578            self.assertEqual((i, t[i], 'joe (%d)' % i, 
    class TicketCommentDeleteTestCase(Ticket 
    602607        ticket = Ticket(self.env, self.id) 
    603608        self.assertEqual('a', ticket['keywords']) 
    604609        self.assertEqual('change4', ticket['foo']) 
    605         ticket.delete_change(4) 
     610        ticket.delete_change(cnum=4) 
    606611        self.assertEqual('a, b', ticket['keywords']) 
    607612        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)) 
    610626        self.assertEqual(self.t3, ticket.time_changed) 
    611627     
    612628    def test_delete_mid_comment(self): 
    class TicketCommentDeleteTestCase(Ticket 
    615631            comment=dict(author='joe', old='4', new='Comment 4'), 
    616632            keywords=dict(author='joe', old='a, b', new='a'), 
    617633            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)) 
    620651        self.assertEqual('a', ticket['keywords']) 
    621652        self.assertChange(ticket, 4, self.t4, 'joe', 
    622653            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): 
    493493            data.update({'action': None, 
    494494                         'reassign_owner': req.authname, 
    495495                         'resolve_resolution': None, 
    496                          'timestamp': str(ticket['changetime'])}) 
     496                         'start_time': ticket['changetime']}) 
    497497        elif req.method == 'POST': # 'Preview' or 'Submit' 
    498498            if 'cancel_comment' in req.args: 
    499499                req.redirect(req.href.ticket(ticket.id)) 
    class TicketModule(Component): 
    556556                req.args['preview'] = True 
    557557 
    558558            # Preview an existing ticket (after a Preview or a failed Save) 
     559            start_time = from_utimestamp(long(req.args.get('start_time', 0))) 
    559560            data.update({ 
    560                 'action': action, 
    561                 'timestamp': req.args.get('ts'), 
     561                'action': action, 'start_time': start_time, 
    562562                'reassign_owner': (req.args.get('reassign_choice')  
    563563                                   or req.authname), 
    564564                'resolve_resolution': req.args.get('resolve_choice'), 
    class TicketModule(Component): 
    570570                         'reassign_owner': req.authname, 
    571571                         'resolve_resolution': None, 
    572572                         # Store a timestamp for detecting "mid air collisions" 
    573                          'timestamp': str(ticket['changetime'])}) 
     573                         'start_time': ticket['changetime']}) 
    574574 
    575575        data.update({'comment': req.args.get('comment'), 
    576576                     'cnum_edit': req.args.get('cnum_edit'), 
    class TicketModule(Component): 
    655655        return 'ticket.html', data, None 
    656656 
    657657    def _prepare_data(self, req, ticket, absurls=False): 
    658         return {'ticket': ticket, 
     658        return {'ticket': ticket, 'to_utimestamp': to_utimestamp, 
    659659                'context': web_context(req, ticket.resource, 
    660660                                                absurls=absurls), 
    661661                'preserve_newlines': self.must_preserve_newlines} 
    class TicketModule(Component): 
    11411141 
    11421142        # Mid air collision? 
    11431143        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)): 
    11451146                add_warning(req, _("Sorry, can not save your changes. " 
    11461147                              "This ticket has been modified by someone else " 
    11471148                              "since you started")) 
    class TicketModule(Component): 
    11871188 
    11881189        # Validate comment numbering 
    11891190        try: 
    1190             # comment index must be a number 
    1191             int(req.args.get('cnum') or 0) 
    11921191            # replyto must be 'description' or a number 
    11931192            replyto = req.args.get('replyto') 
    11941193            if replyto != 'description': 
    class TicketModule(Component): 
    12391238        req.redirect(req.href.ticket(ticket.id)) 
    12401239 
    12411240    def _do_save(self, req, ticket, action): 
    1242         cnum = req.args.get('cnum') 
    1243         replyto = req.args.get('replyto') 
    1244         internal_cnum = cnum 
    1245         if cnum and replyto: # record parent.child relationship 
    1246             internal_cnum = '%s.%s' % (replyto, cnum) 
    1247  
    12481241        # Save the action controllers we need to call side-effects for before 
    12491242        # we save the changes to the ticket. 
    12501243        controllers = list(self._get_action_controllers(req, ticket, action)) 
    class TicketModule(Component): 
    12531246 
    12541247        fragment = '' 
    12551248        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 
    12601254            try: 
    12611255                tn = TicketNotifyEmail(self.env) 
    12621256                tn.notify(ticket, newticket=False, modtime=now) 
    class TicketModule(Component): 
    15491543 
    15501544        # Insert change preview 
    15511545        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, 
    15561547            'comment': req.args.get('comment', data.get('comment')), 
     1548            'comment_history': {}, 
    15571549        } 
    15581550        replyto = req.args.get('replyto') 
    15591551        if replyto: 
    class TicketModule(Component): 
    15751567                                                            ticket[user]) 
    15761568        data.update({ 
    15771569            'context': context, 
    1578             'fields': fields, 'changes': changes, 
    1579             'replies': replies, 'cnum': cnum + 1, 
     1570            'fields': fields, 'changes': changes, 'replies': replies, 
    15801571            'attachments': AttachmentModule(self.env).attachment_data(context), 
    1581             'action_controls': action_controls, 
    1582             'action': selected_action, 
     1572            'action_controls': action_controls, 'action': selected_action, 
    15831573            'change_preview': change_preview, 
    15841574        }) 
    15851575 
  • tracopt/ticket/deleter.py

    diff --git a/tracopt/ticket/deleter.py b/tracopt/ticket/deleter.py
    a b from trac.core import Component, TracErr 
    1919from trac.ticket.model import Ticket 
    2020from trac.ticket.web_ui import TicketModule 
    2121from trac.util import get_reporter_id 
     22from trac.util.datefmt import from_utimestamp 
    2223from trac.util.translation import _ 
    2324from trac.web.api import IRequestFilter, IRequestHandler, ITemplateStreamFilter 
    2425from trac.web.chrome import ITemplateProvider, add_notice, add_stylesheet 
    class TicketDeleter(Component): 
    5455    # ITemplateStreamFilter methods 
    5556     
    5657    def filter_stream(self, req, method, filename, stream, data): 
    57         if filename != 'ticket.html': 
     58        if filename not in ('ticket.html', 'ticket_preview.html'): 
    5859            return stream 
    5960        ticket = data.get('ticket') 
    6061        if not (ticket and ticket.exists 
    class TicketDeleter(Component): 
    7374         
    7475        def delete_comment(): 
    7576            for event in buffer: 
    76                 cnum = event[1][1].get('id')[12:] 
     77                cnum, cdate = event[1][1].get('id')[12:].split('-', 1) 
    7778                return tag.form( 
    7879                    tag.div( 
    7980                        tag.input(type='hidden', name='action', 
    8081                                  value='delete-comment'), 
    8182                        tag.input(type='hidden', name='cnum', value=cnum), 
     83                        tag.input(type='hidden', name='cdate', value=cdate), 
    8284                        tag.input(type='submit', value=_('Delete'), 
    8385                                  title=_('Delete comment %(num)s', 
    8486                                          num=cnum)), 
    class TicketDeleter(Component): 
    8991        return stream | Transformer('//div[@class="description"]' 
    9092                                    '/h3[@id="comment:description"]') \ 
    9193            .after(delete_ticket).end() \ 
    92             .select('//div[@class="change"]/@id') \ 
     94            .select('//div[starts-with(@class, "change")]/@id') \ 
    9395            .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) 
    9699 
    97100    # IRequestFilter methods 
    98101     
    class TicketDeleter(Component): 
    118121        req.perm('ticket', id).require('TICKET_ADMIN') 
    119122        ticket = Ticket(self.env, id) 
    120123        action = req.args['action'] 
     124        cnum = req.args.get('cnum') 
    121125        if req.method == 'POST': 
    122126            if 'cancel' in req.args: 
    123127                href = req.href.ticket(id) 
    124128                if action == 'delete-comment': 
    125                     href += '#comment:%s' % req.args.get('cnum') 
     129                    href += '#comment:%s' % cnum 
    126130                req.redirect(href) 
    127131             
    128132            if action == 'delete': 
    class TicketDeleter(Component): 
    132136                req.redirect(req.href()) 
    133137             
    134138            elif action == 'delete-comment': 
    135                 cnum = int(req.args.get('cnum')) 
    136                 ticket.delete_change(cnum) 
     139                cdate = from_utimestamp(long(req.args.get('cdate'))) 
     140                ticket.delete_change(cdate=cdate) 
    137141                add_notice(req, _('The ticket comment %(num)s on ticket ' 
    138142                                  '#%(id)s has been deleted.', 
    139143                                  num=cnum, id=ticket.id)) 
    class TicketDeleter(Component): 
    143147        data = tm._prepare_data(req, ticket) 
    144148        tm._insert_ticket_data(req, ticket, data, 
    145149                               get_reporter_id(req, 'author'), {}) 
    146         data.update(action=action, del_cnum=None) 
     150        data.update(action=action, cdate=None) 
    147151         
    148152        if action == 'delete-comment': 
    149             cnum = int(req.args.get('cnum')) 
    150             data['del_cnum'] = cnum 
     153            data['cdate'] = req.args.get('cdate') 
     154            cdate = from_utimestamp(long(data['cdate'])) 
    151155            for change in data['changes']: 
    152                 if change.get('cnum') == cnum: 
     156                if change.get('date') == cdate: 
    153157                    data['change'] = change 
     158                    data['cnum'] = change.get('cnum') 
    154159                    break 
    155160            else: 
    156161                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  
    99  <head> 
    1010    <title py:choose="action"> 
    1111      <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> 
    1313    </title> 
    1414  </head> 
    1515 
     
    4646      </py:when> 
    4747 
    4848      <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> 
    5050         
    5151        <div id="changelog"> 
    5252          <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"/> 
    6154          </div> 
    6255        </div> 
    6356         
    6457        <form id="edit" action="" method="post"> 
    6558          <div> 
    6659            <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"/> 
    6862            <p><strong>Are you sure you want to delete this ticket comment?</strong><br/> 
    6963               This is an irreversible operation.</p> 
    7064          </div>