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:
parent
516abc5725
commit
17d37723f7
|
@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)],
|
||||
)
|
||||
|
|
|
@ -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
186
ietf/group/tests_js.py
Normal 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())
|
|
@ -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')
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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 ) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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
74
ietf/utils/jstest.py
Normal 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()
|
||||
|
29
ietf/utils/templatetags/misc_filters.py
Normal file
29
ietf/utils/templatetags/misc_filters.py
Normal 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])
|
Loading…
Reference in a new issue