Repository: carpentries/amy

Issue:
More ways to get to "issue badge"
1. When I click on `[+]` in the "Progress" column and am taken to the "New training progress" page, one of the options there should be "issue badge".  I realize this isn't training progress, but it's a natural time and place to issue the badge.
2. Similarly, when I'm on the "Trainees" page, if I see someone is eligible but doesn't yet have a badge (i.e., they have a green box in the "Eligible?" column), I should be able to click on that and be taken to the "issue a badge" page.



Discussion:
> 1. When I click on [+] in the "Progress" column and am taken to the "New training progress" page, one of the options there should be "issue badge". I realize this isn't training progress, but it's a natural time and place to issue the badge.

Is it enough to add at "new training progress" page a link redirecting to person edit view?

I like the automatic redirect back to the training progress page... If 
it's a pain to add "issue badge" to the "add progress" page, then sure.

You can be redirected back to the training progress page from person edit view if you went there from "add progress" page.

Works for me - thanks.

Here is PR #1018. This PR solves only the second issue. I have problems with adding a button in "new training progress" page. @pbanaszkiewicz, do you have any idea how I can do that? 

@gvwilson, questions:
1. When a person is eligible, you can click the `SWC` or `DC` label in `eligible` column to be moved to "issue a badge" form. Do you want the label to be clickable in the case when the person is not eligible (that is, when the label is grey)? This may be useful when we want to issue instructor badge in special cases when we don't require the person to go through standard checkout procedure.
2. In the PR, when you click one of the labels, you are moved to the "issue a badge" form with prefilled `badge` and `awarded` fields. Do you want the same for `event` (if the person took part in exactly one training) or/and `awarded by` fields? 

1. Yes, it should be clickable even if the person doesn't yet have the 
   official requirements - good thinking.
2. Yes for `event`, but I don't think we can automatically fill the 
   `awarded by` field - it could be the person currently logged in, or the 
   person who did the last part of their homework, or...


Resolving patch:
diff --git a/workshops/forms.py b/workshops/forms.py
--- a/workshops/forms.py
+++ b/workshops/forms.py
@@ -44,7 +44,8 @@ def __init__(self,
                  additional_form_class='',
                  form_tag=True,
                  display_labels=True,
-                 form_action=None):
+                 form_action=None,
+                 form_id=None):
         """
         `duplicate_buttons_on_top` -- Whether submit buttons should be
         displayed on both top and bottom of the form.
@@ -114,11 +115,16 @@ def __init__(self,
         if form_action is not None:
             self.form_action = form_action
 
+        if form_id is not None:
+            self.form_id = form_id
+
+
 
 class BootstrapHelperFilter(FormHelper):
     """A differently shaped forms (more space-efficient) for use in sidebar as
     filter forms."""
     form_method = 'get'
+    form_id = 'filter-form'
 
     def __init__(self, form=None):
         super().__init__(form)
@@ -458,7 +464,7 @@ class PersonForm(forms.ModelForm):
         widget=selectable.AutoComboboxSelectMultipleWidget,
     )
 
-    helper = bootstrap_helper
+    helper = BootstrapHelper(form_id='person-edit-form')
 
     class Meta:
         model = Person
@@ -641,7 +647,7 @@ class PersonAwardForm(forms.ModelForm):
         widget=selectable.AutoComboboxSelectWidget,
     )
 
-    helper = BootstrapHelper(submit_label='Add')
+    helper = BootstrapHelper(submit_label='Add', form_id='person-awards-form')
 
     class Meta:
         model = Award
@@ -658,7 +664,7 @@ class PersonTaskForm(forms.ModelForm):
         widget=selectable.AutoComboboxSelectWidget,
     )
 
-    helper = BootstrapHelper(submit_label='Add')
+    helper = BootstrapHelper(submit_label='Add', form_id='person-tasks-form')
 
     class Meta:
         model = Task
@@ -1333,9 +1339,6 @@ class BulkDiscardProgressesForm(forms.Form):
              'Mail selected trainees</a>'),
     )
 
-    class Meta:
-        model = TrainingProgress
-
 
 class BulkChangeTrainingRequestForm(forms.Form):
     """Form used to bulk discard training requests or bulk unmatch trainees
