Repository: openreview/openreview-py (language: python)

Issue:
Validation: add validation for curly braces in Recruitment
PCs often leave out the double curly braces when using Recruitment or sending Recruitment Reminders. Add validation for this.


Discussion / hints:
I wonder if this is something we need to validate? 
We are catching this in the error: https://openreview.net/forum?id=XmoAOBSvaZ&noteId=WbABCsfpU1g and we have a test for this: https://github.com/openreview/openreview-py/blob/a91c8af1c71ae55dc4646057260f00bdb116d698/tests/test_venue_request.py#L555

cc @melisabok @racheljsmart 
What about adding a pre process function? adding Emilia so she can take a look.
Emilia and I realized that we don't have the `Key error` anymore because we are using find and replace to replace the tokens:

https://github.com/openreview/openreview-py/blob/master/openreview/tools.py#L1284

I still suggest creating a pre-process function to validate that the words that are wrapped between `{{}}` must be supported by us, this is the list of tokens supported, there are different tokens per field:


invitation_email_subject:

- invitee_role

invitation_email_content:

- fullname
- contact_info
- invitation_url (I would ignore accept_url and decline_url because this is legacy recruitment code)
- invitee_role

accepted_email_template:

- fullname
- reviewer_name

Fix patch:
diff --git a/openreview/venue_request/process/deployProcess.py b/openreview/venue_request/process/deployProcess.py
index 68543504d..08441b2da 100644
--- a/openreview/venue_request/process/deployProcess.py
+++ b/openreview/venue_request/process/deployProcess.py
@@ -14,6 +14,42 @@ def process(client, note, invitation):
 
     readers = [conference.get_program_chairs_id(), SUPPORT_GROUP]
 
