[Testbot] Plone 5.0 - Python 2.7 - Build # 1887 - Regression! - 7 failure(s)

jenkins at plone.org jenkins at plone.org
Sat Mar 8 13:58:38 UTC 2014


-------------------------------------------------------------------------------
Plone 5.0 - Python 2.7 - Build # 1887 - Failure!
-------------------------------------------------------------------------------

http://jenkins.plone.org/job/plone-5.0-python-2.7/1887/


-------------------------------------------------------------------------------
CHANGES
-------------------------------------------------------------------------------

Repository: plone.app.widgets
Branch: refs/heads/master
Date: 2014-03-07T15:17:44-08:00
Author: David Glick (davisagli) <david at glicksoftware.com>
Commit: https://github.com/plone/plone.app.widgets/commit/f1ec347884c621b7c66c3ba498d38614967ba284

add support for querying sources to the ajax select and related items widgets

Files changed:
M plone/app/widgets/browser/configure.zcml
M plone/app/widgets/browser/vocabulary.py
M plone/app/widgets/configure.zcml
M plone/app/widgets/dx.py
M plone/app/widgets/tests/test_browser.py
M plone/app/widgets/tests/test_dx.py

diff --git a/plone/app/widgets/browser/configure.zcml b/plone/app/widgets/browser/configure.zcml
index fb4fbd1..d681e53 100644
--- a/plone/app/widgets/browser/configure.zcml
+++ b/plone/app/widgets/browser/configure.zcml
@@ -14,6 +14,13 @@
     />
 
   <browser:page
+    name="getSource"
+    for="z3c.form.interfaces.IWidget"
+    class=".vocabulary.SourceView"
+    permission="zope.Public"
+    />
+
+  <browser:page
     name="fileUpload"
     for="Products.CMFCore.interfaces._content.IFolderish"
     class=".file.FileUploadView"
diff --git a/plone/app/widgets/browser/vocabulary.py b/plone/app/widgets/browser/vocabulary.py
index c83d82f..e1cc615 100644
--- a/plone/app/widgets/browser/vocabulary.py
+++ b/plone/app/widgets/browser/vocabulary.py
@@ -1,18 +1,23 @@
 # -*- coding: utf-8 -*-
 
 from AccessControl import getSecurityManager
+from Products.CMFCore.utils import getToolByName
 from Products.CMFPlone.interfaces import IPloneSiteRoot
 from Products.Five import BrowserView
-from Products.ZCTextIndex.ParseTree import ParseError
 from logging import getLogger
-from plone.app.vocabularies.interfaces import ISlicableVocabulary
+from plone.app.querystring import queryparser
 from plone.app.widgets.interfaces import IFieldPermissionChecker
+from plone.autoform.interfaces import WRITE_PERMISSIONS_KEY
+from plone.supermodel.utils import mergedTaggedValueDict
 from types import FunctionType
+from zope.component import getUtility
 from zope.component import queryAdapter
 from zope.component import queryUtility
+from zope.schema.interfaces import ICollection
 from zope.schema.interfaces import IVocabularyFactory
-
+from zope.security.interfaces import IPermission
 import inspect
+import itertools
 import json
 
 logger = getLogger(__name__)
@@ -39,14 +44,11 @@ def _parseJSON(s):
 _safe_callable_metadata = ['getURL', 'getPath']
 
 
-class VocabularyView(BrowserView):
+class VocabLookupException(Exception):
+    pass
 
-    def error(self):
-        return json.dumps({
-            'results': [],
-            'total': 0,
-            'error': True
-        })
+
+class BaseVocabularyView(BrowserView):
 
     def __call__(self):
         """
@@ -66,89 +68,57 @@ def __call__(self):
             size: size of paged results
         }
         """
-        context = self.context
+        context = self.get_context()
         self.request.response.setHeader("Content-type", "application/json")
 
-        factory_name = self.request.get('name', None)
-        field_name = self.request.get('field', None)
-        if not factory_name:
-            return json.dumps({'error': 'No factory provided.'})
-        authorized = None
-        sm = getSecurityManager()
-        if (factory_name not in _permissions or
-                not IPloneSiteRoot.providedBy(context)):
-            # Check field specific permission
-            if field_name:
-                permission_checker = queryAdapter(context,
-                                                  IFieldPermissionChecker)
-                if permission_checker is not None:
-                    authorized = permission_checker.validate(field_name,
-                                                             factory_name)
-            if not authorized:
-                return json.dumps({'error': 'Vocabulary lookup not allowed'})
-        # Short circuit if we are on the site root and permission is
-        # in global registry
-        elif not sm.checkPermission(_permissions[factory_name], context):
-            return json.dumps({'error': 'Vocabulary lookup not allowed'})
-
-        factory = queryUtility(IVocabularyFactory, factory_name)
-        if not factory:
-            return json.dumps({
-                'error': 'No factory with name "%s" exists.' % factory_name})
-
-        # check if factory accepts query argument
-        query = _parseJSON(self.request.get('query', ''))
-        batch = _parseJSON(self.request.get('batch', ''))
-
-        if type(factory) is FunctionType:
-            factory_spec = inspect.getargspec(factory)
-        else:
-            factory_spec = inspect.getargspec(factory.__call__)
         try:
-            supports_batch = False
-            vocabulary = None
-            if query and 'query' in factory_spec.args:
-                if 'batch' in factory_spec.args:
-                    vocabulary = factory(self.context,
-                                         query=query, batch=batch)
-                    supports_batch = True
-                else:
-                    vocabulary = factory(self.context, query=query)
-            elif query:
-                raise KeyError("The vocabulary factory %s does not support "
-                               "query arguments",
-                               factory)
-
-            if batch and supports_batch:
-                    vocabulary = factory(context, query, batch)
-            elif query:
-                    vocabulary = factory(context, query)
-            else:
-                vocabulary = factory(context)
+            vocabulary = self.get_vocabulary()
+        except VocabLookupException, e:
+            return json.dumps({'error': e.message})
 
-        except (TypeError, ParseError):
-            raise
-            return self.error()
+        results_are_brains = False
+        if hasattr(vocabulary, 'search_catalog'):
+            query = self.parsed_query()
+            results = vocabulary.search_catalog(query)
+            results_are_brains = True
+        elif hasattr(vocabulary, 'search'):
+            try:
+                query = self.parsed_query()['SearchableText']['query']
+            except KeyError:
+                results = iter(vocabulary)
+            else:
+                results = vocabulary.search(query)
+        else:
+            results = vocabulary
 
         try:
-            total = len(vocabulary)
+            total = len(results)
         except TypeError:
             total = 0  # do not error if object does not support __len__
                        # we'll check again later if we can figure some size
                        # out
+
+        # get batch
+        batch = _parseJSON(self.request.get('batch', ''))
         if batch and ('size' not in batch or 'page' not in batch):
             batch = None  # batching not providing correct options
-            logger.error("A vocabulary request contained bad batch "
-                         "information. The batch information is ignored.")
-        if batch and not supports_batch and \
-                ISlicableVocabulary.providedBy(vocabulary):
+        if batch:
             # must be slicable for batching support
             page = int(batch['page'])
             # page is being passed in is 1-based
-            start = (max(page-1, 0)) * int(batch['size'])
+            start = (max(page - 1, 0)) * int(batch['size'])
             end = start + int(batch['size'])
-            vocabulary = vocabulary[start:end]
+            # Try __getitem__-based slice, then iterator slice.
+            # The iterator slice has to consume the iterator through
+            # to the desired slice, but that shouldn't be the end
+            # of the world because at some point the user will hopefully
+            # give up scrolling and search instead.
+            try:
+                results = results[start:end]
+            except TypeError:
+                results = itertools.islice(results, start, end)
 
+        # build result items
         items = []
 
         attributes = _parseJSON(self.request.get('attributes', ''))
@@ -156,8 +126,11 @@ def __call__(self):
             attributes = attributes.split(',')
 
         if attributes:
-            base_path = '/'.join(context.getPhysicalPath())
-            for vocab_item in vocabulary:
+            portal = getToolByName(context, 'portal_url').getPortalObject()
+            base_path = '/'.join(portal.getPhysicalPath())
+            for vocab_item in results:
+                if not results_are_brains:
+                    vocab_item = vocab_item.value
                 item = {}
                 for attr in attributes:
                     key = attr
@@ -167,8 +140,7 @@ def __call__(self):
                         continue
                     if key == 'path':
                         attr = 'getPath'
-                    vocab_value = vocab_item.value
-                    val = getattr(vocab_value, attr, None)
+                    val = getattr(vocab_item, attr, None)
                     if callable(val):
                         if attr in _safe_callable_metadata:
                             val = val()
@@ -179,7 +151,7 @@ def __call__(self):
                     item[key] = val
                 items.append(item)
         else:
-            for item in vocabulary:
+            for item in results:
                 items.append({'id': item.token, 'text': item.title})
 
         if total == 0:
@@ -189,3 +161,97 @@ def __call__(self):
             'results': items,
             'total': total
         })
