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