+    recruitment_pre_preprocess = '''function(){
+    // V1 invitation
+
+    const fieldTokens = {
+        "invitation_email_subject": ['invitee_role'],
+        "invitation_email_content": ['fullname', 'invitee_role', 'invitation_url', 'contact_info'],
+        "accepted_email_template": ['fullname', 'reviewer_name']
+    };
+
+    for (const [field, tokens] of Object.entries(fieldTokens)) {
+        if (field in note.content) {
+            // Find all words wrapped in double curly braces. If it's not a token, raise an error.
+            let regex = /{{([^{}]+)}}/g;
+            let parenthesizedToken = '';
+            let match;
+            while ((match = regex.exec(note.content[field])) !== null) {
+                parenthesizedToken = match[1];
+                if (!tokens.includes(parenthesizedToken)) {
+                    done(`Invalid token: {{${parenthesizedToken}}} in ${field} is not supported. Please use the following tokens in this field: ${tokens.toString()}.`);
+                }
+            }
+
+            // Check for tokens that don't have double curly braces, raise an error.
+            for (const token of tokens) {
+                regex = new RegExp(`(?<!{)[{]?${token}[}]+|[{]+${token}[}]?(?!})`, 'g');
+                while ((match = regex.exec(note.content[field])) !== null) {
+                    parenthesizedToken = match[0];
+                    done(`Invalid token: ${parenthesizedToken} in ${field}. Tokens must be wrapped in double curly braces.`);
+                }
+            }
+        }
+    }
+    done()
+}
+'''
+
     comment_invitation = client.post_invitation(openreview.Invitation(
         id=SUPPORT_GROUP + '/-/Request' + str(forum.number) + '/Comment',
         super=SUPPORT_GROUP + '/-/Comment',
@@ -257,6 +293,10 @@ def process(client, note, invitation):
         signatures = ['~Super_User1'] ##Temporarily use the super user, until we can get a way to send email to invitees
     )
 
+    if isinstance(conference, openreview.venue.Venue):
+       recruitment_invitation.preprocess = recruitment_pre_preprocess
+       remind_recruitment_invitation.preprocess = recruitment_pre_preprocess
+    
     if len(conference.get_roles()) > 1:
         recruitment_invitation.reply['content']['allow_role_overlap'] = {
             'description': 'Do you want to allow the overlap of users in different roles? Selecting "Yes" would allow a user to be invited to serve as both a Reviewer and Area Chair.',


Test patch:
diff --git a/tests/test_venue_request.py b/tests/test_venue_request.py
index 7b17a1f59..e4464c11a 100644
--- a/tests/test_venue_request.py
+++ b/tests/test_venue_request.py
@@ -578,7 +578,7 @@ def test_venue_recruitment(self, client, test_client, selenium, request_page, ve
                 'invitee_role': 'Reviewers',
                 'invitee_details': reviewer_details,
                 'invitation_email_subject': '[' + venue['request_form_note'].content['Abbreviated Venue Name'] + '] Invitation to serve as {{invitee_role}}',
-                'invitation_email_content': 'Dear {{fullname}},\n\nYou have been nominated by the program chair committee of Theoretical Foundations of RL Workshop @ ICML 2020 to serve as {{invitee_role}}.\n\nACCEPT LINK:\n\n{{accept_url}}\n\nDECLINE LINK:\n\n{{decline_url}}\n\nCheers!\n\nProgram Chairs'
+                'invitation_email_content': 'Dear {{fullname}},\n\nYou {{have}} been nominated by the program chair committee of Theoretical Foundations of RL Workshop @ ICML 2020 to serve as {{invitee_role}}.\n\nACCEPT LINK:\n\n{{accept_url}}\n\nDECLINE LINK:\n\n{{decline_url}}\n\nCheers!\n\nProgram Chairs'
             },
             forum=venue['request_form_note'].forum,
             replyto=venue['request_form_note'].forum,
@@ -602,13 +602,13 @@ def test_venue_recruitment(self, client, test_client, selenium, request_page, ve
         messages = client.get_messages(to='reviewer_candidate1@email.com')
         assert messages and len(messages) == 1
         assert messages[0]['content']['subject'] == "[TestVenue@OR'2030] Invitation to serve as Reviewer"
-        assert messages[0]['content']['text'].startswith('Dear Reviewer One,\n\nYou have been nominated by the program chair committee of Theoretical Foundations of RL Workshop @ ICML 2020 to serve as Reviewer.')
+        assert messages[0]['content']['text'].startswith('Dear Reviewer One,\n\nYou {{have}} been nominated by the program chair committee of Theoretical Foundations of RL Workshop @ ICML 2020 to serve as Reviewer.')
         assert "TEST.cc/2030/Conference/-/Recruit_Reviewers" in messages[0]['content']['text']
 
         messages = client.get_messages(to='reviewer_candidate2@email.com')
         assert messages and len(messages) == 1
         assert messages[0]['content']['subject'] == "[TestVenue@OR'2030] Invitation to serve as Reviewer"
-        assert messages[0]['content']['text'].startswith('Dear Reviewer Two,\n\nYou have been nominated by the program chair committee of Theoretical Foundations of RL Workshop @ ICML 2020 to serve as Reviewer.')
+        assert messages[0]['content']['text'].startswith('Dear Reviewer Two,\n\nYou {{have}} been nominated by the program chair committee of Theoretical Foundations of RL Workshop @ ICML 2020 to serve as Reviewer.')
 
         recruitment_status_invitation = '{}/-/Request{}/Recruitment_Status'.format(venue['support_group_id'],
                                                                                    venue['request_form_note'].number)
diff --git a/tests/test_venue_request_v2.py b/tests/test_venue_request_v2.py
index 194e8f881..59c7f0fa4 100644
--- a/tests/test_venue_request_v2.py
+++ b/tests/test_venue_request_v2.py
@@ -456,7 +456,7 @@ def test_venue_recruitment_email_error(self, client, test_client, selenium, requ
                 'invitee_role': 'Reviewers',
                 'invitee_details': reviewer_details,
                 'invitation_email_subject': '[' + venue['request_form_note'].content['Abbreviated Venue Name'] + '] Invitation to serve as {{invitee_role}}',
-                'invitation_email_content': 'Dear {{fullname}},\n\nYou have been nominated by the {program} chair committee of Theoretical Foundations of RL Workshop @ ICML 2020 to serve as {{invitee_role}}.\n\nACCEPT LINK:\n\n{{accept_url}}\n\nDECLINE LINK:\n\n{{decline_url}}\n\nCheers!\n\nProgram Chairs'
+                'invitation_email_content': 'Dear {{fullname}},\n\nYou have been nominated by the {program} chair committee of Theoretical Foundations of RL Workshop @ ICML 2020 to serve as {{invitee_role}}.\n\nTo respond to the invitation, please click on the following link:\n\n{{invitation_url}}\n\nCheers!\n\nProgram Chairs'
             },
             forum=venue['request_form_note'].forum,
             replyto=venue['request_form_note'].forum,
@@ -502,7 +502,7 @@ def test_venue_recruitment_email_error(self, client, test_client, selenium, requ
                 'invitee_role': 'Reviewers',
                 'invitee_details': reviewer_details,
                 'invitation_email_subject': '[' + venue['request_form_note'].content['Abbreviated Venue Name'] + '] Invitation to serve as {{invitee_role}}',
-                'invitation_email_content': 'Dear {name},\n\nYou have been nominated by the program chair committee of Theoretical Foundations of RL Workshop @ ICML 2020 to serve as {{invitee_role}}.\n\nACCEPT LINK:\n\n{{accept_url}}\n\nDECLINE LINK:\n\n{{decline_url}}\n\nCheers!\n\nProgram Chairs'
+                'invitation_email_content': 'Dear {name},\n\nYou have been nominated by the program chair committee of Theoretical Foundations of RL Workshop @ ICML 2020 to serve as {{invitee_role}}.\n\nTo respond to the invitation, please click on the following link:\n\n{{invitation_url}}\n\nCheers!\n\nProgram Chairs'
             },
             forum=venue['request_form_note'].forum,
             replyto=venue['request_form_note'].forum,
@@ -535,13 +535,13 @@ def test_venue_recruitment(self, client, test_client, selenium, request_page, ve
         # buttons = reply_row.find_elements(By.CLASS_NAME, 'btn-xs')
         # assert [btn for btn in buttons if btn.text == 'Recruitment']
         reviewer_details = '''reviewer_candidate1_v2@mail.com, Reviewer One\nreviewer_error@mail.com;, Reviewer Error\nReviewer_Candidate2_v2@mail.com, Reviewer Two'''
-        recruitment_note = test_client.post_note(openreview.Note(
+        recruitment_note = openreview.Note(
             content={
                 'title': 'Recruitment',
                 'invitee_role': 'Reviewers',
                 'invitee_reduced_load': ['1', '2', '3'],
                 'invitee_details': reviewer_details,
-                'invitation_email_subject': '[' + venue['request_form_note'].content['Abbreviated Venue Name'] + '] Invitation to serve as {{invitee_role}}',
+                'invitation_email_subject': '[' + venue['request_form_note'].content['Abbreviated Venue Name'] + '] Invitation to serve as {{invitee_role}}. Contact: {{contact_info}}',
                 'invitation_email_content': 'Dear {{fullname}},\n\nYou have been nominated by the program chair committee of Test 2030 Venue V2 to serve as {{invitee_role}}.\n\nTo respond to the invitation, please click on the following link:\n\n{{invitation_url}}\n\nCheers!\n\nProgram Chairs'
             },
             forum=venue['request_form_note'].forum,
@@ -550,7 +550,18 @@ def test_venue_recruitment(self, client, test_client, selenium, request_page, ve
             readers=['{}/Program_Chairs'.format(venue['venue_id']), venue['support_group_id']],
             signatures=['~SomeFirstName_User1'],
             writers=[]
-        ))
+        )
+
+        with pytest.raises(openreview.OpenReviewException, match=r'Invalid token: {{contact_info}} in invitation_email_subject is not supported.'):
+           recruitment_note=test_client.post_note(recruitment_note)
+
+        recruitment_note.content['invitation_email_subject'] = '[' + venue['request_form_note'].content['Abbreviated Venue Name'] + '] Invitation to serve as {invitee_role'
+        with pytest.raises(openreview.OpenReviewException, match=r'Invalid token: {invitee_role in invitation_email_subject. Tokens must be wrapped in double curly braces.'):
+           recruitment_note=test_client.post_note(recruitment_note)
+
+        recruitment_note.content['invitation_email_subject'] = '[' + venue['request_form_note'].content['Abbreviated Venue Name'] + '] Invitation to serve as {{invitee_role}}'
+        recruitment_note = test_client.post_note(recruitment_note)
+
         assert recruitment_note
 
         invite = client.get_invitation('{}/-/Request{}/Recruitment'.format(venue['support_group_id'], venue['request_form_note'].number))