diff --git a/workshops/util.py b/workshops/util.py
--- a/workshops/util.py
+++ b/workshops/util.py
@@ -23,7 +23,8 @@
 from django.http import Http404
 from django.http.response import HttpResponse
 from django.http.response import HttpResponseForbidden
-from django.shortcuts import render
+from django.shortcuts import render, redirect
+from django.utils.http import is_safe_url
 from selectable.decorators import results_decorator
 
 from workshops.models import Event, Role, Person, Task, Badge, is_admin
@@ -946,3 +947,20 @@ def homework2state(homework):
     else:  # failed homework
         assert homework.state == 'f'
         return 'none'
+
+
+def redirect_with_next_support(request, *args, **kwargs):
+    """Works in the same way as `redirect` except when there is GET parameter
+    named "next". In that case, user is redirected to the URL from that
+    parameter. If you have a class-based view, use RedirectSupportMixin that
+    does the same. """
+
+    next_url = request.GET.get('next', None)
+    if next_url is not None and is_safe_url(next_url):
+        return redirect(next_url)
+    else:
+        return redirect(*args, **kwargs)
+
+
+def dict_without_Nones(**keys):
+    return {k: v for k, v in keys.items() if v is not None}
diff --git a/workshops/views.py b/workshops/views.py
--- a/workshops/views.py
+++ b/workshops/views.py
@@ -143,6 +143,8 @@
     login_not_required,
     LoginNotRequiredMixin,
     homework2state,
+    redirect_with_next_support,
+    dict_without_Nones,
 )
 
 
@@ -862,103 +864,119 @@ def form_valid(self, form):
                      raise_exception=True)
 def person_edit(request, person_id):
     person = get_object_or_404(Person, id=person_id)
-    awards = person.award_set.order_by('badge__name')
-    tasks = person.task_set.order_by('-event__slug')
 
     person_form = PersonForm(prefix='person', instance=person)
-    award_form = PersonAwardForm(prefix='award', initial={
-        'awarded': datetime.date.today(),
-        'person': person,
-    })
     task_form = PersonTaskForm(prefix='task', initial={'person': person})
 
-    if request.method == 'POST':
-        # check which form was submitted
-        if 'award-badge' in request.POST:
-            award_form = PersonAwardForm(request.POST, prefix='award')
+    # Determine initial badge in PersonAwardForm
+    try:
+        badge = Badge.objects.get(name=request.GET['badge'])
+    except (KeyError, Badge.DoesNotExist):
+        badge = None
+
+    # Determine initial event in PersonAwardForm
+    if 'find-training' in request.GET:
+        tasks = person.get_training_tasks()
+        if tasks.count() == 1:
+            event = tasks[0].event
+        else:
+            event = None
+    else:
+        event = None
+
+    # PersonAwardForm
+    award_form = PersonAwardForm(prefix='award', initial=dict_without_Nones(
+        awarded=datetime.date.today(),
+        person=person,
+        badge=badge,
+        event=event,
+    ))
 
-            if award_form.is_valid():
-                award = award_form.save()
+    # Determine which form was sent (if any)
+    if request.method == 'POST' and 'award-badge' in request.POST:
+        award_form = PersonAwardForm(request.POST, prefix='award')
 
-                messages.success(
-                    request,
-                    '{person} was awarded {badge} badge.'.format(
-                        person=str(person),
-                        badge=award.badge.title,
-                    ),
-                    extra_tags='awards',
-                )
+        if award_form.is_valid():
+            award = award_form.save()
 
-                # to reset the form values
-                return redirect('{}#awards'.format(request.path))
+            messages.success(
+                request,
+                '{person} was awarded {badge} badge.'.format(
+                    person=str(person),
+                    badge=award.badge.title,
+                ),
+                extra_tags='awards',
+            )
 
-            else:
-                messages.error(request, 'Fix errors in the award form.',
-                               extra_tags='awards')
+            return redirect_with_next_support(
+                request, '{}#awards'.format(request.path))
 
-        elif 'task-role' in request.POST:
-            task_form = PersonTaskForm(request.POST, prefix='task')
+        else:
+            messages.error(request, 'Fix errors in the award form.',
+                           extra_tags='awards')
 
