Merged in [18939] from jennifer@painless-security.com:

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.
 - Legacy-Id: 18948
Note: SVN reference [18939] has been migrated to Git commit 17d37723f7
This commit is contained in:
Robert Sparks 2021-04-12 22:07:03 +00:00
commit 6d7a0b6d0f
40 changed files with 679 additions and 492 deletions

View file

@ -72,6 +72,7 @@ def manage_list(request, username=None, acronym=None, group_type=None):
return HttpResponseRedirect("") return HttpResponseRedirect("")
rule_form = None
if request.method == 'POST' and action == 'add_rule': if request.method == 'POST' and action == 'add_rule':
rule_type_form = SearchRuleTypeForm(request.POST) rule_type_form = SearchRuleTypeForm(request.POST)
if rule_type_form.is_valid(): if rule_type_form.is_valid():
@ -93,7 +94,6 @@ def manage_list(request, username=None, acronym=None, group_type=None):
return HttpResponseRedirect("") return HttpResponseRedirect("")
else: else:
rule_type_form = SearchRuleTypeForm() rule_type_form = SearchRuleTypeForm()
rule_form = None
if request.method == 'POST' and action == 'remove_rule': if request.method == 'POST' and action == 'remove_rule':
rule_pk = request.POST.get('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() 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', { return render(request, 'community/manage_list.html', {
'clist': clist, 'clist': clist,
'rules': rules, 'rules': rules,
@ -120,6 +122,7 @@ def manage_list(request, username=None, acronym=None, group_type=None):
'empty_rule_forms': empty_rule_forms, 'empty_rule_forms': empty_rule_forms,
'total_count': total_count, 'total_count': total_count,
'add_doc_form': add_doc_form, 'add_doc_form': add_doc_form,
'all_forms': all_forms,
}) })

View file

