Skip to content

Commit

Permalink
Consolidate repeated searchable field code into SearchableField class…
Browse files Browse the repository at this point in the history
…. Fix single-valued searchable fields. Make javascript test config reusable. Use Django Form.media for JS/CSS inclusion. Fixes #3196, #3204. Commit ready for merge.

 - Legacy-Id: 18939
  • Loading branch information
jennifer-richards committed Apr 9, 2021
1 parent 516abc5 commit 17d3772
Show file tree
Hide file tree
Showing 40 changed files with 674 additions and 497 deletions.
5 changes: 4 additions & 1 deletion ietf/community/views.py
Expand Up @@ -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():
Expand All @@ -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')
Expand All @@ -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,
Expand All @@ -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,
})


Expand Down
143 changes: 46 additions & 97 deletions ietf/doc/fields.py
Expand Up @@ -13,123 +13,72 @@

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 parse_select2_value(self, value):
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):
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]

self.widget.attrs["data-pre"] = json.dumps({
d['id']: d for d in select2_id_doc_name(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"] = urlreverse('ietf.doc.views_search.ajax_select2_search_docs', kwargs={
def doc_type_filter(self, queryset):
"""Filter to include only desired doc type"""
return queryset.filter(type=self.doc_type)

def get_model_instances(self, item_ids):
"""Get model instances corresponding to item identifiers in select2 field 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)

def make_select2_data(self, model_instances):
"""Get select2 data items"""
return select2_id_doc_name(model_instances)

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
31 changes: 29 additions & 2 deletions ietf/doc/tests.py
Expand Up @@ -9,7 +9,7 @@
import lxml
import bibtexparser
import mock

import json

from http.cookies import SimpleCookie
from pyquery import PyQuery
Expand All @@ -18,6 +18,8 @@

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

Expand All @@ -29,7 +31,8 @@
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
Expand Down Expand Up @@ -1691,3 +1694,27 @@ def test_personal_chart(self):
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)],
)
1 change: 1 addition & 0 deletions ietf/group/milestones.py
Expand Up @@ -391,6 +391,7 @@ def save_milestone_form(f):
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,
Expand Down

0 comments on commit 17d3772

Please sign in to comment.