Reimplement multiple speaker handling in call for papers
authorMagnus Hagander <magnus@hagander.net>
Sun, 18 Nov 2018 16:56:27 +0000 (17:56 +0100)
committerMagnus Hagander <magnus@hagander.net>
Sun, 18 Nov 2018 16:56:27 +0000 (17:56 +0100)
Instead of the awkward way with a subform, we now use selectize to
populate a "dynamic" list of speakers. This is the same technology we've
arleady used in the backend for some time with good results.

This adds a dependency on jquery to the conference templates. If a
conference template does not include jquery, multiple speakers will not
work (but the rest of the form will keep working fine). This should be
included before the pagescript block in the template to make sure it's
available when the page runs.

postgresqleu/confreg/README
postgresqleu/confreg/forms.py
postgresqleu/confreg/views.py
postgresqleu/urls.py
template.jinja/confreg/callforpapersform.html

index 9728b94c98aa74e8af24e1cbef935760001972fe..cf6005c0240e07a68f689ea9e393257fe601d112 100644 (file)
@@ -41,8 +41,11 @@ subdirectory.
 Base template
 -------------
 
-In the base template, the entire HTML structure should be defined, and
-also the blocks:
+In the base template, the entire HTML structure should be defined. The
+skin can do anything it wants with HTML, css and javascript. Note that
+some pages rely on JQuery being present, so it should always be included.
+
+The following blocks should be defined in the base template:
 
 title
   This block gets the page title. Normally just defined as the
@@ -50,7 +53,11 @@ title
   title, such as the name of the conference.
 extrahead
   This block must be declared inside <head>, so the other templates
-  can insert javascript and css. By default it should be empty.
+  can insert e.g. css. By default it should be empty.
+pagescript
+  This block must be declared either inside the <head> or the <body>,
+  but must be *after* jquery is loaded. This is where the pages will
+  insert local javascript. By default it should be empty.
 content
   This block gets the main content of the page. It should be declared
   inside whatever container elements are used. By default it should be
index b62155f16a3fe1a97d8654e0229d97263f9bf7c7..e125be7d5fc8ef8bf533b5ac9f8d0d9ba36883ce 100644 (file)
@@ -434,26 +434,37 @@ class SpeakerProfileForm(forms.ModelForm):
                return self.cleaned_data['fullname']
 
 
-class CallForPapersSpeakerForm(forms.Form):
-       email = forms.EmailField()
-
-       def clean_email(self):
-               if not Speaker.objects.filter(user__email=self.cleaned_data['email']).exists():
-                       raise ValidationError("No speaker profile for user with email %s exists." % self.cleaned_data['email'])
-               return self.cleaned_data['email']
-
-class CallForPapersSubmissionForm(forms.Form):
-       title = forms.CharField(required=True, max_length=200, min_length=10)
-
 class CallForPapersForm(forms.ModelForm):
        class Meta:
                model = ConferenceSession
-               fields = ('title', 'abstract', 'skill_level', 'track', 'submissionnote',)
+               fields = ('title', 'abstract', 'skill_level', 'track', 'speaker', 'submissionnote')
+
+       def __init__(self, currentspeaker, *args, **kwargs):
+               self.currentspeaker = currentspeaker
 
-       def __init__(self, *args, **kwargs):
                super(CallForPapersForm, self).__init__(*args, **kwargs)
+
+               # Extra speakers should at this point only contain the ones that are already
+               # there. More are added by the javascript code, but we don't want to populate
+               # with a list of everything (too easy to scrape as well).
+               if self.instance.id:
+                       vals = [s.pk for s in self.instance.speaker.all()]
+               else:
+                       vals = [s.pk for s in self.initial['speaker']]
+               # We may also have received a POST that contains new speakers not already on this
+               # record. In this case, we have to add them as possible values, so the validation
+               # doesn't fail.
+               if 'data' in kwargs and u'speaker' in kwargs['data']:
+                       vals.extend([int(x) for x in kwargs['data'].getlist('speaker')])
+
+               self.fields['speaker'].queryset = Speaker.objects.filter(pk__in=vals)
+               self.fields['speaker'].label_from_instance = lambda x: u"{0} <{1}>".format(x.fullname, x.email)
+               self.fields['speaker'].required = True
+               self.fields['speaker'].help_text = "Type the beginning of a speakers email address to add more speakers"
+
                if not self.instance.conference.skill_levels:
                        del self.fields['skill_level']
+
                if not self.instance.conference.track_set.filter(incfp=True).count() > 0:
                        del self.fields['track']
                else:
@@ -470,6 +481,12 @@ class CallForPapersForm(forms.ModelForm):
                        raise ValidationError("Please choose the track that is the closest match to your talk")
                return self.cleaned_data.get('track')
 
