Skip to content

Commit 17d3772

Browse files
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 ietf-tools#3196, ietf-tools#3204. Commit ready for merge.
- Legacy-Id: 18939
1 parent 516abc5 commit 17d3772

40 files changed

Lines changed: 674 additions & 497 deletions

ietf/community/views.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def manage_list(request, username=None, acronym=None, group_type=None):
7272

7373
return HttpResponseRedirect("")
7474

75+
rule_form = None
7576
if request.method == 'POST' and action == 'add_rule':
7677
rule_type_form = SearchRuleTypeForm(request.POST)
7778
if rule_type_form.is_valid():
@@ -93,7 +94,6 @@ def manage_list(request, username=None, acronym=None, group_type=None):
9394
return HttpResponseRedirect("")
9495
else:
9596
rule_type_form = SearchRuleTypeForm()
96-
rule_form = None
9797

9898
if request.method == 'POST' and action == 'remove_rule':
9999
rule_pk = request.POST.get('rule')
@@ -111,6 +111,8 @@ def manage_list(request, username=None, acronym=None, group_type=None):
111111

112112
total_count = docs_tracked_by_community_list(clist).count()
113113

114+
all_forms = [f for f in [rule_type_form, rule_form, add_doc_form, *empty_rule_forms.values()]
115+
if f is not None]
114116
return render(request, 'community/manage_list.html', {
115117
'clist': clist,
116118
'rules': rules,
@@ -120,6 +122,7 @@ def manage_list(request, username=None, acronym=None, group_type=None):
120122
'empty_rule_forms': empty_rule_forms,
121123
'total_count': total_count,
122124
'add_doc_form': add_doc_form,
125+
'all_forms': all_forms,
123126
})
124127

125128

ietf/doc/fields.py

Lines changed: 46 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -13,123 +13,72 @@
1313

1414
from ietf.doc.models import Document, DocAlias
1515
from ietf.doc.utils import uppercase_std_abbreviated_name
16+
from ietf.utils.fields import SearchableField
1617

1718
def select2_id_doc_name(objs):
1819
return [{
19-
"id": o.pk,
20-
"text": escape(uppercase_std_abbreviated_name(o.name)),
20+
"id": o.pk,
21+
"text": escape(uppercase_std_abbreviated_name(o.name)),
2122
} for o in objs]
2223

2324

2425
def select2_id_doc_name_json(objs):
2526
return json.dumps(select2_id_doc_name(objs))
2627

27-
# FIXME: select2 version 4 uses a standard select for the AJAX case -
28-
# switching to that would allow us to derive from the standard
29-
# multi-select machinery in Django instead of the manual CharField
30-
# stuff below
31-
32-
class SearchableDocumentsField(forms.CharField):
33-
"""Server-based multi-select field for choosing documents using
34-
select2.js.
35-
36-
The field uses a comma-separated list of primary keys in a
37-
CharField element as its API with some extra attributes used by
38-
the Javascript part."""
39-
40-
def __init__(self,
41-
max_entries=None, # max number of selected objs
42-
model=Document,
43-
hint_text="Type in name to search for document",
44-
doc_type="draft",
45-
*args, **kwargs):
46-
kwargs["max_length"] = 10000
47-
self.max_entries = max_entries
48-
self.doc_type = doc_type
49-
self.model = model
5028

29+
class SearchableDocumentsField(SearchableField):
30+
"""Server-based multi-select field for choosing documents using select2.js. """
31+
model = Document
32+
default_hint_text = "Type name to search for document"
33+
34+
def __init__(self, doc_type="draft", *args, **kwargs):
5135
super(SearchableDocumentsField, self).__init__(*args, **kwargs)
36+
self.doc_type = doc_type
5237

53-
self.widget.attrs["class"] = "select2-field form-control"
54-
self.widget.attrs["data-placeholder"] = hint_text
55-
if self.max_entries != None:
56-
self.widget.attrs["data-max-entries"] = self.max_entries
57-
58-
def parse_select2_value(self, value):
59-
return [x.strip() for x in value.split(",") if x.strip()]
60-
61-
def prepare_value(self, value):
62-
if not value:
63-
value = ""
64-
if isinstance(value, int):
65-
value = str(value)
66-
if isinstance(value, str):
67-
items = self.parse_select2_value(value)
68-
# accept both names and pks here
69-
names = [ i for i in items if not i.isdigit() ]
70-
ids = [ i for i in items if i.isdigit() ]
71-
value = self.model.objects.filter(Q(name__in=names)|Q(id__in=ids))
72-
filter_args = {}
73-
if self.model == DocAlias:
74-
filter_args["docs__type"] = self.doc_type
75-
else:
76-
filter_args["type"] = self.doc_type
77-
value = value.filter(**filter_args)
78-
if isinstance(value, self.model):
79-
value = [value]
80-
81-
self.widget.attrs["data-pre"] = json.dumps({
82-
d['id']: d for d in select2_id_doc_name(value)
83-
})
84-
85-
# doing this in the constructor is difficult because the URL
86-
# patterns may not have been fully constructed there yet
87-
self.widget.attrs["data-ajax-url"] = urlreverse('ietf.doc.views_search.ajax_select2_search_docs', kwargs={
38+
def doc_type_filter(self, queryset):
39+
"""Filter to include only desired doc type"""
40+
return queryset.filter(type=self.doc_type)
41+
42+
def get_model_instances(self, item_ids):
43+
"""Get model instances corresponding to item identifiers in select2 field value
44+
45+
Accepts both names and pks as IDs
46+
"""
47+
names = [ i for i in item_ids if not i.isdigit() ]
48+
ids = [ i for i in item_ids if i.isdigit() ]
49+
objs = self.model.objects.filter(
50+
Q(name__in=names)|Q(id__in=ids)
51+
)
52+
return self.doc_type_filter(objs)
53+
54+
def make_select2_data(self, model_instances):
55+
"""Get select2 data items"""
56+
return select2_id_doc_name(model_instances)
57+
58+
def ajax_url(self):
59+
"""Get the URL for AJAX searches"""
60+
return urlreverse('ietf.doc.views_search.ajax_select2_search_docs', kwargs={
8861
"doc_type": self.doc_type,
8962
"model_name": self.model.__name__.lower()
9063
})
9164

92-
return ",".join(str(o.pk) for o in value)
93-
94-
def clean(self, value):
95-
value = super(SearchableDocumentsField, self).clean(value)
96-
pks = self.parse_select2_value(value)
97-
98-
try:
99-
objs = self.model.objects.filter(pk__in=pks)
100-
except ValueError as e:
101-
raise forms.ValidationError("Unexpected field value; %s" % e)
102-
103-
found_pks = [ str(o.pk) for o in objs ]
104-
failed_pks = [ x for x in pks if x not in found_pks ]
105-
if failed_pks:
106-
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)))
107-
108-
if self.max_entries != None and len(objs) > self.max_entries:
109-
raise forms.ValidationError("You can select at most %s entries." % self.max_entries)
110-
111-
return objs
11265

