First release
authorThierry Florac <thierry.florac@onf.fr>
Thu, 21 Apr 2016 18:24:52 +0200
changeset 0 5af41c7a366f
child 1 29361e3c8fda
First release
src/pyams_content_es/__init__.py
src/pyams_content_es/component/__init__.py
src/pyams_content_es/component/extfile.py
src/pyams_content_es/component/gallery.py
src/pyams_content_es/component/paragraph.py
src/pyams_content_es/component/theme.py
src/pyams_content_es/component/workflow.py
src/pyams_content_es/doctests/README.txt
src/pyams_content_es/document.py
src/pyams_content_es/include.py
src/pyams_content_es/index.py
src/pyams_content_es/interfaces/__init__.py
src/pyams_content_es/process.py
src/pyams_content_es/scripts/__init__.py
src/pyams_content_es/scripts/index.py
src/pyams_content_es/site.py
src/pyams_content_es/tests/__init__.py
src/pyams_content_es/tests/test_utilsdocs.py
src/pyams_content_es/tests/test_utilsdocstrings.py
src/pyams_content_es/utility.py
src/pyams_content_es/zmi/__init__.py
src/pyams_content_es/zmi/templates/process-test.pt
src/pyams_content_es/zmi/test.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/__init__.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,24 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+from pyramid.i18n import TranslationStringFactory
+_ = TranslationStringFactory('pyams_content_es')
+
+
+def includeme(config):
+    """Pyramid include"""
+
+    from .include import include_package
+    include_package(config)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/component/__init__.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,20 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+
+# import packages
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/component/extfile.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,47 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import base64
+
+# import interfaces
+from pyams_content.component.extfile.interfaces import IExtFileContainerTarget, IExtFileContainer
+from pyams_content_es.interfaces import IDocumentIndexInfo
+
+# import packages
+from pyams_utils.adapter import adapter_config
+
+
+@adapter_config(name='extfile', context=IExtFileContainerTarget, provides=IDocumentIndexInfo)
+def ExtFileContainerTargetIndexInfo(content):
+    """External files index info"""
+    result = []
+    for extfile in IExtFileContainer(content).values():
+        extfile_index = {'title': extfile.title,
+                         'description': extfile.description,
+                         'data': {}}
+        for lang, data in extfile.data.items():
+            if data.content_type.startswith(b'image/') or \
+               data.content_type.startswith(b'audio/') or \
+               data.content_type.startswith(b'video/'):
+                continue
+            extfile_index['data'][lang] = {
+                '_content_type': data.content_type.decode(),
+                '_name': data.filename,
+                '_language': lang,
+                '_content': base64.encodebytes(data.data).decode()
+            }
+        result.append(extfile_index)
+    return {'extfile': result}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/component/gallery.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,70 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.gallery.interfaces import IGalleryContainerTarget, IGalleryContainer, IGallery, \
+    IGalleryFileInfo
+from pyams_content_es.interfaces import IDocumentIndexInfo
+
+# import packages
+from pyams_utils.adapter import adapter_config
+
+
+@adapter_config(context=IGallery, provides=IDocumentIndexInfo)
+def GalleryIndexInfo(gallery):
+    """Gallery index info"""
+    info = {}
+    for lang, title in gallery.title.items():
+        if title:
+            info.setdefault(lang, title)
+    for lang, description in gallery.description.items():
+        if description:
+            new_info = '{old}\n{info}'.format(old=info.get(lang, ''),
+                                              info=description)
+            info[lang] = new_info
+    for image in gallery.values():
+        image_info = IGalleryFileInfo(image, None)
+        if image_info is not None:
+            for lang, title in (image_info.title or {}).items():
+                if title:
+                    new_info = '{old}\n{info}'.format(old=info.get(lang, ''),
+                                                      info=title)
+                    info[lang] = new_info
+            for lang, description in (image_info.description or {}).items():
+                if description:
+                    new_info = '{old}\n{info}'.format(old=info.get(lang, ''),
+                                                      info=description)
+                    info[lang] = new_info
+            for lang, comments in (image_info.author_comments or {}).items():
+                if comments:
+                    new_info = '{old}\n{info}'.format(old=info.get(lang, ''),
+                                                      info=comments)
+                    info[lang] = new_info
+    return info
+
+
+@adapter_config(name='gallery', context=IGalleryContainerTarget, provides=IDocumentIndexInfo)
+def GalleryContainerTargetIndexInfo(content):
+    """Gallery container index info"""
+    body = {}
+    for gallery in IGalleryContainer(content).values():
+        info = IDocumentIndexInfo(gallery, None)
+        if info is not None:
+            for lang, info_body in info.items():
+                body[lang] = '{old}\n{body}'.format(old=body.get(lang, ''),
+                                                    body=info_body)
+    return {'gallery': body}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/component/paragraph.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,68 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.paragraph.interfaces import IParagraphContainer, IParagraphContainerTarget, IHTMLParagraph, \
+    IIllustrationParagraph
+from pyams_content_es.interfaces import IDocumentIndexInfo
+
+# import packages
+from pyams_utils.adapter import adapter_config
+from pyams_utils.html import html_to_text
+
+
+@adapter_config(context=IHTMLParagraph, provides=IDocumentIndexInfo)
+def HTMLParagraphIndexInfo(paragraph):
+    """HTML paragraph index info"""
+    info = {}
+    for lang, title in paragraph.title.items():
+        if title:
+            info.setdefault(lang, title)
+    for lang, body in paragraph.body.items():
+        if body:
+            new_body = '{old}\n{body}'.format(old=info.get(lang, ''),
+                                              body=html_to_text(body).replace('\r', ''))
+            info[lang] = new_body
+    return info
+
+
+@adapter_config(context=IIllustrationParagraph, provides=IDocumentIndexInfo)
+def IllustrationIndexInfo(paragraph):
+    """Illustration index info"""
+    info = {}
+    for lang, title in paragraph.title.items():
+        if title:
+            info.setdefault(lang, title)
+    for lang, legend in paragraph.legend.items():
+        if legend:
+            new_legend = '{old}\n{legend}'.format(old=info.get(lang, ''),
+                                                  legend=legend)
+            info[lang] = new_legend
+    return info
+
+
+@adapter_config(name='body', context=IParagraphContainerTarget, provides=IDocumentIndexInfo)
+def ParagraphContainerTargetIndexInfo(content):
+    """Paragraph container index info"""
+    body = {}
+    for paragraph in IParagraphContainer(content).values():
+        info = IDocumentIndexInfo(paragraph, None)
+        if info is not None:
+            for lang, info_body in info.items():
+                body[lang] = '{old}\n{body}'.format(old=body.get(lang, ''),
+                                                    body=info_body)
+    return {'body': body}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/component/theme.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,44 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+from pyams_utils.list import unique
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.theme.interfaces import IThemesTarget, IThemesInfo
+from pyams_content_es.interfaces import IDocumentIndexInfo
+
+# import packages
+from pyams_utils.adapter import adapter_config
+
+
+@adapter_config(name='themes', context=IThemesTarget, provides=IDocumentIndexInfo)
+def ThemesTargetIndexInfo(content):
+    """Themes target index info"""
+    terms = []
+    parents = []
+    synonyms = []
+    associations = []
+    for term in IThemesInfo(content).themes or ():
+        terms.append(term.label)
+        if term.usage is not None:
+            terms.append(term.usage.label)
+        parents.extend([parent.label for parent in term.get_parents()])
+        synonyms.extend([synonym.label for synonym in term.used_for])
+        associations.extend([association.label for association in term.associations])
+    return {'themes': {'terms': unique(terms),
+                       'parents': unique(parents),
+                       'synonyms': unique(synonyms),
+                       'associations': unique(associations)}}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/component/workflow.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,32 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content_es.interfaces import IDocumentIndexInfo
+from pyams_workflow.interfaces import IWorkflowState, IWorkflowPublicationSupport, IWorkflowInfo
+
+# import packages
+from pyams_utils.adapter import adapter_config
+
+
+@adapter_config(name='workflow', context=IWorkflowPublicationSupport, provides=IDocumentIndexInfo)
+def WorkflowManagedContentIndexInfo(content):
+    """Workflow managed content index info"""
+    workflow_state = IWorkflowState(content)
+    return {'workflow': {'name': IWorkflowInfo(content).name,
+                         'status': workflow_state.state,
+                         'date': workflow_state.state_date}}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/doctests/README.txt	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,3 @@
+========================
+pyams_content_es package
+========================
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/document.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,71 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.shared.common.interfaces import IWfSharedContent
+from pyams_content_es.interfaces import IDocumentIndexInfo, IDocumentIndexTarget
+from pyams_sequence.interfaces import ISequentialIdInfo
+from pyams_workflow.interfaces import IWorkflowState
+from zope.intid.interfaces import IIntIds
+
+# import packages
+from pyams_content.shared.common import WfSharedContent
+from pyams_utils.adapter import adapter_config
+from pyams_utils.registry import get_utility
+from pyramid.threadlocal import get_current_registry
+from pyramid_es.mixin import ElasticMixin as ElasticMixinBase, ESMapping, ESField
+from zope.interface import classImplements
+
+
+class ElasticMixin(ElasticMixinBase):
+    """ElasticSearch base mixin class"""
+
+    @property
+    def id(self):
+        return '{oid}.{version}'.format(oid=ISequentialIdInfo(self).hex_oid,
+                                        version=IWorkflowState(self).version_id)
+
+    @property
+    def internal_id(self):
+        intids = get_utility(IIntIds)
+        return intids.register(self)
+
+    def elastic_mapping(self):
+        return IDocumentIndexInfo(self)
+
+    def elastic_document(self):
+        document_info = super(ElasticMixin, self).elastic_document()
+        registry = get_current_registry()
+        for name, adapted in registry.getAdapters((self, ), IDocumentIndexInfo):
+            if not name:
+                continue
+            document_info.update(adapted)
+        return document_info
+
+
+WfSharedContent.__bases__ += (ElasticMixin, )
+classImplements(WfSharedContent, IDocumentIndexTarget)
+
+
+@adapter_config(context=IWfSharedContent, provides=IDocumentIndexInfo)
+def WfSharedContentIndexInfo(content):
+    return ESMapping(analyzer='content',
+                     properties=ESMapping(ESField('internal_id'),
+                                          ESField('title', boost=3.0),
+                                          ESField('short_name'),
+                                          ESField('description'),
+                                          ESField('keywords', boost=2.0)))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/include.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,72 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import atexit
+import logging
+logger = logging.getLogger('PyAMS (content.es)')
+
+import sys
+
+# import interfaces
+from pyams_content_es.interfaces import INDEXER_HANDLER_KEY
+from pyramid.interfaces import IApplicationCreated
+
+# import packages
+from pyams_content_es.process import ContentIndexerProcess, ContentIndexerMessageHandler
+from pyams_zmq.process import process_exit_func
+from pyramid.events import subscriber
+from zope.component.globalregistry import getGlobalSiteManager
+
+
+def include_package(config):
+    """Pyramid include"""
+
+    # add translations
+    config.add_translation_dirs('pyams_content_es:locales')
+
+    # load registry components
+    try:
+        import pyams_zmi
+    except ImportError:
+        config.scan(ignore='pyams_content_es.zmi')
+    else:
+        config.scan()
+
+
+@subscriber(IApplicationCreated)
+def handle_new_application(event):
+    """Start indexer process when application created"""
+
+    # check for upgrade mode
+    if sys.argv[0].endswith('pyams_upgrade'):
+        return
+
+    registry = getGlobalSiteManager()
+    settings = registry.settings
+    start_handler = settings.get(INDEXER_HANDLER_KEY, False)
+    if start_handler:
+        process = None
+        # create content indexer process
+        try:
+            process = ContentIndexerProcess(start_handler, ContentIndexerMessageHandler, registry)
+            logger.debug('Starting content indexer process {0!r}...'.format(process))
+            process.start()
+            if process.is_alive():
+                atexit.register(process_exit_func, process=process)
+        finally:
+            if process and not process.is_alive():
+                process.terminate()
+                process.join()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/index.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,66 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content_es.interfaces import IContentIndexerUtility, IDocumentIndexTarget
+from transaction.interfaces import ITransactionManager
+from zope.lifecycleevent.interfaces import IObjectAddedEvent, IObjectModifiedEvent, IObjectRemovedEvent
+
+# import packages
+from pyams_utils.registry import query_utility
+from pyramid.events import subscriber
+
+
+#
+# Documents events
+#
+
+def index_document(status, document):
+    if not status:  # aborted transaction
+        return
+    indexer = query_utility(IContentIndexerUtility)
+    if indexer is not None:
+        indexer.index_document(document)
+
+
+def unindex_document(status, document):
+    if not status:  # aborted transaction
+        return
+    indexer = query_utility(IContentIndexerUtility)
+    if indexer is not None:
+        indexer.unindex_document(document)
+
+
+@subscriber(IObjectAddedEvent, context_selector=IDocumentIndexTarget)
+def handle_added_document(event):
+    """Handle added document"""
+    document = event.object
+    ITransactionManager(document).get().addAfterCommitHook(index_document, kws={'document': document})
+
+
+@subscriber(IObjectModifiedEvent, context_selector=IDocumentIndexTarget)
+def handle_modified_document(event):
+    """Handle modified document"""
+    document = event.object
+    ITransactionManager(document).get().addAfterCommitHook(index_document, kws={'document': document})
+
+
+@subscriber(IObjectRemovedEvent, context_selector=IDocumentIndexTarget)
+def handle_removed_document(event):
+    """Handle removed document"""
+    document = event.object
+    ITransactionManager(document).get().addAfterCommitHook(unindex_document, kws={'document': document})
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/interfaces/__init__.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,66 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+
+# import packages
+from zope.interface import Interface
+from zope.schema import Choice
+
+from pyams_content_es import _
+
+
+#
+# Indexer interfaces
+#
+
+INDEXER_NAME = 'ElasticSearch content indexer'
+INDEXER_HANDLER_KEY = 'pyams_content.es.tcp_handler'
+
+
+#
+# Utility interfaces
+#
+
+class IContentIndexerUtility(Interface):
+    """Content indexer utility interface"""
+
+    zeo_connection = Choice(title=_("ZEO connection name"),
+                            description=_("Name of ZEO connection utility defining indexer connection"),
+                            required=False,
+                            vocabulary="PyAMS ZEO connections")
+
+    def index_document(self, document):
+        """Index given document"""
+
+    def unindex_document(self, document):
+        """Un-index given document"""
+
+    def test_process(self):
+        """Send test request to indexer process"""
+
+
+#
+# Contents interfaces
+#
+
+class IDocumentIndexInfo(Interface):
+    """Document index info"""
+
+
+class IDocumentIndexTarget(Interface):
+    """Document index target marker interface"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/process.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,176 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import logging
+logger = logging.getLogger('PyAMS (content.es)')
+
+from multiprocessing import Process
+from pprint import pformat
+from threading import Thread
+
+# import interfaces
+from pyams_utils.interfaces import PYAMS_APPLICATION_SETTINGS_KEY, PYAMS_APPLICATION_DEFAULT_NAME
+from transaction.interfaces import ITransactionManager
+from zope.intid.interfaces import IIntIds
+
+# import packages
+from pyams_utils.registry import set_local_registry, get_utility
+from pyams_utils.zodb import ZEOConnection
+from pyams_zmq.handler import ZMQMessageHandler
+from pyams_zmq.process import ZMQProcess
+from pyramid.threadlocal import manager as threadlocal_manager
+from zope.component.globalregistry import getGlobalSiteManager
+
+
+class BaseIndexerProcess(Process):
+    """Base indexer process"""
+
+    def __init__(self, settings, group=None, target=None, name=None, *args, **kwargs):
+        Process.__init__(self, group, target, name, *args, **kwargs)
+        self.settings = settings
+
+    def run(self):
+        logger.debug("Starting indexer thread...")
+        # Loading components registry
+        registry = getGlobalSiteManager()
+        threadlocal_manager.set({'request': None, 'registry': registry})
+        logger.debug("Getting global registry: {0!r}".format(registry))
+        # Get ES client
+        es_client = getattr(registry, 'pyramid_es_client', None)
+        if es_client is None:
+            logger.debug("Missing ElasticSearch client in registry!")
+            return
+        # Check settings
+        settings = self.settings
+        logger.debug("Checking index parameters: {0}".format(str(settings)))
+        zeo_settings = settings.get('zeo')
+        document_id = settings.get('document')
+        if not (zeo_settings and document_id):
+            logger.warning('Bad indexer request: {0}'.format(str(settings)))
+            return
+        # Open ZEO connection
+        manager = None
+        connection_info = ZEOConnection()
+        connection_info.update(zeo_settings)
+        logger.debug("Opening ZEO connection...")
+        storage, db = connection_info.get_connection(get_storage=True)
+        try:
+            connection = db.open()
+            root = connection.root()
+            logger.debug("Getting connection root {0!r}".format(root))
+            application_name = registry.settings.get(PYAMS_APPLICATION_SETTINGS_KEY, PYAMS_APPLICATION_DEFAULT_NAME)
+            application = root.get(application_name)
+            logger.debug("Loading application {0!r} named {1}".format(application, application_name))
+            if application is not None:
+                # set local registry
+                sm = application.getSiteManager()
+                set_local_registry(sm)
+                logger.debug("Setting local registry {0!r}".format(sm))
+                # find document
+                intids = get_utility(IIntIds)
+                document = intids.queryObject(document_id)
+                if document is None:
+                    logger.warning("Can't find requested document {0}!".format(document_id))
+                    return
+                # index document
+                logger.debug("Starting indexing for {0!r}".format(document))
+                manager = ITransactionManager(document)
+                for attempt in manager.attempts():
+                    with attempt as t:
+                        self.update_index(es_client, document)
+                    if t.status == 'Committed':
+                        break
+        finally:
+            if manager is not None:
+                manager.abort()
+            connection.close()
+            storage.close()
+            threadlocal_manager.pop()
+
+    def update_index(self, client, document):
+        """Update index"""
+        raise NotImplementedError("Index threads must implement update_index method")
+
+
+class IndexerProcess(BaseIndexerProcess):
+    """Content indexer process"""
+
+    def update_index(self, client, document):
+        client.index_object(document)
+
+
+class UnindexerProcess(BaseIndexerProcess):
+    """Content un-indexer process"""
+
+    def update_index(self, client, document):
+        client.delete_object(document)
+
+
+class IndexerThread(Thread):
+    """Content indexer thread"""
+
+    def __init__(self, process):
+        Thread.__init__(self)
+        self.process = process
+
+    def run(self):
+        self.process.start()
+        self.process.join()
+
+
+class ContentIndexerHandler(object):
+    """Content indexer handler"""
+
+    def index(self, settings):
+        IndexerThread(IndexerProcess(settings)).start()
+        return [200, 'Indexer process started']
+
+    def unindex(self, settings):
+        IndexerThread(UnindexerProcess(settings)).start()
+        return [200, 'Un-indexer process started']
+
+    def test(self, settings):
+        messages = ['Content indexer process ready to handle requests.', '']
+        registry = getGlobalSiteManager()
+        es_client = getattr(registry, 'pyramid_es_client', None)
+        if es_client is None:
+            messages.append('WARNING: no ElasticSearch client defined!')
+        else:
+            messages.extend(['ElasticSearch client properties:',
+                             '- servers: {0}'.format(es_client.es.transport.hosts),
+                             '- index: {0}'.format(es_client.index),
+                             ''])
+            ping = es_client.es.ping()
+            messages.append('Server ping: {0}'.format('OK' if ping else 'KO'))
+            if ping:
+                messages.extend(['', 'Server info:'])
+                messages.extend(pformat(es_client.es.info()).split('\n'))
+        return [200, '\n'.join(messages)]
+
+
+class ContentIndexerMessageHandler(ZMQMessageHandler):
+    """Content indexer message handler"""
+
+    handler = ContentIndexerHandler
+
+
+class ContentIndexerProcess(ZMQProcess):
+    """Content indexer ZMQ process"""
+
+    def __init__(self, zmq_address, handler, registry):
+        ZMQProcess.__init__(self, zmq_address, handler)
+        self.registry = registry
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/scripts/__init__.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,20 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+
+# import packages
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/scripts/index.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,44 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import argparse
+import sys
+import textwrap
+
+# import interfaces
+
+# import packages
+from pyams_content_es.site import site_index
+from pyramid.paster import bootstrap
+
+
+def index_site():
+    """Update all ElasticSearch indexes"""
+    usage = "usage: {0} config_uri".format(sys.argv[0])
+    description = """Update all ElasticSearch indexes with all database contents."""
+
+    parser = argparse.ArgumentParser(usage=usage,
+                                     description=textwrap.dedent(description))
+    parser.add_argument('config_uri', help='Name of configuration file')
+    args = parser.parse_args()
+
+    config_uri = args.config_uri
+    env = bootstrap(config_uri)
+    settings, closer = env['registry'].settings, env['closer']
+    try:
+        site_index(env['request'])
+    finally:
+        closer()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/site.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,67 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import transaction
+
+# import interfaces
+from pyams_content_es.interfaces import IContentIndexerUtility, INDEXER_NAME, IDocumentIndexTarget
+from pyams_utils.interfaces.site import ISiteGenerations
+from zope.site.interfaces import INewLocalSite
+
+# import packages
+from pyams_content_es.utility import ContentIndexerUtility
+from pyams_utils.container import find_objects_providing
+from pyams_utils.registry import utility_config, set_local_registry, query_utility
+from pyams_utils.site import check_required_utilities, site_factory
+from pyramid.events import subscriber
+
+
+REQUIRED_UTILITIES = ((IContentIndexerUtility, '', ContentIndexerUtility, INDEXER_NAME), )
+
+
+@subscriber(INewLocalSite)
+def handle_new_local_site(event):
+    """Create a new indexer utility when a site is created"""
+    site = event.manager.__parent__
+    check_required_utilities(site, REQUIRED_UTILITIES)
+
+
+@utility_config(name='PyAMS content indexer', provides=ISiteGenerations)
+class ContentIndexerGenerationsChecker(object):
+    """Content indexer utility generations checker"""
+
+    generation = 1
+
+    def evolve(self, site, current=None):
+        """Check for required utilities"""
+        check_required_utilities(site, REQUIRED_UTILITIES)
+
+
+def site_index(request):
+    """Index all site contents in ElasticSearch"""
+    application = site_factory(request)
+    if application is not None:
+        try:
+            set_local_registry(application.getSiteManager())
+            indexer = query_utility(IContentIndexerUtility)
+            if indexer is not None:
+                for document in find_objects_providing(application, IDocumentIndexTarget):
+                    print("Indexing: {0!r}".format(document))
+                    indexer.index_document(document)
+        finally:
+            set_local_registry(None)
+        transaction.commit()
+    return application
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/tests/__init__.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,1 @@
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/tests/test_utilsdocs.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,59 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+"""
+Generic Test case for pyams_content_es doctest
+"""
+__docformat__ = 'restructuredtext'
+
+import unittest
+import doctest
+import sys
+import os
+
+
+current_dir = os.path.dirname(__file__)
+
+def doc_suite(test_dir, setUp=None, tearDown=None, globs=None):
+    """Returns a test suite, based on doctests found in /doctest."""
+    suite = []
+    if globs is None:
+        globs = globals()
+
+    flags = (doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE |
+             doctest.REPORT_ONLY_FIRST_FAILURE)
+
+    package_dir = os.path.split(test_dir)[0]
+    if package_dir not in sys.path:
+        sys.path.append(package_dir)
+
+    doctest_dir = os.path.join(package_dir, 'doctests')
+
+    # filtering files on extension
+    docs = [os.path.join(doctest_dir, doc) for doc in
+            os.listdir(doctest_dir) if doc.endswith('.txt')]
+
+    for test in docs:
+        suite.append(doctest.DocFileSuite(test, optionflags=flags,
+                                          globs=globs, setUp=setUp,
+                                          tearDown=tearDown,
+                                          module_relative=False))
+
+    return unittest.TestSuite(suite)
+
+def test_suite():
+    """returns the test suite"""
+    return doc_suite(current_dir)
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='test_suite')
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/tests/test_utilsdocstrings.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,62 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+"""
+Generic Test case for pyams_content_es doc strings
+"""
+__docformat__ = 'restructuredtext'
+
+import unittest
+import doctest
+import sys
+import os
+
+
+current_dir = os.path.abspath(os.path.dirname(__file__))
+
+def doc_suite(test_dir, globs=None):
+    """Returns a test suite, based on doc tests strings found in /*.py"""
+    suite = []
+    if globs is None:
+        globs = globals()
+
+    flags = (doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE |
+             doctest.REPORT_ONLY_FIRST_FAILURE)
+
+    package_dir = os.path.split(test_dir)[0]
+    if package_dir not in sys.path:
+        sys.path.append(package_dir)
+
+    # filtering files on extension
+    docs = [doc for doc in
+            os.listdir(package_dir) if doc.endswith('.py')]
+    docs = [doc for doc in docs if not doc.startswith('__')]
+
+    for test in docs:
+        fd = open(os.path.join(package_dir, test))
+        content = fd.read()
+        fd.close()
+        if '>>> ' not in content:
+            continue
+        test = test.replace('.py', '')
+        location = 'pyams_content_es.%s' % test
+        suite.append(doctest.DocTestSuite(location, optionflags=flags,
+                                          globs=globs))
+
+    return unittest.TestSuite(suite)
+
+def test_suite():
+    """returns the test suite"""
+    return doc_suite(current_dir)
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='test_suite')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/utility.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,81 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content_es.interfaces import IContentIndexerUtility, INDEXER_HANDLER_KEY
+from pyams_utils.interfaces.zeo import IZEOConnection
+from zope.intid.interfaces import IIntIds
+
+# import packages
+from persistent import Persistent
+from pyams_utils.registry import get_utility
+from pyams_zmq.socket import zmq_socket, zmq_response
+from pyramid.threadlocal import get_current_registry
+from zope.container.contained import Contained
+from zope.interface import implementer
+from zope.schema.fieldproperty import FieldProperty
+
+
+@implementer(IContentIndexerUtility)
+class ContentIndexerUtility(Persistent, Contained):
+    """Content indexer utility"""
+
+    zeo_connection = FieldProperty(IContentIndexerUtility['zeo_connection'])
+
+    def _get_socket(self):
+        registry = get_current_registry()
+        handler = registry.settings.get(INDEXER_HANDLER_KEY, False)
+        if handler:
+            return zmq_socket(handler)
+
+    def index_document(self, document):
+        """Send index request for given document"""
+        socket = self._get_socket()
+        if socket is None:
+            return [501, "No socket handler defined in configuration file"]
+        if not self.zeo_connection:
+            return [502, "Missing ZEO connection"]
+        zeo = get_utility(IZEOConnection, self.zeo_connection)
+        intids = get_utility(IIntIds)
+        settings = {'zeo': zeo.get_settings(),
+                    'document': intids.register(document)}
+        socket.send_json(['index', settings])
+        return zmq_response(socket)
+
+    def unindex_document(self, document):
+        """Send unindex request for given document"""
+        socket = self._get_socket()
+        if socket is None:
+            return [501, "No socket handler defined in configuration file"]
+        if not self.zeo_connection:
+            return [502, "Missing ZEO connection"]
+        zeo = get_utility(IZEOConnection, self.zeo_connection)
+        intids = get_utility(IIntIds)
+        settings = {'zeo': zeo.get_settings(),
+                    'document': intids.register(document)}
+        socket.send_json(['unindex', settings])
+        return zmq_response(socket)
+
+    def test_process(self):
+        """Send test request to indexer process"""
+        socket = self._get_socket()
+        if socket is None:
+            return [501, "No socket handler defined in configuration file"]
+        if not self.zeo_connection:
+            return [502, "Missing ZEO connection"]
+        socket.send_json(['test', {}])
+        return zmq_response(socket)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/zmi/__init__.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,136 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content_es.interfaces import IContentIndexerUtility
+from pyams_form.interfaces.form import IWidgetsSuffixViewletsManager
+from pyams_skin.interfaces.viewlet import ITableItemColumnActionsMenu
+from pyams_skin.layer import IPyAMSLayer
+from pyams_utils.interfaces import VIEW_SYSTEM_PERMISSION, MANAGE_SYSTEM_PERMISSION
+
+# import packages
+from pyams_form.form import AJAXEditForm, AJAXAddForm
+from pyams_form.schema import CloseButton
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem
+from pyams_template.template import template_config
+from pyams_viewlet.viewlet import viewlet_config, Viewlet
+from pyams_zmi.control_panel import UtilitiesTable
+from pyams_zmi.form import AdminDialogEditForm, AdminDialogAddForm
+from pyams_zmi.layer import IAdminLayer
+from pyramid.view import view_config
+from z3c.form import field, button
+from zope.interface import Interface
+
+from pyams_content_es import _
+
+
+@pagelet_config(name='properties.html', context=IContentIndexerUtility, layer=IPyAMSLayer,
+                permission=VIEW_SYSTEM_PERMISSION)
+class ContentIndexerUtilityPropertiesEditForm(AdminDialogEditForm):
+    """Content indexer utility properties edit form"""
+    
+    @property
+    def title(self):
+        return self.context.__name__
+    
+    legend = _("Update content indexer properties")
+    
+    fields = field.Fields(IContentIndexerUtility).select('zeo_connection')
+
+    ajax_handler = 'properties.json'
+    edit_permission = MANAGE_SYSTEM_PERMISSION
+    
+    
+@view_config(name='properties.json', context=IContentIndexerUtility, request_type=IPyAMSLayer,
+             permission=MANAGE_SYSTEM_PERMISSION, renderer='json', xhr=True)
+class ContentIndexerUtilityPropertiesAJAXEditForm(AJAXEditForm, ContentIndexerUtilityPropertiesEditForm):
+    """Content index utility properties edit form, JSON renderer"""
+
+
+@viewlet_config(name='test-conversion-process.menu', context=IContentIndexerUtility, layer=IAdminLayer,
+                view=UtilitiesTable, manager=ITableItemColumnActionsMenu, permission=MANAGE_SYSTEM_PERMISSION)
+class ContentIndexerProcessTestMenu(ToolbarMenuItem):
+    """Content indexer process test menu"""
+
+    label = _("Test process connection...")
+    label_css_class = 'fa fa-fw fa-server'
+    url = 'test-indexer-process.html'
+    modal_target = True
+    stop_propagation = True
+
+
+class IContentIndexerProcessTestButtons(Interface):
+    """Content indexer process test buttons"""
+
+    close = CloseButton(name='close', title=_("Close"))
+    test = button.Button(name='test', title=_("Test connection"))
+
+
+@pagelet_config(name='test-indexer-process.html', context=IContentIndexerUtility, layer=IPyAMSLayer,
+                permission=MANAGE_SYSTEM_PERMISSION)
+class ContentIndexerProcessTestForm(AdminDialogAddForm):
+    """Content indexer process test form"""
+
+    @property
+    def title(self):
+        return self.context.__name__
+
+    legend = _("Test content indexer process connection")
+    icon_css_class = 'fa fa-fw fa-server'
+
+    prefix = 'test_form.'
+    fields = field.Fields(Interface)
+    buttons = button.Buttons(IContentIndexerProcessTestButtons)
+    ajax_handler = 'test-indexer-process.json'
+    edit_permission = MANAGE_SYSTEM_PERMISSION
+
+    @property
+    def form_target(self):
+        return '#{0}_test_result'.format(self.id)
+
+    def updateActions(self):
+        super(ContentIndexerProcessTestForm, self).updateActions()
+        if 'test' in self.actions:
+            self.actions['test'].addClass('btn-primary')
+
+    def createAndAdd(self, data):
+        return self.context.test_process()
+
+
+@viewlet_config(name='test-indexer-process.suffix', layer=IAdminLayer, manager=IWidgetsSuffixViewletsManager,
+                view=ContentIndexerProcessTestForm, weight=50)
+@template_config(template='templates/process-test.pt')
+class ContentIndexerProcessTestSuffix(Viewlet):
+    """Content indexer process test form suffix"""
+
+
+@view_config(name='test-indexer-process.json', context=IContentIndexerUtility, request_type=IPyAMSLayer,
+             permission=MANAGE_SYSTEM_PERMISSION, renderer='json', xhr=True)
+class ContentIndexerProcessAJAXTestForm(AJAXAddForm, ContentIndexerProcessTestForm):
+    """Content indexer process test form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        status, message = changes
+        if status == 200:
+            return {'status': 'success',
+                    'content': {'html': message},
+                    'close_form': False}
+        else:
+            return {'status': 'info',
+                    'content': {'html': message},
+                    'close_form': False}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/zmi/templates/process-test.pt	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,3 @@
+<div class="no-widget-toolbar">
+	<pre tal:attributes="id string:${view.__parent__.id}_test_result"></pre>
+</div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/zmi/test.py	Thu Apr 21 18:24:52 2016 +0200
@@ -0,0 +1,55 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_i18n.interfaces import II18n
+from zope.intid.interfaces import IIntIds
+
+# import packages
+from pyams_utils.registry import get_utility
+from pyramid.response import Response
+from pyramid.view import view_config
+from pyramid_es import get_client
+
+
+@view_config(name='test-es.json', renderer='string')
+def es_test_view(context, request):
+
+    def get_response():
+        client = get_client(request)
+        # query = client.query() \
+        #               .filter_term('title.fr', 'breve') \
+        #               .add_term_aggregate('status', 'workflow.status') \
+        #               .size(100)
+        # return query.execute(fields=['title.fr']).raw
+
+        query = client.query() \
+                      .filter_term('_type', 'WfNewsEvent') \
+                      .filter_terms('workflow.status', ['published', 'retiring']) \
+                      .add_term_aggregate('status', 'workflow.status') \
+                      .add_date_aggregate('wf_date', 'workflow.date') \
+                      .size(100)
+        intids = get_utility(IIntIds)
+        for result in query.execute(fields=['internal_id']):
+            if not result.internal_id:
+                continue
+            target = intids.queryObject(result.internal_id[0])
+            yield II18n(target).query_attribute('title').encode() + b'\n'
+        # return pformat(query.execute(fields=['title.fr', 'workflow.status', 'internal_id']).raw)
+
+    return Response(app_iter=get_response())