Make selectize fields in backend load entries dynamically
authorMagnus Hagander <magnus@hagander.net>
Fri, 29 Jun 2018 10:45:26 +0000 (12:45 +0200)
committerMagnus Hagander <magnus@hagander.net>
Fri, 29 Jun 2018 10:45:26 +0000 (12:45 +0200)
Poplating three dropdowns with every single user on the system becomes a
bit inefficient (to the point that the form in production started
approaching 1Mb in size and took a lot of time to load).

So instead of doing that, make the selectize fields load their contents
dynamically through an Ajax call. To do this, implement three lookups
for accounts, registrations and speakers.

postgresqleu/confreg/backendforms.py
postgresqleu/confreg/backendlookups.py [new file with mode: 0644]
postgresqleu/urls.py
template/confreg/admin_backend_form.html

index f1b1c439440d8c18858e1356f06be83c2d5b0ab5..7588ebbee961578e306ef636c6f8afc33aaef8c7 100644 (file)
@@ -15,8 +15,6 @@ from postgresqleu.util.admin import SelectableWidgetAdminFormMixin
 from postgresqleu.util.forms import ConcurrentProtectedModelForm
 from postgresqleu.util.random import generate_random_token
 
-from postgresqleu.accountinfo.lookups import UserLookup
-from postgresqleu.confreg.lookups import RegistrationLookup
 import postgresqleu.accounting.models
 
 from postgresqleu.confreg.models import Conference, ConferenceRegistration, ConferenceAdditionalOption
@@ -29,6 +27,8 @@ from postgresqleu.confreg.models import ConferenceSeries
 
 from postgresqleu.confreg.models import valid_status_transitions, get_status_string
 
+from backendlookups import GeneralAccountLookup, RegisteredUsersLookup, SpeakerLookup
+
 class BackendDateInput(TextInput):
        def __init__(self, *args, **kwargs):
                kwargs.update({'attrs': {'type': 'date', 'required-pattern': '[0-9]{4}-[0-9]{2}-[0-9]{2}'}})
@@ -71,6 +71,7 @@ class BackendForm(ConcurrentProtectedModelForm):
 
 
                self.fix_fields()
+               self.fix_selectize_fields(**kwargs)
 
                for k,v in self.fields.items():
                        # Adjust widgets
@@ -96,6 +97,18 @@ class BackendForm(ConcurrentProtectedModelForm):
                for field in self.readonly_fields:
                        self.fields[field].widget.attrs['readonly'] = 'true'
 
+       def fix_selectize_fields(self, **kwargs):
+               for field, lookup in self.selectize_multiple_fields.items():
+                       # If this is a postback of a selectize field, it may contain ids that are not currently
+                       # stored in the field. They must still be among the *allowed* values of course, which
+                       # are handled by the existing queryset on the field.
+                       vals = [o.pk for o in getattr(self.instance, field).all()]
+                       if 'data' in kwargs and unicode(field) in kwargs['data']:
+                               vals.extend([int(x) for x in kwargs['data'].getlist(field)])
+                       self.fields[field].widget.attrs['data-selecturl'] = lookup.url
+                       self.fields[field].queryset = self.fields[field].queryset.filter(pk__in=set(vals))
+                       self.fields[field].label_from_instance = lookup.label_from_instance
+
        def fix_fields(self):
                pass
 
@@ -126,15 +139,15 @@ class BackendConferenceForm(BackendForm):
                                  'asktshirt', 'askfood', 'asknick', 'asktwitter', 'askshareemail', 'askphotoconsent',
                                  'skill_levels', 'additionalintro', 'callforpapersintro', 'sendwelcomemail', 'welcomemail',
                                  'invoice_autocancel_hours', 'attendees_before_waitlist']
-       selectize_multiple_fields = ['testers', 'talkvoters', 'staff', 'volunteers']
-
+       selectize_multiple_fields = {
+               'testers': GeneralAccountLookup(),
+               'talkvoters': GeneralAccountLookup(),
+               'staff': GeneralAccountLookup(),
+               'volunteers': RegisteredUsersLookup(None),
+       }
 
        def fix_fields(self):
