feat: Reclassify nomcom feedback (#6002)

* fix: Clean up view_feedback_pending

- Remove "Unclassified" column header, which caused misalignment in the table body.

- Show the message author - previously displayed as `(None)`.

* feat: Reclassify nomcom feedback (#4669)

- There's a new `Chair/Advisor Tasks` menu item `Reclassify feedback`.

- I overloaded `view_feedback*` URLs with a `?reclassify` parameter.

- This adds a checkbox to each feedback message, and a `Reclassify` button
at the bottom of each feedback page.

- "Reclassifying" basically de-classifies the feedback, and punts it back
to the "Pending emails" view for reclassification.

- If a feedback has been applied to multiple nominees, declassifying it
from one nominee removes it from all.

* fix: Remove unused local variables

* fix: Fix some missing and mis-nested html

* test: Add tests for reclassifying feedback

* refactor: Substantial redesign of feedback reclassification

- Break out reclassify_feedback* as their own URLs and views,
  and revert changes to view_feedback*.html.

- Replace checkboxes with a Reclassify button on each message.

* fix: Remember to clear the feedback associations when reclassifying

* feat: Add an 'Overcome by events' feedback type

* refactor: When invoking reclassification from a view-feedback page, load the corresponding reclassify-feedback page

* fix: De-conflict migration with 0004_statements

Also change the coding style to match, and add a reverse migration.

* fix: Fix a test case to account for new feedback type

* fix: 842e730 broke the Back button

* refactor: Reclassify feedback directly instead of putting it back in the work queue

* fix: Adjust tests to new workflow

* refactor: Further refine reclassification to avoid redirects

* refactor: Impose a FeedbackTypeName ordering

Also add FeedbackTypeName.legend field, rather than synthesizing it every
time we classify or reclassify feedback.

In the reclassification forms, only show the relevant feedback types.

* refactor: Merge reclassify_feedback_* back into view_feedback_*

This means the "Reclassify" button is always present, but eliminates some
complexity.

* refactor: Add filter(used=True) on FeedbackTypeName querysets

* refactor: Add the new FeedbackTypeName to the reclassification success message

* fix: Secure reclassification against rogue nomcom members
This commit is contained in:
Paul Selkirk 2023-08-08 13:33:17 -04:00 committed by GitHub
parent b327a27736
commit 06c9f06d55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 412 additions and 45 deletions

View file

@ -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",

View file

@ -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,
),
),
]

View file

@ -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),
]

View file

@ -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):

View file

@ -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

View file

@ -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)

View file

@ -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")

View file

@ -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/'

View file