-            if task_form.is_valid():
-                task = task_form.save()
+    elif request.method == 'POST' and 'task-role' in request.POST:
+        task_form = PersonTaskForm(request.POST, prefix='task')
 
-                messages.success(
-                    request,
-                    '{person} was added a role {role} during {event} event.'
-                    .format(
-                        person=str(person),
-                        role=task.role.name,
-                        event=task.event.slug,
-                    ),
-                    extra_tags='tasks',
-                )
+        if task_form.is_valid():
+            task = task_form.save()
 
-                # to reset the form values
-                return redirect('{}#tasks'.format(request.path))
+            messages.success(
+                request,
+                '{person} was added a role {role} during {event} event.'
+                .format(
+                    person=str(person),
+                    role=task.role.name,
+                    event=task.event.slug,
+                ),
+                extra_tags='tasks',
+            )
 
-            else:
-                messages.error(request, 'Fix errors in the task form.',
-                               extra_tags='tasks')
+            return redirect('{}#tasks'.format(request.path))
 
         else:
-            person_form = PersonForm(request.POST, prefix='person',
-                                     instance=person)
-            if person_form.is_valid():
-                lessons = person_form.cleaned_data['lessons']
+            messages.error(request, 'Fix errors in the task form.',
+                           extra_tags='tasks')
 
-                # remove existing Qualifications for user
-                Qualification.objects.filter(person=person).delete()
+    elif request.method == 'POST':
+        person_form = PersonForm(request.POST, prefix='person',
+                                 instance=person)
+        if person_form.is_valid():
+            lessons = person_form.cleaned_data['lessons']
 
-                # add new Qualifications
-                for lesson in lessons:
-                    Qualification.objects.create(person=person, lesson=lesson)
+            # remove existing Qualifications for user
+            Qualification.objects.filter(person=person).delete()
+
+            # add new Qualifications
+            for lesson in lessons:
+                Qualification.objects.create(person=person, lesson=lesson)
 
-                # don't save related lessons
-                del person_form.cleaned_data['lessons']
+            # don't save related lessons
+            del person_form.cleaned_data['lessons']
 
-                person = person_form.save()
+            person = person_form.save()
 
-                messages.success(
-                    request,
-                    '{name} was updated successfully.'.format(
-                        name=str(person),
-                    ),
-                )
+            messages.success(
+                request,
+                '{name} was updated successfully.'.format(
+                    name=str(person),
+                ),
+            )
 
-                return redirect(person)
+            return redirect(person)
 
-            else:
-                messages.error(request, 'Fix errors below.')
+        else:
+            messages.error(request, 'Fix errors below.')
 
-    context = {'title': 'Edit Person {0}'.format(str(person)),
-               'person_form': person_form,
-               'object': person,
-               'model': Person,
-               'awards': awards,
-               'award_form': award_form,
-               'tasks': tasks,
-               'task_form': task_form,
-               }
+    context = {
+        'title': 'Edit Person {0}'.format(str(person)),
+        'person_form': person_form,
+        'object': person,
+        'awards': person.award_set.order_by('badge__name'),
+        'award_form': award_form,
+        'tasks': person.task_set.order_by('-event__slug'),
+        'task_form': task_form,
+    }
     return render(request, 'workshops/person_edit_form.html', context)
 
 
@@ -3347,11 +3365,8 @@ def trainingrequest_details(request, pk):
         if form.is_valid():
             ok = _accept_training_request(form, req, request)
             if ok:
-                next_url = request.GET.get('next', None)
-                if next_url is not None and is_safe_url(next_url):
-                    return redirect(next_url)
-                else:
-                    return redirect('trainingrequest_details', req.pk)
+                return redirect_with_next_support(
+                    request, 'trainingrequest_details', req.pk)
 
     else:  # GET request
         # Provide initial value for form.person


Test patch:
diff --git a/workshops/test/base.py b/workshops/test/base.py
--- a/workshops/test/base.py
+++ b/workshops/test/base.py
@@ -8,10 +8,11 @@
 import traceback
 import xml.etree.ElementTree as ET
 