-               self.fields['testers'].label_from_instance = lambda x: u'{0} {1} ({2})'.format(x.first_name, x.last_name, x.username)
-               self.fields['talkvoters'].label_from_instance = lambda x: u'{0} {1} ({2})'.format(x.first_name, x.last_name, x.username)
-               self.fields['staff'].label_from_instance = lambda x: u'{0} {1} ({2})'.format(x.first_name, x.last_name, x.username)
-               self.fields['volunteers'].label_from_instance = lambda x: u'{0} <{1}>'.format(x.fullname, x.email)
-               self.fields['volunteers'].queryset = ConferenceRegistration.objects.filter(conference=self.conference)
+               self.selectize_multiple_fields['volunteers'] = RegisteredUsersLookup(self.conference)
 
 
 class BackendSuperConferenceForm(BackendForm):
@@ -143,12 +156,13 @@ class BackendSuperConferenceForm(BackendForm):
                fields = ['conferencename', 'urlname', 'series', 'startdate', 'enddate', 'location',
                                  'timediff', 'contactaddr', 'sponsoraddr', 'confurl', 'administrators',
                                  'jinjadir', 'accounting_object', 'vat_registrations', 'vat_sponsorship', ]
-       selectize_multiple_fields = ['administrators', ]
+       selectize_multiple_fields = {
+               'administrators': GeneralAccountLookup(),
+       }
        accounting_object = django.forms.ChoiceField(choices=[], required=False)
        exclude_date_validators = ['startdate', 'enddate']
 
        def fix_fields(self):
-               self.fields['administrators'].label_from_instance = lambda x: u'{0} {1} ({2})'.format(x.first_name, x.last_name, x.username)
                self.fields['accounting_object'].choices = [('', '----'),] + [(o.name, o.name) for o in postgresqleu.accounting.models.Object.objects.filter(active=True)]
                if not self.instance.id:
                        del self.fields['accounting_object']
@@ -344,7 +358,9 @@ class BackendConferenceSessionForm(BackendForm):
                'speaker_list': 'Speakers',
                'status_string': 'Status',
        }
-       selectize_multiple_fields = ['speaker']
+       selectize_multiple_fields = {
+               'speaker': SpeakerLookup(),
+       }
        allow_copy_previous = True
        copy_transform_form = BackendTransformConferenceDateTimeForm
        auto_cascade_delete_to = ['conferencesession_speaker', ]
diff --git a/postgresqleu/confreg/backendlookups.py b/postgresqleu/confreg/backendlookups.py
new file mode 100644 (file)
index 0000000..60d9448
--- /dev/null
@@ -0,0 +1,89 @@
+from django.http import HttpResponse, HttpResponseForbidden
+from django.core.exceptions import PermissionDenied
+from django.contrib.auth.models import User
+from django.db.models import Q
+
+import backendviews
+from models import Conference, ConferenceRegistration, Speaker
+
+import datetime
+import json
+
+class LookupBase(object):
+       def __init__(self, conference=None):
+               self.conference = conference
+
+       @classmethod
+       def validate_global_access(self, request):
+               # User must be admin of some conference in the past 3 months (just to add some overlap)
+               # or at some point in the future.
+               if not (request.user.is_superuser or
+                               Conference.objects.filter(administrators=request.user,
+                                                                                 startdate__gt=datetime.datetime.now()-datetime.timedelta(days=90))):
+                       raise PermissionDenied("Access denied.")
+
+       @classmethod
+       def lookup(self, request, urlname=None):
+               if urlname is None:
+                       self.validate_global_access(request)
+                       vals = self.get_values(request.GET['query'])
+               else:
+                       conference = backendviews.get_authenticated_conference(request, urlname)
+                       vals = self.get_values(request.GET['query'], conference)
+
+               return HttpResponse(json.dumps({
+                       'values': vals,
+               }), content_type='application/json')
+
+class GeneralAccountLookup(LookupBase):
+       @property
+       def url(self):
+               return '/events/admin/lookups/accounts/'
+
+       @property
+       def label_from_instance(self):
+               return lambda x: u'{0} {1} ({2})'.format(x.first_name, x.last_name, x.username)
+
+
+       @classmethod
+       def get_values(self, query):
+               return [{'id': u.id, 'value': u'{0} {1} ({2})'.format(u.first_name, u.last_name, u.username)}
+                               for u in User.objects.filter(
+                                               Q(username__icontains=query) | Q(first_name__icontains=query) | Q(last_name__icontains=query)
+                               )[:30]]
+
+
+class RegisteredUsersLookup(LookupBase):
+       @property
+       def url(self):
+               return '/events/admin/{0}/lookups/regs/'.format(self.conference.urlname)
+
+       @property
+       def label_from_instance(self):
+               return lambda x: u'{0} <{1}>'.format(x.fullname, x.email)
+
+       @classmethod
+       def get_values(self, query, conference):
+               return [{'id': r.id, 'value': r.fullname}
+                               for r in ConferenceRegistration.objects.filter(
+                                               conference=conference,
+                                               payconfirmedat__isnull=False).filter(
+                                                       Q(firstname__icontains=query) | Q(lastname__icontains=query) | Q(email__icontains=query)
+                                               )[:30]]
+
+
+class SpeakerLookup(LookupBase):
+       @property
+       def url(self):
+               return '/events/admin/lookups/speakers/'
+
+       @property
+       def label_from_instance(self):
+               return lambda x: unicode(x)
+
+       @classmethod
+       def get_values(self, query):
+               return [{'id': s.id, 'value': unicode(s)}
+                               for s in Speaker.objects.filter(
+                                               Q(fullname__icontains=query) | Q(twittername__icontains=query) | Q(user__username__icontains=query)
+                               )[:30]]
index 538b5c9f4805230d596aefce1029d7c394c53976..20eb39ba35606bc501709bab49b9d68d650ab7e2 100644 (file)
@@ -8,6 +8,7 @@ import postgresqleu.newsevents.views
 import postgresqleu.views
 import postgresqleu.confreg.views
 import postgresqleu.confreg.backendviews