@ -0,0 +1,103 @@
{# Copyright The IETF Trust 2023, All Rights Reserved #}
{% load nomcom_tags textfilters %}
<h2 class="mt-3">Reclassify feedback item</h2>
<form method="post">
{% csrf_token %}
<table class="table table-sm">
<thead>
<tr>
<th scope="col">Code</th>
<th scope="col">Explanation</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">U</th>
<td>Unclassified</td>
</tr>
{% for ft in feedback_types %}
<tr>
<th scope="row">{{ ft.legend }}</th>
<td>{{ ft.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<table class="table table-sm table-striped">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col" class="text-center" title="Unclassified">U</th>
{% for ft in feedback_types %}
<th scope="col" class="text-center" title="{{ ft.name }}">{{ ft.legend }}</th>
{% endfor %}
<th scope="col">Author</th>
<th scope="col">Subject</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr>
<!-- [html-validate-disable-block input-missing-label -- labelled via aria-label] -->
<td>{{ reclassify_feedback.time|date:"r" }}</td>
<td class="text-center">
<input type="radio"
class="form-check-input"
name="type"
value="unclassified"
id="unclassified"
aria-label="Unclassified"
title="Unclassified">
</td>
{% for ft in feedback_types %}
<td class="text-center">
<input type="radio"
class="form-check-input"
name="type"
value="{{ ft.slug }}"
id="{{ ft.name|slugify }}"
aria-label="{{ ft.name }}"
{% if reclassify_feedback.type == t %}checked{% endif %}
title="{{ ft.name }}">
</td>
{% endfor %}
<td>{{ reclassify_feedback.author }}</td>
<td>{{ reclassify_feedback.subject }}</td>
<td>
<button type="button"
class="btn btn-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#modal{{ reclassify_feedback.id }}">
View
</button>
<div class="modal fade"
id="modal{{ reclassify_feedback.id }}"
tabindex="-1"
role="dialog"
aria-labelledby="label{{ reclassify_feedback.id }}"
aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable modal-xl">
<div class="modal-content">
<div class="modal-header">
<p class="h5 modal-title" id="label{{ reclassify_feedback.id }}">{{ reclassify_feedback.subject }}</p>
<button type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
<pre>{% decrypt reclassify_feedback.comments request year 1 %}</pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<input type="hidden" name="feedback_id" value="{{ reclassify_feedback.id }}">
<button class="btn btn-primary" type="submit">Classify</button>
</form>

View file

@ -1,10 +1,13 @@
{% extends "nomcom/nomcom_private_base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{# Copyright The IETF Trust 2015-2023, All Rights Reserved #}
{% load origin %}
{% load nomcom_tags textfilters %}
{% block subtitle %}- View feedback about {{ nominee.email.person.name }}{% endblock %}
{% block nomcom_content %}
{% origin %}
{% origin %}
{% if reclassify_feedback %}
{% include "nomcom/reclassify_feedback_item.html" %}
{% else %}
<h2>Feedback about {{ nominee }}</h2>
<ul class="nav nav-tabs my-3" role="tablist">
{% for ft in feedback_types %}
@ -81,6 +84,19 @@
<dd class="col-sm-10 pasted">
<pre>{% decrypt feedback.comments request year 1 %}</pre>
</dd>
{% if user|is_chair_or_advisor:year %}
<dt class="col-sm-2">
<form id="reclassify-{{ feedback.id }}" method="post">
{% csrf_token %}
<input type="hidden" name="feedback_id" value="{{ feedback.id }}">
<button class="btn btn-warning btn-sm" type="submit">
Reclassify
</button>
</form>
</dt>
<dd>
</dd>
{% endif %}
</dl>
{% if not forloop.last %}<hr>{% endif %}
{% endif %}
@ -90,6 +106,7 @@
</div>
<a class="btn btn-secondary"
href="{% url 'ietf.nomcom.views.view_feedback' year %}">Back</a>
{% endif %}
{% endblock %}
{% block js %}
<script>

View file

@ -73,9 +73,9 @@
</tr>
</thead>
<tbody>
{% for legend, t in type_dict.items %}
{% for t in types %}
<tr>
<th scope="row">{{ legend }}</th>
<th scope="row">{{ t.legend }}</th>
<td>{{ t.name }}</td>
</tr>
{% endfor %}
@ -85,7 +85,7 @@
<thead>
<tr>
<th scope="col">Date</th>
{% for legend, t in type_dict.items %}<th scope="col" class="text-center" title="{{ t.name }}">{{ legend }}</th>{% endfor %}
{% for t in types %}<th scope="col" class="text-center" title="{{ t.name }}">{{ t.legend }}</th>{% endfor %}
<th scope="col">Author</th>
<th scope="col">Subject</th>
<th scope="col"></th>

View file

@ -1,10 +1,13 @@
{% extends "nomcom/nomcom_private_base.html" %}
{# Copyright The IETF Trust 2017, All Rights Reserved #}
{# Copyright The IETF Trust 2017-2023, All Rights Reserved #}
{% load origin %}
{% load nomcom_tags textfilters %}
{% block subtitle %}- View feedback about {{ topic.subject }}{% endblock %}
{% block nomcom_content %}
{% origin %}
{% origin %}
{% if reclassify_feedback %}
{% include "nomcom/reclassify_feedback_item.html" %}
{% else %}
<h2>Feedback about {{ topic.subject }}</h2>
<ul class="nav nav-tabs my-3" role="tablist">
{% for ft in feedback_types %}
@ -44,6 +47,19 @@
<dd class="col-sm-10 pasted">
<pre>{% decrypt feedback.comments request year 1 %}</pre>
</dd>
{% if user|is_chair_or_advisor:year %}
<dt class="col-sm-2">
<form id="reclassify-{{ feedback.id }}" method="post">
{% csrf_token %}
<input type="hidden" name="feedback_id" value="{{ feedback.id }}">
<button class="btn btn-warning btn-sm" type="submit">
Reclassify
</button>
</form>
</dt>
<dd>
</dd>
{% endif %}
</dl>
{% if not forloop.last %}<hr>{% endif %}
{% endif %}
@ -53,6 +69,7 @@
</div>
<a class="btn btn-secondary"
href="{% url 'ietf.nomcom.views.view_feedback' year %}">Back</a>
{% endif %}
{% endblock %}
{% block js %}
<script>

View file

@ -1,10 +1,13 @@
{% extends "nomcom/nomcom_private_base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{# Copyright The IETF Trust 2015-2023, All Rights Reserved #}
{% load origin %}
{% load nomcom_tags textfilters %}
{% block subtitle %}- View unrelated feedback{% endblock %}
{% block nomcom_content %}
{% origin %}
{% origin %}
{% if reclassify_feedback %}
{% include "nomcom/reclassify_feedback_item.html" %}
{% else %}
<h2>Feedback not related to nominees</h2>
<ul role="tablist" class="nav nav-tabs my-3">
{% for ft in feedback_types %}
@ -45,6 +48,19 @@
<dd class="col-sm-10 pasted">
<pre>{% decrypt feedback.comments request year 1 %}</pre>
</dd>
{% if user|is_chair_or_advisor:year %}
<dt class="col-sm-2">
<form id="reclassify-{{ feedback.id }}" method="post">
{% csrf_token %}
<input type="hidden" name="feedback_id" value="{{ feedback.id }}">
<button class="btn btn-warning btn-sm" type="submit">
Reclassify
</button>
</form>
</dt>
<dd>
</dd>
{% endif %}
</dl>
{% endfor %}
</div>
@ -54,6 +70,7 @@
<a class="btn btn-secondary"
href="{% url 'ietf.nomcom.views.view_feedback' year %}">Back</a>
</p>
{% endif %}
{% endblock %}
{% block js %}
<script>