Repository: carpentries/amy

Issue:
Instructor Training Application checkbox to opt-in for Open review process
This is not urgent, but if it is a quick fix, it would be a fantastic improvement.

Currently, applications are routed to our Open Instructor Training program when they are missing a Registration Code. This means that anyone who forgets to enter a code ends up taking time for unnecessary application review.

It would be much more straightforward to have a checkbox with text like this: "OPEN INSTRUCTOR TRAINING APPLICANTS: Check here if you would like this application to be reviewed for admission to our Open Instructor Training program. If you have already been invited to attend a training, please do not select this box."


Discussion:
Thanks @karenword.  This sounds like a good idea.
I'll discuss this with @pbanaszkiewicz  in our upcoming workflows.

We already have some features implemented for v2.8, but I can try to squeeze this into `master` some other way than before and therefore make a v2.7.1 release.
Thanks @karenword 
To be clear, would we also want them to use the Group Code box for anything?  We can still leave it as is otherwise.  Should we have some information about what "Open Instructor Training" means?  Perhaps to say *not* to check this box if they are with a member site?

What if we had two options:

1. If you are joining with a member site, please enter your registration code or member site name here.
2. If you are not affiliated with a member site and are applying through our open application program, check this box.

And then we'd want validation to be sure they didn't do both.








@karenword  I'm assuming your answer will be 1, but please let me know:  


**Which of the following applies to when you want to see this fixed?**
1. Would like to see this fixed as soon as possible, in the next 1-2 development cycles (i.e., by September 2019)
2. Would like to see this fixed later (by the end of 2019)
3. Would like to see this fixed, but possibly as part of a larger project that needs more planning
4. Not sure
5. No longer relevant - OK to close


Definitely # 1! :) I like the options. Maybe this should be a drop-down choice so that they are mutually exclusive? Options could be: 'Member Affiliate (pre-approved for training)' vs 'Open Applicant (submit application for review to receive an Open Instructor Training scholarship)' -- pinging @elizabethwilliams8 for thoughts on my use of "scholarship" here. Then the "Registration Code" field could be identified as "Member Affiliates only:  please enter your Registration Code here."
@karenword I like it. There may be a better word than scholarship in the future, but this is consistent with what we have been saying so far. 
I like this idea - that they choose "member" or "open" (and we can fine tune the language used here) and then if member they get a box to put in a code; if open they don't have that box.
@pbanaszkiewicz  says:

> Changing the form to behave like this is tricky and may cause unpredictible challenges

Piotr, could you clarify what might happen? Are there other ways we could distinguish between open and member applications without relying on whether the group code is blank?
@maneesha: sorry for not being clear. What I meant about changing the form and unpredictable challenges is that adjusting the HTML in the form may require some tricks / hacks / special JavaScript. I don't know upfront what that may be, but it could affect how long does the issue take to implement. The bottom line is that I'm sure I can change the form for you, just the time may vary.
@pbanaszkiewicz is this unpredictability specific to adding a checkbox or dropdown choice alone, or is it related to @maneesha 's  proposal that the registration code box appearance should depend on the checkbox being checked? The latter feature seems less important to me than having the checkbox/dropdown itself.
@karenword and @maneesha Upon reading the options for the check-boxes again, I think we should make the current 'Member (pre-approved for training)' a bit more broad and intuitive. What about 'Pre-Approved Group Registration (through Membership or other agreement with The Carpentries)' and ' Individual Open Training Application (submit application for review to receive a scholarship for Instructor Training through our Open Application Program)'. 

My thinking here is that we have groups that are not members (i.e. the DOE) who have instructor training seats through a contract and we have the waived instructor trainings through the scholarship program and the a la carte training form. In both of these cases, as well as Membership, a registration code should have been provided. And, if there are other things in the future, this covers them too! 

