Repository: carpentries/amy

Issue:
Allow search for instructors by country
The "Locate Instructors" form should allow selection of a country, and should return a list of all instructors whose nearest airport is in that country.



Discussion:

Resolving patch:
diff --git a/workshops/forms.py b/workshops/forms.py
--- a/workshops/forms.py
+++ b/workshops/forms.py
@@ -1,10 +1,10 @@
 from django import forms
-from django.forms import HiddenInput
-from django.forms import formset_factory
+from django.forms import HiddenInput, CheckboxSelectMultiple
 
 from crispy_forms.helper import FormHelper
-from crispy_forms.layout import Layout, Div, HTML, Submit
+from crispy_forms.layout import Layout, Div, HTML, Submit, Field
 from crispy_forms.bootstrap import FormActions
+from django_countries.fields import CountryField
 from selectable import forms as selectable
 
 from workshops.models import (
@@ -12,7 +12,7 @@
 )
 from workshops import lookups
 
-INSTRUCTOR_SEARCH_LEN = 10   # how many instrutors to return from a search by default
+INSTRUCTORS_NUM = 10  # how many instrutors to return from a search by default
 
 AUTOCOMPLETE_HELP_TEXT = (
     "Autocomplete field; type characters to view available options, "
@@ -58,9 +58,6 @@ def __init__(self, form=None):
 class InstructorsForm(forms.Form):
     '''Represent instructor matching form.'''
 
-    wanted = forms.IntegerField(label='Number Wanted',
-                                initial=INSTRUCTOR_SEARCH_LEN,
-                                min_value=1)
     latitude = forms.FloatField(label='Latitude',
                                 min_value=-90.0,
                                 max_value=90.0,
@@ -78,56 +75,61 @@ class InstructorsForm(forms.Form):
         ),
     )
 
+    country = CountryField().formfield(required=False)
+
+    lessons = forms.ModelMultipleChoiceField(queryset=Lesson.objects.all(),
+                                             widget=CheckboxSelectMultiple(),
+                                             required=False)
+
     def __init__(self, *args, **kwargs):
         '''Build checkboxes for qualifications dynamically.'''
         super(InstructorsForm, self).__init__(*args, **kwargs)
         self.helper = FormHelper(self)
         self.helper.form_class = 'form-inline'
+        self.helper.form_method = 'get'
         self.helper.layout = Layout(
-            'wanted',
             Div(
                 Div(
                     'latitude',
                     'longitude',
-                    css_class='col-sm-6'
+                    css_class='panel-body'
                 ),
+                css_class='panel panel-default ',
+            ),
+            HTML('<p>OR</p>'),
+            Div(
                 Div(
-                    HTML('<br><strong>OR</strong>'),
-                    css_class='col-sm-2',
+                    'airport',
+                    css_class='panel-body'
                 ),
+                css_class='panel panel-default ',
+            ),
+            HTML('<p>OR</p>'),
+            Div(
                 Div(
-                    'airport',
-                    css_class='col-sm-4'
+                    'country',
+                    css_class='panel-body'
                 ),
-                css_class='row panel panel-default panel-body',
+                css_class='panel panel-default ',
             ),
-            HTML('<label class="control-label">Lessons</label>'),
+            'lessons',
             FormActions(
                 Submit('submit', 'Submit'),
             ),
         )
-        lessons = Lesson.objects.all()
-        for les in lessons:
-            self.fields[les.name] = forms.BooleanField(label=les.name, required=False)
-            self.helper.layout.insert(3, les.name)
 
     def clean(self):
         cleaned_data = super(InstructorsForm, self).clean()
         airport = cleaned_data.get('airport')
         lat = cleaned_data.get('latitude')
         long = cleaned_data.get('longitude')
+        country = cleaned_data.get('country')
 
-        if airport is None:
-            if lat is None or long is None:
-                raise forms.ValidationError(
-                    'Must specify either an airport code or latitude/longitude')
-        else:
-            if lat is not None or long is not None:
-                raise forms.ValidationError(
-                    'Cannot specify both an airport code and a '
-                    'latitude/longitude. Pick one or the other')
-            cleaned_data['latitude'] = airport.latitude
-            cleaned_data['longitude'] = airport.longitude
+        sum = bool(airport) + bool(lat and long) + bool(country)
+        # user can specify only one: either airport, or lat&long, or country
+        if sum != 1:
+            raise forms.ValidationError('Must specify an airport, or latitude'
+                                        ' and longitude, or a country.')
         return cleaned_data
 
 
