Implement simple message annotations
authorMagnus Hagander <magnus@hagander.net>
Sat, 14 Feb 2015 12:07:48 +0000 (13:07 +0100)
committerMagnus Hagander <magnus@hagander.net>
Sat, 14 Feb 2015 12:07:48 +0000 (13:07 +0100)
This feature makes it possible to "pull in" a message in a thread and highlight
it with an annotation (free text format). This will list the message in a table
along with the annotation and who made it.

Annotations have to be attached to a specific message - for a "generic" one it
makes sense to attach it to the latest message available, as that will put it
at the correct place in time.

pgcommitfest/commitfest/ajax.py
pgcommitfest/commitfest/models.py
pgcommitfest/commitfest/static/commitfest/css/commitfest.css
pgcommitfest/commitfest/static/commitfest/js/commitfest.js
pgcommitfest/commitfest/templates/patch.html

index 92c4575175ffb91c7112103a9ff587b03b2ef065..bc6817dfbc026e72bbf508d2e08bf3a4ce1ee456 100644 (file)
@@ -19,7 +19,8 @@ class HttpResponseServiceUnavailable(HttpResponse):
 class Http503(Exception):
        pass
 
-from models import CommitFest, Patch, MailThread, MailThreadAttachment, PatchHistory
+from models import CommitFest, Patch, MailThread, MailThreadAttachment
+from models import MailThreadAnnotation, PatchHistory
 
 def _archivesAPI(suburl, params=None):
        try:
@@ -63,6 +64,56 @@ def getThreads(request):
        r = _archivesAPI('/list/pgsql-hackers/latest.json', params)
        return sorted(r, key=lambda x: x['date'], reverse=True)
 
+def getMessages(request):
+       threadid = request.GET['t']
+
+       thread = MailThread.objects.get(pk=threadid)
+
+       # Always make a call over to the archives api
+       r = _archivesAPI('/message-id.json/%s' % thread.messageid)
+       return sorted(r, key=lambda x: x['date'], reverse=True)
+
+@transaction.commit_on_success
+def annotateMessage(request):
+       thread = get_object_or_404(MailThread, pk=int(request.POST['t']))
+       msgid = request.POST['msgid']
+       msg = request.POST['msg']
+
+       # Get the subject, author and date from the archives
+       # We only have an API call to get the whole thread right now, so
+       # do that, and then find our entry in it.
+       r = _archivesAPI('/message-id.json/%s' % thread.messageid)
+       for m in r:
+               if m['msgid'] == msgid:
+                       annotation = MailThreadAnnotation(mailthread=thread,
+                                                                                         user=request.user,
+                                                                                         msgid=msgid,
+                                                                                         annotationtext=msg,
+                                                                                         mailsubject=m['subj'],
+                                                                                         maildate=m['date'],
+                                                                                         mailauthor=m['from'])
+                       annotation.save()
+
+                       for p in thread.patches.all():
+                               PatchHistory(patch=p, by=request.user, what='Added annotation "%s" to %s' % (msg, msgid)).save()
+                               p.set_modified()
+                               p.save()
+
+                       return 'OK'
+       return 'Message not found in thread!'
+
+@transaction.commit_on_success
+def deleteAnnotation(request):
+       annotation = get_object_or_404(MailThreadAnnotation, pk=request.POST['id'])
+
+       for p in annotation.mailthread.patches.all():
+               PatchHistory(patch=p, by=request.user, what='Deleted annotation "%s" from %s' % (annotation.annotationtext, annotation.msgid)).save()
+               p.set_modified()
+               p.save()
+
+       annotation.delete()
+
+       return 'OK'
 
 def parse_and_add_attachments(threadinfo, mailthread):
        for t in threadinfo:
@@ -176,8 +227,11 @@ def importUser(request):
 
 _ajax_map={
        'getThreads': getThreads,
+       'getMessages': getMessages,
        'attachThread': attachThread,
        'detachThread': detachThread,
+       'annotateMessage': annotateMessage,
+       'deleteAnnotation': deleteAnnotation,
        'searchUsers': searchUsers,
        'importUser': importUser,
 }
