Repository: ietf-tools/datatracker

Issue:
Download questionnaire responses
### Description

The questionnaire responses, filed in the feedback section, are often sent to the confirming body. It would be convenient to have a "download to file" button. This would address 8bit chars in content, too.

### Code of Conduct

- [X] I agree to follow the [IETF's Code of Conduct](https://github.com/ietf-tools/.github/blob/main/CODE_OF_CONDUCT.md)


Discussion:
@richsalz Could you elaborate on what you're looking for? It sounds like you just want to cut and paste the feedback into an email message.
I'll answer for Rich:

These questionnaires are long documents that people standing for a position fill out. When sent to confirming bodies, the body is often getting _several_ of these (all of the ADs that a nomcom selects go to the IAB for confirmation for example - see https://www.rfc-editor.org/rfc/rfc8713.html#name-candidate-review for more detail).

This is more data than should go through a cut-paste process - the confirming body would rather get these as attachments to one or more email messages, or via some non-email transfer path where "file" is still a better model than "paste buffer". 
Yes.  Even though last year I only offered to make the questionnaires available if anyone asked, and nobody asked.

Resolving patch:
diff --git a/ietf/nomcom/views.py b/ietf/nomcom/views.py
--- a/ietf/nomcom/views.py
+++ b/ietf/nomcom/views.py
@@ -19,6 +19,7 @@
 from django.template.loader import render_to_string
 from django.urls import reverse
 from django.utils.encoding import force_bytes, force_str
+from django.utils.text import slugify
 
 from email.errors import HeaderParseError
 
@@ -1023,24 +1024,38 @@ def view_feedback_nominee(request, year, nominee_id):
             return HttpResponseForbidden('Restricted to roles: Nomcom Chair, Nomcom Advisor')
         feedback_id = request.POST.get('feedback_id', None)
         feedback = get_object_or_404(Feedback, id=feedback_id)
-        type = request.POST.get('type', None)
-        if type:
-            if type == 'unclassified':
-                feedback.type = None
-                feedback.nominees.clear()
-                messages.success(request, 'The selected feedback has been de-classified. Please reclassify it in the Pending emails tab.')
+        submit = request.POST.get('submit', None)
+        if submit == 'download':
+            fn = f'questionnaire-{slugify(nominee.name())}-{feedback.time.date()}.txt'
+            response = render_to_string('nomcom/download_questionnaire.txt',
+                                        {'year': year,
+                                         'nominee': nominee,
+                                         'feedback': feedback,
+                                         'positions': ','.join([str(p) for p in feedback.positions.all()]),
+                                         },
+                                        request=request)
+            response = HttpResponse(response, content_type='text/plain')
+            response['Content-Disposition'] = f'attachment; filename="{fn}"'
+            return response
+        elif submit == 'reclassify':
+            type = request.POST.get('type', None)
+            if type:
+                if type == 'unclassified':
+                    feedback.type = None
+                    feedback.nominees.clear()
+                    messages.success(request, 'The selected feedback has been de-classified. Please reclassify it in the Pending emails tab.')
+                else:
+                    feedback.type = FeedbackTypeName.objects.get(slug=type)
+                    messages.success(request, f'The selected feedback has been reclassified as {feedback.type.name}.')
+                feedback.save()
             else:
-                feedback.type = FeedbackTypeName.objects.get(slug=type)
-                messages.success(request, f'The selected feedback has been reclassified as {feedback.type.name}.')
-            feedback.save()
-        else:
-            return render(request, 'nomcom/view_feedback_nominee.html',
-                              {'year': year,
-                               'nomcom': nomcom,
-                               'feedback_types': feedback_types,
-                               'reclassify_feedback': feedback,
-                               'is_chair_task': True,
-                              })
+                return render(request, 'nomcom/view_feedback_nominee.html',
+                                  {'year': year,
+                                   'nomcom': nomcom,
+                                   'feedback_types': feedback_types,
+                                   'reclassify_feedback': feedback,
+                                   'is_chair_task': True,
+                                  })
 
     last_seen = FeedbackLastSeen.objects.filter(reviewer=request.user.person,nominee=nominee).first()
     last_seen_time = (last_seen and last_seen.time) or datetime.datetime(year=1, month=1, day=1, tzinfo=datetime.timezone.utc)


Test patch:
diff --git a/ietf/nomcom/tests.py b/ietf/nomcom/tests.py
--- a/ietf/nomcom/tests.py
+++ b/ietf/nomcom/tests.py
@@ -2870,7 +2870,6 @@ class ReclassifyFeedbackTests(TestCase):
     def setUp(self):
         super().setUp()
         setup_test_public_keys_dir(self)
-        nomcom_test_data()
         self.nc = NomComFactory.create(**nomcom_kwargs_for_year())
         self.chair = self.nc.group.role_set.filter(name='chair').first().person
         self.member = self.nc.group.role_set.filter(name='member').first().person
@@ -2882,6 +2881,28 @@ def tearDown(self):
         teardown_test_public_keys_dir(self)
         super().tearDown()
 
+    def test_download_feedback_nominee(self):
+        # not really a reclassification test, but in closely adjacent code
+        fb = FeedbackFactory.create(nomcom=self.nc,type_id='questio')
+        fb.positions.add(self.position)
+        fb.nominees.add(self.nominee)
+        fb.save()
+        self.assertEqual(Feedback.objects.questionnaires().count(), 1)
+
+        url = reverse('ietf.nomcom.views.view_feedback_nominee', kwargs={'year':self.nc.year(), 'nominee_id':self.nominee.id})
+        login_testing_unauthorized(self,self.member.user.username,url)
+        provide_private_key_to_test_client(self)
+        response = self.client.post(url, {'feedback_id': fb.id, 'submit': 'download'})
+        self.assertEqual(response.status_code, 403)
+
+        self.client.logout()
+        self.client.login(username=self.chair.user.username, password=self.chair.user.username + "+password")
+        provide_private_key_to_test_client(self)
+
+        response = self.client.post(url, {'feedback_id': fb.id, 'submit': 'download'})
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('questionnaire-', response['Content-Disposition'])
+
     def test_reclassify_feedback_nominee(self):
         fb = FeedbackFactory.create(nomcom=self.nc,type_id='comment')
         fb.positions.add(self.position)
@@ -2892,14 +2913,14 @@ def test_reclassify_feedback_nominee(self):
         url = reverse('ietf.nomcom.views.view_feedback_nominee', kwargs={'year':self.nc.year(), 'nominee_id':self.nominee.id})
         login_testing_unauthorized(self,self.member.user.username,url)
         provide_private_key_to_test_client(self)
-        response = self.client.post(url, {'feedback_id': fb.id, 'type': 'obe'})
+        response = self.client.post(url, {'feedback_id': fb.id, 'type': 'obe', 'submit': 'reclassify'})
         self.assertEqual(response.status_code, 403)
 
         self.client.logout()
         self.client.login(username=self.chair.user.username, password=self.chair.user.username + "+password")
         provide_private_key_to_test_client(self)
 
-        response = self.client.post(url, {'feedback_id': fb.id, 'type': 'obe'})
+        response = self.client.post(url, {'feedback_id': fb.id, 'type': 'obe', 'submit': 'reclassify'})
         self.assertEqual(response.status_code, 200)
 
         fb = Feedback.objects.get(id=fb.id)