@ -4,8 +4,10 @@
import json import json
from typing import Type # pyflakes:ignore
from django.utils.html import escape from django.utils.html import escape
from django import forms from django.db import models # pyflakes:ignore
from django.db.models import Q from django.db.models import Q
from django.urls import reverse as urlreverse from django.urls import reverse as urlreverse
@ -13,123 +15,72 @@ import debug # pyflakes:ignore
from ietf.doc.models import Document, DocAlias from ietf.doc.models import Document, DocAlias
from ietf.doc.utils import uppercase_std_abbreviated_name from ietf.doc.utils import uppercase_std_abbreviated_name
from ietf.utils.fields import SearchableField
def select2_id_doc_name(objs): def select2_id_doc_name(objs):
return [{ return [{
"id": o.pk, "id": o.pk,
"text": escape(uppercase_std_abbreviated_name(o.name)), "text": escape(uppercase_std_abbreviated_name(o.name)),
} for o in objs] } for o in objs]
def select2_id_doc_name_json(objs): def select2_id_doc_name_json(objs):
return json.dumps(select2_id_doc_name(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): class SearchableDocumentsField(SearchableField):
"""Server-based multi-select field for choosing documents using """Server-based multi-select field for choosing documents using select2.js. """
select2.js. model = Document # type: Type[models.Model]
default_hint_text = "Type name to search for document"
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
def __init__(self, doc_type="draft", *args, **kwargs):
super(SearchableDocumentsField, self).__init__(*args, **kwargs) super(SearchableDocumentsField, self).__init__(*args, **kwargs)
self.doc_type = doc_type
self.widget.attrs["class"] = "select2-field form-control" def doc_type_filter(self, queryset):
self.widget.attrs["data-placeholder"] = hint_text """Filter to include only desired doc type"""
if self.max_entries != None: return queryset.filter(type=self.doc_type)
self.widget.attrs["data-max-entries"] = self.max_entries
def parse_select2_value(self, value): def get_model_instances(self, item_ids):
return [x.strip() for x in value.split(",") if x.strip()] """Get model instances corresponding to item identifiers in select2 field value
def prepare_value(self, value): Accepts both names and pks as IDs
if not value: """
value = "" names = [ i for i in item_ids if not i.isdigit() ]
if isinstance(value, int): ids = [ i for i in item_ids if i.isdigit() ]
value = str(value) objs = self.model.objects.filter(
if isinstance(value, str): Q(name__in=names)|Q(id__in=ids)
items = self.parse_select2_value(value) )
# accept both names and pks here return self.doc_type_filter(objs)
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]
self.widget.attrs["data-pre"] = json.dumps({ def make_select2_data(self, model_instances):
d['id']: d for d in select2_id_doc_name(value) """Get select2 data items"""
}) return select2_id_doc_name(model_instances)
# doing this in the constructor is difficult because the URL def ajax_url(self):
# patterns may not have been fully constructed there yet """Get the URL for AJAX searches"""
self.widget.attrs["data-ajax-url"] = urlreverse('ietf.doc.views_search.ajax_select2_search_docs', kwargs={ return urlreverse('ietf.doc.views_search.ajax_select2_search_docs', kwargs={
"doc_type": self.doc_type, "doc_type": self.doc_type,
"model_name": self.model.__name__.lower() "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): class SearchableDocumentField(SearchableDocumentsField):
"""Specialized to only return one Document.""" """Specialized to only return one Document"""
def __init__(self, model=Document, *args, **kwargs): max_entries = 1
kwargs["max_entries"] = 1
super(SearchableDocumentField, self).__init__(model=model, *args, **kwargs)
def clean(self, value):
return super(SearchableDocumentField, self).clean(value).first()
class SearchableDocAliasesField(SearchableDocumentsField): class SearchableDocAliasesField(SearchableDocumentsField):
def __init__(self, model=DocAlias, *args, **kwargs): """Search DocAliases instead of Documents"""
super(SearchableDocAliasesField, self).__init__(model=model, *args, **kwargs) model = DocAlias # type: Type[models.Model]
class SearchableDocAliasField(SearchableDocumentsField): def doc_type_filter(self, queryset):
"""Specialized to only return one DocAlias.""" """Filter to include only desired doc type
def __init__(self, model=DocAlias, *args, **kwargs):
kwargs["max_entries"] = 1
super(SearchableDocAliasField, self).__init__(model=model, *args, **kwargs)
def clean(self, value): For DocAlias, pass through to the docs to check type.
return super(SearchableDocAliasField, self).clean(value).first() """
return queryset.filter(docs__type=self.doc_type)
class SearchableDocAliasField(SearchableDocAliasesField):
"""Specialized to only return one DocAlias"""
max_entries = 1

View file

@ -9,6 +9,7 @@ import io
import lxml import lxml
import bibtexparser import bibtexparser
import mock import mock
import json
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from pyquery import PyQuery from pyquery import PyQuery
@ -18,6 +19,8 @@ from tempfile import NamedTemporaryFile
from django.core.management import call_command from django.core.management import call_command
from django.urls import reverse as urlreverse from django.urls import reverse as urlreverse
from django.conf import settings from django.conf import settings
from django.forms import Form
from django.utils.html import escape
from tastypie.test import ResourceTestCaseMixin from tastypie.test import ResourceTestCaseMixin
@ -29,7 +32,8 @@ from ietf.doc.factories import ( DocumentFactory, DocEventFactory, CharterFactor
ConflictReviewFactory, WgDraftFactory, IndividualDraftFactory, WgRfcFactory, ConflictReviewFactory, WgDraftFactory, IndividualDraftFactory, WgRfcFactory,
IndividualRfcFactory, StateDocEventFactory, BallotPositionDocEventFactory, IndividualRfcFactory, StateDocEventFactory, BallotPositionDocEventFactory,
BallotDocEventFactory ) 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.models import Group
from ietf.group.factories import GroupFactory, RoleFactory from ietf.group.factories import GroupFactory, RoleFactory
from ietf.ipr.factories import HolderIprDisclosureFactory from ietf.ipr.factories import HolderIprDisclosureFactory
@ -1818,3 +1822,27 @@ class ChartTests(ResourceTestCaseMixin, TestCase):
r = self.client.get(page_url) r = self.client.get(page_url)
self.assertEqual(r.status_code, 200) 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, forms=forms,
form_errors=form_errors, form_errors=form_errors,
empty_form=empty_form, empty_form=empty_form,
all_forms=forms + [empty_form],
milestone_set=milestone_set, milestone_set=milestone_set,
needs_review=needs_review, needs_review=needs_review,
reviewer=reviewer, 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 import debug # pyflakes:ignore
from ietf.ipr.models import IprDisclosureBase from ietf.ipr.models import IprDisclosureBase
from ietf.utils.fields import SearchableField
def select2_id_ipr_title(objs): def select2_id_ipr_title(objs):
return [{ return [{
"id": o.pk, "id": o.pk,
"text": escape("%s <%s>" % (o.title, o.time.date().isoformat())), "text": escape("%s <%s>" % (o.title, o.time.date().isoformat())),
} for o in objs] } for o in objs]
def select2_id_ipr_title_json(value): def select2_id_ipr_title_json(value):
return json.dumps(select2_id_ipr_title(value)) return json.dumps(select2_id_ipr_title(value))
class SearchableIprDisclosuresField(forms.CharField): class SearchableIprDisclosuresField(SearchableField):
"""Server-based multi-select field for choosing documents using """Server-based multi-select field for choosing documents using select2.js"""
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 def validate_pks(self, pks):
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):
for pk in pks: for pk in pks:
if not pk.isdigit(): if not pk.isdigit():
raise forms.ValidationError("Unexpected value: %s" % pk) raise forms.ValidationError("You must enter IPR ID(s) as integers (Unexpected value: %s)" % pk)
return pks
def prepare_value(self, value): def get_model_instances(self, item_ids):
if not value: for key in item_ids:
value = "" if not key.isdigit():
if isinstance(value, str): item_ids.remove(key)
pks = self.parse_select2_value(value) return super(SearchableIprDisclosuresField, self).get_model_instances(item_ids)
# 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]
self.widget.attrs["data-pre"] = json.dumps({ def make_select2_data(self, model_instances):
d['id']: d for d in select2_id_ipr_title(value) return select2_id_ipr_title(model_instances)
})
# doing this in the constructor is difficult because the URL def ajax_url(self):
# patterns may not have been fully constructed there yet return urlreverse('ietf.ipr.views.ajax_search')
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