+
+    def parsed_query(self, ):
+        query = _parseJSON(self.request.get('query', '')) or {}
+        if query:
+            parsed = queryparser.parseFormquery(
+                self.get_context(), query['criteria'])
+            if 'sort_on' in query:
+                parsed['sort_on'] = query['sort_on']
+            if 'sort_order' in query:
+                parsed['sort_order'] = str(query['sort_order'])
+            query = parsed
+        return query
+
+
+class VocabularyView(BaseVocabularyView):
+    """Queries a named vocabulary and returns JSON-formatted results."""
+
+    def get_context(self):
+        return self.context
+
+    def get_vocabulary(self):
+        # Look up named vocabulary and check permission.
+
+        context = self.context
+        factory_name = self.request.get('name', None)
+        field_name = self.request.get('field', None)
+        if not factory_name:
+            raise VocabLookupException('No factory provided.')
+        authorized = None
+        sm = getSecurityManager()
+        if (factory_name not in _permissions or
+                not IPloneSiteRoot.providedBy(context)):
+            # Check field specific permission
+            if field_name:
+                permission_checker = queryAdapter(context,
+                                                  IFieldPermissionChecker)
+                if permission_checker is not None:
+                    authorized = permission_checker.validate(field_name,
+                                                             factory_name)
+            if not authorized:
+                raise VocabLookupException('Vocabulary lookup not allowed')
+        # Short circuit if we are on the site root and permission is
+        # in global registry
+        elif not sm.checkPermission(_permissions[factory_name], context):
+            raise VocabLookupException('Vocabulary lookup not allowed')
+
+        factory = queryUtility(IVocabularyFactory, factory_name)
+        if not factory:
+            raise VocabLookupException(
+                'No factory with name "%s" exists.' % factory_name)
+
+        # This part is for backwards-compatibility with the first
+        # generation of vocabularies created for plone.app.widgets,
+        # which take the (unparsed) query as a parameter of the vocab
+        # factory rather than as a separate search method.
+        if type(factory) is FunctionType:
+            factory_spec = inspect.getargspec(factory)
+        else:
+            factory_spec = inspect.getargspec(factory.__call__)
+        query = _parseJSON(self.request.get('query', ''))
+        if query and 'query' in factory_spec.args:
+            vocabulary = factory(context, query=query)
+        else:
+            # This is what is reached for non-legacy vocabularies.
+            vocabulary = factory(context)
+
+        return vocabulary
+
+
+class SourceView(BaseVocabularyView):
+    """Queries a field's source and returns JSON-formatted results."""
+
+    def get_context(self):
+        return self.context.context
+
+    def get_vocabulary(self):
+        widget = self.context
+        field = widget.field.bind(widget.context)
+
+        # check field's write permission
+        info = mergedTaggedValueDict(field.interface, WRITE_PERMISSIONS_KEY)
+        permission_name = info.get(field.__name__, 'cmf.ModifyPortalContent')
+        permission = queryUtility(IPermission, name=permission_name)
+        if permission is None:
+            permission = getUtility(
+                IPermission, name='cmf.ModifyPortalContent')
+        if not getSecurityManager().checkPermission(
+                permission.title, self.get_context()):
+            raise VocabLookupException('Vocabulary lookup not allowed.')
+
+        if ICollection.providedBy(field):
+            return field.value_type.vocabulary
+        else:
+            return field.vocabulary
diff --git a/plone/app/widgets/configure.zcml b/plone/app/widgets/configure.zcml
index 5f5809c..4cf39ae 100644
--- a/plone/app/widgets/configure.zcml
+++ b/plone/app/widgets/configure.zcml
@@ -156,6 +156,7 @@
     <adapter factory=".dx.SelectWidgetConverter" />
     <adapter factory=".dx.AjaxSelectWidgetConverter" />
     <adapter factory=".dx.QueryStringDataConverter" />
+    <adapter factory=".dx.RelationChoiceRelatedItemsWidgetConverter" />
     <adapter factory=".dx.RelatedItemsDataConverter" />
   </configure>
 
@@ -236,6 +237,12 @@
                   z3c.form.interfaces.IFormLayer"
             />
 
+    <adapter
+      factory=".dx.RelatedItemsFieldWidget"
+      for="zope.schema.interfaces.IChoice
+           plone.app.vocabularies.catalog.CatalogSource
+           z3c.form.interfaces.IFormLayer" />
+
     <adapter factory=".dx.QueryStringFieldWidget" />
     <adapter factory=".dx.RichTextFieldWidget" />
 
diff --git a/plone/app/widgets/dx.py b/plone/app/widgets/dx.py
index c3a3643..67aa472 100644
--- a/plone/app/widgets/dx.py
+++ b/plone/app/widgets/dx.py
@@ -53,6 +53,7 @@
 from zope.interface import implementer
 from zope.interface import implements
 from zope.interface import implementsOnly
+from zope.interface import Interface
 from zope.publisher.browser import TestRequest
 from zope.schema.interfaces import IChoice
 from zope.schema.interfaces import ICollection
@@ -73,6 +74,17 @@
     HAS_PAC = False
 
 
+try:
+    from z3c.relationfield.interfaces import IRelationChoice
+    from z3c.relationfield.interfaces import IRelationList
+except ImportError:  # pragma: no cover
+    class IRelationChoice(Interface):
+        pass
+
+    class IRelationList(Interface):
+        pass
+
+
 class IDateField(IDate):
     """Marker interface for the DateField."""
 
@@ -232,7 +244,8 @@ def toFieldValue(self, value):
 
 
 class AjaxSelectWidgetConverter(BaseDataConverter):
-    """Data converter for ICollection."""
+    """Data converter for ICollection fields using the AjaxSelectWidget.
+    """
 
     adapts(ICollection, IAjaxSelectWidget)
 
@@ -272,8 +285,34 @@ def toFieldValue(self, value):
                               for v in value.split(separator))
 
 
+class RelationChoiceRelatedItemsWidgetConverter(BaseDataConverter):
+    """Data converter for RelationChoice fields using the RelatedItemsWidget.
+    """
+
+    adapts(IRelationChoice, IRelatedItemsWidget)
+
+    def toWidgetValue(self, value):
+        if not value:
+            return self.field.missing_value
+        return IUUID(value)
+
+    def toFieldValue(self, value):
+        if not value:
+            return self.field.missing_value
+        try:
+            catalog = getToolByName(self.widget.context, 'portal_catalog')
+        except AttributeError:
+            catalog = getToolByName(getSite(), 'portal_catalog')
+
+        res = catalog(UID=value)
+        if res:
+            return res[0].getObject()
+        else:
+            return self.field.missing_value
+
+
 class RelatedItemsDataConverter(BaseDataConverter):
-    """Data converter for ICollection."""
+    """Data converter for ICollection fields using the RelatedItemsWidget."""
 
     adapts(ICollection, IRelatedItemsWidget)
 
@@ -289,7 +328,10 @@ def toWidgetValue(self, value):
         if not value:
             return self.field.missing_value
         separator = getattr(self.widget, 'separator', ';')
-        return separator.join([IUUID(o) for o in value if value])
+        if IRelationList.providedBy(self.field):
+            return separator.join([IUUID(o) for o in value if value])
+        else:
+            return separator.join(v for v in value if v)
 
     def toFieldValue(self, value):
         """Converts from widget value to field.
@@ -300,24 +342,26 @@ def toFieldValue(self, value):
         :returns: List of content objects
         :rtype: list | tuple | set
         """
+        if not value:
+            return self.field.missing_value
+
         collectionType = self.field._type
         if isinstance(collectionType, tuple):
             collectionType = collectionType[-1]
 
-        if not len(value):
-            return self.field.missing_value
-
         separator = getattr(self.widget, 'separator', ';')
         value = value.split(separator)
-        value = [v.split('/')[0] for v in value]
 
-        try:
-            catalog = getToolByName(self.widget.context, 'portal_catalog')
-        except AttributeError:
-            catalog = getToolByName(getSite(), 'portal_catalog')
+        if IRelationList.providedBy(self.field):
+            try:
+                catalog = getToolByName(self.widget.context, 'portal_catalog')
+            except AttributeError:
+                catalog = getToolByName(getSite(), 'portal_catalog')
 
-        return collectionType(item.getObject()
-                              for item in catalog(UID=value) if item)
+            return collectionType(item.getObject()
+                                  for item in catalog(UID=value) if item)
+        else:
+            return collectionType(v for v in value)
 
 
 class QueryStringDataConverter(BaseDataConverter):
@@ -578,11 +622,6 @@ class AjaxSelectWidget(BaseWidget, z3cform_TextWidget):
     vocabulary_view = '@@getVocabulary'
     orderable = False
 
