Skip to content

Commit 608b8e1

Browse files
feat: only offer IAB/IESG members for bofreq responsible leadership (ietf-tools#4276)
* refactor: avoid using select2 data-* attrs on html elements Using "data-ajax--url" shadows explicit configuration in our select2.js wrapper. Use "data-select2-ajax-url" to avoid this. Also add ability to omit the ajax setup entirely by returning None from ajax_url(). * chore: hook up a flag to disable ajax for SearchablePersonsField * refactor: send select2 prefetch data as array and allow config of min input length * feat: only offer IAB/IESG members for bofreq responsible leadership * test: area directors/IAB members should be options for bofreq responsible leaders * test: update tests to match changes to SearchableField * fix: clean up SearchablePersonsField breakage when searching by email address * chore: finish incomplete comment
1 parent 28b8ed0 commit 608b8e1

9 files changed

Lines changed: 95 additions & 31 deletions

File tree

ietf/doc/tests.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2468,12 +2468,12 @@ class _TestForm(Form):
24682468
decoded = json.loads(json_data)
24692469
except json.JSONDecodeError as e:
24702470
self.fail('data-pre contained invalid JSON data: %s' % str(e))
2471-
decoded_ids = list(decoded.keys())
2472-
self.assertCountEqual(decoded_ids, [str(doc.id) for doc in docs])
2471+
decoded_ids = [item['id'] for item in decoded]
2472+
self.assertEqual(decoded_ids, [doc.id for doc in docs])
24732473
for doc in docs:
24742474
self.assertEqual(
24752475
dict(id=doc.pk, selected=True, url=doc.get_absolute_url(), text=escape(uppercase_std_abbreviated_name(doc.name))),
2476-
decoded[str(doc.pk)],
2476+
decoded[decoded_ids.index(doc.pk)],
24772477
)
24782478

24792479
class MaterialsTests(TestCase):

ietf/doc/tests_bofreq.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import datetime
44
import debug # pyflakes:ignore
5+
import json
56
import os
67

78
from pathlib import Path
@@ -18,7 +19,9 @@
1819
from ietf.doc.factories import BofreqFactory, NewRevisionDocEventFactory
1920
from ietf.doc.models import State, Document, DocAlias, NewRevisionDocEvent
2021
from ietf.doc.utils_bofreq import bofreq_editors, bofreq_responsible
22+
from ietf.ietfauth.utils import has_role
2123
from ietf.person.factories import PersonFactory
24+
from ietf.person.models import Person
2225
from ietf.utils.mail import outbox, empty_outbox
2326
from ietf.utils.test_utils import TestCase, reload_db_objects, unicontent, login_testing_unauthorized
2427
from ietf.utils.text import xslugify
@@ -270,6 +273,25 @@ def test_change_responsible_validation(self):
270273
for p in bad_batch:
271274
self.assertIn(p.plain_name(), error_text)
272275

276+
def test_change_responsible_options(self):
277+
"""Only valid options should be offered for responsible leadership field"""
278+
doc = BofreqFactory()
279+
url = urlreverse('ietf.doc.views_bofreq.change_responsible', kwargs={'name': doc.name})
280+
self.client.login(username='secretary', password='secretary+password')
281+
r = self.client.get(url)
282+
self.assertEqual(r.status_code, 200)
283+
q = PyQuery(r.content)
284+
option_ids = [opt['id'] for opt in json.loads(q('#id_responsible').attr('data-pre'))]
285+
ad_people = [p for p in Person.objects.all() if has_role(p.user, 'Area Director')]
286+
iab_people = [p for p in Person.objects.all() if has_role(p.user, 'IAB')]
287+
self.assertGreater(len(ad_people), 0, 'Need at least one AD')
288+
self.assertGreater(len(iab_people), 0, 'Need at least one IAB member')
289+
self.assertGreater(Person.objects.count(), len(ad_people) + len(iab_people),
290+
'Need at least one Person not an AD nor IAB member')
291+
# Next line will fail if there's overlap between ad_people and iab_people. This is by design.
292+
# If the test setup changes and overlap is expected, need to separately check that area directors
293+
# and IAB members wind up in the options list.
294+
self.assertCountEqual(option_ids, [p.pk for p in ad_people + iab_people])
273295

274296
def test_submit(self):
275297
doc = BofreqFactory()

ietf/doc/views_bofreq.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django import forms
88
from django.conf import settings
99
from django.contrib.auth.decorators import login_required
10+
from django.db.models import Q
1011
from django.shortcuts import get_object_or_404, redirect, render
1112
from django.template.loader import render_to_string
1213
from django.urls import reverse as urlreverse
@@ -20,6 +21,7 @@
2021
from ietf.doc.utils_bofreq import bofreq_editors, bofreq_responsible
2122
from ietf.ietfauth.utils import has_role, role_required
2223
from ietf.person.fields import SearchablePersonsField
24+
from ietf.person.models import Person
2325
from ietf.utils import markdown
2426
from ietf.utils.response import permission_denied
2527
from ietf.utils.text import xslugify
@@ -224,7 +226,22 @@ def change_editors(request, name):
224226

225227

226228
class ChangeResponsibleForm(forms.Form):
227-
responsible = SearchablePersonsField(required=False)
229+
def __init__(self, *args, **kwargs):
230+
super().__init__(*args, **kwargs)
231+
232+
# The queryset in extra_prefetch finds users for whom has_role(u, 'Area Director') or
233+
# has_role(u, 'IAB') is True. It needs to match the queries in the has_role() method.
234+
# Disable ajax requests because the SearchablePersonsField cannot enforce the queryset filter.
235+
self.fields['responsible'] = SearchablePersonsField(
236+
required=False,
237+
extra_prefetch=Person.objects.filter(
238+
Q(role__name='member', role__group__acronym='iab')
239+
| Q(role__name__in=('pre-ad', 'ad'), role__group__type='area', role__group__state='active')
240+
).distinct(),
241+
disable_ajax=True, # only use the prefetched options
242+
min_search_length=0, # do not require typing to display options
243+
)
244+
228245
def clean_responsible(self):
229246
responsible = self.cleaned_data['responsible']
230247
not_leadership = list()

ietf/liaisons/tests.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1014,7 +1014,7 @@ def test_liaison_reply(self):
10141014
self.assertEqual(q('#id_from_groups').find('option:selected').val(),reply_from_group_id)
10151015
self.assertEqual(q('#id_to_groups').find('option:selected').val(),reply_to_group_id)
10161016
pre = json.loads(q('#id_related_to').attr("data-pre"))
1017-
self.assertEqual(pre[str(liaison.pk)]['id'], liaison.pk)
1017+
self.assertEqual(pre[0]['id'], liaison.pk)
10181018

10191019
def test_search(self):
10201020
# Statement 1

ietf/person/fields.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from typing import Type # pyflakes:ignore
1111

12+
import unidecode
1213
from django import forms
1314
from django.core.validators import validate_email
1415
from django.db import models # pyflakes:ignore
@@ -62,19 +63,24 @@ class SearchablePersonsField(SearchableField):
6263
that may be added to the initial set should be included in the extra_prefetch
6364
list. These can then be added by updating val() and triggering the 'change'
6465
event on the select2 field in JavaScript.
66+
67+
If disable_ajax is True, only objects that are prefetched can be selected. This
68+
will be any currently selected items plus any in extra_prefetch.
6569
"""
6670
model = Person # type: Type[models.Model]
6771
default_hint_text = "Type name to search for person."
6872
def __init__(self,
6973
only_users=False, # only select persons who also have a user
7074
all_emails=False, # select only active email addresses
7175
extra_prefetch=None, # extra data records to include in prefetch
76+
disable_ajax=False, # use ajax to search outside of prefetched set
7277
*args, **kwargs):
7378
super(SearchablePersonsField, self).__init__(*args, **kwargs)
7479
self.only_users = only_users
7580
self.all_emails = all_emails
7681
self.extra_prefetch = extra_prefetch or []
7782
assert all([isinstance(obj, self.model) for obj in self.extra_prefetch])
83+
self.disable_ajax = disable_ajax
7884

7985
def validate_pks(self, pks):
8086
"""Validate format of PKs"""
@@ -87,21 +93,27 @@ def make_select2_data(self, model_instances):
8793
# via the extra_prefetch property.
8894
prefetch_set = set(model_instances) if model_instances else set()
8995
prefetch_set = prefetch_set.union(set(self.extra_prefetch)) # eliminate duplicates
90-
return select2_id_name(list(prefetch_set))
96+
return sorted(
97+
select2_id_name(list(prefetch_set)),
98+
key=lambda item: unidecode.unidecode(item['text']),
99+
)
91100

92101
def ajax_url(self):
93-
url = urlreverse(
94-
"ietf.person.views.ajax_select2_search",
95-
kwargs={ "model_name": self.model.__name__.lower() }
96-
)
97-
query_args = {}
98-
if self.only_users:
99-
query_args["user"] = "1"
100-
if self.all_emails:
101-
query_args["a"] = "1"
102-
if len(query_args) > 0:
103-
url += '?%s' % urlencode(query_args)
104-
return url
102+
if self.disable_ajax:
103+
return None
104+
else:
105+
url = urlreverse(
106+
"ietf.person.views.ajax_select2_search",
107+
kwargs={ "model_name": self.model.__name__.lower() }
108+
)
109+
query_args = {}
110+
if self.only_users:
111+
query_args["user"] = "1"
112+
if self.all_emails:
113+
query_args["a"] = "1"
114+
if len(query_args) > 0:
115+
url += '?%s' % urlencode(query_args)
116+
return url
105117

106118

107119
class SearchablePersonField(SearchablePersonsField):

ietf/static/js/select2.js

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,27 @@ function prettify_tz(x) {
2222
// Copyright The IETF Trust 2015-2021, All Rights Reserved
2323
// JS for ietf.utils.fields.SearchableField subclasses
2424
window.setupSelect2Field = function (e) {
25-
var url = e.data("ajax--url");
26-
var maxEntries = e.data("max-entries");
27-
var result_key = e.data("result-key");
28-
var options = e.data("pre");
29-
for (var id in options) {
30-
e.append(new Option(options[id].text, options[id].id, false, options[id].selected));
25+
// Avoid using data attributes that match any of the search2 option attributes.
26+
// These will override settings in the options object that we use below.
27+
// (see https://select2.org/configuration/data-attributes)
28+
// Note: e.data('k') returns undefined if there is no data-k attribute.
29+
let url = e.data("select2-ajax-url");
30+
let maxEntries = e.data("max-entries");
31+
let minSearchLength = e.data("min-search-length");
32+
let result_key = e.data("result-key");
33+
let options = e.data("pre");
34+
if (options) {
35+
options.forEach(
36+
opt => e.append(new Option(opt.text, opt.id, false, opt.selected))
37+
);
3138
}
3239

3340
template_modify = e.hasClass("tz-select") ? prettify_tz : undefined;
3441

3542
e.select2({
3643
multiple: maxEntries !== 1,
3744
maximumSelectionSize: maxEntries,
45+
minimumInputLength: minSearchLength,
3846
templateResult: template_modify,
3947
templateSelection: template_modify,
4048
ajax: url ? {

ietf/templates/base.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
<label aria-label="Document search">
6262
<input class="form-control select2-field"
6363
id="navbar-doc-search"
64-
data-ajax--url="{% url 'ietf.doc.views_search.ajax_select2_search_docs' model_name='docalias' doc_type='draft' %}"
64+
data-select2-ajax-url="{% url 'ietf.doc.views_search.ajax_select2_search_docs' model_name='docalias' doc_type='draft' %}"
6565
type="text"
6666
data-placeholder="Document search">
6767
</label>

ietf/templates/doc/status_change/edit_related_rows.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
{% for rfc,choice_slug in form.relations.items %}
66
<div class="input-group mb-3">
77
<select class="form-control select2-field"
8-
data-ajax--url="{% url 'ietf.doc.views_search.ajax_select2_search_docs' model_name='document' doc_type='draft' %}"
8+
data-select2-ajax-url="{% url 'ietf.doc.views_search.ajax_select2_search_docs' model_name='document' doc_type='draft' %}"
99
data-max-entries="1"
1010
data-width="resolve"
1111
data-result-key="text"
@@ -39,7 +39,7 @@
3939
id="new_relation_row_rfc"
4040
aria-label="Enter new affected RFC"
4141
class="form-control select2-field"
42-
data-ajax--url="{% url 'ietf.doc.views_search.ajax_select2_search_docs' model_name='docalias' doc_type='draft' %}"
42+
data-select2-ajax-url="{% url 'ietf.doc.views_search.ajax_select2_search_docs' model_name='docalias' doc_type='draft' %}"
4343
data-result-key="text"
4444
data-max-entries="1"
4545
data-width="resolve"

ietf/utils/fields.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ class SearchableField(forms.MultipleChoiceField):
201201
model = None # type:Optional[Type[models.Model]]
202202
# max_entries = None # may be overridden in __init__
203203
max_entries = None # type: Optional[int]
204+
min_search_length = None # type: Optional[int]
204205
default_hint_text = 'Type a value to search'
205206

206207
def __init__(self, hint_text=None, *args, **kwargs):
@@ -210,13 +211,17 @@ def __init__(self, hint_text=None, *args, **kwargs):
210211
# not setting the parameter at all.
211212
if 'max_entries' in kwargs:
212213
self.max_entries = kwargs.pop('max_entries')
214+
if 'min_search_length' in kwargs:
215+
self.min_search_length = kwargs.pop('min_search_length')
213216

214217
super(SearchableField, self).__init__(*args, **kwargs)
215218

216219
self.widget.attrs["class"] = "select2-field"
217220
self.widget.attrs["data-placeholder"] = self.hint_text
218221
if self.max_entries is not None:
219222
self.widget.attrs["data-max-entries"] = self.max_entries
223+
if self.min_search_length is not None:
224+
self.widget.attrs["data-min-search-length"] = self.min_search_length
220225

221226
def make_select2_data(self, model_instances):
222227
"""Get select2 data items
@@ -281,13 +286,13 @@ def prepare_value(self, value):
281286
d["selected"] = any([v.pk == d["id"] for v in value])
282287
else:
283288
d["selected"] = value.exists() and value.filter(pk__in=[d["id"]]).exists()
284-
self.widget.attrs["data-pre"] = json.dumps({
285-
d['id']: d for d in pre
286-
})
289+
self.widget.attrs["data-pre"] = json.dumps(list(pre))
287290

288291
# doing this in the constructor is difficult because the URL
289292
# patterns may not have been fully constructed there yet
290-
self.widget.attrs["data-ajax--url"] = self.ajax_url()
293+
ajax_url = self.ajax_url()
294+
if ajax_url is not None:
295+
self.widget.attrs["data-select2-ajax-url"] = ajax_url
291296

292297
result = value
293298
return result

0 commit comments

Comments
 (0)