View file

@ -9,81 +9,38 @@ from django import forms
from django.urls import reverse as urlreverse from django.urls import reverse as urlreverse
from ietf.liaisons.models import LiaisonStatement from ietf.liaisons.models import LiaisonStatement
from ietf.utils.fields import SearchableField
def select2_id_liaison(objs): def select2_id_liaison(objs):
return [{ return [{
"id": o.pk, "id": o.pk,
"text":"[{}] {}".format(o.pk, escape(o.title)), "text":"[{}] {}".format(o.pk, escape(o.title)),
} for o in objs] } for o in objs]
def select2_id_liaison_json(objs): def select2_id_liaison_json(objs):
return json.dumps(select2_id_liaison(objs)) return json.dumps(select2_id_liaison(objs))
def select2_id_group_json(objs): def select2_id_group_json(objs):
return json.dumps([{ "id": o.pk, "text": escape(o.acronym) } for o in 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 """Server-based multi-select field for choosing liaison statements using
select2.js.""" select2.js."""
model = LiaisonStatement
default_hint_text = "Type in title to search for document"
def __init__(self, def validate_pks(self, pks):
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):
for pk in pks: for pk in pks:
if not pk.isdigit(): if not pk.isdigit():
raise forms.ValidationError("Unexpected value: %s" % pk) raise forms.ValidationError("Unexpected value: %s" % pk)
return pks
def prepare_value(self, value): def make_select2_data(self, model_instances):
if not value: return select2_id_liaison(model_instances)
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]
self.widget.attrs["data-pre"] = json.dumps({ def ajax_url(self):
d['id']: d for d in select2_id_liaison(value) return urlreverse("ietf.liaisons.views.ajax_select2_search_liaison_statements")
})
# doing this in the constructor is difficult because the URL def describe_failed_pks(self, failed_pks):
# patterns may not have been fully constructed there yet return "Could not recognize the following groups: {pks}.".format(pks=", ".join(failed_pks))
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

View file

@ -10,7 +10,7 @@ import re
from unittest import skipIf from unittest import skipIf
import django import django
from django.urls import reverse as urlreverse #from django.urls import reverse as urlreverse
from django.utils.text import slugify from django.utils.text import slugify
from django.db.models import F from django.db.models import F
from pytz import timezone from pytz import timezone
@ -30,72 +30,20 @@ from ietf.meeting.models import (Schedule, SchedTimeSessAssignment, Session,
Room, TimeSlot, Constraint, ConstraintName, Room, TimeSlot, Constraint, ConstraintName,
Meeting, SchedulingEvent, SessionStatusName) Meeting, SchedulingEvent, SessionStatusName)
from ietf.meeting.utils import add_event_info_to_session_qs 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.test_utils import assert_ical_response_is_valid
from ietf.utils.jstest import IetfSeleniumTestCase, ifSeleniumEnabled, selenium_enabled
from ietf import settings from ietf import settings
skip_selenium = False if selenium_enabled():
skip_message = ""
try:
from selenium import webdriver
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import Select, WebDriverWait from selenium.webdriver.support.ui import Select, WebDriverWait
from selenium.webdriver.support import expected_conditions from selenium.webdriver.support import expected_conditions
from selenium.common.exceptions import NoSuchElementException 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) @ifSeleniumEnabled
class EditMeetingScheduleTests(MeetingTestCase): class EditMeetingScheduleTests(IetfSeleniumTestCase):
def test_edit_meeting_schedule(self): def test_edit_meeting_schedule(self):
meeting = make_meeting_test_data() meeting = make_meeting_test_data()
@ -278,9 +226,9 @@ class EditMeetingScheduleTests(MeetingTestCase):
self.assertTrue(self.driver.find_elements_by_css_selector('#timeslot{} #session{}'.format(slot4.pk, s1.pk))) 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") @skipIf(django.VERSION[0]==2, "Skipping test with race conditions under Django 2")
class ScheduleEditTests(MeetingTestCase): class ScheduleEditTests(IetfSeleniumTestCase):
def testUnschedule(self): def testUnschedule(self):
meeting = make_meeting_test_data() meeting = make_meeting_test_data()
@ -317,8 +265,8 @@ class ScheduleEditTests(MeetingTestCase):
self.assertEqual(SchedTimeSessAssignment.objects.filter(session__meeting__number=72,session__group__acronym='mars',schedule__name='test-schedule').count(),0) self.assertEqual(SchedTimeSessAssignment.objects.filter(session__meeting__number=72,session__group__acronym='mars',schedule__name='test-schedule').count(),0)
@skipIf(skip_selenium, skip_message) @ifSeleniumEnabled
class SlideReorderTests(MeetingTestCase): class SlideReorderTests(IetfSeleniumTestCase):
def setUp(self): def setUp(self):
super(SlideReorderTests, self).setUp() super(SlideReorderTests, self).setUp()
self.session = SessionFactory(meeting__type_id='ietf', status_id='sched') self.session = SessionFactory(meeting__type_id='ietf', status_id='sched')
@ -348,8 +296,8 @@ class SlideReorderTests(MeetingTestCase):
self.assertEqual(list(names),['one','three','two']) self.assertEqual(list(names),['one','three','two'])
@skipIf(skip_selenium, skip_message) @ifSeleniumEnabled
class AgendaTests(MeetingTestCase): class AgendaTests(IetfSeleniumTestCase):
def setUp(self): def setUp(self):
super(AgendaTests, self).setUp() super(AgendaTests, self).setUp()
self.meeting = make_meeting_test_data() self.meeting = make_meeting_test_data()
@ -1057,8 +1005,8 @@ class AgendaTests(MeetingTestCase):
self.assertIn('tz=america/halifax', wv_url) self.assertIn('tz=america/halifax', wv_url)
@skipIf(skip_selenium, skip_message) @ifSeleniumEnabled
class WeekviewTests(MeetingTestCase): class WeekviewTests(IetfSeleniumTestCase):
def setUp(self): def setUp(self):
super(WeekviewTests, self).setUp() super(WeekviewTests, self).setUp()
self.meeting = make_meeting_test_data() self.meeting = make_meeting_test_data()
@ -1254,8 +1202,8 @@ class WeekviewTests(MeetingTestCase):
) )
_assert_not_wrapped(displayed, time_string) _assert_not_wrapped(displayed, time_string)
@skipIf(skip_selenium, skip_message) @ifSeleniumEnabled
class InterimTests(MeetingTestCase): class InterimTests(IetfSeleniumTestCase):
def setUp(self): def setUp(self):
super(InterimTests, self).setUp() super(InterimTests, self).setUp()
self.materials_dir = self.tempdir('materials') self.materials_dir = self.tempdir('materials')

View file

@ -7,14 +7,19 @@ import json
from collections import Counter from collections import Counter
from urllib.parse import urlencode from urllib.parse import urlencode
from typing import Type # pyflakes:ignore
from django import forms from django import forms
from django.core.validators import validate_email from django.core.validators import validate_email
from django.db import models # pyflakes:ignore
from django.urls import reverse as urlreverse from django.urls import reverse as urlreverse
from django.utils.html import escape from django.utils.html import escape
import debug # pyflakes:ignore import debug # pyflakes:ignore
from ietf.person.models import Email, Person from ietf.person.models import Email, Person
from ietf.utils.fields import SearchableField
def select2_id_name(objs): def select2_id_name(objs):
def format_email(e): def format_email(e):
@ -41,7 +46,7 @@ def select2_id_name_json(objs):
return json.dumps(select2_id_name(objs)) return json.dumps(select2_id_name(objs))
class SearchablePersonsField(forms.CharField): class SearchablePersonsField(SearchableField):
"""Server-based multi-select field for choosing """Server-based multi-select field for choosing
persons/emails or just persons using select2.js. persons/emails or just persons using select2.js.
@ -58,126 +63,67 @@ class SearchablePersonsField(forms.CharField):
list. These can then be added by updating val() and triggering the 'change' list. These can then be added by updating val() and triggering the 'change'
event on the select2 field in JavaScript. event on the select2 field in JavaScript.
""" """
model = Person # type: Type[models.Model]
default_hint_text = "Type name to search for person."
def __init__(self, def __init__(self,
max_entries=None, # max number of selected objs
only_users=False, # only select persons who also have a user only_users=False, # only select persons who also have a user
all_emails=False, # select only active email addresses all_emails=False, # select only active email addresses
extra_prefetch=None, # extra data records to include in prefetch 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): *args, **kwargs):
kwargs["max_length"] = 10000 super(SearchablePersonsField, self).__init__(*args, **kwargs)
self.max_entries = max_entries
self.only_users = only_users self.only_users = only_users
self.all_emails = all_emails 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 [] self.extra_prefetch = extra_prefetch or []
assert all([isinstance(obj, self.model) for obj in self.extra_prefetch]) assert all([isinstance(obj, self.model) for obj in self.extra_prefetch])
def parse_select2_value(self, value): def validate_pks(self, pks):
return [x.strip() for x in value.split(",") if x.strip()] """Validate format of PKs"""
for pk in pks:
if not pk.isdigit():
raise forms.ValidationError("Unexpected value: %s" % pk)
def check_pks(self, pks): def make_select2_data(self, model_instances):
if self.model == Person: # Include records needed by the initial value of the field plus any added
for pk in pks: # via the extra_prefetch property.
if not pk.isdigit(): prefetch_set = set(model_instances).union(set(self.extra_prefetch)) # eliminate duplicates
raise forms.ValidationError("Unexpected value: %s" % pk) return select2_id_name(list(prefetch_set))
elif self.model == Email:
for pk in pks:
validate_email(pk)
return pks
def prepare_value(self, value): def ajax_url(self):
if not value: url = urlreverse(
value = "" "ietf.person.views.ajax_select2_search",
if isinstance(value, str): kwargs={ "model_name": self.model.__name__.lower() }
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() })
query_args = {} query_args = {}
if self.only_users: if self.only_users:
query_args["user"] = "1" query_args["user"] = "1"
if self.all_emails: if self.all_emails:
query_args["a"] = "1" query_args["a"] = "1"
if query_args: if len(query_args) > 0:
self.widget.attrs["data-ajax-url"] += "?%s" % urlencode(query_args) 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): class SearchablePersonField(SearchablePersonsField):
"""Version of SearchablePersonsField specialized to a single object.""" """Version of SearchablePersonsField specialized to a single object."""
max_entries = 1
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()
class SearchableEmailsField(SearchablePersonsField): class SearchableEmailsField(SearchablePersonsField):
"""Version of SearchablePersonsField with the defaults right for Emails.""" """Version of SearchablePersonsField with the defaults right for Emails."""
model = Email # type: Type[models.Model]
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): class SearchableEmailField(SearchableEmailsField):
"""Version of SearchableEmailsField specialized to a single object.""" """Version of SearchableEmailsField specialized to a single object."""
max_entries = 1
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()
class PersonEmailChoiceField(forms.ModelChoiceField): class PersonEmailChoiceField(forms.ModelChoiceField):

View file

@ -5,11 +5,7 @@
{% block extrahead %}{{ block.super }} {% block extrahead %}{{ block.super }}
<script type="text/javascript" src="{% static 'secr/js/utils.js' %}"></script> <script type="text/javascript" src="{% static 'secr/js/utils.js' %}"></script>
<script type="text/javascript" src="{% static 'secr/js/sessions.js' %}"></script> <script type="text/javascript" src="{% static 'secr/js/sessions.js' %}"></script>
{{ form.media }}
<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>
{% endblock %} {% endblock %}
{% block breadcrumbs %}{{ block.super }} {% block breadcrumbs %}{{ block.super }}

View file

@ -6,11 +6,7 @@
{% block extrahead %}{{ block.super }} {% block extrahead %}{{ block.super }}
<script type="text/javascript" src="{% static 'secr/js/utils.js' %}"></script> <script type="text/javascript" src="{% static 'secr/js/utils.js' %}"></script>
<script type="text/javascript" src="{% static 'secr/js/sessions.js' %}"></script> <script type="text/javascript" src="{% static 'secr/js/sessions.js' %}"></script>
{{ form.media }}
<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>
{% endblock %} {% endblock %}
{% block breadcrumbs %}{{ block.super }} {% block breadcrumbs %}{{ block.super }}

View file

@ -101,7 +101,7 @@ $(document).ready(function () {
new_edit_milestone.show(); new_edit_milestone.show();
new_edit_milestone.find(".select2-field").each(function () { 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 ) { if ( ! group_uses_milestone_dates ) {

View file

@ -1,5 +1,5 @@
// currently we only include select2 CSS/JS on those pages where forms // Copyright The IETF Trust 2015-2021, All Rights Reserved
// need it, so the generic setup code here is also kept separate // JS for ietf.utils.fields.SearchableField subclasses
function setupSelect2Field(e) { function setupSelect2Field(e) {
var url = e.data("ajax-url"); var url = e.data("ajax-url");
if (!url) if (!url)
@ -8,6 +8,18 @@ function setupSelect2Field(e) {
var maxEntries = e.data("max-entries"); var maxEntries = e.data("max-entries");
var multiple = maxEntries !== 1; var multiple = maxEntries !== 1;
var prefetched = e.data("pre"); 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({ e.select2({
multiple: multiple, multiple: multiple,
minimumInputLength: 2, minimumInputLength: 2,
@ -37,8 +49,9 @@ function setupSelect2Field(e) {
initSelection: function (element, cb) { initSelection: function (element, cb) {
element = $(element); // jquerify element = $(element); // jquerify
// The original data set will contain any values looked up via ajax // The original data set will contain any values looked up via ajax.
var data = element.select2('data') | [] ; // When !multiple, select2('data') will be null - turn that into []
var data = element.select2('data') || [];
var data_map = {}; var data_map = {};
// map id to its data representation // 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: for author in submission.authors:
author["cleaned_country"] = clean_country_name(author.get("country")) author["cleaned_country"] = clean_country_name(author.get("country"))
all_forms = [submitter_form, replaces_form]
return render(request, 'submit/submission_status.html', { return render(request, 'submit/submission_status.html', {
'selected': 'status', 'selected': 'status',
'submission': submission, '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_group_approval': accept_submission_requires_group_approval(submission),
'requires_prev_authors_approval': accept_submission_requires_prev_auth_approval(submission), 'requires_prev_authors_approval': accept_submission_requires_prev_auth_approval(submission),
'confirmation_list': confirmation_list, '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) author_forms = [ AuthorForm(initial=author, prefix="authors-%s" % i)
for i, author in enumerate(submission.authors) ] 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', return render(request, 'submit/edit_submission.html',
{'selected': 'status', {'selected': 'status',
'submission': submission, 'submission': submission,
@ -486,6 +491,7 @@ def edit_submission(request, submission_id, access_token=None):
'empty_author_form': empty_author_form, 'empty_author_form': empty_author_form,
'errors': errors, 'errors': errors,
'form_errors': form_errors, 'form_errors': form_errors,
'all_forms': all_forms,
}) })

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,14 +2,13 @@
{# Copyright The IETF Trust 2015, All Rights Reserved #} {# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %} {% load origin %}
{% load static %} {% 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 title %}Submission status of {{ submission.name }}-{{ submission.rev }}{% endblock %}
{% block pagehead %} {% block pagehead %}
{{ block.super }} {{ block.super }}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}"> {{ all_forms|merge_media:'css' }}
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{% endblock %} {% endblock %}
{% block submit_content %} {% block submit_content %}
@ -410,6 +409,5 @@
{% endblock %} {% endblock %}
{% block js %} {% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script> {{ all_forms|merge_media:'js' }}
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{% endblock %} {% endblock %}

View file

@ -3,11 +3,15 @@
import datetime import datetime
import json
import re import re
import debug # pyflakes:ignore import debug # pyflakes:ignore
from typing import Optional, Type # pyflakes:ignore
from django import forms from django import forms
from django.db import models # pyflakes:ignore
from django.core.validators import validate_email from django.core.validators import validate_email
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.dateparse import parse_duration from django.utils.dateparse import parse_duration
@ -117,4 +121,146 @@ class DurationField(forms.DurationField):
if value is None: if value is None:
raise ValidationError(self.error_messages['invalid'], code='invalid') raise ValidationError(self.error_messages['invalid'], code='invalid')
return value 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
model = None # type:Optional[Type[models.Model]]
# max_entries = None # may be overridden in __init__
max_entries = None # type: Optional[int]
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])