+import postgresqleu.confreg.backendlookups
 import postgresqleu.confreg.reporting
 import postgresqleu.confreg.mobileviews
 import postgresqleu.confreg.feedback
@@ -115,9 +116,12 @@ urlpatterns = [
        url(r'^events/admin/([^/]+)/reports/schedule/$', postgresqleu.confreg.pdfschedule.pdfschedule),
        url(r'^events/admin/newconference/$', postgresqleu.confreg.backendviews.new_conference),
        url(r'^events/admin/meta/series/(.*/)?$', postgresqleu.confreg.backendviews.edit_series),
+       url(r'^events/admin/lookups/accounts/$', postgresqleu.confreg.backendlookups.GeneralAccountLookup.lookup),
+       url(r'^events/admin/lookups/speakers/$', postgresqleu.confreg.backendlookups.SpeakerLookup.lookup),
        url(r'^events/admin/(\w+)/$', postgresqleu.confreg.views.admin_dashboard_single),
        url(r'^events/admin/(\w+)/edit/$', postgresqleu.confreg.backendviews.edit_conference),
        url(r'^events/admin/(\w+)/superedit/$', postgresqleu.confreg.backendviews.superedit_conference),
+       url(r'^events/admin/(\w+)/lookups/regs/$', postgresqleu.confreg.backendlookups.RegisteredUsersLookup.lookup),
        url(r'^events/admin/(\w+)/mail/$', postgresqleu.confreg.views.admin_attendeemail),
        url(r'^events/admin/(\w+)/mail/(\d+)/$', postgresqleu.confreg.views.admin_attendeemail_view),
        url(r'^events/admin/(\w+)/regdashboard/$', postgresqleu.confreg.views.admin_registration_dashboard),
index f972a6ad5ceb604c69a9786828e37afbe4782129..8946b0785e39805bf2844f6aa9f6ac8537048d4e 100644 (file)
@@ -8,8 +8,26 @@
 
 <script language="javascript">
 $(function() {
-{%for f in form.selectize_multiple_fields%}
-   $('#id_{{f}}').selectize({plugins: ['remove_button']});
+{%for f,lookup in form.selectize_multiple_fields.items%}
+   $('#id_{{f}}').selectize({
+      plugins: ['remove_button'],
+      valueField: 'id',
+      labelField: 'value',
+      searchField: 'value',
+      load: function(query, callback) {
+         if (!query.length) return callback();
+         $.ajax({
+            'url': '{{lookup.url}}',
+            'type': 'GET',
+            'dataType': 'json',
+            'data': {
+               'query': query,
+            },
+            'error': function() { callback();},
+            'success': function(res) { callback(res.values);},
+         });
+      }
+   });
 {%endfor%}
 
 {%for f in form.json_fields%}