Updated views and view content portlet to use iterators and allow merging of several views
authorThierry Florac <thierry.florac@onf.fr>
Mon, 02 Jul 2018 17:11:41 +0200
changeset 783 e34cc04e9786
parent 782 bfdfb25e2f9e
child 784 3ce9a6fabe43
Updated views and view content portlet to use iterators and allow merging of several views
src/pyams_content/shared/view/__init__.py
src/pyams_content/shared/view/interfaces/__init__.py
src/pyams_content/shared/view/merge.py
src/pyams_content/shared/view/portlet/__init__.py
src/pyams_content/shared/view/portlet/interfaces.py
src/pyams_content/shared/view/portlet/zmi/templates/view-items-list-preview.pt
src/pyams_content/shared/view/reference.py
--- a/src/pyams_content/shared/view/__init__.py	Mon Jul 02 17:10:37 2018 +0200
+++ b/src/pyams_content/shared/view/__init__.py	Mon Jul 02 17:11:41 2018 +0200
@@ -36,7 +36,7 @@
 from pyams_catalog.query import CatalogResultSet, or_
 from pyams_content.shared.common import WfSharedContent, register_content_type, SharedContent, IWfSharedContentFactory
 from pyams_utils.adapter import adapter_config, ContextAdapter
-from pyams_utils.list import unique
+from pyams_utils.list import unique_iter
 from pyams_utils.registry import get_utility, get_global_registry
 from pyams_utils.timezone import tztime
 from pyams_workflow.interfaces import IWorkflow
@@ -86,7 +86,7 @@
             content_types |= set(self.selected_content_types)
         return list(content_types)
 