+       def clean_speaker(self):
+               if not self.currentspeaker in self.cleaned_data.get('speaker'):
+                       raise ValidationError("You cannot remove yourself as a speaker!")
+               return self.cleaned_data.get('speaker')
+
+
 class SessionCopyField(forms.ModelMultipleChoiceField):
        def label_from_instance(self, obj):
                return u"{0}: {1} ({2})".format(obj.conference, obj.title, obj.status_string)
index 3065d60add4740b18f0ab4e8da0165e7c6b137ec..4f88b850f7b8e97c0bbdfd873076ea5aff8aab31 100644 (file)
@@ -26,7 +26,7 @@ from models import STATUS_CHOICES
 from models import ConferenceNews
 from forms import ConferenceRegistrationForm, RegistrationChangeForm, ConferenceSessionFeedbackForm
 from forms import ConferenceFeedbackForm, SpeakerProfileForm
-from forms import CallForPapersForm, CallForPapersSpeakerForm
+from forms import CallForPapersForm
 from forms import CallForPapersCopyForm, PrepaidCreateForm, BulkRegistrationForm
 from forms import EmailSendForm, EmailSessionForm, CrossConferenceMailForm
 from forms import AttendeeMailForm, WaitlistOfferForm, WaitlistSendmailForm, TransferRegForm
@@ -1296,53 +1296,50 @@ def callforpapers_edit(request, confname, sessionid):
                        'slides': ConferenceSessionSlides.objects.filter(session=session),
                        })
 
-       SpeakerFormset = formsets.formset_factory(CallForPapersSpeakerForm, can_delete=True, extra=1)
-
-       if sessionid != 'new':
-               # Get all additional speakers (that means all speakers who isn't the current one)
-               speaker_initialdata = [{'email': s.user.email} for s in session.speaker.exclude(user=request.user)]
+       if session.id:
+               initial = {}
        else:
-               speaker_initialdata = None
+               initial = {
+                       'speaker': [speaker, ],
+               }
 
        if request.method == 'POST':
                # Save it!
-               form = CallForPapersForm(data=request.POST, instance=session)
-               speaker_formset = SpeakerFormset(data=request.POST, initial=speaker_initialdata, prefix="extra_speakers")
-               if form.is_valid() and speaker_formset.is_valid():
+               form = CallForPapersForm(speaker, data=request.POST, instance=session, initial=initial)
+               if form.is_valid():
                        form.save()
-                       # Explicitly add the submitter as a speaker
-                       session.speaker.add(speaker)
-
-                       if speaker_formset.has_changed():
-                               # Additional speaker either added or removed
-                               for f in speaker_formset:
-                                       # There is at least one empty form at the end, so skip it
-                                       if not getattr(f, 'cleaned_data', False): continue
 
-                                       # Somehow we can end up with an unspecified email. Not sure how it can happen,
-                                       # since the field is mandatory, but if it does just ignore it.
-                                       if not 'email' in f.cleaned_data: continue
-
-                                       # Speaker always exist, since the form has validated
-                                       spk = Speaker.objects.get(user__email=f.cleaned_data['email'])
-
-                                       if f.cleaned_data['DELETE']:
-                                               session.speaker.remove(spk)
-                                       else:
-                                               session.speaker.add(spk)
                        messages.info(request, "Your session '%s' has been saved!" % session.title)
                        return HttpResponseRedirect("../")
        else:
                # GET --> render empty form
-               form = CallForPapersForm(instance=session)
-               speaker_formset = SpeakerFormset(initial=speaker_initialdata, prefix="extra_speakers")
+               form = CallForPapersForm(speaker, instance=session, initial=initial)
 
        return render_conference_response(request, conference, 'cfp', 'confreg/callforpapersform.html', {
                        'form': form,
-                       'speaker_formset': speaker_formset,
                        'session': session,
        })
 
+@login_required
+def public_speaker_lookup(request, confname):
+       conference = get_object_or_404(Conference, urlname=confname)
+       speaker = get_object_or_404(Speaker, user=request.user)
+
+       # This is a lookup for speakers that's public. To avoid harvesting, we allow
+       # only *prefix* matching of email addresses, and you have to type at least 6 characters
+       # before you get anything.
+       prefix = request.GET['query']
+       if len(prefix) > 5:
+               vals = [{
+                       'id': s.id,
+                       'value': u"{0} <{1}>".format(s.fullname, s.email),
+               } for s in Speaker.objects.filter(user__email__startswith=prefix).exclude(fullname='')]
+       else:
+               vals = []
+       return HttpResponse(json.dumps({
+               'values': vals,
+       }), content_type='application/json')
+
 @login_required
 @transaction.atomic
 def callforpapers_copy(request, confname):