11366
class SearchableDocumentField(SearchableDocumentsField):
114-
"""Specialized to only return one Document."""
115-
def __init__(self, model=Document, *args, **kwargs):
116-
kwargs["max_entries"] = 1
117-
super(SearchableDocumentField, self).__init__(model=model, *args, **kwargs)
67+
"""Specialized to only return one Document"""
68+
max_entries = 1
69+
11870

119-
def clean(self, value):
120-
return super(SearchableDocumentField, self).clean(value).first()
121-
12271
class SearchableDocAliasesField(SearchableDocumentsField):
123-
def __init__(self, model=DocAlias, *args, **kwargs):
124-
super(SearchableDocAliasesField, self).__init__(model=model, *args, **kwargs)
72+
"""Search DocAliases instead of Documents"""
73+
model = DocAlias
12574

126-
class SearchableDocAliasField(SearchableDocumentsField):
127-
"""Specialized to only return one DocAlias."""
128-
def __init__(self, model=DocAlias, *args, **kwargs):
129-
kwargs["max_entries"] = 1
130-
super(SearchableDocAliasField, self).__init__(model=model, *args, **kwargs)
75+
def doc_type_filter(self, queryset):
76+
"""Filter to include only desired doc type
13177
132-
def clean(self, value):
133-
return super(SearchableDocAliasField, self).clean(value).first()
78+
For DocAlias, pass through to the docs to check type.
79+
"""
80+
return queryset.filter(docs__type=self.doc_type)
13481

135-
82+
class SearchableDocAliasField(SearchableDocAliasesField):
83+
"""Specialized to only return one DocAlias"""
84+
max_entries = 1

ietf/doc/tests.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import lxml
1010
import bibtexparser
1111
import mock
12-
12+
import json
1313

1414
from http.cookies import SimpleCookie
1515
from pyquery import PyQuery
@@ -18,6 +18,8 @@
1818

1919
from django.urls import reverse as urlreverse
2020
from django.conf import settings
21+
from django.forms import Form
22+
from django.utils.html import escape
2123

2224
from tastypie.test import ResourceTestCaseMixin
2325

@@ -29,7 +31,8 @@
2931
ConflictReviewFactory, WgDraftFactory, IndividualDraftFactory, WgRfcFactory,
3032
IndividualRfcFactory, StateDocEventFactory, BallotPositionDocEventFactory,
3133
BallotDocEventFactory )
32-
from ietf.doc.utils import create_ballot_if_not_open
34+
from ietf.doc.fields import SearchableDocumentsField
35+
from ietf.doc.utils import create_ballot_if_not_open, uppercase_std_abbreviated_name
3336
from ietf.group.models import Group
3437
from ietf.group.factories import GroupFactory, RoleFactory
3538
from ietf.ipr.factories import HolderIprDisclosureFactory
@@ -1691,3 +1694,27 @@ def test_personal_chart(self):
16911694
r = self.client.get(page_url)
16921695
self.assertEqual(r.status_code, 200)
16931696

1697+
1698+
class FieldTests(TestCase):
1699+
def test_searchabledocumentsfield_pre(self):
1700+
# so far, just tests that the format expected by select2-field.js is set up
1701+
docs = IndividualDraftFactory.create_batch(3)
1702+
1703+
class _TestForm(Form):
1704+
test_field = SearchableDocumentsField()
1705+
1706+
form = _TestForm(initial=dict(test_field=docs))
1707+
html = str(form)
1708+
q = PyQuery(html)
1709+
json_data = q('input.select2-field').attr('data-pre')
1710+
try:
1711+
decoded = json.loads(json_data)
1712+
except json.JSONDecodeError as e:
1713+
self.fail('data-pre contained invalid JSON data: %s' % str(e))
1714+
decoded_ids = list(decoded.keys())
1715+
self.assertCountEqual(decoded_ids, [str(doc.id) for doc in docs])
1716+
for doc in docs:
1717+
self.assertEqual(
1718+
dict(id=doc.pk, text=escape(uppercase_std_abbreviated_name(doc.name))),
1719+
decoded[str(doc.pk)],
1720+
)

ietf/group/milestones.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ def save_milestone_form(f):
391391
forms=forms,
392392
form_errors=form_errors,
393393
empty_form=empty_form,
394+
all_forms=forms + [empty_form],
394395
milestone_set=milestone_set,
395396
needs_review=needs_review,
396397
reviewer=reviewer,

0 commit comments

Comments
 (0)