diff --git a/workshops/views.py b/workshops/views.py
--- a/workshops/views.py
+++ b/workshops/views.py
@@ -14,7 +14,8 @@
 from django.conf import settings
 from django.http import Http404, HttpResponse
 from django.db import IntegrityError, transaction
-from django.db.models import Count, Sum, Q, Model, ProtectedError
+from django.db.models import Count, Sum, Q, F, Model, ProtectedError
+from django.db.models import Case, When, Value, IntegerField
 from django.shortcuts import redirect, render, get_object_or_404
 from django.views.generic.base import ContextMixin
 from django.views.generic.edit import CreateView, UpdateView
@@ -42,7 +43,7 @@
     PersonPermissionsForm, bootstrap_helper_filter, PersonMergeForm,
 )
 from workshops.util import (
-    earth_distance, upload_person_task_csv,  verify_upload_person_task,
+    upload_person_task_csv,  verify_upload_person_task,
     create_uploaded_persons_tasks, InternalError, Paginator, merge_persons
 )
 
@@ -883,46 +884,70 @@ def badge_details(request, badge_name):
 @login_required
 def instructors(request):
     '''Search for instructors.'''
+    instructor_badge = Badge.objects.get(name='instructor')
+    instructors = instructor_badge.person_set.filter(airport__isnull=False) \
+                                  .select_related('airport') \
+                                  .prefetch_related('lessons')
+    instructors = instructors.annotate(
+        num_taught=Count(
+            Case(
+                When(
+                    task__role__name='instructor',
+                    then=Value(1)
+                ),
+                output_field=IntegerField()
+            )
+        )
+    )
+    form = InstructorsForm()
 
-    persons = None
+    lessons = list()
 
-    if request.method == 'POST':
-        form = InstructorsForm(request.POST)
+    if 'submit' in request.GET:
+        form = InstructorsForm(request.GET)
         if form.is_valid():
+            data = form.cleaned_data
 
-            # Filter by qualifications.
-            persons = Person.objects.filter(airport__isnull=False)
-            for les in Lesson.objects.all():
-                if form.cleaned_data[les.name]:
-                    persons = persons.filter(qualification__lesson=les)
-
-            # Add metadata which we will eventually filter by
-            for p in persons:
-                p.num_taught = p.task_set.instructors().count()
-
-            # Sort by location.
-            loc = (form.cleaned_data['latitude'],
-                   form.cleaned_data['longitude'])
-            persons = [(earth_distance(loc, (p.airport.latitude, p.airport.longitude)), p)
-                       for p in persons]
-            persons.sort(
-                key=lambda distance_person: (
-                    distance_person[0],
-                    distance_person[1].family,
-                    distance_person[1].personal,
-                    distance_person[1].middle))
-
-            # Return number desired.
-            wanted = form.cleaned_data['wanted']
-            persons = [x[1] for x in persons[:wanted]]
-
-    # if a GET (or any other method) we'll create a blank form
-    else:
-        form = InstructorsForm()
-
-    context = {'title' : 'Find Instructors',
-               'form': form,
-               'persons' : persons}
+            if data['lessons']:
+                lessons = data['lessons']
+                # this has to be in a loop to match a *subset* of lessons,
+                # not any lesson within the list (as it would be with
+                # `.filter(lessons_in=lessons)`)
+                for lesson in lessons:
+                    instructors = instructors.filter(
+                        qualification__lesson=lesson
+                    )
+
+            if data['airport']:
+                x = data['airport'].latitude
+                y = data['airport'].longitude
+                # using Euclidean distance just because it's faster and easier
+                complex_F = ((F('airport__latitude') - x) ** 2
+                             + (F('airport__longitude') - y) ** 2)
+                instructors = instructors.annotate(distance=complex_F) \
+                                         .order_by('distance', 'family')
+
+            if data['latitude'] and data['longitude']:
+                x = data['latitude']
+                y = data['longitude']
+                # using Euclidean distance just because it's faster and easier
+                complex_F = ((F('airport__latitude') - x) ** 2
+                             + (F('airport__longitude') - y) ** 2)
+                instructors = instructors.annotate(distance=complex_F) \
+                                         .order_by('distance', 'family')
+
+            if data['country']:
+                instructors = instructors.filter(
+                    airport__country=data['country']
+                ).order_by('family')
+
+    instructors = _get_pagination_items(request, instructors)
+    context = {
+        'title': 'Find Instructors',
+        'form': form,
+        'persons': instructors,
+        'lessons': lessons,
+    }
     return render(request, 'workshops/instructors.html', context)
 
 #------------------------------------------------------------