-    def get_results(self, context):
+    def get_results(self, context, sort_index=None, reverse=None, limit=None):
         views_cache = get_cache(VIEWS_CACHE_REGION, VIEWS_CACHE_NAME)
         if self.is_using_context:
             cache_key = VIEW_CONTEXT_CACHE_KEY.format(view=ICacheKeyValue(self),
@@ -100,7 +100,10 @@
             adapter = registry.queryAdapter(self, IViewQuery, name='es')
             if adapter is None:
                 adapter = registry.getAdapter(self, IViewQuery)
-            results = adapter.get_results(context, self.limit)
+            results = adapter.get_results(context,
+                                          sort_index or self.order_by,
+                                          reverse if reverse is not None else self.reversed_order,
+                                          limit or self.limit)
             intids = get_utility(IIntIds)
             views_cache.set_value(cache_key, [intids.queryId(item) for item in results])
             logger.debug("Storing view items to cache key {0}".format(cache_key))
@@ -152,19 +155,19 @@
                 params &= new_params
         return params
 
-    def get_results(self, context, limit=None):
+    def get_results(self, context, sort_index, reverse, limit):
         view = self.context
         catalog = get_utility(ICatalog)
         registry = get_current_registry()
         params = self.get_params(context)
         items = CatalogResultSet(CatalogQuery(catalog).query(params,
-                                                             sort_index=view.order_by,
-                                                             reverse=view.reversed_order,
+                                                             sort_index=sort_index,
+                                                             reverse=reverse,
                                                              limit=limit))
         for name, adapter in sorted(registry.getAdapters((view,), IViewQueryFilterExtension),
                                     key=lambda x: x[1].weight):
             items = adapter.filter(context, items)
-        return unique(items)
+        return unique_iter(items)
 
 
 @subscriber(IObjectModifiedEvent, context_selector=IWfView)
--- a/src/pyams_content/shared/view/interfaces/__init__.py	Mon Jul 02 17:10:37 2018 +0200
+++ b/src/pyams_content/shared/view/interfaces/__init__.py	Mon Jul 02 17:11:41 2018 +0200
@@ -37,13 +37,15 @@
 PUBLICATION_DATE_ORDER = 'publication_date'
 FIRSTPUBLICATION_DATE_ORDER = 'first_publication_date'
 
-VIEW_ORDER = {CREATION_DATE_ORDER: _("Creation date"),
-              UPDATE_DATE_ORDER: _("Last update date"),
-              PUBLICATION_DATE_ORDER: _("Current publication date"),
-              FIRSTPUBLICATION_DATE_ORDER: _("First publication date")}
+VIEW_ORDERS = (
+    {'id': CREATION_DATE_ORDER, 'title': _("Creation date")},
+    {'id': UPDATE_DATE_ORDER,'title': _("Last update date")},
+    {'id': PUBLICATION_DATE_ORDER, 'title': _("Current publication date")},
+    {'id': FIRSTPUBLICATION_DATE_ORDER, 'title': _("First publication date")}
+)
 
-VIEW_ORDER_VOCABULARY = SimpleVocabulary([SimpleTerm(v, title=t)
-                                          for v, t in VIEW_ORDER.items()])
+VIEW_ORDER_VOCABULARY = SimpleVocabulary([SimpleTerm(item['id'], title=item['title'])
+                                          for item in VIEW_ORDERS])
 
 
 class IViewsManager(ISharedTool):
@@ -87,7 +89,7 @@
 
     is_using_context = Attribute("Check if view is using context settings")
 
-    def get_results(self, context):
+    def get_results(self, context, sort_index=None, reverse=True, limit=None):
         """Get results of catalog query"""
 
 
@@ -108,7 +110,7 @@
 class IViewQuery(Interface):
     """View query interface"""
 
-    def get_results(self, context, limit=None):
+    def get_results(self, context, sort_index, reverse, limit):
         """Get results of catalog query"""
 
 
@@ -171,7 +173,7 @@
 
 
 class IViewThemesSettings(IViewSettings):
-    """View themess ettings"""
+    """View themes ettings"""
 
     select_context_themes = Bool(title=_("Select context themes?"),
                                  description=_("If 'yes', themes will be extracted from context"),
@@ -186,3 +188,16 @@
 
     def get_themes_index(self, context):
         """Get all themes index values for given context"""
+
+
+VIEWS_MERGERS_VOCABULARY = 'pyams_content.views.mergers'
+
+
+class IViewsMerger(Interface):
+    """Interface used to define views mergers
+
+    Mergers are used to merge results of several views.
+    """
+
+    def get_results(self, views, context):
+        """Merge results of several views together"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/view/merge.py	Mon Jul 02 17:11:41 2018 +0200
@@ -0,0 +1,135 @@
+#
+# Copyright (c) 2008-2018 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
+from heapq import merge
+from random import shuffle
+from itertools import chain, zip_longest
+
+# import interfaces
+from pyams_content.shared.view.interfaces import VIEWS_MERGERS_VOCABULARY, IViewsMerger, CREATION_DATE_ORDER, \
+    UPDATE_DATE_ORDER, PUBLICATION_DATE_ORDER, FIRSTPUBLICATION_DATE_ORDER
+
+# import packages
+from pyams_utils.registry import utility_config
+from pyams_utils.request import check_request
+from pyams_utils.vocabulary import vocabulary_config
+from zope.componentvocabulary.vocabulary import UtilityVocabulary, UtilityTerm
+
+from pyams_content import _
+
+
+@vocabulary_config(name=VIEWS_MERGERS_VOCABULARY)
+class ViewsMergersVocabulary(UtilityVocabulary):
+    """Views mergers vocabulary"""
+
+    interface = IViewsMerger
+    nameOnly = True
+
+    def __init__(self, context, **kw):
+        request = check_request()
+        registry = request.registry
+        translate = request.localizer.translate
+        utils = [(name, translate(util.label))
+                 for (name, util) in registry.getUtilitiesFor(self.interface)]
+        self._terms = dict((title, UtilityTerm(name, title)) for name, title in utils)
+
+
+CONCAT_VIEWS_MERGE_MODE = 'concat'
+
+
+@utility_config(name=CONCAT_VIEWS_MERGE_MODE, provides=IViewsMerger)
+class ViewsConcatenateMergeMode(object):
+    """Views concatenate merge mode"""
+
+    label = _("Concatenate views items in order")
+
+    @classmethod
+    def get_results(cls, views, context):
+        results = (view.get_results(context) for view in views)
+        yield from chain(*results)
+
+
+@utility_config(name='zip', provides=IViewsMerger)
+class ViewsZipMergeMode(object):
+    """Views zip merge mode"""
+
+    label = _("Take items from views one by one, in views order")
+
+    @classmethod
+    def get_results(cls, views, context):
+        results = (view.get_results(context) for view in views)
+        for array in zip_longest(*results):
+            yield from filter(lambda x: x is not None, array)
+
+
+@utility_config(name='zip_random', provides=IViewsMerger)
+class ViewsRandomZipMergeMode(object):
+    """Views random zip merge mode"""
+
+    label = _("Take items from views one by one, in random order")
+
+    @classmethod
+    def get_results(cls, views, context):
+        results = [view.get_results(context) for view in views]
+        shuffle(results)
+        for array in zip_longest(*results):
+            yield from filter(lambda x: x is not None, array)
+
+
+class SortedMergeMode(object):
+    """Sorted merge mode base class"""
+
+    sort_index = None
+
+    @classmethod
+    def get_results(cls, views, context):
+        results = (view.get_results(context,
+                                    sort_index=cls.sort_index,
+                                    reverse=True)
+                   for view in views)
+        yield from merge(*results)
+
+
+@utility_config(name='{0}.sort'.format(CREATION_DATE_ORDER), provides=IViewsMerger)
+class CreationDateSortedMergeMode(SortedMergeMode):
+    """Merge pre-sorted views by creation date"""
+
+    label = _("Sort all results by creation date")
+    sort_index = CREATION_DATE_ORDER
+
+
+@utility_config(name='{0}.sort'.format(UPDATE_DATE_ORDER), provides=IViewsMerger)
+class UpdateDateSortedMergeMode(SortedMergeMode):
+    """Merge pre-sorted views by last update date"""
+
+    label = _("Sort all results by last update date")
+    sort_index = UPDATE_DATE_ORDER
+
+
+@utility_config(name='{0}.sort'.format(PUBLICATION_DATE_ORDER), provides=IViewsMerger)
+class PublicationDateSortedMergeMode(SortedMergeMode):
+    """Merge pre-sorted views by publication date"""
+
+    label = _("Sort all results by current publication date")
+    sort_index = PUBLICATION_DATE_ORDER
+
+
+@utility_config(name='{0}.sort'.format(FIRSTPUBLICATION_DATE_ORDER), provides=IViewsMerger)
+class FirstPublicationDateSortedMergeMode(SortedMergeMode):
+    """Merge pre-sorted views by first publication date"""
+
+    label = _("Sort all results by first publication date")
+    sort_index = FIRSTPUBLICATION_DATE_ORDER
--- a/src/pyams_content/shared/view/portlet/__init__.py	Mon Jul 02 17:10:37 2018 +0200
+++ b/src/pyams_content/shared/view/portlet/__init__.py	Mon Jul 02 17:11:41 2018 +0200
@@ -14,16 +14,21 @@
 
 
 # import standard library
+from itertools import islice
 
 # import interfaces
-from pyams_content.shared.view.portlet.interfaces import IViewItemsPortletSettings
+from pyams_content.shared.view.interfaces import IViewsMerger
+from pyams_content.shared.view.portlet.interfaces import IViewItemsPortletSettings, VIEW_DISPLAY_CONTEXT
 from pyams_utils.interfaces import VIEW_PERMISSION
+from pyams_utils.interfaces.url import DISPLAY_CONTEXT
 
 # import packages
 from pyams_content.workflow import PUBLISHED_STATES
 from pyams_portal.portlet import PortletSettings, portlet_config, Portlet
 from pyams_sequence.utility import get_sequence_target
 from pyams_utils.factory import factory_config
+from pyams_utils.list import unique_iter
+from pyams_utils.request import check_request
 from zope.interface import implementer
 from zope.schema.fieldproperty import FieldProperty
 
@@ -38,16 +43,37 @@
 class ViewItemsPortletSettings(PortletSettings):
     """View items portlet settings"""
 
-    view = FieldProperty(IViewItemsPortletSettings['view'])
+    views = FieldProperty(IViewItemsPortletSettings['views'])
+    views_context = FieldProperty(IViewItemsPortletSettings['views_context'])
+    views_merge_mode = FieldProperty(IViewItemsPortletSettings['views_merge_mode'])
+    limit = FieldProperty(IViewItemsPortletSettings['limit'])
+
+    def get_merger(self, request=None):
+        if request is None:
+            request = check_request()
+        return request.registry.queryUtility(IViewsMerger, name=self.views_merge_mode)
+
+    def get_views(self):
+        for oid in self.views or ():
+            view = get_sequence_target(oid, state=PUBLISHED_STATES)
+            if view is not None:
+                yield view
 
-    def get_view(self):
-        if self.view is not None:
-            return get_sequence_target(self.view, state=PUBLISHED_STATES)
-
-    def get_items(self, context):
-        view = self.get_view()
-        if view is not None:
-            return view.get_results(context)
+    def get_items(self, request=None):
+        if request is None:
+            request = check_request()
+        if self.views_context == VIEW_DISPLAY_CONTEXT:
+            context = request.annotations.get(DISPLAY_CONTEXT, request.root)
+        else:
+            context = request.context
+        if len(self.views or ()) < 2:  # single view
+            for view in self.get_views():
+                if view is not None:
+                    yield from islice(view.get_results(context), self.limit)
+        else:  # several views merged together
+            merger = self.get_merger(request)
+            if merger is not None:
+                yield from islice(unique_iter(merger.get_results(self.get_views(), context)), self.limit)
 
 
 @portlet_config(permission=VIEW_PERMISSION)
--- a/src/pyams_content/shared/view/portlet/interfaces.py	Mon Jul 02 17:10:37 2018 +0200
+++ b/src/pyams_content/shared/view/portlet/interfaces.py	Mon Jul 02 17:11:41 2018 +0200
@@ -16,19 +16,68 @@
 # import standard library
 
 # import interfaces
+from pyams_content.shared.view.interfaces import VIEWS_MERGERS_VOCABULARY
 from pyams_portal.interfaces import IPortletSettings
 
 # import packages
 from pyams_content.shared.view import WfView
-from pyams_sequence.schema import InternalReference
+from pyams_content.shared.view.merge import CONCAT_VIEWS_MERGE_MODE
+from pyams_sequence.schema import InternalReferencesList
+from zope.schema import Choice, Int
+from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
 
 from pyams_content import _
 
 
+#
+# Views display contexts
+#
+
+VIEW_DISPLAY_CONTEXT = 'display'
+VIEW_CONTENT_CONTEXT = 'content'
+
+VIEW_CONTEXTS = (
+    {'id': VIEW_DISPLAY_CONTEXT, 'title': _("Display context")},
+    {'id': VIEW_CONTENT_CONTEXT, 'title': _("Content context")}
+)
+
+VIEW_CONTEXT_VOCABULARY = SimpleVocabulary([SimpleTerm(item['id'], title=item['title'])
+                                            for item in VIEW_CONTEXTS])
+
+
+#
+# Views merge modes
+#
+
 class IViewItemsPortletSettings(IPortletSettings):
     """View items portlet settings interface"""
 
-    view = InternalReference(title=_("Selected view"),
-                             description=_("Reference to the view from which items are extracted"),
-                             content_type=WfView.content_type,
-                             required=True)
+    views = InternalReferencesList(title=_("Selected views"),
+                                   description=_("Reference to the view(s) from which items are extracted; "
+                                                 "you can combine several views together and specify in which order "
+                                                 "they should be mixed"),
+                                   content_type=WfView.content_type,
+                                   required=True)
+
+    views_context = Choice(title=_("Views context"),
+                           description=_("When searching for items, a view receives a \"context\" which is the object "
+                                         "from which settings can be extracted; this context can be the \"display\" "
+                                         "context or the \"content\" context: when the portlet is used to display the "
+                                         "site root, a site manager or a site folder, both are identical; when the "
+                                         "portlet is used to display a shared content, the \"content\" context is the "
+                                         "displayed content, while the \"display\" context is the container (site "
+                                         "root, site manager or site folder) into which content is displayed"),
+                           vocabulary=VIEW_CONTEXT_VOCABULARY,
+                           default=VIEW_DISPLAY_CONTEXT,
+                           required=True)
+
+    views_merge_mode = Choice(title=_("Views merge mode"),
+                              description=_("If you select several views, you can select \"merge\" mode, which is "
+                                            "the way used to merge items from several views"),
+                              vocabulary=VIEWS_MERGERS_VOCABULARY,
+                              default=CONCAT_VIEWS_MERGE_MODE,
+                              required=True)
+
+    limit = Int(title=_("Results count limit"),
+                description=_("Maximum number of results that the component may extract from merged views"),
+                required=False)
--- a/src/pyams_content/shared/view/portlet/zmi/templates/view-items-list-preview.pt	Mon Jul 02 17:10:37 2018 +0200
+++ b/src/pyams_content/shared/view/portlet/zmi/templates/view-items-list-preview.pt	Mon Jul 02 17:11:41 2018 +0200
@@ -1,12 +1,9 @@
-<tal:var define="settings view.settings">
-	<tal:var define="items settings.get_items(context)">
-		<tal:if condition="items">
-			<div tal:repeat="item items">
-				<tal:var content="i18n:item.title" />
-			</div>
-		</tal:if>
-		<tal:if condition="not:items" i18n:translate="">
-			No result found
-		</tal:if>
-	</tal:var>
+<tal:var define="settings view.settings; global count 0;" i18n:domain="pyams_content">
+	<div tal:repeat="item settings.get_items(request)">
+		<tal:var content="i18n:item.title" />
+		<tal:var define="global count count+1" />
+	</div>
+	<tal:if condition="not:count" i18n:translate="">
+		No result found
+	</tal:if>
 </tal:var>
--- a/src/pyams_content/shared/view/reference.py	Mon Jul 02 17:10:37 2018 +0200
+++ b/src/pyams_content/shared/view/reference.py	Mon Jul 02 17:11:41 2018 +0200
@@ -9,7 +9,6 @@
 # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
 # FOR A PARTICULAR PURPOSE.
 #
-from pyams_sequence.interfaces import ISequentialIdInfo
 
 __docformat__ = 'restructuredtext'
 
@@ -21,6 +20,7 @@
 from hypatia.interfaces import ICatalog
 from pyams_content.shared.view.interfaces import IWfView, IViewSettings, IViewInternalReferencesSettings, \
     IViewQueryParamsExtension, IViewQueryFilterExtension, VIEW_REFERENCES_SETTINGS_KEY, ALWAYS_REFERENCE_MODE
+from pyams_sequence.interfaces import ISequentialIdInfo
 
 # import packages
 from hypatia.catalog import CatalogQuery
@@ -62,13 +62,15 @@
     weight = 50
 
     def get_params(self, context):
-        catalog = get_utility(ICatalog)
         settings = IViewInternalReferencesSettings(self.context)
         params = None
         # check themes
         if settings.exclude_context:
-            oid = ISequentialIdInfo(context).hex_oid
-            params = and_(params, NotEq(catalog['oid'], oid))
+            sequence = ISequentialIdInfo(context, None)
+            if sequence is not None:
+                oid = sequence.hex_oid
+                catalog = get_utility(ICatalog)
+                params = and_(params, NotEq(catalog['oid'], oid))
         return params