Consolidate repeated searchable field code into SearchableField class. Fix single-valued searchable fields. Make javascript test config reusable. Use Django Form.media for JS/CSS inclusion. Fixes #3196, #3204. Commit ready for merge.

- Legacy-Id: 18939
This commit is contained in:
Jennifer Richards 2021-04-09 15:18:11 +00:00
parent 516abc5725
commit 17d37723f7
40 changed files with 667 additions and 490 deletions

View file

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

View file

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

View file

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

View file

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

186
ietf/group/tests_js.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,11 +5,7 @@
{% block extrahead %}{{ block.super }}
<script type="text/javascript" src="{% static 'secr/js/utils.js' %}"></script>
<script type="text/javascript" src="{% static 'secr/js/sessions.js' %}"></script>
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media }}
{% endblock %}
{% block breadcrumbs %}{{ block.super }}

View file

@ -6,11 +6,7 @@
{% block extrahead %}{{ block.super }}
<script type="text/javascript" src="{% static 'secr/js/utils.js' %}"></script>
<script type="text/javascript" src="{% static 'secr/js/sessions.js' %}"></script>
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media }}
{% endblock %}
{% block breadcrumbs %}{{ block.super }}

View file

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

View file

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

View file

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

View file

@ -3,10 +3,10 @@
{% load origin %}
{% load bootstrap3 %}
{% load static %}
{% load misc_filters %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ all_forms|merge_media:'css' }}
{% endblock %}
{% block title %}Manage {{ clist.long_name }}{% endblock %}
@ -137,7 +137,6 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ all_forms|merge_media:'js' }}
<script src="{% static 'ietf/js/manage-community-list.js' %}"></script>
{% endblock %}

View file

@ -7,8 +7,7 @@
{% block title %}Change document shepherd for {{ doc.name }}-{{ doc.rev }}{% endblock %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css }}
{% endblock %}
{% block content %}
@ -30,6 +29,5 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js}}
{% endblock %}

View file

@ -7,8 +7,7 @@
{% block title %}{{ title }}{% endblock %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ add_downref_form.media.css }}
{% endblock %}
{% block content %}
@ -39,6 +38,5 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ add_downref_form.media.js }}
{% endblock %}

View file

@ -7,8 +7,7 @@
{% block title %}Change documents replaced by {{ doc }}{% endblock %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css }}
{% endblock %}
{% block content %}
@ -33,6 +32,5 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js }}
{% endblock %}

View file

@ -9,8 +9,7 @@
{% endblock %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css}}
{% endblock %}
{% block content %}
@ -50,8 +49,7 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js }}
<script type="text/javascript">
local_js = function () {
let select2_elem = $('.select2-field');

View file

@ -3,8 +3,7 @@
{% load origin bootstrap3 static %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css }}
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
{% endblock %}
@ -46,6 +45,5 @@
{% block js %}
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js }}
{% endblock %}

View file

@ -14,8 +14,7 @@
{% endblock %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css }}
{% endblock %}
{% block content %}
@ -56,8 +55,7 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js }}
<script>
$(document).ready(function () {
$("#id_acronym").closest(".form-group").each(function() {

View file

@ -3,10 +3,10 @@
{% load origin %}
{% load static %}
{% load bootstrap3 %}
{% load misc_filters %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ all_forms|merge_media:'css' }}
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
{% endblock %}
@ -119,11 +119,11 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ all_forms|merge_media:'js' }}
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
{% if not group.uses_milestone_dates %}
<script src="{% static 'Sortable/Sortable.min.js' %}"></script>
{% endif %}
<script src="{% static 'ietf/js/edit-milestones.js' %}"></script>
{% endblock %}

View file

@ -8,8 +8,7 @@
{% block title %}Manage {{ group.name }} RFC stream{% endblock %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css }}
{% endblock %}
{% block content %}
@ -43,6 +42,5 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js }}
{% endblock %}

View file