-    def update(self, *args, **kwargs):
-        if not hasattr(self, 'vocabulary'):
-            self.vocabulary = getattr(self.field, 'vocabularyName', None)
-        z3cform_TextWidget.update(self, *args, **kwargs)
-
     def _base_args(self):
         """Method which will calculate _base class arguments.
 
@@ -610,12 +649,27 @@ def _base_args(self):
         if IAddForm.providedBy(getattr(self, 'form')):
             context = self.form
 
+        vocabulary_name = self.vocabulary
+        field = None
+        if IChoice.providedBy(self.field):
+            args['pattern_options']['maximumSelectionSize'] = 1
+            field = self.field
+        elif ICollection.providedBy(self.field):
+            field = self.field.value_type
+        if not vocabulary_name and field is not None:
+            vocabulary_name = field.vocabularyName
+
         args['pattern_options'] = dict_merge(
             get_ajaxselect_options(context, args['value'], self.separator,
-                                   self.vocabulary, self.vocabulary_view,
+                                   vocabulary_name, self.vocabulary_view,
                                    field_name),
             args['pattern_options'])
 
+        if field and getattr(field, 'vocabulary', None):
+            form_url = self.request.getURL()
+            source_url = "%s/++widget++%s/@@getSource" % (form_url, self.name)
+            args['pattern_options']['vocabularyUrl'] = source_url
+
         # ISequence represents an orderable collection
         if ISequence.providedBy(self.field) or self.orderable:
             args['pattern_options']['orderable'] = True
@@ -634,20 +688,10 @@ class RelatedItemsWidget(BaseWidget, z3cform_TextWidget):
     pattern_options = BaseWidget.pattern_options.copy()
 
     separator = ';'
-    vocabulary = None
+    vocabulary = 'plone.app.vocabularies.Catalog'
     vocabulary_view = '@@getVocabulary'
     orderable = False
 
-    def update(self, *args, **kwargs):
-        value_type = getattr(self.field, 'value_type', None)
-        if value_type:
-            self.vocabulary = getattr(value_type,
-                                      'vocabularyName',
-                                      'plone.app.vocabularies.Catalog')
-        if self.vocabulary is None:
-            self.vocabulary = 'plone.app.vocabularies.Catalog'
-        z3cform_TextWidget.update(self, *args, **kwargs)
-
     def _base_args(self):
         """Method which will calculate _base class arguments.
 
@@ -664,17 +708,30 @@ def _base_args(self):
 
         args['name'] = self.name
         args['value'] = self.value
-
         args.setdefault('pattern_options', {})
+
+        vocabulary_name = self.vocabulary
+        field = None
         if IChoice.providedBy(self.field):
             args['pattern_options']['maximumSelectionSize'] = 1
+            field = self.field
+        elif ICollection.providedBy(self.field):
+            field = self.field.value_type
+        if field is not None and field.vocabularyName:
+            vocabulary_name = field.vocabularyName
+
         field_name = self.field and self.field.__name__ or None
         args['pattern_options'] = dict_merge(
             get_relateditems_options(self.context, args['value'],
-                                     self.separator, self.vocabulary,
+                                     self.separator, vocabulary_name,
                                      self.vocabulary_view, field_name),
             args['pattern_options'])
 
+        if field and getattr(field, 'vocabulary', None):
+            form_url = self.request.getURL()
+            source_url = "%s/++widget++%s/@@getSource" % (form_url, self.name)
+            args['pattern_options']['vocabularyUrl'] = source_url
+
         return args
 
 
@@ -770,9 +827,16 @@ def DatetimeFieldWidget(field, request):
 
 
 @implementer(IFieldWidget)
-def RelatedItemsFieldWidget(field, request):
-    # TODO: when field is type IRelationChoice configure widget to only allow
-    # one item to be selected
+def AjaxSelectFieldWidget(field, request, extra=None):
+    if extra is not None:
+        request = extra
+    return FieldWidget(field, AjaxSelectWidget(request))
+
+
+ at implementer(IFieldWidget)
+def RelatedItemsFieldWidget(field, request, extra=None):
+    if extra is not None:
+        request = extra
     return FieldWidget(field, RelatedItemsWidget(request))
 
 
diff --git a/plone/app/widgets/tests/test_browser.py b/plone/app/widgets/tests/test_browser.py
index 66ab761..6d31b2f 100644
--- a/plone/app/widgets/tests/test_browser.py
+++ b/plone/app/widgets/tests/test_browser.py
@@ -1,8 +1,10 @@
 # -*- coding: utf-8 -*-
 import os
+from mock import Mock
 from plone.app.testing import TEST_USER_ID
 from plone.app.testing import TEST_USER_NAME
 from plone.app.testing import login
+from plone.app.testing import logout
 from plone.app.testing import setRoles
 from plone.app.widgets.browser import vocabulary
 from plone.app.widgets.browser.file import FileUploadView
@@ -277,6 +279,122 @@ def testVocabularyUsers(self):
         data = json.loads(view())
         self.assertEqual(len(data['results']), amount)
 
+    def testSource(self):
+        from z3c.form.browser.text import TextWidget
+        from zope.interface import implementer
+        from zope.interface import Interface
+        from zope.schema import Choice
+        from zope.schema.interfaces import ISource
+
+        @implementer(ISource)
+        class DummyCatalogSource(object):
+            def search_catalog(self, query):
+                querytext = query['SearchableText']['query']
+                return [Mock(id=querytext)]
+
+        widget = TextWidget(self.request)
+        widget.context = self.portal
+        widget.field = Choice(source=DummyCatalogSource())
+        widget.field.interface = Interface
+
+        from plone.app.widgets.browser.vocabulary import SourceView
+        view = SourceView(widget, self.request)
+        query = {
+            'criteria': [
+                {
+                    'i': 'SearchableText',
+                    'o': 'plone.app.querystring.operation.string.is',
+                    'v': 'foo'
+                }
+            ]
+        }
+        self.request.form.update({
+            'query': json.dumps(query),
+            'attributes': 'id',
+        })
+        data = json.loads(view())
+        self.assertEquals(len(data['results']), 1)
+        self.assertEquals(data['results'][0]['id'], 'foo')
+
+    def testSourceCollectionField(self):
+        # This test uses a collection field
+        # and a source providing the 'search' method
+        # to help achieve coverage.
+        from z3c.form.browser.text import TextWidget
+        from zope.interface import implementer
+        from zope.interface import Interface
+        from zope.schema import List, Choice
+        from zope.schema.interfaces import ISource
+        from zope.schema.vocabulary import SimpleTerm
+
+        @implementer(ISource)
+        class DummySource(object):
+            def search(self, query):
+                terms = [SimpleTerm(query, query)]
+                return iter(terms)
+
+        widget = TextWidget(self.request)
+        widget.context = self.portal
+        widget.field = List(value_type=Choice(source=DummySource()))
+        widget.field.interface = Interface
+
+        from plone.app.widgets.browser.vocabulary import SourceView
+        view = SourceView(widget, self.request)
+        query = {
+            'criteria': [
+                {
+                    'i': 'SearchableText',
+                    'o': 'plone.app.querystring.operation.string.is',
+                    'v': 'foo'
+                }
+            ],
+            'sort_on': 'id',
+            'sort_order': 'ascending',
+        }
+        self.request.form.update({
+            'query': json.dumps(query),
+            'batch': json.dumps({'size': 10, 'page': 1}),
+        })
+        data = json.loads(view())
+        self.assertEquals(len(data['results']), 1)
+        self.assertEquals(data['results'][0]['id'], 'foo')
+
+    def testSourcePermissionDenied(self):
+        from z3c.form.browser.text import TextWidget
+        from zope.interface import implementer
+        from zope.interface import Interface
+        from zope.schema import Choice
+        from zope.schema.interfaces import ISource
+
+        @implementer(ISource)
+        class DummyCatalogSource(object):
+            def search_catalog(self, query):
+                querytext = query['SearchableText']['query']
+                return [Mock(id=querytext)]
+
+        widget = TextWidget(self.request)
+        widget.context = self.portal
+        widget.field = Choice(source=DummyCatalogSource())
+        widget.field.interface = Interface
+
+        from plone.app.widgets.browser.vocabulary import SourceView
+        view = SourceView(widget, self.request)
+        query = {
+            'criteria': [
+                {
+                    'i': 'SearchableText',
+                    'o': 'plone.app.querystring.operation.string.is',
+                    'v': 'foo'
+                }
+            ]
+        }
+        self.request.form.update({
+            'query': json.dumps(query),
+        })
+        logout()
+        data = json.loads(view())
+        self.assertEquals(data['error'], 'Vocabulary lookup not allowed.')
+
     def testQueryStringConfiguration(self):
         view = QueryStringIndexOptions(self.portal, self.request)
         data = json.loads(view())
diff --git a/plone/app/widgets/tests/test_dx.py b/plone/app/widgets/tests/test_dx.py
index 7eba468..2ea1385 100644
--- a/plone/app/widgets/tests/test_dx.py
+++ b/plone/app/widgets/tests/test_dx.py
@@ -35,6 +35,7 @@
 from zope.schema import Tuple
 from zope.schema import Set
 
+import mock
 import json
 
 try:
@@ -170,6 +171,16 @@ def test_data_converter(self):
             converter.toWidgetValue(date(21, 10, 30)),
         )
 