index e1b83b4e60318271872a4c59b16ae99e083cb8ac..0aa66dc178e3c14c2a5cf5bcd39808b759acc9f3 100644 (file)
@@ -232,6 +232,23 @@ class MailThreadAttachment(models.Model):
                ordering = ('-date',)
                unique_together = (('mailthread', 'messageid',), )
 
+class MailThreadAnnotation(models.Model):
+       mailthread = models.ForeignKey(MailThread, null=False, blank=False)
+       date = models.DateTimeField(null=False, blank=False, auto_now_add=True)
+       user = models.ForeignKey(User, null=False, blank=False)
+       msgid = models.CharField(max_length=1000, null=False, blank=False)
+       annotationtext = models.TextField(null=False, blank=False, max_length=2000)
+       mailsubject = models.CharField(max_length=500, null=False, blank=False)
+       maildate = models.DateTimeField(null=False, blank=False)
+       mailauthor = models.CharField(max_length=500, null=False, blank=False)
+
+       @property
+       def user_string(self):
+               return "%s %s (%s)" % (self.user.first_name, self.user.last_name, self.user.username)
+
+       class Meta:
+               ordering = ('date', )
+
 class PatchStatus(models.Model):
        status = models.IntegerField(null=False, blank=False, primary_key=True)
        statusstring = models.TextField(max_length=50, null=False, blank=False)
index 74cb01851a09c20743db4f03f79f0b3998a4dcba..e3058a32b5c7d413ddd6ce878245d6e84c5deded 100644 (file)
@@ -60,3 +60,16 @@ div.form-group div.controls input.threadpick-input {
 #attachThreadListWrap.loading * {
     display: none;
 }
+
+/*
+ * Annotate message dialog */
+#annotateMessageBody.loading {
+    display: block;
+    background: url('/static/commitfest/spinner.gif') no-repeat center;
+    width: 124px;
+    height: 124px;
+    margin: 0 auto;
+}
+#annotateMessageBody.loading * {
+    display: none;
+}
index f1797ffd9fb554ea63ea0fff5663cd9f055d0299..4fd06e5a6a3554853636866809a56b3812976540 100644 (file)
@@ -118,6 +118,71 @@ function doAttachThread(cfid, patchid, msgid, reloadonsuccess) {
    });
 }
 