@ -5,8 +5,7 @@
{% load bootstrap3 static %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ review_wish_form.media.css }}
{% endblock %}
{% block title %}Review overview for {{ request.user }}{% endblock %}
@ -179,6 +178,5 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ review_wish_form.media.js }}
{% endblock %}

View file

@ -7,8 +7,7 @@
{% block title %}{% if form.instance %}Edit IPR #{{ form.instance.id }}{% else %}New IPR{% endif %}{% endblock %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css }}
{% endblock %}
{% block content %}
@ -266,8 +265,7 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js }}
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
<script src="{% static 'ietf/js/ipr-edit.js' %}"></script>
{% endblock %}

View file

@ -9,8 +9,7 @@
{% block pagehead %}
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css }} {# n.b., liaisons.js relies on select2 CSS being loaded by this #}
<link rel="stylesheet" href="{% static 'ietf/css/liaisons.css' %}">
{% endblock %}
@ -71,7 +70,6 @@
{% block js %}
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js }} {# n.b., liaisons.js relies on select2.js being loaded by this #}
<script src="{% static 'ietf/js/liaisons.js' %}"></script>
{% endblock %}

View file

@ -5,8 +5,7 @@
{% block title %}Add drafts to {{ session.meeting }} : {{ session.group.acronym }}{% endblock %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css }}
{% endblock %}
{% block content %}
@ -55,6 +54,5 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js }}
{% endblock %}

View file

@ -3,8 +3,7 @@
{% load origin %}
{% load static %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css }}
{% endblock %}
{% load bootstrap3 %}
@ -27,6 +26,5 @@
{% endblock %}
{% block content_end %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js }}
{% endblock %}

View file

@ -3,8 +3,7 @@
{% load origin %}
{% load static %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css }}
{% endblock %}
{% load bootstrap3 %}
@ -27,6 +26,5 @@
{% endblock %}
{% block content_end %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js }}
{% endblock %}

View file

@ -6,8 +6,7 @@
{% load static %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css }}
{% endblock %}
{% block subtitle %} - Edit members{% endblock %}
@ -30,6 +29,5 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js }}
{% endblock %}

View file

@ -6,8 +6,7 @@
{% load bootstrap3 %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css }}
{% endblock %}
{% block subtitle %} - Merge Nominee Records {% endblock %}
@ -45,6 +44,5 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js }}
{% endblock %}

View file

@ -6,8 +6,7 @@
{% load bootstrap3 %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css }}
{% endblock %}
{% block subtitle %} - Request Merge of Person Records {% endblock %}
@ -55,6 +54,5 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js }}
{% endblock %}

View file

@ -7,8 +7,7 @@
{% load nomcom_tags %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css }}
{% endblock %}
{% block subtitle %} - Nominate{% endblock %}
@ -32,6 +31,5 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js }}
{% endblock %}

View file

@ -7,8 +7,7 @@
{% load nomcom_tags %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ form.media.css }}
{% endblock %}
{% block subtitle %} - Nominate{% endblock %}
@ -45,6 +44,5 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ form.media.js }}
{% endblock %}

View file

@ -7,8 +7,7 @@
{% load nomcom_tags %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ formset.media.css }}
{% endblock %}
{% block subtitle %} - Feeback pending{% endblock %}
@ -181,6 +180,5 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ formset.media.js }}
{% endblock %}

View file

@ -4,11 +4,11 @@
{% load static %}
{% load bootstrap3 %}
{% load submit_tags %}
{% load misc_filters %}
{% block pagehead %}
{{ block.super }}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ all_forms|merge_media:'css' }}
{% endblock %}
{% block title %}Adjust meta-data of submitted {{ submission.name }}{% endblock %}
@ -93,6 +93,5 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ all_forms|merge_media:'js' }}
{% endblock %}

View file

@ -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 }}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{{ all_forms|merge_media:'css' }}
{% endblock %}
{% block submit_content %}
@ -410,6 +409,5 @@
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{{ all_forms|merge_media:'js' }}
{% endblock %}

View file

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

74
ietf/utils/jstest.py Normal file
View file

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

View file

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