Test patch:
diff --git a/workshops/test/test_instructors.py b/workshops/test/test_instructors.py
--- a/workshops/test/test_instructors.py
+++ b/workshops/test/test_instructors.py
@@ -9,74 +9,51 @@ def setUp(self):
         super().setUp()
         self._setUpUsersAndLogin()
 
-    def test_search_for_one_instructor_by_airport(self):
-        response = self.client.post(reverse('instructors'),
-                                    {'airport' : self.airport_0_0.id,
-                                     'wanted' : 1})
-        assert response.status_code == 200
-        content = response.content.decode('utf-8')
-        assert "Hermione Granger" in content
-        assert "Harry Potter" not in content
-        assert "Ron Weasley" not in content
-
-    def test_search_for_two_instructors_by_airport(self):
-        response = self.client.post(reverse('instructors'),
-                                    {'airport' : self.airport_0_0.id,
-                                     'wanted' : 2})
+    def test_non_instructors_not_returned_by_search(self):
+        response = self.client.get(
+            reverse('instructors'),
+            {'airport_1': self.airport_0_0.pk, 'submit': 'Submit'}
+        )
         assert response.status_code == 200
         content = response.content.decode('utf-8')
         assert "Hermione Granger" in content
         assert "Harry Potter" in content
-        assert "Ron Weasley" not in content
-
-    def test_search_for_one_instructor_near_airport(self):
-        response = self.client.post(reverse('instructors'),
-                                    {'airport' : self.airport_55_105.id,
-                                     'wanted' : 1})
-        assert response.status_code == 200
-        content = response.content.decode('utf-8')
-        assert "Hermione Granger" not in content
-        assert "Harry Potter" not in content
         assert "Ron Weasley" in content
 
-    def test_search_for_one_instructor_near_latlong(self):
-        response = self.client.post(reverse('instructors'),
-                                    {'latitude' : 5,
-                                     'longitude' : 45,
-                                     'wanted' : 1})
-        assert response.status_code == 200
-        content = response.content.decode('utf-8')
-        assert "Hermione Granger" not in content
-        assert "Harry Potter" in content
-        assert "Ron Weasley" not in content
-
-    def test_non_instructors_not_returned_by_search(self):
-        response = self.client.post(reverse('instructors'),
-                                    {'airport' : self.airport_0_0.id,
-                                     'wanted' : 1000})
+    def test_match_instructors_on_one_skill(self):
+        response = self.client.get(
+            reverse('instructors'),
+            {'airport_1': self.airport_50_100.pk, 'lessons': [self.git.pk],
+             'submit': 'Submit'}
+        )
         assert response.status_code == 200
         content = response.content.decode('utf-8')
         assert "Hermione Granger" in content
-        assert "Harry Potter" in content
+        assert "Harry Potter" not in content
         assert "Ron Weasley" in content
 
-    def test_match_instructors_on_one_skill(self):
-        response = self.client.post(reverse('instructors'),
-                                    {'airport' : self.airport_50_100.id,
-                                     'swc/git' : 'on',
-                                     'wanted' : 1000})
+    def test_match_instructors_on_two_skills(self):
+        response = self.client.get(
+            reverse('instructors'),
+            {'airport_1': self.airport_50_100.pk,
+             'lessons': [self.git.pk, self.sql.pk],
+             'submit': 'Submit'}
+        )
         assert response.status_code == 200
         content = response.content.decode('utf-8')
         assert "Hermione Granger" in content
         assert "Harry Potter" not in content
-        assert "Ron Weasley" in content
+        assert "Ron Weasley" not in content
 
-    def test_match_instructors_on_two_skills(self):
-        response = self.client.post(reverse('instructors'),
-                                    {'airport' : self.airport_50_100.id,
-                                     'swc/git' : 'on',
-                                     'dc/sql' : 'on',
-                                     'wanted' : 1000})
+    def test_match_by_country(self):
+        # set up country for an airport, 'cause it doesn't have one
+        self.airport_0_0.country = 'GB'
+        self.airport_0_0.save()
+
+        response = self.client.get(
+            reverse('instructors'),
+            {'country': 'GB', 'submit': 'Submit'}
+        )
         assert response.status_code == 200
         content = response.content.decode('utf-8')
         assert "Hermione Granger" in content