+function updateAnnotationMessages(threadid) {
+    $('#annotateMessageBody').addClass('loading');
+    $('#doAnnotateMessageButton').addClass('disabled');
+    $.get('/ajax/getMessages', {
+       't': threadid,
+    }).success(function(data) {
+       sel = $('#annotateMessageList')
+       sel.find('option').remove();
+       $.each(data, function(i,m) {
+           sel.append('<option value="' + m.msgid + '">' + m.from + ': ' + m.subj + ' (' + m.date + ')</option>');
+       });
+    }).always(function() {
+       $('#annotateMessageBody').removeClass('loading');
+    });
+}
+function addAnnotation(threadid) {
+    $('#annotateThreadList').find('option').remove();
+    $('#annotateMessage').val('');
+    $('#annotateModal').modal();
+    updateAnnotationMessages(threadid);
+    $('#doAnnotateMessageButton').unbind('click');
+    $('#doAnnotateMessageButton').click(function() {
+       $('#doAnnotateMessageButton').addClass('disabled');
+       $('#annotateMessageBody').addClass('loading');
+       $.post('/ajax/annotateMessage/', {
+           't': threadid,
+           'msgid': $('#annotateMessageList').val(),
+           'msg': $('#annotateMessage').val()
+       }).success(function(data) {
+           if (data != 'OK') {
+               alert(data);
+           }
+           else {
+               $('#annotateModal').modal('hide');
+               location.reload();
+           }
+       }).fail(function(data) {
+           alert('Failed to annotate message');
+           $('#annotateMessageBody').removeClass('loading');
+       });
+    });
+}
+
+function annotateChanged() {
+    /* Enable/disable the annotate button */
+    if ($('#annotateMessage').val() != '' && $('#annotateMessageList').val()) {
+       $('#doAnnotateMessageButton').removeClass('disabled');
+    }
+    else {
+       $('#doAnnotateMessageButton').addClass('disabled');
+    }
+}
+
+function deleteAnnotation(annid) {
+    if (confirm('Are you sure you want to delete this annotation?')) {
+      $.post('/ajax/deleteAnnotation/', {
+         'id': annid,
+      }).success(function(data) {
+         location.reload();
+      }).fail(function(data) {
+         alert('Failed to delete annotation!');
+      });
+    }
+}
+
 function flagCommitted(committer) {
    $('#commitModal').modal();
    $('#committerSelect').val(committer);
index ab66df49af3ae7e3e6fc7d12e02e57a21f0ad72a..f18cf9d0e20e0a948c46905864ad4f67e5cc4207 100644 (file)
            &nbsp;&nbsp;&nbsp;&nbsp;Attachment (<a href="http://www.postgresql.org/message-id/attachment/{{ta.attachmentid}}/{{ta.filename}}">{{ta.filename}}</a>) at <a href="http://www.postgresql.org/message-id/{{ta.messageid}}/">{{ta.date}}</a> from {{ta.author|hidemail}} (Patch: {{ta.ispatch|yesno:"Yes,No,Pending check"}})<br/>
           {%if forloop.last%}</div>{%endif%}
          {%endfor%}
+          <div>
+            {%for a in t.mailthreadannotation_set.all%}
+            {%if forloop.first%}
+            <h4>Annotations</h4>
+            <table class="table table-bordered table-striped table-condensed small">
+              <thead>
+                <tr>
+                  <th>When</th>
+                  <th>Who</th>
+                  <th>Mail</th>
+                  <th>Annotation</th>
+                </tr>
+              </thead>
+              <tbody>
+            {%endif%}
+                <tr>
+                  <td>{{a.date}}</td>
+                  <td style="white-space: nowrap">{{a.user_string}}</td>
+                  <td style="white-space: nowrap">From {{a.mailauthor}}<br/>at <a href="http://www.postgresql.org/message-id/{{a.msgid}}/">{{a.maildate}}</a></td>
+                  <td width="99%">{{a.annotationtext}} <button type="button" class="close" title="Delete this annotation" onclick="deleteAnnotation({{a.id}})">&times;</button></td>
+                </tr>
+            {%if forloop.last%}
+               </body>
+             </table>
+             {%endif%}
+            {%endfor%}
+            <button class="btn btn-xs btn-default" onclick="addAnnotation({{t.id}})">Add annotation</button>
+          </div>
         </dd>
        {%endfor%}
        </dl>
 </div>
 
 {%include "thread_attach.inc"%}
+{%comment%}Modal dialog for adding annotation{%endcomment%}
+<div class="modal fade" id="annotateModal" role="dialog">
+ <div class="modal-dialog modal-lg"><div class="modal-content">
+  <div class="modal-header">
+   <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+   <h3>Add annotation</h3>
+  </div>
+  <div id="annotateMessageBody" class="modal-body">
+   <div>Pick one of the messages in this thread</div>
+   <div id="annotateListWrap">
+    <select id="annotateMessageList" style="width:100%;" onChange="annotateChanged()">
+    </select>
+   </div>
+   <div><br/></div>
+   <div>Enter a messages for the annotation</div>
+   <div id="annotateTextWrap">
+     <input id="annotateMessage" type="text" style="width:100%" onKeyUp="annotateChanged()">
+   </div>
+  </div>
+  <div class="modal-footer">
+   <a href="#" class="btn btn-default" data-dismiss="modal">Close</a>
+   <a href="#" id="doAnnotateMessageButton" class="btn btn-default btn-primary disabled">Add annotation</a>
+  </div>
+ </div></div>
+</div>
 {%endblock%}
 
 {%block morescript%}