Attempt to simplify talk voting pages
authorMagnus Hagander <magnus@hagander.net>
Mon, 9 Jul 2018 14:09:25 +0000 (16:09 +0200)
committerMagnus Hagander <magnus@hagander.net>
Mon, 9 Jul 2018 14:24:08 +0000 (16:24 +0200)
This merges a couple of changes:

1. Each individual vote is cast by selection in dropdown and is
   immediately ajax-posted to the backend, so there is no huge save
   button for everything that risks overwriting other fields. Changing
   the vote will automatically update the average value based on the
   changes (and other changes made by others).
2. No dedicated text-edit box for own comments. Instead, own comments
   are listed alongside other comments, and there is an edit button that
   brings up a popup and lets the comment be edited.
3. No popup windows for talk descriptions. Instead, accordions are used,
   so clicking the title will expand the description in-line. This also
   makes it a lot easier to handle things like copy/paste from the
   descriptions.

postgresqleu/confreg/views.py
postgresqleu/urls.py
template/confreg/sessionvotes.html

index 05560add84d863afe8e16692fa07fe820b9fcc57..8c02f2449a20cba60add823862f8a07c03350078 100644 (file)
@@ -8,13 +8,13 @@ from django.contrib.auth.models import User
 from django.contrib import messages
 from django.conf import settings
 from django.db import transaction, connection
-from django.db.models import Q, Count
+from django.db.models import Q, Count, Avg
 from django.db.models.expressions import F
 from django.forms import formsets
 from django.forms import ValidationError
 
 from models import Conference, ConferenceRegistration, ConferenceSession
-from models import ConferenceSessionSlides, GlobalOptOut
+from models import ConferenceSessionSlides, ConferenceSessionVote, GlobalOptOut
 from models import ConferenceSessionFeedback, Speaker, Speaker_Photo
 from models import ConferenceFeedbackQuestion, ConferenceFeedbackAnswer
 from models import RegistrationType, PrepaidVoucher, PrepaidBatch
@@ -2088,28 +2088,6 @@ def talkvote(request, confname):
 
        curs = connection.cursor()
 
-       if request.method=='POST':
-               # Record votes
-               # We could probably do this with some fancy writable CTEs, but
-               # this code won't run often, so we don't really care about being
-               # fast, and this is easier...
-               # Thus, remove existing entries and replace them with current ones.
-               curs.execute("DELETE FROM confreg_conferencesessionvote WHERE voter_id=%(userid)s AND session_id IN (SELECT id FROM confreg_conferencesession WHERE conference_id=%(confid)s)", {
-                               'confid': conference.id,
-                               'userid': request.user.id,
-                               })
-               curs.executemany("INSERT INTO confreg_conferencesessionvote (session_id, voter_id, vote, comment) VALUES (%(sid)s, %(vid)s, %(vote)s, %(comment)s)", [
-                               {
-                                       'sid': k[3:],
-                                       'vid': request.user.id,
-                                       'vote': int(v) > 0 and int(v) or None,
-                                       'comment': request.POST['tc_%s' % k[3:]],
-                                       }
-                               for k,v in request.POST.items() if k.startswith("sv_") and (int(v)>0 or request.POST['tc_%s' % k[3:]])
-                               ])
-
-               return HttpResponseRedirect(".")
-
        order = ""
        if request.GET.has_key("sort"):
                if request.GET["sort"] == "avg":
@@ -2202,6 +2180,46 @@ def talkvote_status(request, confname):
                                                                                 session.status!=session.lastnotifiedstatus and 1 or 0,
                                                                         ),     content_type='text/plain')
 
+@login_required
+@transaction.atomic
+def talkvote_vote(request, confname):
+       conference = get_object_or_404(Conference, urlname=confname)
+       if not conference.talkvoters.filter(pk=request.user.id):
+               return HttpResponse('You are not a talk voter for this conference!')
+       if request.method!='POST':
+               return HttpResponse('Can only use POST')
+
+       session = get_object_or_404(ConferenceSession, conference=conference, id=request.POST['sessionid'])
+       v = int(request.POST['vote'])
+       if v > 0:
+               vote,created = ConferenceSessionVote.objects.get_or_create(session=session, voter=request.user)
+               vote.vote = v
+               vote.save()
+       else:
+               ConferenceSessionVote.objects.filter(session=session, voter=request.user).delete()
+
+       # Re-calculate the average
+       avg = session.conferencesessionvote_set.all().aggregate(Avg('vote'))['vote__avg']
+       if avg is None:
+               avg = 0
+       return HttpResponse("{0:.2f}".format(avg), content_type='text/plain')
+
+@login_required
+@transaction.atomic
+def talkvote_comment(request, confname):
+       conference = get_object_or_404(Conference, urlname=confname)
+       if not conference.talkvoters.filter(pk=request.user.id):
+               return HttpResponse('You are not a talk voter for this conference!')
+       if request.method!='POST':
+               return HttpResponse('Can only use POST')
+
+       session = get_object_or_404(ConferenceSession, conference=conference, id=request.POST['sessionid'])
+       vote,created = ConferenceSessionVote.objects.get_or_create(session=session, voter=request.user)
+       vote.comment = request.POST['comment']
+       vote.save()
+
+       return HttpResponse(vote.comment, content_type='text/plain')
+
 @login_required
 @csrf_exempt
 @transaction.atomic
