diff --git a/ietf/community/views.py b/ietf/community/views.py
index 0e0dcb9a0..7717c6ee1 100644
--- a/ietf/community/views.py
+++ b/ietf/community/views.py
@@ -72,6 +72,7 @@ def manage_list(request, username=None, acronym=None, group_type=None):
return HttpResponseRedirect("")
+ rule_form = None
if request.method == 'POST' and action == 'add_rule':
rule_type_form = SearchRuleTypeForm(request.POST)
if rule_type_form.is_valid():
@@ -93,7 +94,6 @@ def manage_list(request, username=None, acronym=None, group_type=None):
return HttpResponseRedirect("")
else:
rule_type_form = SearchRuleTypeForm()
- rule_form = None
if request.method == 'POST' and action == 'remove_rule':
rule_pk = request.POST.get('rule')
@@ -111,6 +111,8 @@ def manage_list(request, username=None, acronym=None, group_type=None):
total_count = docs_tracked_by_community_list(clist).count()
+ all_forms = [f for f in [rule_type_form, rule_form, add_doc_form, *empty_rule_forms.values()]
+ if f is not None]
return render(request, 'community/manage_list.html', {
'clist': clist,
'rules': rules,
@@ -120,6 +122,7 @@ def manage_list(request, username=None, acronym=None, group_type=None):
'empty_rule_forms': empty_rule_forms,
'total_count': total_count,
'add_doc_form': add_doc_form,
+ 'all_forms': all_forms,
})
diff --git a/ietf/doc/fields.py b/ietf/doc/fields.py
index 4258fc81c..644de54ac 100644
--- a/ietf/doc/fields.py
+++ b/ietf/doc/fields.py
@@ -13,123 +13,72 @@ import debug # pyflakes:ignore
from ietf.doc.models import Document, DocAlias
from ietf.doc.utils import uppercase_std_abbreviated_name
+from ietf.utils.fields import SearchableField
def select2_id_doc_name(objs):
return [{
- "id": o.pk,
- "text": escape(uppercase_std_abbreviated_name(o.name)),
+ "id": o.pk,
+ "text": escape(uppercase_std_abbreviated_name(o.name)),
} for o in objs]
def select2_id_doc_name_json(objs):
return json.dumps(select2_id_doc_name(objs))
-# FIXME: select2 version 4 uses a standard select for the AJAX case -
-# switching to that would allow us to derive from the standard
-# multi-select machinery in Django instead of the manual CharField
-# stuff below
-class SearchableDocumentsField(forms.CharField):
- """Server-based multi-select field for choosing documents using
- select2.js.
-
- The field uses a comma-separated list of primary keys in a
- CharField element as its API with some extra attributes used by
- the Javascript part."""
-
- def __init__(self,
- max_entries=None, # max number of selected objs
- model=Document,
- hint_text="Type in name to search for document",
- doc_type="draft",
- *args, **kwargs):
- kwargs["max_length"] = 10000
- self.max_entries = max_entries
- self.doc_type = doc_type
- self.model = model
+class SearchableDocumentsField(SearchableField):
+ """Server-based multi-select field for choosing documents using select2.js. """
+ model = Document
+ default_hint_text = "Type name to search for document"
+ def __init__(self, doc_type="draft", *args, **kwargs):
super(SearchableDocumentsField, self).__init__(*args, **kwargs)
+ self.doc_type = doc_type
- self.widget.attrs["class"] = "select2-field form-control"
- self.widget.attrs["data-placeholder"] = hint_text
- if self.max_entries != None:
- self.widget.attrs["data-max-entries"] = self.max_entries
+ def doc_type_filter(self, queryset):
+ """Filter to include only desired doc type"""
+ return queryset.filter(type=self.doc_type)
- def parse_select2_value(self, value):
- return [x.strip() for x in value.split(",") if x.strip()]
+ def get_model_instances(self, item_ids):
+ """Get model instances corresponding to item identifiers in select2 field value
- def prepare_value(self, value):
- if not value:
- value = ""
- if isinstance(value, int):
- value = str(value)
- if isinstance(value, str):
- items = self.parse_select2_value(value)
- # accept both names and pks here
- names = [ i for i in items if not i.isdigit() ]
- ids = [ i for i in items if i.isdigit() ]
- value = self.model.objects.filter(Q(name__in=names)|Q(id__in=ids))
- filter_args = {}
- if self.model == DocAlias:
- filter_args["docs__type"] = self.doc_type
- else:
- filter_args["type"] = self.doc_type
- value = value.filter(**filter_args)
- if isinstance(value, self.model):
- value = [value]
+ Accepts both names and pks as IDs
+ """
+ names = [ i for i in item_ids if not i.isdigit() ]
+ ids = [ i for i in item_ids if i.isdigit() ]
+ objs = self.model.objects.filter(
+ Q(name__in=names)|Q(id__in=ids)
+ )
+ return self.doc_type_filter(objs)
- self.widget.attrs["data-pre"] = json.dumps({
- d['id']: d for d in select2_id_doc_name(value)
- })
+ def make_select2_data(self, model_instances):
+ """Get select2 data items"""
+ return select2_id_doc_name(model_instances)
- # doing this in the constructor is difficult because the URL
- # patterns may not have been fully constructed there yet
- self.widget.attrs["data-ajax-url"] = urlreverse('ietf.doc.views_search.ajax_select2_search_docs', kwargs={
+ def ajax_url(self):
+ """Get the URL for AJAX searches"""
+ return urlreverse('ietf.doc.views_search.ajax_select2_search_docs', kwargs={
"doc_type": self.doc_type,
"model_name": self.model.__name__.lower()
})
- return ",".join(str(o.pk) for o in value)
-
- def clean(self, value):
- value = super(SearchableDocumentsField, self).clean(value)
- pks = self.parse_select2_value(value)
-
- try:
- objs = self.model.objects.filter(pk__in=pks)
- except ValueError as e:
- raise forms.ValidationError("Unexpected field value; %s" % e)
-
- found_pks = [ str(o.pk) for o in objs ]
- failed_pks = [ x for x in pks if x not in found_pks ]
- if failed_pks:
- raise forms.ValidationError("Could not recognize the following documents: {names}. You can only input documents already registered in the Datatracker.".format(names=", ".join(failed_pks)))
-
- if self.max_entries != None and len(objs) > self.max_entries:
- raise forms.ValidationError("You can select at most %s entries." % self.max_entries)
-
- return objs
class SearchableDocumentField(SearchableDocumentsField):
- """Specialized to only return one Document."""
- def __init__(self, model=Document, *args, **kwargs):
- kwargs["max_entries"] = 1
- super(SearchableDocumentField, self).__init__(model=model, *args, **kwargs)
+ """Specialized to only return one Document"""
+ max_entries = 1
+
- def clean(self, value):
- return super(SearchableDocumentField, self).clean(value).first()
-
class SearchableDocAliasesField(SearchableDocumentsField):
- def __init__(self, model=DocAlias, *args, **kwargs):
- super(SearchableDocAliasesField, self).__init__(model=model, *args, **kwargs)
+ """Search DocAliases instead of Documents"""
+ model = DocAlias
-class SearchableDocAliasField(SearchableDocumentsField):
- """Specialized to only return one DocAlias."""
- def __init__(self, model=DocAlias, *args, **kwargs):
- kwargs["max_entries"] = 1
- super(SearchableDocAliasField, self).__init__(model=model, *args, **kwargs)
+ def doc_type_filter(self, queryset):
+ """Filter to include only desired doc type
- def clean(self, value):
- return super(SearchableDocAliasField, self).clean(value).first()
+ For DocAlias, pass through to the docs to check type.
+ """
+ return queryset.filter(docs__type=self.doc_type)
-
+class SearchableDocAliasField(SearchableDocAliasesField):
+ """Specialized to only return one DocAlias"""
+ max_entries = 1
diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py
index 8d3f81b56..53c3b6f21 100644
--- a/ietf/doc/tests.py
+++ b/ietf/doc/tests.py
@@ -9,7 +9,7 @@ import io
import lxml
import bibtexparser
import mock
-
+import json
from http.cookies import SimpleCookie
from pyquery import PyQuery
@@ -18,6 +18,8 @@ from tempfile import NamedTemporaryFile
from django.urls import reverse as urlreverse
from django.conf import settings
+from django.forms import Form
+from django.utils.html import escape
from tastypie.test import ResourceTestCaseMixin
@@ -29,7 +31,8 @@ from ietf.doc.factories import ( DocumentFactory, DocEventFactory, CharterFactor
ConflictReviewFactory, WgDraftFactory, IndividualDraftFactory, WgRfcFactory,
IndividualRfcFactory, StateDocEventFactory, BallotPositionDocEventFactory,
BallotDocEventFactory )
-from ietf.doc.utils import create_ballot_if_not_open
+from ietf.doc.fields import SearchableDocumentsField
+from ietf.doc.utils import create_ballot_if_not_open, uppercase_std_abbreviated_name
from ietf.group.models import Group
from ietf.group.factories import GroupFactory, RoleFactory
from ietf.ipr.factories import HolderIprDisclosureFactory
@@ -1691,3 +1694,27 @@ class ChartTests(ResourceTestCaseMixin, TestCase):
r = self.client.get(page_url)
self.assertEqual(r.status_code, 200)
+
+class FieldTests(TestCase):
+ def test_searchabledocumentsfield_pre(self):
+ # so far, just tests that the format expected by select2-field.js is set up
+ docs = IndividualDraftFactory.create_batch(3)
+
+ class _TestForm(Form):
+ test_field = SearchableDocumentsField()
+
+ form = _TestForm(initial=dict(test_field=docs))
+ html = str(form)
+ q = PyQuery(html)
+ json_data = q('input.select2-field').attr('data-pre')
+ try:
+ decoded = json.loads(json_data)
+ except json.JSONDecodeError as e:
+ self.fail('data-pre contained invalid JSON data: %s' % str(e))
+ decoded_ids = list(decoded.keys())
+ self.assertCountEqual(decoded_ids, [str(doc.id) for doc in docs])
+ for doc in docs:
+ self.assertEqual(
+ dict(id=doc.pk, text=escape(uppercase_std_abbreviated_name(doc.name))),
+ decoded[str(doc.pk)],
+ )
diff --git a/ietf/group/milestones.py b/ietf/group/milestones.py
index 643dcac97..c79116ff3 100644
--- a/ietf/group/milestones.py
+++ b/ietf/group/milestones.py
@@ -391,6 +391,7 @@ def edit_milestones(request, acronym, group_type=None, milestone_set="current"):
forms=forms,
form_errors=form_errors,
empty_form=empty_form,
+ all_forms=forms + [empty_form],
milestone_set=milestone_set,
needs_review=needs_review,
reviewer=reviewer,
diff --git a/ietf/group/tests_js.py b/ietf/group/tests_js.py
new file mode 100644
index 000000000..4200efeff
--- /dev/null
+++ b/ietf/group/tests_js.py
@@ -0,0 +1,186 @@
+# Copyright The IETF Trust 2021, All Rights Reserved
+# -*- coding: utf-8 -*-
+
+import datetime
+import debug # pyflakes:ignore
+
+from ietf.doc.factories import WgDraftFactory
+from ietf.group.factories import GroupFactory, RoleFactory, DatedGroupMilestoneFactory
+from ietf.utils.jstest import IetfSeleniumTestCase, ifSeleniumEnabled, selenium_enabled
+
+if selenium_enabled():
+ from selenium.common.exceptions import TimeoutException
+ from selenium.webdriver.common.by import By
+ from selenium.webdriver.support.ui import WebDriverWait
+ from selenium.webdriver.support import expected_conditions
+
+
+@ifSeleniumEnabled
+class MilestoneTests(IetfSeleniumTestCase):
+ def setUp(self):
+ super(MilestoneTests, self).setUp()
+
+ self.wait = WebDriverWait(self.driver, 2)
+ self.group = GroupFactory()
+ self.chair = RoleFactory(group=self.group, name_id='chair').person
+
+ def _search_draft_and_locate_result(self, draft_input, search_string, draft):
+ """Search for a draft and get the search result element"""
+ draft_input.send_keys(search_string)
+
+ result_selector = 'ul.select2-results > li > div.select2-result-label'
+ self.wait.until(
+ expected_conditions.text_to_be_present_in_element(
+ (By.CSS_SELECTOR, result_selector),
+ draft.name
+ ))
+ results = self.driver.find_elements_by_css_selector(result_selector)
+ matching_results = [r for r in results if draft.name in r.text]
+ self.assertEqual(len(matching_results), 1)
+ return matching_results[0]
+
+ def _click_milestone_submit_button(self, label):
+ submit_button_selector = 'form#milestones-form button[type="submit"]'
+ submit_button = self.wait.until(
+ expected_conditions.element_to_be_clickable((By.CSS_SELECTOR, submit_button_selector))
+ )
+ self.assertIn(label, submit_button.text)
+ submit_button.click()
+
+ def _assert_milestone_changed(self):
+ """Wait for milestone to be marked as changed and assert that this succeeded"""
+ milestone_selector = 'form#milestones-form .milestone'
+ try:
+ found_expected_text = self.wait.until(
+ expected_conditions.text_to_be_present_in_element(
+ (By.CSS_SELECTOR, milestone_selector),
+ 'Changed'
+ )
+ )
+ except TimeoutException:
+ found_expected_text = False
+ self.assertTrue(found_expected_text, 'Milestone never marked as "changed"')
+ return self.driver.find_element_by_css_selector(milestone_selector)
+
+ def test_add_milestone(self):
+ draft = WgDraftFactory()
+ WgDraftFactory.create_batch(3) # some drafts to ignore
+ description = 'some description'
+ due_date = datetime.date.today() + datetime.timedelta(days=60)
+
+ assert(len(draft.name) > 5)
+ draft_search_string = draft.name[-5:]
+
+ self.login(self.chair.user.username)
+ url = self.absreverse('ietf.group.milestones.edit_milestones;current',
+ kwargs=dict(acronym=self.group.acronym))
+ self.driver.get(url)
+
+ add_milestone_button = self.wait.until(
+ expected_conditions.element_to_be_clickable(
+ (By.CSS_SELECTOR, 'button.add-milestone')
+ ))
+ add_milestone_button.click()
+
+ edit_div = self.wait.until(
+ expected_conditions.visibility_of_element_located(
+ (By.CSS_SELECTOR, 'form#milestones-form div.edit-milestone')
+ ))
+
+ desc_input = edit_div.find_element_by_css_selector('input[id$="_desc"]')
+ due_input = edit_div.find_element_by_css_selector('input[id$="_due"]')
+ draft_input = edit_div.find_element_by_css_selector(
+ 'div.select2-container[id$="id_docs"] input.select2-input'
+ )
+
+ # fill in the edit milestone form
+ desc_input.send_keys(description)
+ due_input.send_keys(due_date.strftime('%m %Y\n')) # \n closes the date selector
+ self._search_draft_and_locate_result(draft_input, draft_search_string, draft).click()
+
+ self._click_milestone_submit_button('Review')
+ result_row = self._assert_milestone_changed()
+ self.assertIn(description, result_row.text)
+ self._click_milestone_submit_button('Save')
+
+ # Wait for page to return to group page
+ self.wait.until(
+ expected_conditions.text_to_be_present_in_element(
+ (By.CSS_SELECTOR, 'div#content h1'),
+ self.group.name
+ )
+ )
+ self.assertIn('1 new milestone', self.driver.page_source)
+ self.assertEqual(self.group.groupmilestone_set.count(), 1)
+ gms = self.group.groupmilestone_set.first()
+ self.assertEqual(gms.desc, description)
+ self.assertEqual(gms.due.strftime('%m %Y'), due_date.strftime('%m %Y'))
+ self.assertEqual(list(gms.docs.all()), [draft])
+
+ def test_edit_milestone(self):
+ milestone = DatedGroupMilestoneFactory(group=self.group)
+ draft = WgDraftFactory()
+ WgDraftFactory.create_batch(3) # some drafts to ignore
+
+ assert(len(draft.name) > 5)
+ draft_search_string = draft.name[-5:]
+
+ url = self.absreverse('ietf.group.milestones.edit_milestones;current',
+ kwargs=dict(acronym=self.group.acronym))
+ self.login(self.chair.user.username)
+ self.driver.get(url)
+
+ # should only be one milestone row - test will fail later if we somehow get the wrong one
+ edit_element = self.wait.until(
+ expected_conditions.element_to_be_clickable(
+ (By.CSS_SELECTOR, 'form#milestones-form div.milestonerow')
+ )
+ )
+ edit_element.click()
+
+ # find the description field corresponding to our milestone
+ desc_field = self.wait.until(
+ expected_conditions.visibility_of_element_located(
+ (By.CSS_SELECTOR, 'input[value="%s"]' % milestone.desc)
+ )
+ )
+ # Get the prefix used to identify inputs related to this milestone
+ prefix = desc_field.get_attribute('id')[:-4] # -4 to strip off 'desc', leave '-'
+
+ due_field = self.driver.find_element_by_id(prefix + 'due')
+ hidden_drafts_field = self.driver.find_element_by_id(prefix + 'docs')
+ draft_input = self.driver.find_element_by_css_selector(
+ 'div.select2-container[id*="%s"] input.select2-input' % prefix
+ )
+ self.assertEqual(due_field.get_attribute('value'), milestone.due.strftime('%B %Y'))
+ self.assertEqual(hidden_drafts_field.get_attribute('value'),
+ ','.join([str(doc.pk) for doc in milestone.docs.all()]))
+
+ # modify the fields
+ new_due_date = (milestone.due + datetime.timedelta(days=31)).strftime('%m %Y')
+ due_field.clear()
+ due_field.send_keys(new_due_date + '\n')
+
+ self._search_draft_and_locate_result(draft_input, draft_search_string, draft).click()
+
+ self._click_milestone_submit_button('Review')
+ self._assert_milestone_changed()
+ self._click_milestone_submit_button('Save')
+
+ # Wait for page to return to group page
+ self.wait.until(
+ expected_conditions.text_to_be_present_in_element(
+ (By.CSS_SELECTOR, 'div#content h1'),
+ self.group.name
+ )
+ )
+
+ expected_desc = milestone.desc
+ expected_due_date = new_due_date
+ expected_docs = [draft]
+
+ self.assertEqual(self.group.groupmilestone_set.count(), 1)
+ gms = self.group.groupmilestone_set.first()
+ self.assertEqual(gms.desc, expected_desc)
+ self.assertEqual(gms.due.strftime('%m %Y'), expected_due_date)
+ self.assertCountEqual(expected_docs, gms.docs.all())
diff --git a/ietf/ipr/fields.py b/ietf/ipr/fields.py
index 3a4ffeb94..182957231 100644
--- a/ietf/ipr/fields.py
+++ b/ietf/ipr/fields.py
@@ -11,87 +11,36 @@ from django.urls import reverse as urlreverse
import debug # pyflakes:ignore
from ietf.ipr.models import IprDisclosureBase
+from ietf.utils.fields import SearchableField
+
def select2_id_ipr_title(objs):
return [{
- "id": o.pk,
+ "id": o.pk,
"text": escape("%s <%s>" % (o.title, o.time.date().isoformat())),
} for o in objs]
-
+
def select2_id_ipr_title_json(value):
return json.dumps(select2_id_ipr_title(value))
-class SearchableIprDisclosuresField(forms.CharField):
- """Server-based multi-select field for choosing documents using
- select2.js.
+class SearchableIprDisclosuresField(SearchableField):
+ """Server-based multi-select field for choosing documents using select2.js"""
+ model = IprDisclosureBase
+ default_hint_text = "Type in terms to search disclosure title"
- The field uses a comma-separated list of primary keys in a
- CharField element as its API with some extra attributes used by
- the Javascript part."""
-
- def __init__(self,
- max_entries=None, # max number of selected objs
- model=IprDisclosureBase,
- hint_text="Type in terms to search disclosure title",
- *args, **kwargs):
- kwargs["max_length"] = 1000
- self.max_entries = max_entries
- self.model = model
-
- super(SearchableIprDisclosuresField, self).__init__(*args, **kwargs)
-
- self.widget.attrs["class"] = "select2-field form-control"
- self.widget.attrs["data-placeholder"] = hint_text
- if self.max_entries != None:
- self.widget.attrs["data-max-entries"] = self.max_entries
-
- def parse_select2_value(self, value):
- return [x.strip() for x in value.split(",") if x.strip()]
-
- def check_pks(self, pks):
+ def validate_pks(self, pks):
for pk in pks:
if not pk.isdigit():
- raise forms.ValidationError("Unexpected value: %s" % pk)
- return pks
+ raise forms.ValidationError("You must enter IPR ID(s) as integers (Unexpected value: %s)" % pk)
- def prepare_value(self, value):
- if not value:
- value = ""
- if isinstance(value, str):
- pks = self.parse_select2_value(value)
- # if the user posted a non integer value we need to remove it
- for key in pks:
- if not key.isdigit():
- pks.remove(key)
- value = self.model.objects.filter(pk__in=pks)
- if isinstance(value, self.model):
- value = [value]
+ def get_model_instances(self, item_ids):
+ for key in item_ids:
+ if not key.isdigit():
+ item_ids.remove(key)
+ return super(SearchableIprDisclosuresField, self).get_model_instances(item_ids)
- self.widget.attrs["data-pre"] = json.dumps({
- d['id']: d for d in select2_id_ipr_title(value)
- })
+ def make_select2_data(self, model_instances):
+ return select2_id_ipr_title(model_instances)
- # doing this in the constructor is difficult because the URL
- # patterns may not have been fully constructed there yet
- self.widget.attrs["data-ajax-url"] = urlreverse('ietf.ipr.views.ajax_search')
-
- return ",".join(str(e.pk) for e in value)
-
- def clean(self, value):
- value = super(SearchableIprDisclosuresField, self).clean(value)
- pks = self.check_pks(self.parse_select2_value(value))
-
- if not all([ key.isdigit() for key in pks ]):
- raise forms.ValidationError('You must enter IPR ID(s) as integers')
-
- objs = self.model.objects.filter(pk__in=pks)
-
- found_pks = [str(o.pk) for o in objs]
- failed_pks = [x for x in pks if x not in found_pks]
- if failed_pks:
- raise forms.ValidationError("Could not recognize the following {model_name}s: {pks}. You can only input {model_name}s already registered in the Datatracker.".format(pks=", ".join(failed_pks), model_name=self.model.__name__.lower()))
-
- if self.max_entries != None and len(objs) > self.max_entries:
- raise forms.ValidationError("You can select at most %s entries only." % self.max_entries)
-
- return objs
+ def ajax_url(self):
+ return urlreverse('ietf.ipr.views.ajax_search')
diff --git a/ietf/liaisons/fields.py b/ietf/liaisons/fields.py
index 8c2a306f0..e7762bb09 100644
--- a/ietf/liaisons/fields.py
+++ b/ietf/liaisons/fields.py
@@ -9,81 +9,38 @@ from django import forms
from django.urls import reverse as urlreverse
from ietf.liaisons.models import LiaisonStatement
+from ietf.utils.fields import SearchableField
+
def select2_id_liaison(objs):
return [{
- "id": o.pk,
- "text":"[{}] {}".format(o.pk, escape(o.title)),
+ "id": o.pk,
+ "text":"[{}] {}".format(o.pk, escape(o.title)),
} for o in objs]
-
+
def select2_id_liaison_json(objs):
return json.dumps(select2_id_liaison(objs))
def select2_id_group_json(objs):
return json.dumps([{ "id": o.pk, "text": escape(o.acronym) } for o in objs])
-class SearchableLiaisonStatementsField(forms.CharField):
+
+class SearchableLiaisonStatementsField(SearchableField):
"""Server-based multi-select field for choosing liaison statements using
select2.js."""
+ model = LiaisonStatement
+ default_hint_text = "Type in title to search for document"
- def __init__(self,
- max_entries = None,
- hint_text="Type in title to search for document",
- model = LiaisonStatement,
- *args, **kwargs):
- kwargs["max_length"] = 10000
- self.model = model
- self.max_entries = max_entries
-
- super(SearchableLiaisonStatementsField, self).__init__(*args, **kwargs)
-
- self.widget.attrs["class"] = "select2-field form-control"
- self.widget.attrs["data-placeholder"] = hint_text
- if self.max_entries != None:
- self.widget.attrs["data-max-entries"] = self.max_entries
-
- def parse_select2_value(self, value):
- return [x.strip() for x in value.split(",") if x.strip()]
-
- def check_pks(self, pks):
+ def validate_pks(self, pks):
for pk in pks:
if not pk.isdigit():
raise forms.ValidationError("Unexpected value: %s" % pk)
- return pks
- def prepare_value(self, value):
- if not value:
- value = ""
- if isinstance(value, int):
- value = str(value)
- if isinstance(value, str):
- pks = self.parse_select2_value(value)
- value = self.model.objects.filter(pk__in=pks)
- if isinstance(value, LiaisonStatement):
- value = [value]
+ def make_select2_data(self, model_instances):
+ return select2_id_liaison(model_instances)
- self.widget.attrs["data-pre"] = json.dumps({
- d['id']: d for d in select2_id_liaison(value)
- })
+ def ajax_url(self):
+ return urlreverse("ietf.liaisons.views.ajax_select2_search_liaison_statements")
- # doing this in the constructor is difficult because the URL
- # patterns may not have been fully constructed there yet
- self.widget.attrs["data-ajax-url"] = urlreverse("ietf.liaisons.views.ajax_select2_search_liaison_statements")
-
- return ",".join(str(o.pk) for o in value)
-
- def clean(self, value):
- value = super(SearchableLiaisonStatementsField, self).clean(value)
- pks = self.check_pks(self.parse_select2_value(value))
-
- objs = self.model.objects.filter(pk__in=pks)
-
- found_pks = [str(o.pk) for o in objs]
- failed_pks = [x for x in pks if x not in found_pks]
- if failed_pks:
- raise forms.ValidationError("Could not recognize the following groups: {pks}.".format(pks=", ".join(failed_pks)))
-
- if self.max_entries != None and len(objs) > self.max_entries:
- raise forms.ValidationError("You can select at most %s entries only." % self.max_entries)
-
- return objs
+ def describe_failed_pks(self, failed_pks):
+ return "Could not recognize the following groups: {pks}.".format(pks=", ".join(failed_pks))
diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py
index 9342c6a3f..f1a77d5b1 100644
--- a/ietf/meeting/tests_js.py
+++ b/ietf/meeting/tests_js.py
@@ -30,72 +30,21 @@ from ietf.meeting.models import (Schedule, SchedTimeSessAssignment, Session,
Room, TimeSlot, Constraint, ConstraintName,
Meeting, SchedulingEvent, SessionStatusName)
from ietf.meeting.utils import add_event_info_to_session_qs
-from ietf.utils.pipe import pipe
from ietf.utils.test_runner import IetfLiveServerTestCase
from ietf.utils.test_utils import assert_ical_response_is_valid
+from ietf.utils.jstest import IetfSeleniumTestCase, ifSeleniumEnabled, selenium_enabled
from ietf import settings
-skip_selenium = False
-skip_message = ""
-try:
- from selenium import webdriver
+if selenium_enabled():
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import Select, WebDriverWait
from selenium.webdriver.support import expected_conditions
from selenium.common.exceptions import NoSuchElementException
-except ImportError as e:
- skip_selenium = True
- skip_message = "Skipping selenium tests: %s" % e
-
-executable_name = 'chromedriver'
-code, out, err = pipe('{} --version'.format(executable_name))
-if code != 0:
- skip_selenium = True
- skip_message = "Skipping selenium tests: '{}' executable not found.".format(executable_name)
-if skip_selenium:
- print(" "+skip_message)
-
-def start_web_driver():
- options = webdriver.ChromeOptions()
- options.add_argument("headless")
- options.add_argument("disable-extensions")
- options.add_argument("disable-gpu") # headless needs this
- options.add_argument("no-sandbox") # docker needs this
- return webdriver.Chrome(options=options, service_log_path=settings.TEST_GHOSTDRIVER_LOG_PATH)
-
-# This class is subclassed later, and does not contain any tests, so doesn't need @skipIf()
-class MeetingTestCase(IetfLiveServerTestCase):
- def __init__(self, *args, **kwargs):
- super(MeetingTestCase, self).__init__(*args, **kwargs)
- self.login_view = 'ietf.ietfauth.views.login'
-
- def setUp(self):
- super(MeetingTestCase, self).setUp()
- self.driver = start_web_driver()
- self.driver.set_window_size(1024,768)
-
- def tearDown(self):
- self.driver.close()
-
- def absreverse(self,*args,**kwargs):
- return '%s%s'%(self.live_server_url,urlreverse(*args,**kwargs))
-
- def login(self, username='plain'):
- url = self.absreverse(self.login_view)
- password = '%s+password' % username
- self.driver.get(url)
- self.driver.find_element_by_name('username').send_keys(username)
- self.driver.find_element_by_name('password').send_keys(password)
- self.driver.find_element_by_xpath('//button[@type="submit"]').click()
-
- def debug_snapshot(self,filename='debug_this.png'):
- self.driver.execute_script("document.body.bgColor = 'white';")
- self.driver.save_screenshot(filename)
-@skipIf(skip_selenium, skip_message)
-class EditMeetingScheduleTests(MeetingTestCase):
+@ifSeleniumEnabled
+class EditMeetingScheduleTests(IetfSeleniumTestCase):
def test_edit_meeting_schedule(self):
meeting = make_meeting_test_data()
@@ -278,9 +227,9 @@ class EditMeetingScheduleTests(MeetingTestCase):
self.assertTrue(self.driver.find_elements_by_css_selector('#timeslot{} #session{}'.format(slot4.pk, s1.pk)))
-@skipIf(skip_selenium, skip_message)
+@ifSeleniumEnabled
@skipIf(django.VERSION[0]==2, "Skipping test with race conditions under Django 2")
-class ScheduleEditTests(MeetingTestCase):
+class ScheduleEditTests(IetfSeleniumTestCase):
def testUnschedule(self):
meeting = make_meeting_test_data()
@@ -317,8 +266,8 @@ class ScheduleEditTests(MeetingTestCase):
self.assertEqual(SchedTimeSessAssignment.objects.filter(session__meeting__number=72,session__group__acronym='mars',schedule__name='test-schedule').count(),0)
-@skipIf(skip_selenium, skip_message)
-class SlideReorderTests(MeetingTestCase):
+@ifSeleniumEnabled
+class SlideReorderTests(IetfSeleniumTestCase):
def setUp(self):
super(SlideReorderTests, self).setUp()
self.session = SessionFactory(meeting__type_id='ietf', status_id='sched')
@@ -348,8 +297,8 @@ class SlideReorderTests(MeetingTestCase):
self.assertEqual(list(names),['one','three','two'])
-@skipIf(skip_selenium, skip_message)
-class AgendaTests(MeetingTestCase):
+@ifSeleniumEnabled
+class AgendaTests(IetfSeleniumTestCase):
def setUp(self):
super(AgendaTests, self).setUp()
self.meeting = make_meeting_test_data()
@@ -1057,8 +1006,8 @@ class AgendaTests(MeetingTestCase):
self.assertIn('tz=america/halifax', wv_url)
-@skipIf(skip_selenium, skip_message)
-class WeekviewTests(MeetingTestCase):
+@ifSeleniumEnabled
+class WeekviewTests(IetfSeleniumTestCase):
def setUp(self):
super(WeekviewTests, self).setUp()
self.meeting = make_meeting_test_data()
@@ -1254,8 +1203,8 @@ class WeekviewTests(MeetingTestCase):
)
_assert_not_wrapped(displayed, time_string)
-@skipIf(skip_selenium, skip_message)
-class InterimTests(MeetingTestCase):
+@ifSeleniumEnabled
+class InterimTests(IetfSeleniumTestCase):
def setUp(self):
super(InterimTests, self).setUp()
self.materials_dir = self.tempdir('materials')
diff --git a/ietf/person/fields.py b/ietf/person/fields.py
index 969450bd7..5d9d53065 100644
--- a/ietf/person/fields.py
+++ b/ietf/person/fields.py
@@ -15,6 +15,8 @@ from django.utils.html import escape
import debug # pyflakes:ignore
from ietf.person.models import Email, Person
+from ietf.utils.fields import SearchableField
+
def select2_id_name(objs):
def format_email(e):
@@ -41,7 +43,7 @@ def select2_id_name_json(objs):
return json.dumps(select2_id_name(objs))
-class SearchablePersonsField(forms.CharField):
+class SearchablePersonsField(SearchableField):
"""Server-based multi-select field for choosing
persons/emails or just persons using select2.js.
@@ -58,126 +60,67 @@ class SearchablePersonsField(forms.CharField):
list. These can then be added by updating val() and triggering the 'change'
event on the select2 field in JavaScript.
"""
-
+ model = Person
+ default_hint_text = "Type name to search for person."
def __init__(self,
- max_entries=None, # max number of selected objs
only_users=False, # only select persons who also have a user
all_emails=False, # select only active email addresses
extra_prefetch=None, # extra data records to include in prefetch
- model=Person, # or Email
- hint_text="Type in name to search for person.",
*args, **kwargs):
- kwargs["max_length"] = 10000
- self.max_entries = max_entries
+ super(SearchablePersonsField, self).__init__(*args, **kwargs)
self.only_users = only_users
self.all_emails = all_emails
- assert model in [ Email, Person ]
- self.model = model
-
- super(SearchablePersonsField, self).__init__(*args, **kwargs)
-
- self.widget.attrs["class"] = "select2-field"
- self.widget.attrs["data-placeholder"] = hint_text
- if self.max_entries != None:
- self.widget.attrs["data-max-entries"] = self.max_entries
-
self.extra_prefetch = extra_prefetch or []
assert all([isinstance(obj, self.model) for obj in self.extra_prefetch])
- def parse_select2_value(self, value):
- return [x.strip() for x in value.split(",") if x.strip()]
+ def validate_pks(self, pks):
+ """Validate format of PKs"""
+ for pk in pks:
+ if not pk.isdigit():
+ raise forms.ValidationError("Unexpected value: %s" % pk)
- def check_pks(self, pks):
- if self.model == Person:
- for pk in pks:
- if not pk.isdigit():
- raise forms.ValidationError("Unexpected value: %s" % pk)
- elif self.model == Email:
- for pk in pks:
- validate_email(pk)
- return pks
+ def make_select2_data(self, model_instances):
+ # Include records needed by the initial value of the field plus any added
+ # via the extra_prefetch property.
+ prefetch_set = set(model_instances).union(set(self.extra_prefetch)) # eliminate duplicates
+ return select2_id_name(list(prefetch_set))
- def prepare_value(self, value):
- if not value:
- value = ""
- if isinstance(value, str):
- pks = self.parse_select2_value(value)
- if self.model == Person:
- value = self.model.objects.filter(pk__in=pks)
- if self.model == Email:
- value = self.model.objects.filter(pk__in=pks).select_related("person")
- if isinstance(value, self.model):
- value = [value]
-
- # data-pre is a map from ID to full data. It includes records needed by the
- # initial value of the field plus any added via extra_prefetch.
- prefetch_set = set(value).union(set(self.extra_prefetch)) # eliminate duplicates
- self.widget.attrs["data-pre"] = json.dumps({
- d['id']: d for d in select2_id_name(list(prefetch_set))
- })
-
- # doing this in the constructor is difficult because the URL
- # patterns may not have been fully constructed there yet
- self.widget.attrs["data-ajax-url"] = urlreverse("ietf.person.views.ajax_select2_search", kwargs={ "model_name": self.model.__name__.lower() })
+ def ajax_url(self):
+ url = urlreverse(
+ "ietf.person.views.ajax_select2_search",
+ kwargs={ "model_name": self.model.__name__.lower() }
+ )
query_args = {}
if self.only_users:
query_args["user"] = "1"
if self.all_emails:
query_args["a"] = "1"
- if query_args:
- self.widget.attrs["data-ajax-url"] += "?%s" % urlencode(query_args)
+ if len(query_args) > 0:
+ url += '?%s' % urlencode(query_args)
+ return url
- return ",".join(str(p.pk) for p in value)
-
- def clean(self, value):
- value = super(SearchablePersonsField, self).clean(value)
- pks = self.check_pks(self.parse_select2_value(value))
-
- objs = self.model.objects.filter(pk__in=pks)
- if self.model == Email:
- objs = objs.exclude(person=None).select_related("person")
-
- # there are still a couple of active roles without accounts so don't disallow those yet
- #if self.only_users:
- # objs = objs.exclude(person__user=None)
-
- found_pks = [ str(o.pk) for o in objs]
- failed_pks = [x for x in pks if x not in found_pks]
- if failed_pks:
- raise forms.ValidationError("Could not recognize the following {model_name}s: {pks}. You can only input {model_name}s already registered in the Datatracker.".format(pks=", ".join(failed_pks), model_name=self.model.__name__.lower()))
-
- if self.max_entries != None and len(objs) > self.max_entries:
- raise forms.ValidationError("You can select at most %s entries only." % self.max_entries)
-
- return objs
class SearchablePersonField(SearchablePersonsField):
"""Version of SearchablePersonsField specialized to a single object."""
-
- def __init__(self, *args, **kwargs):
- kwargs["max_entries"] = 1
- super(SearchablePersonField, self).__init__(*args, **kwargs)
-
- def clean(self, value):
- return super(SearchablePersonField, self).clean(value).first()
+ max_entries = 1
class SearchableEmailsField(SearchablePersonsField):
"""Version of SearchablePersonsField with the defaults right for Emails."""
+ model = Email
+ default_hint_text = "Type name or email to search for person and email address."
+
+ def validate_pks(self, pks):
+ for pk in pks:
+ validate_email(pk)
+
+ def get_model_instances(self, item_ids):
+ return self.model.objects.filter(pk__in=item_ids).select_related("person")
- def __init__(self, model=Email, hint_text="Type in name or email to search for person and email address.",
- *args, **kwargs):
- super(SearchableEmailsField, self).__init__(model=model, hint_text=hint_text, *args, **kwargs)
class SearchableEmailField(SearchableEmailsField):
"""Version of SearchableEmailsField specialized to a single object."""
-
- def __init__(self, *args, **kwargs):
- kwargs["max_entries"] = 1
- super(SearchableEmailField, self).__init__(*args, **kwargs)
-
- def clean(self, value):
- return super(SearchableEmailField, self).clean(value).first()
+ max_entries = 1
class PersonEmailChoiceField(forms.ModelChoiceField):
diff --git a/ietf/secr/templates/sreq/edit.html b/ietf/secr/templates/sreq/edit.html
index b7bcb9b3b..f1b97e834 100755
--- a/ietf/secr/templates/sreq/edit.html
+++ b/ietf/secr/templates/sreq/edit.html
@@ -5,11 +5,7 @@
{% block extrahead %}{{ block.super }}
-
-
-
-
-
+ {{ form.media }}
{% endblock %}
{% block breadcrumbs %}{{ block.super }}
diff --git a/ietf/secr/templates/sreq/new.html b/ietf/secr/templates/sreq/new.html
index 70dd5ebed..fcf9b4607 100755
--- a/ietf/secr/templates/sreq/new.html
+++ b/ietf/secr/templates/sreq/new.html
@@ -6,11 +6,7 @@
{% block extrahead %}{{ block.super }}
-
-
-
-
-
+ {{ form.media }}
{% endblock %}
{% block breadcrumbs %}{{ block.super }}
diff --git a/ietf/static/ietf/js/edit-milestones.js b/ietf/static/ietf/js/edit-milestones.js
index 0165bbf8e..8424761e2 100644
--- a/ietf/static/ietf/js/edit-milestones.js
+++ b/ietf/static/ietf/js/edit-milestones.js
@@ -101,7 +101,7 @@ $(document).ready(function () {
new_edit_milestone.show();
new_edit_milestone.find(".select2-field").each(function () {
- window.setupSelect2Field($(this)); // from ietf.js
+ window.setupSelect2Field($(this)); // from select2-field.js
});
if ( ! group_uses_milestone_dates ) {
diff --git a/ietf/static/ietf/js/select2-field.js b/ietf/static/ietf/js/select2-field.js
index c4efe6f7a..7fd4f34dd 100644
--- a/ietf/static/ietf/js/select2-field.js
+++ b/ietf/static/ietf/js/select2-field.js
@@ -1,5 +1,5 @@
-// currently we only include select2 CSS/JS on those pages where forms
-// need it, so the generic setup code here is also kept separate
+// Copyright The IETF Trust 2015-2021, All Rights Reserved
+// JS for ietf.utils.fields.SearchableField subclasses
function setupSelect2Field(e) {
var url = e.data("ajax-url");
if (!url)
@@ -8,6 +8,18 @@ function setupSelect2Field(e) {
var maxEntries = e.data("max-entries");
var multiple = maxEntries !== 1;
var prefetched = e.data("pre");
+
+ // Validate prefetched
+ for (var id in prefetched) {
+ if (prefetched.hasOwnProperty(id)) {
+ if (String(prefetched[id].id) !== id) {
+ throw 'data-pre attribute for a select2-field input ' +
+ 'must be a JSON object mapping id to object, but ' +
+ id + ' does not map to an object with that id.';
+ }
+ }
+ }
+
e.select2({
multiple: multiple,
minimumInputLength: 2,
@@ -37,8 +49,9 @@ function setupSelect2Field(e) {
initSelection: function (element, cb) {
element = $(element); // jquerify
- // The original data set will contain any values looked up via ajax
- var data = element.select2('data');
+ // The original data set will contain any values looked up via ajax.
+ // When !multiple, select2('data') will be null - turn that into []
+ var data = element.select2('data') || [];
var data_map = {};
// map id to its data representation
diff --git a/ietf/submit/views.py b/ietf/submit/views.py
index b0b96ba20..c939603f2 100644
--- a/ietf/submit/views.py
+++ b/ietf/submit/views.py
@@ -379,6 +379,8 @@ def submission_status(request, submission_id, access_token=None):
for author in submission.authors:
author["cleaned_country"] = clean_country_name(author.get("country"))
+ all_forms = [submitter_form, replaces_form]
+
return render(request, 'submit/submission_status.html', {
'selected': 'status',
'submission': submission,
@@ -395,6 +397,7 @@ def submission_status(request, submission_id, access_token=None):
'requires_group_approval': accept_submission_requires_group_approval(submission),
'requires_prev_authors_approval': accept_submission_requires_prev_auth_approval(submission),
'confirmation_list': confirmation_list,
+ 'all_forms': all_forms,
})
@@ -476,6 +479,8 @@ def edit_submission(request, submission_id, access_token=None):
author_forms = [ AuthorForm(initial=author, prefix="authors-%s" % i)
for i, author in enumerate(submission.authors) ]
+ all_forms = [edit_form, submitter_form, replaces_form, *author_forms, empty_author_form]
+
return render(request, 'submit/edit_submission.html',
{'selected': 'status',
'submission': submission,
@@ -486,6 +491,7 @@ def edit_submission(request, submission_id, access_token=None):
'empty_author_form': empty_author_form,
'errors': errors,
'form_errors': form_errors,
+ 'all_forms': all_forms,
})
diff --git a/ietf/templates/community/manage_list.html b/ietf/templates/community/manage_list.html
index a7406a969..e37c24126 100644
--- a/ietf/templates/community/manage_list.html
+++ b/ietf/templates/community/manage_list.html
@@ -3,10 +3,10 @@
{% load origin %}
{% load bootstrap3 %}
{% load static %}
+{% load misc_filters %}
{% block pagehead %}
-
-
+ {{ all_forms|merge_media:'css' }}
{% endblock %}
{% block title %}Manage {{ clist.long_name }}{% endblock %}
@@ -137,7 +137,6 @@
{% endblock %}
{% block js %}
-
-
+ {{ all_forms|merge_media:'js' }}
{% endblock %}
diff --git a/ietf/templates/doc/change_shepherd.html b/ietf/templates/doc/change_shepherd.html
index d9f23eb1b..a470f42e1 100644
--- a/ietf/templates/doc/change_shepherd.html
+++ b/ietf/templates/doc/change_shepherd.html
@@ -7,8 +7,7 @@
{% block title %}Change document shepherd for {{ doc.name }}-{{ doc.rev }}{% endblock %}
{% block pagehead %}
-
-
+ {{ form.media.css }}
{% endblock %}
{% block content %}
@@ -30,6 +29,5 @@
{% endblock %}
{% block js %}
-
-
+ {{ form.media.js}}
{% endblock %}
diff --git a/ietf/templates/doc/downref_add.html b/ietf/templates/doc/downref_add.html
index 62eb3b381..a790ca492 100644
--- a/ietf/templates/doc/downref_add.html
+++ b/ietf/templates/doc/downref_add.html
@@ -7,8 +7,7 @@
{% block title %}{{ title }}{% endblock %}
{% block pagehead %}
-
-
+ {{ add_downref_form.media.css }}
{% endblock %}
{% block content %}
@@ -39,6 +38,5 @@
{% endblock %}
{% block js %}
-
-
+ {{ add_downref_form.media.js }}
{% endblock %}
diff --git a/ietf/templates/doc/draft/change_replaces.html b/ietf/templates/doc/draft/change_replaces.html
index 1297d2cf7..1703d1d45 100644
--- a/ietf/templates/doc/draft/change_replaces.html
+++ b/ietf/templates/doc/draft/change_replaces.html
@@ -7,8 +7,7 @@
{% block title %}Change documents replaced by {{ doc }}{% endblock %}
{% block pagehead %}
-
-
+ {{ form.media.css }}
{% endblock %}
{% block content %}
@@ -33,6 +32,5 @@
{% endblock %}
{% block js %}
-
-
+ {{ form.media.js }}
{% endblock %}
diff --git a/ietf/templates/doc/edit_action_holders.html b/ietf/templates/doc/edit_action_holders.html
index c7b8d5632..f608d2f37 100644
--- a/ietf/templates/doc/edit_action_holders.html
+++ b/ietf/templates/doc/edit_action_holders.html
@@ -9,8 +9,7 @@
{% endblock %}
{% block pagehead %}
-
-
+ {{ form.media.css}}
{% endblock %}
{% block content %}
@@ -50,8 +49,7 @@
{% endblock %}
{% block js %}
-
-
+ {{ form.media.js }}
-
-
+ {{ form.media.js }}
{% endblock %}
diff --git a/ietf/templates/group/edit.html b/ietf/templates/group/edit.html
index b350973d7..5456a9088 100644
--- a/ietf/templates/group/edit.html
+++ b/ietf/templates/group/edit.html
@@ -14,8 +14,7 @@
{% endblock %}
{% block pagehead %}
-
-
+ {{ form.media.css }}
{% endblock %}
{% block content %}
@@ -56,8 +55,7 @@
{% endblock %}
{% block js %}
-
-
+ {{ form.media.js }}
-
+ {{ all_forms|merge_media:'js' }}
{% if not group.uses_milestone_dates %}
{% endif %}
{% endblock %}
+
diff --git a/ietf/templates/group/stream_edit.html b/ietf/templates/group/stream_edit.html
index 79066728a..4f2afe78e 100644
--- a/ietf/templates/group/stream_edit.html
+++ b/ietf/templates/group/stream_edit.html
@@ -8,8 +8,7 @@
{% block title %}Manage {{ group.name }} RFC stream{% endblock %}
{% block pagehead %}
-
-
+ {{ form.media.css }}
{% endblock %}
{% block content %}
@@ -43,6 +42,5 @@
{% endblock %}
{% block js %}
-
-
+ {{ form.media.js }}
{% endblock %}
diff --git a/ietf/templates/ietfauth/review_overview.html b/ietf/templates/ietfauth/review_overview.html
index 274508d1c..bdc7ad90b 100644
--- a/ietf/templates/ietfauth/review_overview.html
+++ b/ietf/templates/ietfauth/review_overview.html
@@ -5,8 +5,7 @@
{% load bootstrap3 static %}
{% block pagehead %}
-
-
+ {{ review_wish_form.media.css }}
{% endblock %}
{% block title %}Review overview for {{ request.user }}{% endblock %}
@@ -179,6 +178,5 @@
{% endblock %}
{% block js %}
-
-
+ {{ review_wish_form.media.js }}
{% endblock %}
diff --git a/ietf/templates/ipr/details_edit.html b/ietf/templates/ipr/details_edit.html
index 8ee61b98a..4157b4b82 100644
--- a/ietf/templates/ipr/details_edit.html
+++ b/ietf/templates/ipr/details_edit.html
@@ -7,8 +7,7 @@
{% block title %}{% if form.instance %}Edit IPR #{{ form.instance.id }}{% else %}New IPR{% endif %}{% endblock %}
{% block pagehead %}
-
-
+ {{ form.media.css }}
{% endblock %}
{% block content %}
@@ -266,8 +265,7 @@
{% endblock %}
{% block js %}
-
-
+ {{ form.media.js }}
{% endblock %}
diff --git a/ietf/templates/liaisons/edit.html b/ietf/templates/liaisons/edit.html
index 16a7ca6eb..dd39827eb 100644
--- a/ietf/templates/liaisons/edit.html
+++ b/ietf/templates/liaisons/edit.html
@@ -9,8 +9,7 @@
{% block pagehead %}
-
-
+ {{ form.media.css }} {# n.b., liaisons.js relies on select2 CSS being loaded by this #}
{% endblock %}
@@ -71,7 +70,6 @@
{% block js %}
-
-
+ {{ form.media.js }} {# n.b., liaisons.js relies on select2.js being loaded by this #}
{% endblock %}
diff --git a/ietf/templates/meeting/add_session_drafts.html b/ietf/templates/meeting/add_session_drafts.html
index 97308ab18..9931db7c8 100644
--- a/ietf/templates/meeting/add_session_drafts.html
+++ b/ietf/templates/meeting/add_session_drafts.html
@@ -5,8 +5,7 @@
{% block title %}Add drafts to {{ session.meeting }} : {{ session.group.acronym }}{% endblock %}
{% block pagehead %}
-
-
+ {{ form.media.css }}
{% endblock %}
{% block content %}
@@ -55,6 +54,5 @@
{% endblock %}
{% block js %}
-
-
+ {{ form.media.js }}
{% endblock %}
diff --git a/ietf/templates/nomcom/edit_position.html b/ietf/templates/nomcom/edit_position.html
index 22dde0cc1..a94d90420 100644
--- a/ietf/templates/nomcom/edit_position.html
+++ b/ietf/templates/nomcom/edit_position.html
@@ -3,8 +3,7 @@
{% load origin %}
{% load static %}
{% block pagehead %}
-
-
+ {{ form.media.css }}
{% endblock %}
{% load bootstrap3 %}
@@ -27,6 +26,5 @@
{% endblock %}
{% block content_end %}
-
-
+ {{ form.media.js }}
{% endblock %}
diff --git a/ietf/templates/nomcom/edit_topic.html b/ietf/templates/nomcom/edit_topic.html
index 020a1be0e..0c85e2cd9 100644
--- a/ietf/templates/nomcom/edit_topic.html
+++ b/ietf/templates/nomcom/edit_topic.html
@@ -3,8 +3,7 @@
{% load origin %}
{% load static %}
{% block pagehead %}
-
-
+ {{ form.media.css }}
{% endblock %}
{% load bootstrap3 %}
@@ -27,6 +26,5 @@
{% endblock %}
{% block content_end %}
-
-
+ {{ form.media.js }}
{% endblock %}
diff --git a/ietf/templates/nomcom/new_edit_members.html b/ietf/templates/nomcom/new_edit_members.html
index fcfba9007..472e25f8f 100644
--- a/ietf/templates/nomcom/new_edit_members.html
+++ b/ietf/templates/nomcom/new_edit_members.html
@@ -6,8 +6,7 @@
{% load static %}
{% block pagehead %}
-
-
+ {{ form.media.css }}
{% endblock %}
{% block subtitle %} - Edit members{% endblock %}
@@ -30,6 +29,5 @@
{% endblock %}
{% block js %}
-
-
+ {{ form.media.js }}
{% endblock %}
diff --git a/ietf/templates/nomcom/private_merge_nominee.html b/ietf/templates/nomcom/private_merge_nominee.html
index bf4d2ca0c..6109eb28d 100644
--- a/ietf/templates/nomcom/private_merge_nominee.html
+++ b/ietf/templates/nomcom/private_merge_nominee.html
@@ -6,8 +6,7 @@
{% load bootstrap3 %}
{% block pagehead %}
-
-
+ {{ form.media.css }}
{% endblock %}
{% block subtitle %} - Merge Nominee Records {% endblock %}
@@ -45,6 +44,5 @@
{% endblock %}
{% block js %}
-
-
+ {{ form.media.js }}
{% endblock %}
diff --git a/ietf/templates/nomcom/private_merge_person.html b/ietf/templates/nomcom/private_merge_person.html
index 23ac0cbd3..19f3905a7 100644
--- a/ietf/templates/nomcom/private_merge_person.html
+++ b/ietf/templates/nomcom/private_merge_person.html
@@ -6,8 +6,7 @@
{% load bootstrap3 %}
{% block pagehead %}
-
-
+ {{ form.media.css }}
{% endblock %}
{% block subtitle %} - Request Merge of Person Records {% endblock %}
@@ -55,6 +54,5 @@
{% endblock %}
{% block js %}
-
-
+ {{ form.media.js }}
{% endblock %}
diff --git a/ietf/templates/nomcom/private_nominate.html b/ietf/templates/nomcom/private_nominate.html
index e793de82a..272026163 100644
--- a/ietf/templates/nomcom/private_nominate.html
+++ b/ietf/templates/nomcom/private_nominate.html
@@ -7,8 +7,7 @@
{% load nomcom_tags %}
{% block pagehead %}
-
-
+ {{ form.media.css }}
{% endblock %}
{% block subtitle %} - Nominate{% endblock %}
@@ -32,6 +31,5 @@
{% endblock %}
{% block js %}
-
-
+ {{ form.media.js }}
{% endblock %}
diff --git a/ietf/templates/nomcom/public_nominate.html b/ietf/templates/nomcom/public_nominate.html
index 41ef7b1be..227704b97 100644
--- a/ietf/templates/nomcom/public_nominate.html
+++ b/ietf/templates/nomcom/public_nominate.html
@@ -7,8 +7,7 @@
{% load nomcom_tags %}
{% block pagehead %}
-
-
+ {{ form.media.css }}
{% endblock %}
{% block subtitle %} - Nominate{% endblock %}
@@ -45,6 +44,5 @@
{% endblock %}
{% block js %}
-
-
+ {{ form.media.js }}
{% endblock %}
diff --git a/ietf/templates/nomcom/view_feedback_pending.html b/ietf/templates/nomcom/view_feedback_pending.html
index 78d2423ad..d79bf05a3 100644
--- a/ietf/templates/nomcom/view_feedback_pending.html
+++ b/ietf/templates/nomcom/view_feedback_pending.html
@@ -7,8 +7,7 @@
{% load nomcom_tags %}
{% block pagehead %}
-
-
+ {{ formset.media.css }}
{% endblock %}
{% block subtitle %} - Feeback pending{% endblock %}
@@ -181,6 +180,5 @@
{% endblock %}
{% block js %}
-
-
+ {{ formset.media.js }}
{% endblock %}
diff --git a/ietf/templates/submit/edit_submission.html b/ietf/templates/submit/edit_submission.html
index 7b57ff684..9d491cce8 100644
--- a/ietf/templates/submit/edit_submission.html
+++ b/ietf/templates/submit/edit_submission.html
@@ -4,11 +4,11 @@
{% load static %}
{% load bootstrap3 %}
{% load submit_tags %}
+{% load misc_filters %}
{% block pagehead %}
{{ block.super }}
-
-
+ {{ all_forms|merge_media:'css' }}
{% endblock %}
{% block title %}Adjust meta-data of submitted {{ submission.name }}{% endblock %}
@@ -93,6 +93,5 @@
{% endblock %}
{% block js %}
-
-
+ {{ all_forms|merge_media:'js' }}
{% endblock %}
diff --git a/ietf/templates/submit/submission_status.html b/ietf/templates/submit/submission_status.html
index aa6f3ae5e..2037244a5 100644
--- a/ietf/templates/submit/submission_status.html
+++ b/ietf/templates/submit/submission_status.html
@@ -2,14 +2,13 @@
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}
{% load static %}
-{% load ietf_filters submit_tags %}
+{% load ietf_filters submit_tags misc_filters %}
{% block title %}Submission status of {{ submission.name }}-{{ submission.rev }}{% endblock %}
{% block pagehead %}
{{ block.super }}
-
-
+ {{ all_forms|merge_media:'css' }}
{% endblock %}
{% block submit_content %}
@@ -410,6 +409,5 @@
{% endblock %}
{% block js %}
-
-
+ {{ all_forms|merge_media:'js' }}
{% endblock %}
diff --git a/ietf/utils/fields.py b/ietf/utils/fields.py
index 681b111db..b79434322 100644
--- a/ietf/utils/fields.py
+++ b/ietf/utils/fields.py
@@ -3,6 +3,7 @@
import datetime
+import json
import re
import debug # pyflakes:ignore
@@ -117,4 +118,144 @@ class DurationField(forms.DurationField):
if value is None:
raise ValidationError(self.error_messages['invalid'], code='invalid')
return value
-
+
+
+class SearchableTextInput(forms.TextInput):
+ class Media:
+ css = {
+ 'all': (
+ 'select2/select2.css',
+ 'select2-bootstrap-css/select2-bootstrap.min.css',
+ )
+ }
+ js = (
+ 'select2/select2.min.js',
+ 'ietf/js/select2-field.js',
+ )
+
+# FIXME: select2 version 4 uses a standard select for the AJAX case -
+# switching to that would allow us to derive from the standard
+# multi-select machinery in Django instead of the manual CharField
+# stuff below
+
+class SearchableField(forms.CharField):
+ """Base class for searchable fields
+
+ The field uses a comma-separated list of primary keys in a CharField element as its
+ API with some extra attributes used by the Javascript part.
+
+ When used in a form, the template rendering that form must include the form's media.
+ This is done by putting {{ form.media }} in a header block. If CSS and JS should be
+ separated for the template, use {{ form.media.css }} and {{ form.media.js }} instead.
+
+ To make a usable subclass, you must fill in the model (either as a class-scoped variable
+ or in the __init__() method before calling the superclass __init__()) and define
+ the make_select2_data() and ajax_url() methods. You likely want to provide a more
+ specific default_hint_text as well.
+ """
+ widget = SearchableTextInput
+ model = None # must be filled in by subclass
+ max_entries = None # may be overridden in __init__
+ default_hint_text = 'Type a value to search'
+
+ def __init__(self, hint_text=None, *args, **kwargs):
+ assert self.model is not None
+ self.hint_text = hint_text if hint_text is not None else self.default_hint_text
+ kwargs["max_length"] = 10000
+ # Pop max_entries out of kwargs - this distinguishes passing 'None' from
+ # not setting the parameter at all.
+ if 'max_entries' in kwargs:
+ self.max_entries = kwargs.pop('max_entries')
+
+ super(SearchableField, self).__init__(*args, **kwargs)
+
+ self.widget.attrs["class"] = "select2-field form-control"
+ self.widget.attrs["data-placeholder"] = self.hint_text
+ if self.max_entries is not None:
+ self.widget.attrs["data-max-entries"] = self.max_entries
+
+ def make_select2_data(self, model_instances):
+ """Get select2 data items
+
+ Should return an array of dicts, each with at least 'id' and 'text' keys.
+ """
+ raise NotImplementedError('Must implement make_select2_data')
+
+ def ajax_url(self):
+ """Get the URL for AJAX searches
+
+ Doing this in the constructor is difficult because the URL patterns may not have been
+ fully constructed there yet.
+ """
+ raise NotImplementedError('Must implement ajax_url')
+
+ def get_model_instances(self, item_ids):
+ """Get model instances corresponding to item identifiers in select2 field value
+
+ Default implementation expects identifiers to be model pks. Return value is an iterable.
+ """
+ return self.model.objects.filter(pk__in=item_ids)
+
+ def validate_pks(self, pks):
+ """Validate format of PKs
+
+ Base implementation does nothing, but subclasses may override if desired.
+ Should raise a forms.ValidationError in case of a failed validation.
+ """
+ pass
+
+ def describe_failed_pks(self, failed_pks):
+ """Format error message to display when non-existent PKs are referenced"""
+ return ('Could not recognize the following {model_name}s: {pks}. '
+ 'You can only input {model_name}s already registered in the Datatracker.'.format(
+ pks=', '.join(failed_pks),
+ model_name=self.model.__name__.lower())
+ )
+
+ def parse_select2_value(self, value):
+ """Parse select2 field value into individual item identifiers"""
+ return [x.strip() for x in value.split(",") if x.strip()]
+
+ def prepare_value(self, value):
+ if not value:
+ value = ""
+ if isinstance(value, int):
+ value = str(value)
+ if isinstance(value, str):
+ item_ids = self.parse_select2_value(value)
+ value = self.get_model_instances(item_ids)
+ if isinstance(value, self.model):
+ value = [value]
+
+ self.widget.attrs["data-pre"] = json.dumps({
+ d['id']: d for d in self.make_select2_data(value)
+ })
+
+ # doing this in the constructor is difficult because the URL
+ # patterns may not have been fully constructed there yet
+ self.widget.attrs["data-ajax-url"] = self.ajax_url()
+
+ return ",".join(str(o.pk) for o in value)
+
+ def clean(self, value):
+ value = super(SearchableField, self).clean(value)
+ pks = self.parse_select2_value(value)
+ self.validate_pks(pks)
+
+ try:
+ objs = self.model.objects.filter(pk__in=pks)
+ except ValueError as e:
+ raise forms.ValidationError('Unexpected field value; {}'.format(e))
+
+ found_pks = [ str(o.pk) for o in objs ]
+ failed_pks = [ x for x in pks if x not in found_pks ]
+ if failed_pks:
+ raise forms.ValidationError(self.describe_failed_pks(failed_pks))
+
+ if self.max_entries != None and len(objs) > self.max_entries:
+ raise forms.ValidationError('You can select at most {} {}.'.format(
+ self.max_entries,
+ 'entry' if self.max_entries == 1 else 'entries',
+ ))
+
+ return objs.first() if self.max_entries == 1 else objs
diff --git a/ietf/utils/jstest.py b/ietf/utils/jstest.py
new file mode 100644
index 000000000..b425dc965
--- /dev/null
+++ b/ietf/utils/jstest.py
@@ -0,0 +1,74 @@
+# Copyright The IETF Trust 2014-2021, All Rights Reserved
+# -*- coding: utf-8 -*-
+
+from django.urls import reverse as urlreverse
+from unittest import skipIf
+
+skip_selenium = False
+skip_message = ""
+try:
+ from selenium import webdriver
+except ImportError as e:
+ skip_selenium = True
+ skip_message = "Skipping selenium tests: %s" % e
+
+
+from ietf.utils.pipe import pipe
+from ietf.utils.test_runner import IetfLiveServerTestCase
+from ietf import settings
+
+
+executable_name = 'chromedriver'
+code, out, err = pipe('{} --version'.format(executable_name))
+if code != 0:
+ skip_selenium = True
+ skip_message = "Skipping selenium tests: '{}' executable not found.".format(executable_name)
+if skip_selenium:
+ print(" "+skip_message)
+
+def start_web_driver():
+ options = webdriver.ChromeOptions()
+ options.add_argument("headless")
+ options.add_argument("disable-extensions")
+ options.add_argument("disable-gpu") # headless needs this
+ options.add_argument("no-sandbox") # docker needs this
+ return webdriver.Chrome(options=options, service_log_path=settings.TEST_GHOSTDRIVER_LOG_PATH)
+
+
+def selenium_enabled():
+ """Are Selenium tests enabled?"""
+ return not skip_selenium
+
+
+def ifSeleniumEnabled(func):
+ """Only run test if Selenium testing is enabled"""
+ return skipIf(skip_selenium, skip_message)(func)
+
+
+class IetfSeleniumTestCase(IetfLiveServerTestCase):
+ login_view = 'ietf.ietfauth.views.login'
+
+ def setUp(self):
+ super(IetfSeleniumTestCase, self).setUp()
+ self.driver = start_web_driver()
+ self.driver.set_window_size(1024,768)
+
+ def tearDown(self):
+ super(IetfSeleniumTestCase, self).tearDown()
+ self.driver.close()
+
+ def absreverse(self,*args,**kwargs):
+ return '%s%s'%(self.live_server_url, urlreverse(*args, **kwargs))
+
+ def debug_snapshot(self,filename='debug_this.png'):
+ self.driver.execute_script("document.body.bgColor = 'white';")
+ self.driver.save_screenshot(filename)
+
+ def login(self, username='plain'):
+ url = self.absreverse(self.login_view)
+ password = '%s+password' % username
+ self.driver.get(url)
+ self.driver.find_element_by_name('username').send_keys(username)
+ self.driver.find_element_by_name('password').send_keys(password)
+ self.driver.find_element_by_xpath('//button[@type="submit"]').click()
+
diff --git a/ietf/utils/templatetags/misc_filters.py b/ietf/utils/templatetags/misc_filters.py
new file mode 100644
index 000000000..e46dd1196
--- /dev/null
+++ b/ietf/utils/templatetags/misc_filters.py
@@ -0,0 +1,29 @@
+# Copyright The IETF Trust 2021, All Rights Reserved
+# -*- coding: utf-8 -*-
+
+from django import template
+
+
+register = template.Library()
+
+
+@register.filter
+def merge_media(forms, arg=None):
+ """Merge media for a list of forms
+
+ Usage: {{ form_list|merge_media }}
+ * With no arg, returns all media from all forms with duplicates removed
+
+ Usage: {{ form_list|merge_media:'media_type' }}
+ * With an arg, returns only media of that type. Types 'css' and 'js' are common.
+ See Django documentation for more information about form media.
+ """
+ if len(forms) == 0:
+ return ''
+ combined = forms[0].media
+ if len(forms) > 1:
+ for val in forms[1:]:
+ combined += val.media
+ if arg is None:
+ return str(combined)
+ return str(combined[arg])