+import webtest
 from django.conf import settings
 from django.contrib.auth.models import Group, Permission
 from django.core.urlresolvers import reverse
-from django.test import TestCase
+from django_webtest import WebTest
 
 from ..models import (
     Airport,
@@ -44,7 +45,8 @@ def subTest(self, *args, **kwargs):
             return super().subTest(*args, **kwargs)
 
 
-class TestBase(DummySubTestWhenTestsLaunchedInParallelMixin, TestCase):
+class TestBase(DummySubTestWhenTestsLaunchedInParallelMixin,
+               WebTest):  # Support for functional tests (django-webtest)
     '''Base class for AMY test cases.'''
 
     ERR_DIR = 'htmlerror' # where to save error HTML files
@@ -527,3 +529,20 @@ def _get_test_name_from_stack(self):
         assert callers, 'Internal error: unable to find caller'
         caller = callers[-1]
         return caller
+
+    ### Web-test helpers
+
+    def assertSelected(self, field, expected):
+        if not isinstance(field, webtest.forms.Select):
+            raise TypeError
+
+        expected_value = field._get_value_for_text(expected)
+        got_value = field.value
+
+        # field.options is a list of (value, selected?, verbose name) triples
+        selected = [o[2] for o in field.options if o[1]]
+
+        self.assertEqual(
+            expected_value, got_value,
+            msg='Expected "{}" to be selected '
+                'while {} is/are selected.'.format(expected, selected))
diff --git a/workshops/test/test_person.py b/workshops/test/test_person.py
--- a/workshops/test/test_person.py
+++ b/workshops/test/test_person.py
@@ -447,6 +447,48 @@ def test_get_training_tasks(self):
         self.assertEqual(set(p1.get_training_tasks()),
                          {t1})
 
+    def test_awarding_instructor_badge_workflow(self):
+        """Test that you can click "SWC" and "DC" labels in "eligible"
+        column in trainees list view. When you click them, you're moved to
+        the view where you can edit person's awards. "Award" and "event"
+        field should be prefilled in. Also test if you're moved back to
+        trainees view after adding the badge."""
+
+        trainee = Person.objects.create_user(
+            username='trainee', personal='Bob',
+            family='Smith', email='bob.smith@example.com')
+        host = Organization.objects.create(domain='example.com',
+                                           fullname='Test Organization')
+        ttt, _ = Tag.objects.get_or_create(name='TTT')
+        learner, _ = Role.objects.get_or_create(name='learner')
+        training = Event.objects.create(slug='2016-08-10-training', host=host)
+        training.tags.add(ttt)
+        Task.objects.create(person=trainee, event=training, role=learner)
+
+        trainees = self.app.get(reverse('all_trainees'), user='admin')
+
+        # Test workflow starting from clicking at "SWC" label
+        swc_res = trainees.click('^SWC$')
+        self.assertSelected(swc_res.forms['person-awards-form']['award-badge'],
+                            'Software Carpentry Instructor')
+        self.assertEqual(swc_res.forms['person-awards-form']['award-event_0'].value,
+                         '2016-08-10-training')
+        swc_res = swc_res.forms['person-awards-form'].submit().follow()
+        self.assertIn("Bob Smith &lt;bob.smith@example.com&gt; was awarded "
+                      "Software Carpentry Instructor badge.", swc_res)
+        self.assertEqual(trainees.request.url, swc_res.request.url)
+
+        # Test workflow starting from clicking at "DC" label
+        dc_res = trainees.click('^DC$')
+        self.assertSelected(dc_res.forms['person-awards-form']['award-badge'],
+                            'Data Carpentry Instructor')
+        self.assertEqual(dc_res.forms['person-awards-form']['award-event_0'].value,
+                         '2016-08-10-training')
+        dc_res = dc_res.forms['person-awards-form'].submit().follow()
+        self.assertIn("Bob Smith &lt;bob.smith@example.com&gt; was awarded "
+                      "Data Carpentry Instructor badge.", dc_res)
+        self.assertEqual(trainees.request.url, dc_res.request.url)
+
 
 class TestPersonPassword(TestBase):
     """Separate tests for testing password setting.