+    def test_fieldwidget(self):
+        from plone.app.widgets.dx import DateWidget
+        from plone.app.widgets.dx import DateFieldWidget
+        field = Mock(__name__='field', title=u'', required=True)
+        request = Mock()
+        widget = DateFieldWidget(field, request)
+        self.assertTrue(isinstance(widget, DateWidget))
+        self.assertIs(widget.field, field)
+        self.assertIs(widget.request, request)
+
 
 class DatetimeWidgetTests(unittest.TestCase):
 
@@ -305,6 +316,16 @@ def test_data_converter_timezone(self):
         # cleanup
         self.widget.context = None
 
+    def test_fieldwidget(self):
+        from plone.app.widgets.dx import DatetimeWidget
+        from plone.app.widgets.dx import DatetimeFieldWidget
+        field = Mock(__name__='field', title=u'', required=True)
+        request = Mock()
+        widget = DatetimeFieldWidget(field, request)
+        self.assertTrue(isinstance(widget, DatetimeWidget))
+        self.assertIs(widget.field, field)
+        self.assertIs(widget.request, request)
+
 
 class SelectWidgetTests(unittest.TestCase):
 
@@ -396,8 +417,8 @@ def test_widget_list_orderable(self):
             {
                 'multiple': True,
                 'name': None,
-                'pattern_options': {'orderable': True, 'multiple': True,
-                                    'separator': '.'},
+                'pattern_options': {
+                    'orderable': True, 'multiple': True, 'separator': '.'},
                 'pattern': 'select2',
                 'value': (),
                 'items': [
@@ -421,8 +442,8 @@ def test_widget_tuple_orderable(self):
             {
                 'multiple': True,
                 'name': None,
-                'pattern_options': {'orderable': True, 'multiple': True,
-                                    'separator': ';'},
+                'pattern_options': {
+                    'orderable': True, 'multiple': True, 'separator': ';'},
                 'pattern': 'select2',
                 'value': (),
                 'items': [
@@ -447,7 +468,8 @@ def test_widget_set_not_orderable(self):
             {
                 'multiple': True,
                 'name': None,
-                'pattern_options': {'multiple': True, 'separator': ';'},
+                'pattern_options': {
+                    'multiple': True, 'separator': ';'},
                 'pattern': 'select2',
                 'value': (),
                 'items': [
@@ -660,6 +682,29 @@ def test_widget_set_not_orderable(self):
             widget._base_args(),
         )
 
+    def test_widget_choice(self):
+        from plone.app.widgets.dx import AjaxSelectWidget
+        from zope.schema.interfaces import ISource
+        widget = AjaxSelectWidget(self.request)
+        source = Mock()
+        alsoProvides(source, ISource)
+        widget.field = Choice(__name__='choicefield', source=source)
+        widget.name = 'choicefield'
+        self.assertEqual(
+            {
+                'name': 'choicefield',
+                'value': u'',
+                'pattern': 'select2',
+                'pattern_options': {
+                    'separator': ';',
+                    'maximumSelectionSize': 1,
+                    'vocabularyUrl':
+                    'http://127.0.0.1/++widget++choicefield/@@getSource',
+                    },
+            },
+            widget._base_args(),
+        )
+
     def test_widget_addform_url_on_addform(self):
         from plone.app.widgets.dx import AjaxSelectWidget
         widget = AjaxSelectWidget(self.request)
@@ -751,6 +796,27 @@ def test_data_converter_tuple(self):
             '123;456;789',
         )
 
+    def test_fieldwidget(self):
+        from plone.app.widgets.dx import AjaxSelectWidget
+        from plone.app.widgets.dx import AjaxSelectFieldWidget
+        field = Mock(__name__='field', title=u'', required=True)
+        request = Mock()
+        widget = AjaxSelectFieldWidget(field, request)
+        self.assertTrue(isinstance(widget, AjaxSelectWidget))
+        self.assertIs(widget.field, field)
+        self.assertIs(widget.request, request)
+
+    def test_fieldwidget_sequence(self):
+        from plone.app.widgets.dx import AjaxSelectWidget
+        from plone.app.widgets.dx import AjaxSelectFieldWidget
+        field = Mock(__name__='field', title=u'', required=True)
+        vocabulary = Mock()
+        request = Mock()
+        widget = AjaxSelectFieldWidget(field, vocabulary, request)
+        self.assertTrue(isinstance(widget, AjaxSelectWidget))
+        self.assertIs(widget.field, field)
+        self.assertIs(widget.request, request)
+
 
 class QueryStringWidgetTests(unittest.TestCase):
 
@@ -837,19 +903,98 @@ def test_multiple_selection(self):
         """The pattern_options key maximumSelectionSize shouldn't be
         set when the field allows multiple selections"""
         from plone.app.widgets.dx import RelatedItemsFieldWidget
+        from zope.schema.interfaces import ISource
+        from zope.schema.vocabulary import VocabularyRegistry
+
         context = Mock(absolute_url=lambda: 'fake_url')
         context.portal_properties.site_properties\
             .getProperty.return_value = ['SomeType']
         field = List(
             __name__='selectfield',
-            value_type=Choice(values=['one', 'two', 'three'])
+            value_type=Choice(vocabulary='foobar')
         )
         widget = RelatedItemsFieldWidget(field, self.request)
         widget.context = context
-        widget.update()
-        base_args = widget._base_args()
+
+        vocab = Mock()
+        alsoProvides(vocab, ISource)
+        with mock.patch.object(VocabularyRegistry, 'get', return_value=vocab):
+            widget.update()
+            base_args = widget._base_args()
         patterns_options = base_args['pattern_options']
         self.assertFalse('maximumSelectionSize' in patterns_options)
+        self.assertEqual(
+            patterns_options['vocabularyUrl'],
+            '/@@getVocabulary?name=foobar&field=selectfield',
+            )
+
+    def test_converter_RelationChoice(self):
+        from plone.app.widgets.dx import \
+            RelationChoiceRelatedItemsWidgetConverter
+        brain = Mock(getObject=Mock(return_value='obj'))
+        portal_catalog = Mock(return_value=[brain])
+        widget = Mock()
+        converter = RelationChoiceRelatedItemsWidgetConverter(
+            TextLine(), widget)
+
+        with mock.patch('plone.app.widgets.dx.IUUID', return_value='id'):
+            self.assertEqual(converter.toWidgetValue('obj'), 'id')
+        self.assertEqual(converter.toWidgetValue(None), None)
+
+        with mock.patch(
+                'plone.app.widgets.dx.getToolByName',
+                return_value=portal_catalog):
+            self.assertEqual(converter.toFieldValue('id'), 'obj')
+        self.assertEqual(converter.toFieldValue(None), None)
+
+    def test_converter_RelationList(self):
+        from plone.app.widgets.dx import RelatedItemsDataConverter
+        from plone.app.widgets.dx import IRelationList
+        field = List()
+        alsoProvides(field, IRelationList)
+        brain1 = Mock(getObject=Mock(return_value='obj1'))
+        brain2 = Mock(getObject=Mock(return_value='obj2'))
+        portal_catalog = Mock(return_value=[brain1, brain2])
+        widget = Mock(separator=';')
+        converter = RelatedItemsDataConverter(field, widget)
+
+        self.assertEqual(converter.toWidgetValue(None), None)
+        with mock.patch(
+                'plone.app.widgets.dx.IUUID', side_effect=['id1', 'id2']):
+            self.assertEqual(
+                converter.toWidgetValue(['obj1', 'obj2']), 'id1;id2')
+
+        self.assertEqual(converter.toFieldValue(None), None)
+        with mock.patch(
+                'plone.app.widgets.dx.getToolByName',
+                return_value=portal_catalog):
+            self.assertEqual(
+                converter.toFieldValue('id1;id2'), ['obj1', 'obj2'])
+
+    def test_converter_List_of_Choice(self):
+        from plone.app.widgets.dx import RelatedItemsDataConverter
+        field = List()
+        widget = Mock(separator=';')
+        converter = RelatedItemsDataConverter(field, widget)
+
+        self.assertEqual(converter.toWidgetValue(None), None)
+        self.assertEqual(
+            converter.toWidgetValue(['id1', 'id2']), 'id1;id2')
+
+        self.assertEqual(converter.toFieldValue(None), None)
+        self.assertEqual(
+            converter.toFieldValue('id1;id2'), ['id1', 'id2'])
+
+    def test_fieldwidget(self):
+        from plone.app.widgets.dx import RelatedItemsWidget
+        from plone.app.widgets.dx import RelatedItemsFieldWidget
+        field = Mock(__name__='field', title=u'', required=True)
+        vocabulary = Mock()
+        request = Mock()
+        widget = RelatedItemsFieldWidget(field, vocabulary, request)
+        self.assertTrue(isinstance(widget, RelatedItemsWidget))
+        self.assertIs(widget.field, field)
+        self.assertIs(widget.request, request)
 
 
 def add_mock_fti(portal):


Repository: plone.app.widgets
Branch: refs/heads/master
Date: 2014-03-08T13:52:42Z
Author: Rok Garbas (garbas) <rok at garbas.si>
Commit: https://github.com/plone/plone.app.widgets/commit/e9623d07e981f80265df5759e232127b6baabd80

Merge pull request #62 from plone/davisagli-ajax-sources

improvements to RelatedItemsWidget

Files changed:
M plone/app/widgets/browser/configure.zcml
M plone/app/widgets/browser/vocabulary.py
M plone/app/widgets/configure.zcml
M plone/app/widgets/dx.py
M plone/app/widgets/tests/test_browser.py
M plone/app/widgets/tests/test_dx.py

diff --git a/plone/app/widgets/browser/configure.zcml b/plone/app/widgets/browser/configure.zcml
index fb4fbd1..d681e53 100644
--- a/plone/app/widgets/browser/configure.zcml
+++ b/plone/app/widgets/browser/configure.zcml
@@ -14,6 +14,13 @@
     />
 
   <browser:page
+    name="getSource"
+    for="z3c.form.interfaces.IWidget"
+    class=".vocabulary.SourceView"
+    permission="zope.Public"
+    />
+
+  <browser:page
     name="fileUpload"
     for="Products.CMFCore.interfaces._content.IFolderish"
     class=".file.FileUploadView"
diff --git a/plone/app/widgets/browser/vocabulary.py b/plone/app/widgets/browser/vocabulary.py
index c83d82f..e1cc615 100644
--- a/plone/app/widgets/browser/vocabulary.py
+++ b/plone/app/widgets/browser/vocabulary.py
@@ -1,18 +1,23 @@
 # -*- coding: utf-8 -*-
 
 from AccessControl import getSecurityManager
+from Products.CMFCore.utils import getToolByName
 from Products.CMFPlone.interfaces import IPloneSiteRoot
 from Products.Five import BrowserView
-from Products.ZCTextIndex.ParseTree import ParseError
 from logging import getLogger
-from plone.app.vocabularies.interfaces import ISlicableVocabulary
+from plone.app.querystring import queryparser
 from plone.app.widgets.interfaces import IFieldPermissionChecker
+from plone.autoform.interfaces import WRITE_PERMISSIONS_KEY
+from plone.supermodel.utils import mergedTaggedValueDict
 from types import FunctionType
+from zope.component import getUtility
 from zope.component import queryAdapter
 from zope.component import queryUtility
+from zope.schema.interfaces import ICollection
 from zope.schema.interfaces import IVocabularyFactory
-
+from zope.security.interfaces import IPermission
 import inspect
+import itertools
 import json
 
 logger = getLogger(__name__)
@@ -39,14 +44,11 @@ def _parseJSON(s):
 _safe_callable_metadata = ['getURL', 'getPath']
 
 
-class VocabularyView(BrowserView):
+class VocabLookupException(Exception):
+    pass
 
-    def error(self):
-        return json.dumps({
-            'results': [],
-            'total': 0,
-            'error': True
-        })
+
+class BaseVocabularyView(BrowserView):
 
     def __call__(self):
         """
@@ -66,89 +68,57 @@ def __call__(self):
             size: size of paged results
         }
         """
-        context = self.context
+        context = self.get_context()
         self.request.response.setHeader("Content-type", "application/json")
 
-        factory_name = self.request.get('name', None)
-        field_name = self.request.get('field', None)
-        if not factory_name:
-            return json.dumps({'error': 'No factory provided.'})
-        authorized = None
-        sm = getSecurityManager()
-        if (factory_name not in _permissions or
-                not IPloneSiteRoot.providedBy(context)):
-            # Check field specific permission
-            if field_name:
-                permission_checker = queryAdapter(context,
-                                                  IFieldPermissionChecker)
-                if permission_checker is not None:
-                    authorized = permission_checker.validate(field_name,
-                                                             factory_name)
-            if not authorized:
-                return json.dumps({'error': 'Vocabulary lookup not allowed'})
-        # Short circuit if we are on the site root and permission is
-        # in global registry
-        elif not sm.checkPermission(_permissions[factory_name], context):
-            return json.dumps({'error': 'Vocabulary lookup not allowed'})
-
-        factory = queryUtility(IVocabularyFactory, factory_name)
-        if not factory:
-            return json.dumps({
-                'error': 'No factory with name "%s" exists.' % factory_name})
-
-        # check if factory accepts query argument
-        query = _parseJSON(self.request.get('query', ''))
-        batch = _parseJSON(self.request.get('batch', ''))
-
-        if type(factory) is FunctionType:
-            factory_spec = inspect.getargspec(factory)
-        else:
-            factory_spec = inspect.getargspec(factory.__call__)
         try:
-            supports_batch = False
-            vocabulary = None
-            if query and 'query' in factory_spec.args:
-                if 'batch' in factory_spec.args:
-                    vocabulary = factory(self.context,
-                                         query=query, batch=batch)
-                    supports_batch = True
-                else:
-                    vocabulary = factory(self.context, query=query)
-            elif query:
-                raise KeyError("The vocabulary factory %s does not support "
-                               "query arguments",
-                               factory)
-
-            if batch and supports_batch:
-                    vocabulary = factory(context, query, batch)
-            elif query:
-                    vocabulary = factory(context, query)
-            else:
-                vocabulary = factory(context)
+            vocabulary = self.get_vocabulary()
+        except VocabLookupException, e:
+            return json.dumps({'error': e.message})
 
-        except (TypeError, ParseError):
-            raise
-            return self.error()
+        results_are_brains = False
+        if hasattr(vocabulary, 'search_catalog'):
+            query = self.parsed_query()
+            results = vocabulary.search_catalog(query)
+            results_are_brains = True
+        elif hasattr(vocabulary, 'search'):
+            try:
+                query = self.parsed_query()['SearchableText']['query']
+            except KeyError:
+                results = iter(vocabulary)
+            else:
+                results = vocabulary.search(query)
+        else:
+            results = vocabulary
 
         try:
-            total = len(vocabulary)
+            total = len(results)
         except TypeError:
             total = 0  # do not error if object does not support __len__
                        # we'll check again later if we can figure some size
                        # out
+
+        # get batch
+        batch = _parseJSON(self.request.get('batch', ''))
         if batch and ('size' not in batch or 'page' not in batch):
             batch = None  # batching not providing correct options
-            logger.error("A vocabulary request contained bad batch "
-                         "information. The batch information is ignored.")
-        if batch and not supports_batch and \
-                ISlicableVocabulary.providedBy(vocabulary):
+        if batch:
             # must be slicable for batching support
             page = int(batch['page'])
             # page is being passed in is 1-based
-            start = (max(page-1, 0)) * int(batch['size'])
+            start = (max(page - 1, 0)) * int(batch['size'])
             end = start + int(batch['size'])
-            vocabulary = vocabulary[start:end]
+            # Try __getitem__-based slice, then iterator slice.
+            # The iterator slice has to consume the iterator through
+            # to the desired slice, but that shouldn't be the end
+            # of the world because at some point the user will hopefully
+            # give up scrolling and search instead.
+            try:
+                results = results[start:end]
+            except TypeError:
+                results = itertools.islice(results, start, end)
 
+        # build result items
         items = []
 
         attributes = _parseJSON(self.request.get('attributes', ''))
@@ -156,8 +126,11 @@ def __call__(self):
             attributes = attributes.split(',')
 
         if attributes:
-            base_path = '/'.join(context.getPhysicalPath())
-            for vocab_item in vocabulary:
+            portal = getToolByName(context, 'portal_url').getPortalObject()
+            base_path = '/'.join(portal.getPhysicalPath())
+            for vocab_item in results:
+                if not results_are_brains:
+                    vocab_item = vocab_item.value
                 item = {}
                 for attr in attributes:
                     key = attr
@@ -167,8 +140,7 @@ def __call__(self):
                         continue
                     if key == 'path':
                         attr = 'getPath'
-                    vocab_value = vocab_item.value
-                    val = getattr(vocab_value, attr, None)
+                    val = getattr(vocab_item, attr, None)
                     if callable(val):
                         if attr in _safe_callable_metadata:
                             val = val()
@@ -179,7 +151,7 @@ def __call__(self):
                     item[key] = val
                 items.append(item)
         else:
-            for item in vocabulary:
+            for item in results:
                 items.append({'id': item.token, 'text': item.title})
 
         if total == 0:
@@ -189,3 +161,97 @@ def __call__(self):
             'results': items,
             'total': total
         })
+
+    def parsed_query(self, ):
+        query = _parseJSON(self.request.get('query', '')) or {}
+        if query:
+            parsed = queryparser.parseFormquery(
+                self.get_context(), query['criteria'])
+            if 'sort_on' in query:
+                parsed['sort_on'] = query['sort_on']
+            if 'sort_order' in query:
+                parsed['sort_order'] = str(query['sort_order'])
+            query = parsed
+        return query
+
+
+class VocabularyView(BaseVocabularyView):
+    """Queries a named vocabulary and returns JSON-formatted results."""
+
+    def get_context(self):
+        return self.context
+
+    def get_vocabulary(self):
+        # Look up named vocabulary and check permission.
+
+        context = self.context
+        factory_name = self.request.get('name', None)
+        field_name = self.request.get('field', None)
+        if not factory_name:
+            raise VocabLookupException('No factory provided.')
+        authorized = None
+        sm = getSecurityManager()
+        if (factory_name not in _permissions or
+                not IPloneSiteRoot.providedBy(context)):
+            # Check field specific permission
+            if field_name:
+                permission_checker = queryAdapter(context,
+                                                  IFieldPermissionChecker)
+                if permission_checker is not None:
+                    authorized = permission_checker.validate(field_name,
+                                                             factory_name)
+            if not authorized:
+                raise VocabLookupException('Vocabulary lookup not allowed')
+        # Short circuit if we are on the site root and permission is
+        # in global registry
+        elif not sm.checkPermission(_permissions[factory_name], context):
+            raise VocabLookupException('Vocabulary lookup not allowed')
+
+        factory = queryUtility(IVocabularyFactory, factory_name)
+        if not factory:
+            raise VocabLookupException(
+                'No factory with name "%s" exists.' % factory_name)
+
+        # This part is for backwards-compatibility with the first
+        # generation of vocabularies created for plone.app.widgets,
+        # which take the (unparsed) query as a parameter of the vocab
+        # factory rather than as a separate search method.
+        if type(factory) is FunctionType:
+            factory_spec = inspect.getargspec(factory)
+        else:
+            factory_spec = inspect.getargspec(factory.__call__)
+        query = _parseJSON(self.request.get('query', ''))
+        if query and 'query' in factory_spec.args:
+            vocabulary = factory(context, query=query)
+        else:
+            # This is what is reached for non-legacy vocabularies.
+            vocabulary = factory(context)
+
+        return vocabulary
+
+
+class SourceView(BaseVocabularyView):
+    """Queries a field's source and returns JSON-formatted results."""
+
+    def get_context(self):
+        return self.context.context
+
+    def get_vocabulary(self):
+        widget = self.context
+        field = widget.field.bind(widget.context)
+
+        # check field's write permission
+        info = mergedTaggedValueDict(field.interface, WRITE_PERMISSIONS_KEY)
+        permission_name = info.get(field.__name__, 'cmf.ModifyPortalContent')
+        permission = queryUtility(IPermission, name=permission_name)
+        if permission is None:
+            permission = getUtility(
+                IPermission, name='cmf.ModifyPortalContent')
+        if not getSecurityManager().checkPermission(
+                permission.title, self.get_context()):
+            raise VocabLookupException('Vocabulary lookup not allowed.')
+
+        if ICollection.providedBy(field):
+            return field.value_type.vocabulary
+        else:
+            return field.vocabulary
diff --git a/plone/app/widgets/configure.zcml b/plone/app/widgets/configure.zcml
index 5f5809c..4cf39ae 100644
--- a/plone/app/widgets/configure.zcml
+++ b/plone/app/widgets/configure.zcml
@@ -156,6 +156,7 @@
     <adapter factory=".dx.SelectWidgetConverter" />
     <adapter factory=".dx.AjaxSelectWidgetConverter" />
     <adapter factory=".dx.QueryStringDataConverter" />
+    <adapter factory=".dx.RelationChoiceRelatedItemsWidgetConverter" />
     <adapter factory=".dx.RelatedItemsDataConverter" />
   </configure>
 
@@ -236,6 +237,12 @@
                   z3c.form.interfaces.IFormLayer"
             />
 
+    <adapter
+      factory=".dx.RelatedItemsFieldWidget"
+      for="zope.schema.interfaces.IChoice
+           plone.app.vocabularies.catalog.CatalogSource
+           z3c.form.interfaces.IFormLayer" />
+
     <adapter factory=".dx.QueryStringFieldWidget" />
     <adapter factory=".dx.RichTextFieldWidget" />
 
diff --git a/plone/app/widgets/dx.py b/plone/app/widgets/dx.py
index c3a3643..67aa472 100644
--- a/plone/app/widgets/dx.py
+++ b/plone/app/widgets/dx.py
@@ -53,6 +53,7 @@
 from zope.interface import implementer
 from zope.interface import implements
 from zope.interface import implementsOnly
+from zope.interface import Interface
 from zope.publisher.browser import TestRequest
 from zope.schema.interfaces import IChoice
 from zope.schema.interfaces import ICollection
@@ -73,6 +74,17 @@
     HAS_PAC = False
 
 
+try:
+    from z3c.relationfield.interfaces import IRelationChoice
+    from z3c.relationfield.interfaces import IRelationList
+except ImportError:  # pragma: no cover
+    class IRelationChoice(Interface):
+        pass
+
+    class IRelationList(Interface):
+        pass
+
+
 class IDateField(IDate):
     """Marker interface for the DateField."""
 
@@ -232,7 +244,8 @@ def toFieldValue(self, value):
 
 
 class AjaxSelectWidgetConverter(BaseDataConverter):
-    """Data converter for ICollection."""
+    """Data converter for ICollection fields using the AjaxSelectWidget.
+    """
 
     adapts(ICollection, IAjaxSelectWidget)
 
@@ -272,8 +285,34 @@ def toFieldValue(self, value):
                               for v in value.split(separator))
 
 
+class RelationChoiceRelatedItemsWidgetConverter(BaseDataConverter):
+    """Data converter for RelationChoice fields using the RelatedItemsWidget.
+    """
+
+    adapts(IRelationChoice, IRelatedItemsWidget)
+
+    def toWidgetValue(self, value):
+        if not value:
+            return self.field.missing_value
+        return IUUID(value)
+
+    def toFieldValue(self, value):
+        if not value:
+            return self.field.missing_value
+        try:
+            catalog = getToolByName(self.widget.context, 'portal_catalog')
+        except AttributeError:
+            catalog = getToolByName(getSite(), 'portal_catalog')
+
+        res = catalog(UID=value)
+        if res:
+            return res[0].getObject()
+        else:
+            return self.field.missing_value
+
+
 class RelatedItemsDataConverter(BaseDataConverter):
-    """Data converter for ICollection."""
+    """Data converter for ICollection fields using the RelatedItemsWidget."""
 
     adapts(ICollection, IRelatedItemsWidget)
 
@@ -289,7 +328,10 @@ def toWidgetValue(self, value):
         if not value:
             return self.field.missing_value
         separator = getattr(self.widget, 'separator', ';')
-        return separator.join([IUUID(o) for o in value if value])
+        if IRelationList.providedBy(self.field):
+            return separator.join([IUUID(o) for o in value if value])
+        else:
+            return separator.join(v for v in value if v)
 
     def toFieldValue(self, value):
         """Converts from widget value to field.
@@ -300,24 +342,26 @@ def toFieldValue(self, value):
         :returns: List of content objects
         :rtype: list | tuple | set
         """
+        if not value:
+            return self.field.missing_value
+
         collectionType = self.field._type
         if isinstance(collectionType, tuple):
             collectionType = collectionType[-1]
 
-        if not len(value):
-            return self.field.missing_value
-
         separator = getattr(self.widget, 'separator', ';')
         value = value.split(separator)
-        value = [v.split('/')[0] for v in value]
 
-        try:
-            catalog = getToolByName(self.widget.context, 'portal_catalog')
-        except AttributeError:
-            catalog = getToolByName(getSite(), 'portal_catalog')
+        if IRelationList.providedBy(self.field):
+            try:
+                catalog = getToolByName(self.widget.context, 'portal_catalog')
+            except AttributeError:
+                catalog = getToolByName(getSite(), 'portal_catalog')
 
-        return collectionType(item.getObject()
-                              for item in catalog(UID=value) if item)
+            return collectionType(item.getObject()
+                                  for item in catalog(UID=value) if item)
+        else:
+            return collectionType(v for v in value)
 
 
 class QueryStringDataConverter(BaseDataConverter):
@@ -578,11 +622,6 @@ class AjaxSelectWidget(BaseWidget, z3cform_TextWidget):
     vocabulary_view = '@@getVocabulary'
     orderable = False
 
-    def update(self, *args, **kwargs):
-        if not hasattr(self, 'vocabulary'):
-            self.vocabulary = getattr(self.field, 'vocabularyName', None)
-        z3cform_TextWidget.update(self, *args, **kwargs)
-
     def _base_args(self):
         """Method which will calculate _base class arguments.
 
@@ -610,12 +649,27 @@ def _base_args(self):
         if IAddForm.providedBy(getattr(self, 'form')):
             context = self.form
 
+        vocabulary_name = self.vocabulary
+        field = None
+        if IChoice.providedBy(self.field):
+            args['pattern_options']['maximumSelectionSize'] = 1
+            field = self.field
+        elif ICollection.providedBy(self.field):
+            field = self.field.value_type
+        if not vocabulary_name and field is not None:
+            vocabulary_name = field.vocabularyName
+
         args['pattern_options'] = dict_merge(
             get_ajaxselect_options(context, args['value'], self.separator,
-                                   self.vocabulary, self.vocabulary_view,
+                                   vocabulary_name, self.vocabulary_view,
                                    field_name),
             args['pattern_options'])
 
+        if field and getattr(field, 'vocabulary', None):
+            form_url = self.request.getURL()
+            source_url = "%s/++widget++%s/@@getSource" % (form_url, self.name)
+            args['pattern_options']['vocabularyUrl'] = source_url
+
         # ISequence represents an orderable collection
         if ISequence.providedBy(self.field) or self.orderable:
             args['pattern_options']['orderable'] = True
@@ -634,20 +688,10 @@ class RelatedItemsWidget(BaseWidget, z3cform_TextWidget):
     pattern_options = BaseWidget.pattern_options.copy()
 
     separator = ';'
-    vocabulary = None
+    vocabulary = 'plone.app.vocabularies.Catalog'
     vocabulary_view = '@@getVocabulary'
     orderable = False
 
-    def update(self, *args, **kwargs):
-        value_type = getattr(self.field, 'value_type', None)
-        if value_type:
-            self.vocabulary = getattr(value_type,
-                                      'vocabularyName',
-                                      'plone.app.vocabularies.Catalog')
-        if self.vocabulary is None:
-            self.vocabulary = 'plone.app.vocabularies.Catalog'
-        z3cform_TextWidget.update(self, *args, **kwargs)
-
     def _base_args(self):
         """Method which will calculate _base class arguments.
 
@@ -664,17 +708,30 @@ def _base_args(self):
 
         args['name'] = self.name
         args['value'] = self.value
-
         args.setdefault('pattern_options', {})
+
+        vocabulary_name = self.vocabulary
+        field = None
         if IChoice.providedBy(self.field):
             args['pattern_options']['maximumSelectionSize'] = 1
+            field = self.field
+        elif ICollection.providedBy(self.field):
+            field = self.field.value_type
+        if field is not None and field.vocabularyName:
+            vocabulary_name = field.vocabularyName
+
         field_name = self.field and self.field.__name__ or None
         args['pattern_options'] = dict_merge(
             get_relateditems_options(self.context, args['value'],
-                                     self.separator, self.vocabulary,
+                                     self.separator, vocabulary_name,
                                      self.vocabulary_view, field_name),
             args['pattern_options'])
 
+        if field and getattr(field, 'vocabulary', None):
+            form_url = self.request.getURL()
+            source_url = "%s/++widget++%s/@@getSource" % (form_url, self.name)
+            args['pattern_options']['vocabularyUrl'] = source_url
+
         return args
 
 
@@ -770,9 +827,16 @@ def DatetimeFieldWidget(field, request):
 
 
 @implementer(IFieldWidget)
-def RelatedItemsFieldWidget(field, request):
-    # TODO: when field is type IRelationChoice configure widget to only allow
-    # one item to be selected
+def AjaxSelectFieldWidget(field, request, extra=None):
+    if extra is not None:
+        request = extra
+    return FieldWidget(field, AjaxSelectWidget(request))
+
+
+ at implementer(IFieldWidget)
+def RelatedItemsFieldWidget(field, request, extra=None):
+    if extra is not None:
+        request = extra
     return FieldWidget(field, RelatedItemsWidget(request))
 
 
diff --git a/plone/app/widgets/tests/test_browser.py b/plone/app/widgets/tests/test_browser.py
index 66ab761..6d31b2f 100644
--- a/plone/app/widgets/tests/test_browser.py
+++ b/plone/app/widgets/tests/test_browser.py
@@ -1,8 +1,10 @@
 # -*- coding: utf-8 -*-
 import os
+from mock import Mock
 from plone.app.testing import TEST_USER_ID
 from plone.app.testing import TEST_USER_NAME
 from plone.app.testing import login
+from plone.app.testing import logout
 from plone.app.testing import setRoles
 from plone.app.widgets.browser import vocabulary
 from plone.app.widgets.browser.file import FileUploadView
@@ -277,6 +279,122 @@ def testVocabularyUsers(self):
         data = json.loads(view())
         self.assertEqual(len(data['results']), amount)
 
+    def testSource(self):
+        from z3c.form.browser.text import TextWidget
+        from zope.interface import implementer
+        from zope.interface import Interface
+        from zope.schema import Choice
+        from zope.schema.interfaces import ISource
+
+        @implementer(ISource)
+        class DummyCatalogSource(object):
+            def search_catalog(self, query):
+                querytext = query['SearchableText']['query']
+                return [Mock(id=querytext)]
+
+        widget = TextWidget(self.request)
+        widget.context = self.portal
+        widget.field = Choice(source=DummyCatalogSource())
+        widget.field.interface = Interface
+
+        from plone.app.widgets.browser.vocabulary import SourceView
+        view = SourceView(widget, self.request)
+        query = {
+            'criteria': [
+                {
+                    'i': 'SearchableText',
+                    'o': 'plone.app.querystring.operation.string.is',
+                    'v': 'foo'
+                }
+            ]
+        }
+        self.request.form.update({
+            'query': json.dumps(query),
+            'attributes': 'id',
+        })
+        data = json.loads(view())
+        self.assertEquals(len(data['results']), 1)
+        self.assertEquals(data['results'][0]['id'], 'foo')
+
+    def testSourceCollectionField(self):
+        # This test uses a collection field
+        # and a source providing the 'search' method
+        # to help achieve coverage.
+        from z3c.form.browser.text import TextWidget
+        from zope.interface import implementer
+        from zope.interface import Interface
+        from zope.schema import List, Choice
+        from zope.schema.interfaces import ISource
+        from zope.schema.vocabulary import SimpleTerm
+
+        @implementer(ISource)
+        class DummySource(object):
+            def search(self, query):
+                terms = [SimpleTerm(query, query)]
+                return iter(terms)
+
+        widget = TextWidget(self.request)
+        widget.context = self.portal
+        widget.field = List(value_type=Choice(source=DummySource()))
+        widget.field.interface = Interface
+
+        from plone.app.widgets.browser.vocabulary import SourceView
+        view = SourceView(widget, self.request)
+        query = {
+            'criteria': [
+                {
+                    'i': 'SearchableText',
+                    'o': 'plone.app.querystring.operation.string.is',
+                    'v': 'foo'
+                }
+            ],
+            'sort_on': 'id',
+            'sort_order': 'ascending',
+        }
+        self.request.form.update({
+            'query': json.dumps(query),
+            'batch': json.dumps({'size': 10, 'page': 1}),
+        })
+        data = json.loads(view())
+        self.assertEquals(len(data['results']), 1)
+        self.assertEquals(data['results'][0]['id'], 'foo')
+
+    def testSourcePermissionDenied(self):
+        from z3c.form.browser.text import TextWidget
+        from zope.interface import implementer
+        from zope.interface import Interface
+        from zope.schema import Choice
+        from zope.schema.interfaces import ISource
+
+        @implementer(ISource)
+        class DummyCatalogSource(object):
+            def search_catalog(self, query):
+                querytext = query['SearchableText']['query']
+                return [Mock(id=querytext)]
+
+        widget = TextWidget(self.request)
+        widget.context = self.portal
+        widget.field = Choice(source=DummyCatalogSource())
+        widget.field.interface = Interface
+
+        from plone.app.widgets.browser.vocabulary import SourceView
+        view = SourceView(widget, self.request)
+        query = {
+            'criteria': [
+                {
+                    'i': 'SearchableText',
+                    'o': 'plone.app.querystring.operation.string.is',
+                    'v': 'foo'
+                }
+            ]
+        }
+        self.request.form.update({
+            'query': json.dumps(query),
+        })
+        logout()
+        data = json.loads(view())
+        self.assertEquals(data['error'], 'Vocabulary lookup not allowed.')
+
     def testQueryStringConfiguration(self):
         view = QueryStringIndexOptions(self.portal, self.request)
         data = json.loads(view())
diff --git a/plone/app/widgets/tests/test_dx.py b/plone/app/widgets/tests/test_dx.py
index b11ff38..fd96fae 100644
--- a/plone/app/widgets/tests/test_dx.py
+++ b/plone/app/widgets/tests/test_dx.py
@@ -35,6 +35,7 @@
 from zope.schema import Tuple
 from zope.schema import Set
 
+import mock
 import json
 
 try:
@@ -170,6 +171,16 @@ def test_data_converter(self):
             converter.toWidgetValue(date(21, 10, 30)),
         )
 
