From 5604914bfc45dd2ade78119a254bbebe6788d68f Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 9 Oct 2014 17:14:14 +0000 Subject: [PATCH] Add AutocompletedDocumentsField to be used for replacements submission and refactor milestones tool to use this field - Legacy-Id: 8381 --- ietf/doc/fields.py | 83 ++++++++++++++++++++++++ ietf/doc/tests.py | 27 ++++++++ ietf/doc/urls.py | 1 + ietf/doc/views_search.py | 29 ++++++++- ietf/group/milestones.py | 12 +--- ietf/group/tests_info.py | 9 --- ietf/group/urls.py | 1 - ietf/group/urls_info.py | 1 - ietf/templates/group/milestone_form.html | 2 +- 9 files changed, 142 insertions(+), 23 deletions(-) create mode 100644 ietf/doc/fields.py diff --git a/ietf/doc/fields.py b/ietf/doc/fields.py new file mode 100644 index 000000000..264beb08e --- /dev/null +++ b/ietf/doc/fields.py @@ -0,0 +1,83 @@ +import json + +from django.utils.html import escape +from django import forms +from django.core.urlresolvers import reverse as urlreverse + +import debug # pyflakes:ignore + +from ietf.doc.models import Document, DocAlias + +def tokeninput_id_doc_name_json(objs): + return json.dumps([{ "id": o.pk, "name": escape(o.name) } for o in objs]) + +class AutocompletedDocumentsField(forms.CharField): + """Tokenizing autocompleted multi-select field for choosing + documents using jquery.tokeninput.js. + + The field uses a comma-separated list of primary keys in a + CharField element as its API, the tokeninput Javascript adds some + selection magic on top of this so we have to pass it a JSON + representation of ids and user-understandable labels.""" + + 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 + + super(AutocompletedDocumentsField, self).__init__(*args, **kwargs) + + self.widget.attrs["class"] = "tokenized-field" + self.widget.attrs["data-hint-text"] = hint_text + if self.max_entries != None: + self.widget.attrs["data-max-entries"] = self.max_entries + + def parse_tokenized_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, basestring): + pks = self.parse_tokenized_value(value) + value = self.model.objects.filter(pk__in=pks, type=self.doc_type) + if isinstance(value, self.model): + value = [value] + + self.widget.attrs["data-pre"] = tokeninput_id_doc_name_json(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("ajax_tokeninput_search_docs", kwargs={ + "doc_type": self.doc_type, + "model_name": self.model.__name__.lower() + }) + + return ",".join(o.pk for o in value) + + def clean(self, value): + value = super(AutocompletedDocumentsField, self).clean(value) + pks = self.parse_tokenized_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(u"Could not recognize the following documents: {pks}. You can only input documents already registered in the Datatracker.".format(pks=", ".join(failed_pks))) + + if self.max_entries != None and len(objs) > self.max_entries: + raise forms.ValidationError(u"You can select at most %s entries only." % self.max_entries) + + return objs + +class AutocompletedDocAliasField(AutocompletedDocumentsField): + def __init__(self, model=DocAlias, *args, **kwargs): + super(AutocompletedDocAliasField, self).__init__(model=model, *args, **kwargs) + diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index 19e01887f..06d194365 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -1,4 +1,5 @@ import datetime +import json import sys if sys.version_info[0] == 2 and sys.version_info[1] < 7: import unittest2 as unittest @@ -121,6 +122,32 @@ class SearchTestCase(TestCase): r = self.client.get(urlreverse("index_active_drafts")) self.assertEqual(r.status_code, 200) self.assertTrue(draft.title in r.content) + + def test_ajax_search_docs(self): + draft = make_test_data() + + # Document + url = urlreverse("ajax_tokeninput_search_docs", kwargs={ + "model_name": "document", + "doc_type": "draft", + }) + r = self.client.get(url, dict(q=draft.name)) + self.assertEqual(r.status_code, 200) + data = json.loads(r.content) + self.assertEqual(data[0]["id"], draft.pk) + + # DocAlias + doc_alias = draft.docalias_set.get() + + url = urlreverse("ajax_tokeninput_search_docs", kwargs={ + "model_name": "docalias", + "doc_type": "draft", + }) + + r = self.client.get(url, dict(q=doc_alias.name)) + self.assertEqual(r.status_code, 200) + data = json.loads(r.content) + self.assertEqual(data[0]["id"], doc_alias.pk) class DocTestCase(TestCase): diff --git a/ietf/doc/urls.py b/ietf/doc/urls.py index 940307d2a..54be71e34 100644 --- a/ietf/doc/urls.py +++ b/ietf/doc/urls.py @@ -49,6 +49,7 @@ urlpatterns = patterns('', url(r'^all/$', views_search.index_all_drafts, name="index_all_drafts"), url(r'^active/$', views_search.index_active_drafts, name="index_active_drafts"), + url(r'^tokeninputsearch/(?P(document|docalias))/(?Pdraft)/$', views_search.ajax_tokeninput_search_docs, name="ajax_tokeninput_search_docs"), url(r'^(?P[A-Za-z0-9._+-]+)/(?:(?P[0-9-]+)/)?$', views_doc.document_main, name="doc_view"), url(r'^(?P[A-Za-z0-9._+-]+)/history/$', views_doc.document_history, name="doc_history"), diff --git a/ietf/doc/views_search.py b/ietf/doc/views_search.py index 365c594e4..1875b4e42 100644 --- a/ietf/doc/views_search.py +++ b/ietf/doc/views_search.py @@ -37,7 +37,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.shortcuts import render_to_response from django.db.models import Q from django.template import RequestContext -from django.http import Http404, HttpResponseBadRequest +from django.http import Http404, HttpResponseBadRequest, HttpResponse import debug # pyflakes:ignore @@ -45,6 +45,7 @@ from ietf.community.models import CommunityList from ietf.doc.models import ( Document, DocAlias, State, RelatedDocument, DocEvent, LastCallDocEvent, TelechatDocEvent, IESG_SUBSTATE_TAGS ) from ietf.doc.expire import expirable_draft +from ietf.doc.fields import tokeninput_id_doc_name_json from ietf.group.models import Group from ietf.idindex.index import active_drafts_index_by_group from ietf.ipr.models import IprDocAlias @@ -52,6 +53,7 @@ from ietf.name.models import DocTagName, DocTypeName, StreamName from ietf.person.models import Person from ietf.utils.draft_search import normalize_draftname + class SearchForm(forms.Form): name = forms.CharField(required=False) rfcs = forms.BooleanField(required=False, initial=True) @@ -626,3 +628,28 @@ def index_active_drafts(request): groups = active_drafts_index_by_group() return render_to_response("doc/index_active_drafts.html", { 'groups': groups }, context_instance=RequestContext(request)) + +def ajax_tokeninput_search_docs(request, model_name, doc_type): + if model_name == "docalias": + model = DocAlias + else: + model = Document + + q = [w.strip() for w in request.GET.get('q', '').split() if w.strip()] + + if not q: + objs = model.objects.none() + else: + qs = model.objects.all() + + if model == Document: + qs = qs.filter(type=doc_type) + elif model == DocAlias: + qs = qs.filter(document__type=doc_type) + + for t in q: + qs = qs.filter(name__icontains=t) + + objs = qs.distinct().order_by("name")[:20] + + return HttpResponse(tokeninput_id_doc_name_json(objs), content_type='application/json') diff --git a/ietf/group/milestones.py b/ietf/group/milestones.py index 1778e316c..0be402508 100644 --- a/ietf/group/milestones.py +++ b/ietf/group/milestones.py @@ -11,6 +11,7 @@ from django.contrib.auth.decorators import login_required from ietf.doc.models import Document, DocEvent from ietf.doc.utils import get_chartering_type +from ietf.doc.fields import AutocompletedDocumentsField from ietf.group.models import GroupMilestone, MilestoneGroupEvent from ietf.group.utils import (save_milestone_in_history, can_manage_group_type, milestone_reviewer_for_group_type, get_group_or_404) @@ -34,7 +35,7 @@ class MilestoneForm(forms.Form): delete = forms.BooleanField(required=False, initial=False) - docs = forms.CharField(max_length=10000, required=False) + docs = AutocompletedDocumentsField(required=False) accept = forms.ChoiceField(choices=(("accept", "Accept"), ("reject", "Reject and delete"), ("noaction", "No action")), required=False, initial="noaction", widget=forms.RadioSelect) @@ -95,10 +96,6 @@ class MilestoneForm(forms.Form): # calculate whether we've changed self.changed = self.is_bound and (not self.milestone or any(unicode(self[f].data) != unicode(self.initial[f]) for f in self.fields.iterkeys())) - def clean_docs(self): - s = self.cleaned_data["docs"] - return Document.objects.filter(pk__in=[x.strip() for x in s.split(",") if x.strip()], type="draft") - def clean_resolved(self): r = self.cleaned_data["resolved"].strip() @@ -391,8 +388,3 @@ def reset_charter_milestones(request, group_type, acronym): charter_milestones=charter_milestones, current_milestones=current_milestones, )) - - -def ajax_search_docs(request, group_type, acronym): - docs = Document.objects.filter(name__icontains=request.GET.get('q',''), type="draft").order_by('name').distinct()[:20] - return HttpResponse(json_doc_names(docs), content_type='application/json') diff --git a/ietf/group/tests_info.py b/ietf/group/tests_info.py index 802eb6442..ec1955a73 100644 --- a/ietf/group/tests_info.py +++ b/ietf/group/tests_info.py @@ -866,15 +866,6 @@ class MilestoneTests(TestCase): self.assertTrue(m1.desc in unicode(outbox[-1])) self.assertTrue(m2.desc in unicode(outbox[-1])) - def test_ajax_search_docs(self): - draft = make_test_data() - - r = self.client.get(urlreverse("group_ajax_search_docs", kwargs=dict(group_type=draft.group.type_id, acronym=draft.group.acronym)), - dict(q=draft.name)) - self.assertEqual(r.status_code, 200) - data = json.loads(r.content) - self.assertTrue(data[0]["id"], draft.name) - class CustomizeWorkflowTests(TestCase): def test_customize_workflow(self): make_test_data() diff --git a/ietf/group/urls.py b/ietf/group/urls.py index 283eb38cd..66bc59231 100644 --- a/ietf/group/urls.py +++ b/ietf/group/urls.py @@ -22,7 +22,6 @@ urlpatterns = patterns('', (r'^(?P[a-zA-Z0-9-._]+)/milestones/$', 'ietf.group.milestones.edit_milestones', {'milestone_set': "current"}, "group_edit_milestones"), (r'^(?P[a-zA-Z0-9-._]+)/milestones/charter/$', 'ietf.group.milestones.edit_milestones', {'milestone_set': "charter"}, "group_edit_charter_milestones"), (r'^(?P[a-zA-Z0-9-._]+)/milestones/charter/reset/$', 'ietf.group.milestones.reset_charter_milestones', None, "group_reset_charter_milestones"), - (r'^(?P[a-zA-Z0-9-._]+)/ajax/searchdocs/$', 'ietf.group.milestones.ajax_search_docs', None, "group_ajax_search_docs"), (r'^(?P[a-zA-Z0-9-._]+)/workflow/$', 'ietf.group.edit.customize_workflow'), (r'^(?P[a-zA-Z0-9-._]+)/about/(?P.)?$', 'ietf.group.info.group_about', None, 'group_about'), diff --git a/ietf/group/urls_info.py b/ietf/group/urls_info.py index ba5d2ab1f..42ba9257e 100644 --- a/ietf/group/urls_info.py +++ b/ietf/group/urls_info.py @@ -31,6 +31,5 @@ urlpatterns = patterns('', (r'^(?P[a-zA-Z0-9-._]+)/milestones/$', milestones.edit_milestones, {'milestone_set': "current"}, "group_edit_milestones"), (r'^(?P[a-zA-Z0-9-._]+)/milestones/charter/$', milestones.edit_milestones, {'milestone_set': "charter"}, "group_edit_charter_milestones"), (r'^(?P[a-zA-Z0-9-._]+)/milestones/charter/reset/$', milestones.reset_charter_milestones, None, "group_reset_charter_milestones"), - (r'^(?P[a-zA-Z0-9-._]+)/ajax/searchdocs/$', milestones.ajax_search_docs, None, "group_ajax_search_docs"), (r'^(?P[a-zA-Z0-9-._]+)/workflow/$', edit.customize_workflow), ) diff --git a/ietf/templates/group/milestone_form.html b/ietf/templates/group/milestone_form.html index bee0e8746..0a1a271fc 100644 --- a/ietf/templates/group/milestone_form.html +++ b/ietf/templates/group/milestone_form.html @@ -26,7 +26,7 @@ Drafts: - + {{ form.docs }} {{ form.docs.errors }}