diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index f44a446e2..f746231a4 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -11113,8 +11113,9 @@ { "fields": { "desc": "", + "legend": "C", "name": "Comment", - "order": 0, + "order": 1, "used": true }, "model": "name.feedbacktypename", @@ -11123,8 +11124,9 @@ { "fields": { "desc": "", + "legend": "J", "name": "Junk", - "order": 0, + "order": 5, "used": true }, "model": "name.feedbacktypename", @@ -11133,8 +11135,9 @@ { "fields": { "desc": "", + "legend": "N", "name": "Nomination", - "order": 0, + "order": 2, "used": true }, "model": "name.feedbacktypename", @@ -11143,8 +11146,20 @@ { "fields": { "desc": "", + "legend": "O", + "name": "Overcome by events", + "order": 4, + "used": true + }, + "model": "name.feedbacktypename", + "pk": "obe" + }, + { + "fields": { + "desc": "", + "legend": "Q", "name": "Questionnaire response", - "order": 0, + "order": 3, "used": true }, "model": "name.feedbacktypename", @@ -11153,8 +11168,9 @@ { "fields": { "desc": "", + "legend": "R", "name": "Read", - "order": 0, + "order": 6, "used": true }, "model": "name.feedbacktypename", diff --git a/ietf/name/migrations/0005_feedbacktypename_schema.py b/ietf/name/migrations/0005_feedbacktypename_schema.py new file mode 100644 index 000000000..cedb129be --- /dev/null +++ b/ietf/name/migrations/0005_feedbacktypename_schema.py @@ -0,0 +1,20 @@ +# Copyright The IETF Trust 2023, All Rights Reserved + +from django.db import migrations, models + +class Migration(migrations.Migration): + dependencies = [ + ("name", "0004_statements"), + ] + + operations = [ + migrations.AddField( + model_name="FeedbackTypeName", + name="legend", + field=models.CharField( + default="", + help_text="One-character legend for feedback classification form", + max_length=1, + ), + ), + ] diff --git a/ietf/name/migrations/0006_feedbacktypename_data.py b/ietf/name/migrations/0006_feedbacktypename_data.py new file mode 100644 index 000000000..f11fca889 --- /dev/null +++ b/ietf/name/migrations/0006_feedbacktypename_data.py @@ -0,0 +1,36 @@ +# Copyright The IETF Trust 2023, All Rights Reserved + +from django.db import migrations + +def forward(apps, schema_editor): + FeedbackTypeName = apps.get_model("name", "FeedbackTypeName") + FeedbackTypeName.objects.create(slug="obe", name="Overcome by events") + for slug, legend, order in ( + ('comment', 'C', 1), + ('nomina', 'N', 2), + ('questio', 'Q', 3), + ('obe', 'O', 4), + ('junk', 'J', 5), + ('read', 'R', 6), + ): + ft = FeedbackTypeName.objects.get(slug=slug) + ft.legend = legend + ft.order = order + ft.save() + +def reverse(apps, schema_editor): + FeedbackTypeName = apps.get_model("name", "FeedbackTypeName") + FeedbackTypeName.objects.filter(slug="obe").delete() + for ft in FeedbackTypeName.objects.all(): + ft.legend = "" + ft.order = 0 + ft.save() + +class Migration(migrations.Migration): + dependencies = [ + ("name", "0005_feedbacktypename_schema"), + ] + + operations = [ + migrations.RunPython(forward, reverse), + ] diff --git a/ietf/name/models.py b/ietf/name/models.py index b9e75e8f9..4eda88afd 100644 --- a/ietf/name/models.py +++ b/ietf/name/models.py @@ -94,6 +94,7 @@ class NomineePositionStateName(NameModel): """Status of a candidate for a position: None, Accepted, Declined""" class FeedbackTypeName(NameModel): """Type of feedback: questionnaires, nominations, comments""" + legend = models.CharField(max_length=1, default="", help_text="One-character legend for feedback classification form") class DBTemplateTypeName(NameModel): """reStructuredText, Plain, Django""" class DraftSubmissionStateName(NameModel): diff --git a/ietf/nomcom/forms.py b/ietf/nomcom/forms.py index ad7bc67c2..919ed6e18 100644 --- a/ietf/nomcom/forms.py +++ b/ietf/nomcom/forms.py @@ -653,7 +653,7 @@ class PrivateKeyForm(forms.Form): class PendingFeedbackForm(forms.ModelForm): - type = forms.ModelChoiceField(queryset=FeedbackTypeName.objects.all().order_by('pk'), widget=forms.RadioSelect, empty_label='Unclassified', required=False) + type = forms.ModelChoiceField(queryset=FeedbackTypeName.objects.all(), widget=forms.RadioSelect, empty_label='Unclassified', required=False) class Meta: model = Feedback diff --git a/ietf/nomcom/tests.py b/ietf/nomcom/tests.py index bce7e5a21..b68b0beb8 100644 --- a/ietf/nomcom/tests.py +++ b/ietf/nomcom/tests.py @@ -1450,7 +1450,7 @@ class FeedbackIndexTests(TestCase): self.assertEqual(response.status_code,200) q = PyQuery(response.content) r = q('tfoot').eq(0).find('td').contents() - self.assertEqual([a.strip() for a in r], ['1', '1', '1']) + self.assertEqual([a.strip() for a in r], ['1', '1', '1', '0']) class FeedbackLastSeenTests(TestCase): @@ -2863,3 +2863,92 @@ class VolunteerDecoratorUnitTests(TestCase): self.assertEqual(v.qualifications,'path_2') if v.person == author_person: self.assertEqual(v.qualifications,'path_3') + +class ReclassifyFeedbackTests(TestCase): + """Tests for feedback reclassification""" + + 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 + self.nominee = self.nc.nominee_set.order_by('pk').first() + self.position = self.nc.position_set.first() + self.topic = self.nc.topic_set.first() + + def tearDown(self): + teardown_test_public_keys_dir(self) + super().tearDown() + + def test_reclassify_feedback_nominee(self): + fb = FeedbackFactory.create(nomcom=self.nc,type_id='comment') + fb.positions.add(self.position) + fb.nominees.add(self.nominee) + fb.save() + self.assertEqual(Feedback.objects.comments().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, 'type': 'obe'}) + 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'}) + self.assertEqual(response.status_code, 200) + + fb = Feedback.objects.get(id=fb.id) + self.assertEqual(fb.type_id,'obe') + self.assertEqual(Feedback.objects.comments().count(), 0) + self.assertEqual(Feedback.objects.filter(type='obe').count(), 1) + + def test_reclassify_feedback_topic(self): + fb = FeedbackFactory.create(nomcom=self.nc,type_id='comment') + fb.topics.add(self.topic) + fb.save() + self.assertEqual(Feedback.objects.comments().count(), 1) + + url = reverse('ietf.nomcom.views.view_feedback_topic', kwargs={'year':self.nc.year(), 'topic_id':self.topic.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': 'unclassified'}) + 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': 'unclassified'}) + self.assertEqual(response.status_code, 200) + + fb = Feedback.objects.get(id=fb.id) + self.assertEqual(fb.type_id,None) + self.assertEqual(Feedback.objects.comments().count(), 0) + self.assertEqual(Feedback.objects.filter(type=None).count(), 1) + + def test_reclassify_feedback_unrelated(self): + fb = FeedbackFactory(nomcom=self.nc, type_id='read') + self.assertEqual(Feedback.objects.filter(type='read').count(), 1) + + url = reverse('ietf.nomcom.views.view_feedback_unrelated', kwargs={'year':self.nc.year()}) + 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': 'junk'}) + 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': 'junk'}) + self.assertEqual(response.status_code, 200) + + fb = Feedback.objects.get(id=fb.id) + self.assertEqual(fb.type_id, 'junk') + self.assertEqual(Feedback.objects.filter(type='read').count(), 0) + self.assertEqual(Feedback.objects.filter(type='junk').count(), 1) diff --git a/ietf/nomcom/views.py b/ietf/nomcom/views.py index 883bc3ca1..b36f66450 100644 --- a/ietf/nomcom/views.py +++ b/ietf/nomcom/views.py @@ -1,10 +1,10 @@ -# Copyright The IETF Trust 2012-2020, All Rights Reserved +# Copyright The IETF Trust 2012-2023, All Rights Reserved # -*- coding: utf-8 -*- import datetime import re -from collections import OrderedDict, Counter +from collections import Counter import csv import hmac @@ -14,7 +14,7 @@ from django.contrib.auth.decorators import login_required from django.contrib.auth.models import AnonymousUser from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.forms.models import modelformset_factory, inlineformset_factory -from django.http import Http404, HttpResponseRedirect, HttpResponse +from django.http import Http404, HttpResponseRedirect, HttpResponse, HttpResponseForbidden from django.shortcuts import render, get_object_or_404, redirect from django.template.loader import render_to_string from django.urls import reverse @@ -767,7 +767,6 @@ def process_nomination_status(request, year, nominee_position_id, state, date, h 'selected': 'feedback', 'form': form }) - @role_required("Nomcom") @nomcom_private_key_required def view_feedback(request, year): @@ -775,7 +774,7 @@ def view_feedback(request, year): nominees = Nominee.objects.get_by_nomcom(nomcom).not_duplicated().distinct() independent_feedback_types = [] nominee_feedback_types = [] - for ft in FeedbackTypeName.objects.all(): + for ft in FeedbackTypeName.objects.filter(used=True): if ft.slug in settings.NOMINEE_FEEDBACK_TYPES: nominee_feedback_types.append(ft) else: @@ -838,7 +837,8 @@ def view_feedback(request, year): 'topics_feedback': topics_feedback, 'independent_feedback': independent_feedback, 'nominees_feedback': nominees_feedback, - 'nomcom': nomcom}) + 'nomcom': nomcom, + }) @role_required("Nomcom Chair", "Nomcom Advisor") @@ -924,23 +924,13 @@ def view_feedback_pending(request, year): formset = FeedbackFormSet(queryset=feedback_page.object_list) for form in formset.forms: form.set_nomcom(nomcom, request.user) - type_dict = OrderedDict() - for t in FeedbackTypeName.objects.all().order_by('pk'): - rest = t.name - slug = rest[0] - rest = rest[1:] - while slug in type_dict and rest: - slug = rest[0] - rest = rest[1] - type_dict[slug] = t return render(request, 'nomcom/view_feedback_pending.html', {'year': year, 'selected': 'feedback_pending', 'formset': formset, 'extra_step': extra_step, - 'type_dict': type_dict, 'extra_ids': extra_ids, - 'types': FeedbackTypeName.objects.all().order_by('pk'), + 'types': FeedbackTypeName.objects.filter(used=True), 'nomcom': nomcom, 'is_chair_task' : True, 'page': feedback_page, @@ -951,22 +941,59 @@ def view_feedback_pending(request, year): @nomcom_private_key_required def view_feedback_unrelated(request, year): nomcom = get_nomcom_by_year(year) + + if request.method == 'POST': + if not nomcom.group.has_role(request.user, ['chair','advisor']): + 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 + 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: + return render(request, 'nomcom/view_feedback_unrelated.html', + {'year': year, + 'nomcom': nomcom, + 'feedback_types': FeedbackTypeName.objects.filter(used=True).exclude(slug__in=settings.NOMINEE_FEEDBACK_TYPES), + 'reclassify_feedback': feedback, + 'is_chair_task' : True, + }) + feedback_types = [] - for ft in FeedbackTypeName.objects.exclude(slug__in=settings.NOMINEE_FEEDBACK_TYPES): + for ft in FeedbackTypeName.objects.filter(used=True).exclude(slug__in=settings.NOMINEE_FEEDBACK_TYPES): feedback_types.append({'ft': ft, 'feedback': ft.feedback_set.get_by_nomcom(nomcom)}) - return render(request, 'nomcom/view_feedback_unrelated.html', {'year': year, - 'selected': 'view_feedback', 'feedback_types': feedback_types, - 'nomcom': nomcom}) + 'nomcom': nomcom, + }) @role_required("Nomcom") @nomcom_private_key_required def view_feedback_topic(request, year, topic_id): - nomcom = get_nomcom_by_year(year) + # At present, the only feedback type for topics is 'comment'. + # Reclassifying from 'comment' to 'comment' is a no-op, + # so the only meaningful action is to de-classify it. + if request.method == 'POST': + nomcom = get_nomcom_by_year(year) + if not nomcom.group.has_role(request.user, ['chair','advisor']): + 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) + feedback.type = None + feedback.topics.clear() + feedback.save() + messages.success(request, 'The selected feedback has been de-classified. Please reclassify it in the Pending emails tab.') + topic = get_object_or_404(Topic, id=topic_id) + nomcom = get_nomcom_by_year(year) feedback_types = FeedbackTypeName.objects.filter(slug__in=['comment',]) last_seen = TopicFeedbackLastSeen.objects.filter(reviewer=request.user.person,topic=topic).first() @@ -978,18 +1005,42 @@ def view_feedback_topic(request, year, topic_id): return render(request, 'nomcom/view_feedback_topic.html', {'year': year, - 'selected': 'view_feedback', 'topic': topic, 'feedback_types': feedback_types, 'last_seen_time' : last_seen_time, - 'nomcom': nomcom}) + 'nomcom': nomcom, + }) @role_required("Nomcom") @nomcom_private_key_required def view_feedback_nominee(request, year, nominee_id): nomcom = get_nomcom_by_year(year) nominee = get_object_or_404(Nominee, id=nominee_id) - feedback_types = FeedbackTypeName.objects.filter(slug__in=settings.NOMINEE_FEEDBACK_TYPES) + feedback_types = FeedbackTypeName.objects.filter(used=True, slug__in=settings.NOMINEE_FEEDBACK_TYPES) + + if request.method == 'POST': + if not nomcom.group.has_role(request.user, ['chair','advisor']): + 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.') + 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, + }) 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) @@ -1000,11 +1051,11 @@ def view_feedback_nominee(request, year, nominee_id): return render(request, 'nomcom/view_feedback_nominee.html', {'year': year, - 'selected': 'view_feedback', 'nominee': nominee, 'feedback_types': feedback_types, 'last_seen_time' : last_seen_time, - 'nomcom': nomcom}) + 'nomcom': nomcom, + }) @role_required("Nomcom Chair", "Nomcom Advisor") diff --git a/ietf/settings.py b/ietf/settings.py index be2d62c7b..678ba4057 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2007-2022, All Rights Reserved +# Copyright The IETF Trust 2007-2023, All Rights Reserved # -*- coding: utf-8 -*- @@ -802,7 +802,7 @@ NOMCOM_PUBLIC_KEYS_DIR = '/a/www/nomcom/public_keys/' NOMCOM_FROM_EMAIL = 'nomcom-chair-{year}@ietf.org' OPENSSL_COMMAND = '/usr/bin/openssl' DAYS_TO_EXPIRE_NOMINATION_LINK = '' -NOMINEE_FEEDBACK_TYPES = ['comment', 'questio', 'nomina'] +NOMINEE_FEEDBACK_TYPES = ['comment', 'questio', 'nomina', 'obe'] # SlideSubmission settings SLIDE_STAGING_PATH = '/a/www/www6s/staging/' diff --git a/ietf/templates/nomcom/reclassify_feedback_item.html b/ietf/templates/nomcom/reclassify_feedback_item.html new file mode 100644 index 000000000..efa71dc10 --- /dev/null +++ b/ietf/templates/nomcom/reclassify_feedback_item.html @@ -0,0 +1,103 @@ +{# Copyright The IETF Trust 2023, All Rights Reserved #} +{% load nomcom_tags textfilters %} +