index 555ec09cd4066f829508feafc4aec36606dbaaed..e03b1c5f28773b74862a31dd4e7629553c10172b 100644 (file)
@@ -77,6 +77,8 @@ urlpatterns = [
        url(r'^events/(?P<urlname>[^/]+)/volunteer/', include(postgresqleu.confreg.volsched)),
        url(r'^events/([^/]+)/talkvote/$', postgresqleu.confreg.views.talkvote),
        url(r'^events/([^/]+)/talkvote/changestatus/$', postgresqleu.confreg.views.talkvote_status),
+       url(r'^events/([^/]+)/talkvote/vote/$', postgresqleu.confreg.views.talkvote_vote),
+       url(r'^events/([^/]+)/talkvote/comment/$', postgresqleu.confreg.views.talkvote_comment),
        url(r'^events/([^/]+)/sessions/$', postgresqleu.confreg.views.sessionlist),
        url(r'^events/speaker/(\d+)/photo/$', postgresqleu.confreg.views.speakerphoto),
        url(r'^events/([^/]+)/speakerprofile/$', postgresqleu.confreg.views.speakerprofile),
index aa32f85262dfe3c4997726afff8f5d21764552f3..f698a9d25529ddd949ddaa976054f3f3e32757a9 100644 (file)
@@ -7,13 +7,13 @@
 
 <script type="text/javascript">
 $(function() {
-  $('.dlg').each(function(idx, el) {
-    $(el).dialog({
-      autoOpen: false,
-      minWidth: 400,
-      minHeight: 250,
-      height: 600,
-    });
+  $('.talkaccd').accordion({
+     'collapsible': true,
+     'active': false,
+     'heightStyle': 'content',
+     'animate': {
+        duration: 100,
+     },
   });
 
   $('#dlgStatus').dialog({
@@ -22,6 +22,18 @@ $(function() {
      resizable: false,
   });
 
+  $('#dlgComment').dialog({
+     autoOpen: false,
+     modal: true,
+     resizable: false,
+     minWidth: 350,
+  });
+  $('#dlgComment').live('keyup', function(e){
+     if (e.keyCode == 13) {
+        $(':button:contains("Save")').click();
+     }
+  });
+
   $('#ajaxStatus').hide();
 });
 
@@ -86,6 +98,66 @@ function changeStatus(id) {
      buttons: buttons,
    }).dialog('open');
 }
+
+function castVote(sessionid) {
+   var s = $('#sv_' + sessionid);
+   var p = s.parent('td');
+   var val = s.val();
+   var avgbox = p.siblings('td.avgbox');
+
+   p.css('background-color', 'gray');
+   $.post('vote/',
+      {
+         'csrfmiddlewaretoken': '{{csrf_token}}',
+         'sessionid': sessionid,
+         'vote': val,
+      },
+      function(data) {
+           p.css('background-color', (val==0)?'red':'white');
+           avgbox.html(data);
+      }
+   ).fail(function() {
+      alert('AJAX call failed');
+   });
+}
+
+function editComment(sessionid) {
+   var old = $('#owncomment_' + sessionid).text();
+   $('#dlgCommentText').val(old);
+
+   $('#dlgComment').dialog('option', {
+      'title': 'Edit comment',
+      'modal': true,
+   }).dialog('option', {
+     buttons: [{
+        id: 'dlgCommentSaveButton',
+        text: 'Save',
+        click: function() {
+           var dlg = $(this);
+           var txt = $('#dlgCommentText').val();
+           $('#dlgCommentSaveButton').button("disable");
+           if (txt != old) {
+              $.post('comment/', {
+                 'csrfmiddlewaretoken': '{{csrf_token}}',
+                 'sessionid': sessionid,
+                 'comment': txt,
+              },
+              function (data) {
+                 dlg.dialog("close");
+                 $('#owncomment_' + sessionid).text(data);
+                 $('#owncommentwrap_' + sessionid).css('display', (data=='')?'none':'block');
+              }
+              ).fail(function() {
+                 alert('AJAX call failed');
+              });
+           }
+           else {
+              dlg.dialog("close");
+           }
+        },
+     }],
+   }).dialog('open');
+}
 </script>
 <style>
 td.dlgClickable {
@@ -106,40 +178,42 @@ width: 100%;
 
 <div id="ajaxStatus" class="alert alert-success" style="position: fixed; width: 100%;opacity: .8; text-align: center; font-weight: bold; font-size: 1.2em;">Nothing Yet</div>
 
-<form method="post" action=".">{% csrf_token %}
 <table class="table table-bordered table-condensed">
  <tr>
-  <th><a href="?sort=session">Session</a> | <a href="?sort=speakers">Speakers</a></th>
+  <th class="col-md-6"><a href="?sort=session">Session</a> | <a href="?sort=speakers">Speakers</a></th>
   <th>Status</th>
   {%for u in users%}
   <th>{{u}}</th>
   {%endfor%}
   <th><a href="?sort=avg">Average</a></th>
-  <th>Your comments</th>
-  <th>Other comments</th>
+  <th>Comments</th>
  </tr>
 {%for s in sessionvotes%}
  <tr>
-   <td onClick="showDialog({{s.id}}, '{{s.title|escape|escapejs|escape}}')" class="dlgClickable">{{s.title}} ({{s.speakers}})
-    <div id="popup_{{s.id}}" class="dlg">
-    <div><strong>Speakers:</strong> {{s.speakers_full}}</div>
-    <div><strong>Track:</strong> {{s.track}}</div>
-    <p>
+   <td>
+     <div class="talkaccd">
+       <h3>{{s.title}} ({{s.speakers}})</h3>
+       <div>
+        <div><strong>Speakers:</strong> {{s.speakers_full}}</div>
+        <div><strong>Track:</strong> {{s.track}}</div>
+        <br/>
+        <p>
 {{s.abstract}}
-    </p>
+        </p>
 {%if s.submissionnote%}
-    <hr/>
-    <h3>Submission notes</h3>
-    <p>
+        <hr/>
+        <h3>Submission notes</h3>
+        <p>
 {{s.submissionnote}}
-    </p>
+        </p>
 {%endif%}
-    <hr/>
-    <h3>Speaker profile</h3>
-    <p>
+        <hr/>
+        <h3>Speaker profile</h3>
+        <p>
 {{s.speakers_long|markdown}}
-    </p>
-    </div>
+        </p>
+       </div>
+     </div>
    </td>
    {%if isadmin%}
    <td{%if not s.statusid == s.laststatusid %} bgcolor="yellow"{%endif%} onClick="changeStatus({{s.id}})" class="dlgClickable" id="statusstr{{s.id}}">{{s.status}}</td>
@@ -148,7 +222,7 @@ width: 100%;
 {%endif%}
    {%for u in s.users%}
    <td{%if u|default_if_none:0 == 0%} style="background-color:red"{%endif%}>{%if forloop.first %}
-     <select name="sv_{{s.id}}">
+     <select id="sv_{{s.id}}" onChange="castVote({{s.id}})">
        {%for opt in "0123456789"|make_list%}
        <option value="{{opt}}"{%if opt|add:0 == u|add:0%} SELECTED{%endif%}>{{opt}}</option>
        {%endfor%}
@@ -158,14 +232,19 @@ width: 100%;
      {%endif%}
    </td>
    {%endfor%}
-   <td>{{s.avg|default_if_none:''}}</td>
-   <td><input type="text" name="tc_{{s.id}}" maxlength="200" value="{{s.owncomment}}"/></td>
-   <td>{{s.comments|safe}}</td>
+   <td class="avgbox">{{s.avg|default_if_none:''}}</td>
+   <td>
+     <div style="margin-right: 0.5em; float:left;">
+       <a class="btn btn-default btn-xs" href="javascript:editComment({{s.id}})"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
+     </div>
+     <div style="display:inline-block;">
+       <span id="owncommentwrap_{{s.id}}" style="display:{%if s.owncomment%}block{%else%}none{%endif%}">{{user.username}}: <span id="owncomment_{{s.id}}">{{s.owncomment}}</span></span>{{s.comments|safe}}
+     </div>
+   </td>
  </tr>
 {%endfor%}
 </table>
-<input type="submit" value="Save">
-</form>
+
 {%if isadmin and conference.pending_session_notifications%}
 <h2>Notifications</h2>
 <p>
@@ -178,4 +257,8 @@ width: 100%;
 <div id="dlgStatus">
 </div>
 
+<div id="dlgComment">
+  <input type="text" id="dlgCommentText" style="width: 300px;">
+</div>
+
 {%endblock%}