I would love to hear your thoughts on the wording for the Open Training Applications, Karen. My thinking is that using the word 'Individual' will clue people in memberships that they are part of a group and need that code (if it hasn't been given to them), and empower individuals thinking about applying for training and not sure if it is for them to make the decision alone. 
I think I like this option - to select one of the following as a radio button, with the option to enter a code if they are coming through a membership or agreement:

- Pre-Approved ~~Group~~ Registration (through Membership or other agreement with The Carpentries).  Please enter the code you were given here: 
- Individual Open Training Application (submit application for review to receive a scholarship for Instructor Training through our Open Application Program)
@pbanaszkiewicz  Just tagging you to note a small change in the text for the question above (remove the word "Group").

Resolving patch:
diff --git a/amy/extforms/forms.py b/amy/extforms/forms.py
--- a/amy/extforms/forms.py
+++ b/amy/extforms/forms.py
@@ -1,6 +1,7 @@
 from captcha.fields import ReCaptchaField
-from crispy_forms.layout import HTML
+from crispy_forms.layout import HTML, Div, Field
 from django import forms
+from django.core.exceptions import ValidationError
 
 from extrequests.forms import (
     WorkshopRequestBaseForm,
@@ -30,6 +31,7 @@ class TrainingRequestForm(forms.ModelForm):
     class Meta:
         model = TrainingRequest
         fields = (
+            'review_process',
             'group_name',
             'personal',
             'family',
@@ -66,6 +68,7 @@ class Meta:
             'workshop_teaching_agreement',
         )
         widgets = {
+            'review_process': forms.RadioSelect(),
             'occupation': RadioSelectWithOther('occupation_other'),
             'domains': CheckboxSelectMultipleWithOthers('domains_other'),
             'gender': forms.RadioSelect(),
@@ -87,6 +90,7 @@ def __init__(self, *args, initial_group_name=None, **kwargs):
         initial = kwargs.pop('initial', {})
         if initial_group_name is not None:
             initial['group_name'] = initial_group_name
+            initial['review_process'] = 'preapproved'
         super().__init__(*args, initial=initial, **kwargs)
         if initial_group_name is not None:
             field = self.fields['group_name']
@@ -117,6 +121,35 @@ def __init__(self, *args, initial_group_name=None, **kwargs):
             'teaching_frequency_expectation_other')
         self.helper.layout.fields.remove('max_travelling_frequency_other')
 
+        # fake requiredness of the registration code / group name
+        self['group_name'].field.widget.fake_required = True
+
+        # special accordion display for the review process
+        self['review_process'].field.widget.subfields = {
+            'preapproved': [
+                self['group_name'],
+            ],
+            'open': [],  # this option doesn't require any additional fields
+        }
+        self['review_process'].field.widget.notes = \
+            TrainingRequest.REVIEW_CHOICES_NOTES
+
+        # get current position of `review_process` field
+        pos_index = self.helper.layout.fields.index('review_process')
+
+        self.helper.layout.fields.remove('review_process')
+        self.helper.layout.fields.remove('group_name')
+
+        # insert div+field at previously saved position
+        self.helper.layout.insert(
+            pos_index,
+            Div(
+                Field('review_process',
+                      template="bootstrap4/layout/radio-accordion.html"),
+                css_class='form-group row',
+            ),
+        )
+
         # add <HR> around "underrepresented*" fields
         index = self.helper.layout.fields.index('underrepresented')
         self.helper.layout.insert(
@@ -126,6 +159,32 @@ def __init__(self, *args, initial_group_name=None, **kwargs):
         self.helper.layout.insert(
             index + 1, HTML(self.helper.hr()))
 
+    def clean(self):
+        super().clean()
+        errors = dict()
+
+        # 1: validate registration code / group name
+        review_process = self.cleaned_data.get('review_process', '')
+        group_name = self.cleaned_data.get('group_name', '').split()
+
+        # it's required when review_process is 'preapproved', but not when
+        # 'open'
+        if review_process == 'preapproved' and not group_name:
+            errors['review_process'] = ValidationError(
+                "Registration code is required for pre-approved training "
+                "review process."
+            )
+
+        # it's required to be empty when review_process is 'open'
+        if review_process == 'open' and group_name:
+            errors['review_process'] = ValidationError(
+                "Registration code must be empty for open training review "
+                "process."
+            )
+
+        if errors:
+            raise ValidationError(errors)
+
 
 class WorkshopRequestExternalForm(WorkshopRequestBaseForm):
     captcha = ReCaptchaField()
diff --git a/amy/workshops/migrations/0204_trainingrequest_review_process.py b/amy/workshops/migrations/0204_trainingrequest_review_process.py
new file mode 100644
--- /dev/null
+++ b/amy/workshops/migrations/0204_trainingrequest_review_process.py
@@ -0,0 +1,35 @@
+# Generated by Django 2.2.5 on 2019-09-15 08:15
+
+from django.db import migrations, models
+
+
+def update_trainingrequest_review_process(apps, schema_editor):
+    TrainingRequest = apps.get_model('workshops', 'TrainingRequest')
+    qs = TrainingRequest.objects.filter(review_process='')
+
+    # 1. update review process for every Training Request that has a code
+    qs.exclude(group_name='').update(review_process='preapproved')
+
+    # 2. update review process for every Training Request that has no code
+    qs.filter(group_name='').update(review_process='open')
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('workshops', '0203_auto_20190914_2021'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='trainingrequest',
+            name='review_process',
+            field=models.CharField(choices=[('preapproved', 'Pre-approved Registration'), ('open', 'Open Training Application')], default='', max_length=20, verbose_name='Application Type'),
+        ),
+        migrations.RunPython(update_trainingrequest_review_process),
+        migrations.AlterField(
+            model_name='trainingrequest',
+            name='group_name',
+            field=models.CharField(blank=True, default='', help_text='If you have been given a registration code through a Carpentries member site or for a specific scheduled event, please enter it here:', max_length=100, verbose_name='Registration Code'),
+        ),
+    ]
diff --git a/amy/workshops/models.py b/amy/workshops/models.py
--- a/amy/workshops/models.py
+++ b/amy/workshops/models.py
@@ -1492,15 +1492,31 @@ class TrainingRequest(CreatedUpdatedMixin, DataPrivacyAgreementMixin,
                                verbose_name='Matched Trainee',
                                on_delete=models.SET_NULL)
 
-    # no association with Event
+    REVIEW_CHOICES = (
+        ('preapproved', 'Pre-approved Registration'),
+        ('open', 'Open Training Application'),
+    )
+    REVIEW_CHOICES_NOTES = {
+        'preapproved': "If you have been invited to apply through an "
+                       "institutional membership or other agreement "
+                       "with The Carpentries.",
+        'open': "Submit application for review to receive a scholarship for "
+                "Instructor Training through our Open Application Program.",
+    }
+    review_process = models.CharField(
+        blank=False, default='', null=False,
+        max_length=20,
+        choices=REVIEW_CHOICES,
+        verbose_name='Application Type',
+    )
 
     group_name = models.CharField(
         blank=True, default='', null=False,
         max_length=STR_LONG,
         verbose_name='Registration Code',
-        help_text='If you are scheduled to receive training at a member site, '
-                  'please enter the same code you were given to register on Eventbrite. Otherwise '
-                  'please leave this blank.',
+        help_text='If you have been given a registration code through '
+                  'a Carpentries member site or for a specific scheduled '
+                  'event, please enter it here:',
     )
 
     personal = models.CharField(


Test patch:
diff --git a/amy/extforms/tests/test_training_request_form.py b/amy/extforms/tests/test_training_request_form.py
--- a/amy/extforms/tests/test_training_request_form.py
+++ b/amy/extforms/tests/test_training_request_form.py
@@ -11,11 +11,9 @@ class TestTrainingRequestForm(TestBase):
     def setUp(self):
         self._setUpUsersAndLogin()
         self._setUpRoles()
-
-    def test_request_added(self):
-        email = 'john@smith.com'
-        data = {
-            'group': 'coolprogrammers',
+        self.data = {
+            'review_process': 'preapproved',
+            'group_name': 'coolprogrammers',
             'personal': 'John',
             'family': 'Smith',
             'email': 'john@smith.com',
@@ -48,13 +46,16 @@ def test_request_added(self):
             'agreed_to_teach_workshops': 'on',
             'privacy_consent': True,
         }
-        self.passCaptcha(data)
 
-        rv = self.client.post(reverse('training_request'), data,
+    def test_request_added(self):
+        email = 'john@smith.com'
+        self.passCaptcha(self.data)
+
+        rv = self.client.post(reverse('training_request'), self.data,
                               follow=True)
         self.assertEqual(rv.status_code, 200)
         content = rv.content.decode('utf-8')
-        self.assertNotIn('Fix errors below', content)
+        self.assertNotIn('fix errors in the form below', content)
         self.assertEqual(TrainingRequest.objects.all().count(), 1)
 
         # Test that the sender was emailed
@@ -66,6 +67,31 @@ def test_request_added(self):
         # with open('email.eml', 'wb') as f:
         #     f.write(msg.message().as_bytes())
 
+    def test_review_process_validation(self):
+        # 1: shouldn't pass when review_process requires group_name
+        self.data['review_process'] = 'preapproved'
+        self.data['group_name'] = ''
+        self.passCaptcha(self.data)
+
+        rv = self.client.post(reverse('training_request'), self.data,
+                              follow=True)
+        self.assertEqual(rv.status_code, 200)
+        content = rv.content.decode('utf-8')
+        self.assertIn('fix errors in the form below', content)
+        self.assertEqual(TrainingRequest.objects.all().count(), 0)
+
+        # 2: shouldn't pass when review_process requires *NO* group_name
+        self.data['review_process'] = 'open'
+        self.data['group_name'] = 'some_code'
+        self.passCaptcha(self.data)
+
+        rv = self.client.post(reverse('training_request'), self.data,
+                              follow=True)
+        self.assertEqual(rv.status_code, 200)
+        content = rv.content.decode('utf-8')
+        self.assertIn('fix errors in the form below', content)
+        self.assertEqual(TrainingRequest.objects.all().count(), 0)
+
 
 class GroupNameFieldTestsBase(TestBase):
     """Tests for [#1005] feature -- Allow pre-population of Group in training
@@ -81,6 +107,10 @@ def setUp(self):
 
     def fillin_and_submit(self):
         # fillin the form
+        if self.form['group_name'].value:
+            self.form['review_process'] = 'preapproved'
+        else:
+            self.form['review_process'] = 'open'
         self.form['personal'] = 'John'
         self.form['family'] = 'Smith'
         self.form['email'] = 'john@smith.com'
diff --git a/amy/extrequests/tests/test_training_request.py b/amy/extrequests/tests/test_training_request.py
--- a/amy/extrequests/tests/test_training_request.py
+++ b/amy/extrequests/tests/test_training_request.py
@@ -23,8 +23,10 @@
 )
 
 
-def create_training_request(state, person):
+def create_training_request(state, person, open_review=True, reg_code=''):
     return TrainingRequest.objects.create(
+        review_process='open' if open_review else 'preapproved',
+        group_name=reg_code,
         personal='John',
         family='Smith',
         email='john@smith.com',