index 3de4a75b0c9a83da7816d61329ea9d65ed1603a6..c2ece4745a8d42e5f105e9f78bac8719338bb826 100644 (file)
@@ -88,6 +88,7 @@ urlpatterns = [
        url(r'^events/([^/]+)/callforpapers/copy/$', postgresqleu.confreg.views.callforpapers_copy),
        url(r'^events/([^/]+)/callforpapers/(\d+)/delslides/(\d+)/$', postgresqleu.confreg.views.callforpapers_delslides),
        url(r'^events/([^/]+)/callforpapers/(\d+)/speakerconfirm/$', postgresqleu.confreg.views.callforpapers_confirm),
+       url(r'^events/([^/]+)/callforpapers/lookups/speakers/$', postgresqleu.confreg.views.public_speaker_lookup),
        url(r'^events/([^/]+)/register/confirm/$', postgresqleu.confreg.views.confirmreg),
        url(r'^events/([^/]+)/register/waitlist_signup/$', postgresqleu.confreg.views.waitlist_signup),
        url(r'^events/([^/]+)/register/waitlist_cancel/$', postgresqleu.confreg.views.waitlist_cancel),
index 88d80390ecf86dab2077ac70bee62b4e06af5a13..5496fddc13645102bc54ad0a379d7b15e70b862d 100644 (file)
@@ -1,5 +1,50 @@
 {%extends "base.html" %}
 {%block title%}Call for Papers - {{conference}}{%endblock%}
+{%block pagescript%}
+<script type="text/javascript" src="/media/js/selectize.min.js"></script>
+<link rel="stylesheet" href="/media/css/selectize.css" />
+<link rel="stylesheet" href="/media/css/selectize.default.css" />
+
+<script language="javascript">
+$(function() {
+  /* Re-enable the speaker field, and turn it into selectize */
+  $('tr#tr_speaker').css({'display': 'table-row'});
+  $('#id_speaker').selectize({
+    plugins: ['remove_button'],
+    valueField: 'id',
+    labelField: 'value',
+    searchField: 'value',
+    load: function(query, callback) {
+       if (!query.length) return callback();
+       $.ajax({
+         'url': '/events/{{conference.urlname}}/callforpapers/lookups/speakers/',
+         'type': 'GET',
+         'dataType': 'json',
+         'data': {
+           'query': query,
+         },
+         'error': function() { callback(); },
+         'success': function(res) { callback(res.values);},
+       });
+    },
+  });
+});
+</script>
+{%endblock%}
+
+{%block extrahead%}
+<style>
+tr.err {
+   background-color: #ffb6b6;
+}
+
+/* Hide the speaker field for non-javascript sessions */
+tr#tr_speaker {
+   display:none;
+}
+</style>
+{%endblock%}
+
 {%block content%}
 <h1>Call for Papers - {{conference}}</h1>
 
@@ -20,43 +65,12 @@ Please complete the following fields. You may use markdown in the abstract.
 {% endif %}
 <form class="pgeucfpform" method="post" action=".">{{ csrf_input }}
 <table id="cfp_table">
-{{form.as_table()}}
-<tr>
-  <th><label for="id_not_real">Additional speakers:</label></th>
-  <td>
-    <p>
-    If there will be more than one speaker of this session, please add them below by
-    entering their email addresses. Note that all speakers must set up their
-    own speaker profile before they can be added.
-    </p>
-    {{ speaker_formset.non_form_errors().as_ul() }}
-    {{ speaker_formset.management_form }}
-    <table id="cfp_form_speakers_list">
-      {%for f in speaker_formset.forms%}
-      {%if loop.first%}
-      <tr>
-       {%for field in f.visible_fields()%}
-       <th>{{field.label}}</th>
-       {%endfor%}
-      </tr>
-      {%endif%}
-      <tr>
-       {%for field in f.visible_fields()%}
-       <td>
-         {%if loop.first%}
-          {%for hidden in form.hidden_fields()%}
-          {{hidden}}
-          {%endfor%}
-         {%endif%}
-         {{field.errors.as_ul()}}
-         {{field}}
-       </td>
-       {%endfor%}
-      </tr>
-      {%endfor%}
-    </table>
-  </td>
-</tr>
+{%for f in form%}
+ <tr{%if f.errors%} class="err"{%endif%} id="tr_{{f.name}}">
+  <th>{{f.label_tag()}}</th>
+  <td>{{f}}{%if f.errors%}{{f.errors}}{%endif%}</td>
+ </tr>
+{%endfor%}
 </table>
 <input type="submit" value="Save">
 <input class="button" type="button" onclick="window.location.href = '../'" value="Cancel" />