+    def test_fieldwidget(self):
+        from plone.app.widgets.dx import DateWidget
+        from plone.app.widgets.dx import DateFieldWidget
+        field = Mock(__name__='field', title=u'', required=True)
+        request = Mock()
+        widget = DateFieldWidget(field, request)
+        self.assertTrue(isinstance(widget, DateWidget))
+        self.assertIs(widget.field, field)
+        self.assertIs(widget.request, request)
+
 
 class DatetimeWidgetTests(unittest.TestCase):
 
@@ -305,6 +316,16 @@ def test_data_converter_timezone(self):
         # cleanup
         self.widget.context = None
 
+    def test_fieldwidget(self):
+        from plone.app.widgets.dx import DatetimeWidget
+        from plone.app.widgets.dx import DatetimeFieldWidget
+        field = Mock(__name__='field', title=u'', required=True)
+        request = Mock()
+        widget = DatetimeFieldWidget(field, request)
+        self.assertTrue(isinstance(widget, DatetimeWidget))
+        self.assertIs(widget.field, field)
+        self.assertIs(widget.request, request)
+
 
 class SelectWidgetTests(unittest.TestCase):
 
@@ -396,8 +417,8 @@ def test_widget_list_orderable(self):
             {
                 'multiple': True,
                 'name': None,
-                'pattern_options': {'orderable': True, 'multiple': True,
-                                    'separator': '.'},
+                'pattern_options': {
+                    'orderable': True, 'multiple': True, 'separator': '.'},
                 'pattern': 'select2',
                 'value': (),
                 'items': [
@@ -421,8 +442,8 @@ def test_widget_tuple_orderable(self):
             {
                 'multiple': True,
                 'name': None,
-                'pattern_options': {'orderable': True, 'multiple': True,
-                                    'separator': ';'},
+                'pattern_options': {
+                    'orderable': True, 'multiple': True, 'separator': ';'},
                 'pattern': 'select2',
                 'value': (),
                 'items': [
@@ -447,7 +468,8 @@ def test_widget_set_not_orderable(self):
             {
                 'multiple': True,
                 'name': None,
-                'pattern_options': {'multiple': True, 'separator': ';'},
+                'pattern_options': {
+                    'multiple': True, 'separator': ';'},
                 'pattern': 'select2',
                 'value': (),
                 'items': [
@@ -660,6 +682,29 @@ def test_widget_set_not_orderable(self):
             widget._base_args(),
         )
 
+    def test_widget_choice(self):
+        from plone.app.widgets.dx import AjaxSelectWidget
+        from zope.schema.interfaces import ISource
+        widget = AjaxSelectWidget(self.request)
+        source = Mock()
+        alsoProvides(source, ISource)
+        widget.field = Choice(__name__='choicefield', source=source)
+        widget.name = 'choicefield'
+        self.assertEqual(
+            {
+                'name': 'choicefield',
+                'value': u'',
+                'pattern': 'select2',
+                'pattern_options': {
+                    'separator': ';',
+                    'maximumSelectionSize': 1,
+                    'vocabularyUrl':
+                    'http://127.0.0.1/++widget++choicefield/@@getSource',
+                    },
+            },
+            widget._base_args(),
+        )
+
     def test_widget_addform_url_on_addform(self):
         from plone.app.widgets.dx import AjaxSelectWidget
         widget = AjaxSelectWidget(self.request)
@@ -751,6 +796,27 @@ def test_data_converter_tuple(self):
             '123;456;789',
         )
 
+    def test_fieldwidget(self):
+        from plone.app.widgets.dx import AjaxSelectWidget
+        from plone.app.widgets.dx import AjaxSelectFieldWidget
+        field = Mock(__name__='field', title=u'', required=True)
+        request = Mock()
+        widget = AjaxSelectFieldWidget(field, request)
+        self.assertTrue(isinstance(widget, AjaxSelectWidget))
+        self.assertIs(widget.field, field)
+        self.assertIs(widget.request, request)
+
+    def test_fieldwidget_sequence(self):
+        from plone.app.widgets.dx import AjaxSelectWidget
+        from plone.app.widgets.dx import AjaxSelectFieldWidget
+        field = Mock(__name__='field', title=u'', required=True)
+        vocabulary = Mock()
+        request = Mock()
+        widget = AjaxSelectFieldWidget(field, vocabulary, request)
+        self.assertTrue(isinstance(widget, AjaxSelectWidget))
+        self.assertIs(widget.field, field)
+        self.assertIs(widget.request, request)
+
 
 class QueryStringWidgetTests(unittest.TestCase):
 
@@ -840,19 +906,98 @@ def test_multiple_selection(self):
         """The pattern_options key maximumSelectionSize shouldn't be
         set when the field allows multiple selections"""
         from plone.app.widgets.dx import RelatedItemsFieldWidget
+        from zope.schema.interfaces import ISource
+        from zope.schema.vocabulary import VocabularyRegistry
+
         context = Mock(absolute_url=lambda: 'fake_url')
         context.portal_properties.site_properties\
             .getProperty.return_value = ['SomeType']
         field = List(
             __name__='selectfield',
-            value_type=Choice(values=['one', 'two', 'three'])
+            value_type=Choice(vocabulary='foobar')
         )
         widget = RelatedItemsFieldWidget(field, self.request)
         widget.context = context
-        widget.update()
-        base_args = widget._base_args()
+
+        vocab = Mock()
+        alsoProvides(vocab, ISource)
+        with mock.patch.object(VocabularyRegistry, 'get', return_value=vocab):
+            widget.update()
+            base_args = widget._base_args()
         patterns_options = base_args['pattern_options']
         self.assertFalse('maximumSelectionSize' in patterns_options)
+        self.assertEqual(
+            patterns_options['vocabularyUrl'],
+            '/@@getVocabulary?name=foobar&field=selectfield',
+            )
+
+    def test_converter_RelationChoice(self):
+        from plone.app.widgets.dx import \
+            RelationChoiceRelatedItemsWidgetConverter
+        brain = Mock(getObject=Mock(return_value='obj'))
+        portal_catalog = Mock(return_value=[brain])
+        widget = Mock()
+        converter = RelationChoiceRelatedItemsWidgetConverter(
+            TextLine(), widget)
+
+        with mock.patch('plone.app.widgets.dx.IUUID', return_value='id'):
+            self.assertEqual(converter.toWidgetValue('obj'), 'id')
+        self.assertEqual(converter.toWidgetValue(None), None)
+
+        with mock.patch(
+                'plone.app.widgets.dx.getToolByName',
+                return_value=portal_catalog):
+            self.assertEqual(converter.toFieldValue('id'), 'obj')
+        self.assertEqual(converter.toFieldValue(None), None)
+
+    def test_converter_RelationList(self):
+        from plone.app.widgets.dx import RelatedItemsDataConverter
+        from plone.app.widgets.dx import IRelationList
+        field = List()
+        alsoProvides(field, IRelationList)
+        brain1 = Mock(getObject=Mock(return_value='obj1'))
+        brain2 = Mock(getObject=Mock(return_value='obj2'))
+        portal_catalog = Mock(return_value=[brain1, brain2])
+        widget = Mock(separator=';')
+        converter = RelatedItemsDataConverter(field, widget)
+
+        self.assertEqual(converter.toWidgetValue(None), None)
+        with mock.patch(
+                'plone.app.widgets.dx.IUUID', side_effect=['id1', 'id2']):
+            self.assertEqual(
+                converter.toWidgetValue(['obj1', 'obj2']), 'id1;id2')
+
+        self.assertEqual(converter.toFieldValue(None), None)
+        with mock.patch(
+                'plone.app.widgets.dx.getToolByName',
+                return_value=portal_catalog):
+            self.assertEqual(
+                converter.toFieldValue('id1;id2'), ['obj1', 'obj2'])
+
+    def test_converter_List_of_Choice(self):
+        from plone.app.widgets.dx import RelatedItemsDataConverter
+        field = List()
+        widget = Mock(separator=';')
+        converter = RelatedItemsDataConverter(field, widget)
+
+        self.assertEqual(converter.toWidgetValue(None), None)
+        self.assertEqual(
+            converter.toWidgetValue(['id1', 'id2']), 'id1;id2')
+
+        self.assertEqual(converter.toFieldValue(None), None)
+        self.assertEqual(
+            converter.toFieldValue('id1;id2'), ['id1', 'id2'])
+
+    def test_fieldwidget(self):
+        from plone.app.widgets.dx import RelatedItemsWidget
+        from plone.app.widgets.dx import RelatedItemsFieldWidget
+        field = Mock(__name__='field', title=u'', required=True)
+        vocabulary = Mock()
+        request = Mock()
+        widget = RelatedItemsFieldWidget(field, vocabulary, request)
+        self.assertTrue(isinstance(widget, RelatedItemsWidget))
+        self.assertIs(widget.field, field)
+        self.assertIs(widget.request, request)
 
 
 def add_mock_fti(portal):




-------------------------------------------------------------------------------
-------------- next part --------------
A non-text attachment was scrubbed...
Name: CHANGES.log
Type: application/octet-stream
Size: 75826 bytes
Desc: not available
URL: <http://lists.plone.org/pipermail/plone-testbot/attachments/20140308/baa69b7a/attachment-0002.obj>
-------------- next part --------------
A non-text attachment was scrubbed...
Name: build.log
Type: application/octet-stream
Size: 806987 bytes
Desc: not available
URL: <http://lists.plone.org/pipermail/plone-testbot/attachments/20140308/baa69b7a/attachment-0003.obj>


More information about the Testbot mailing list