Added adapters to advanced search forms to be able to use Elasticsearch in back-office
Binary file src/pyams_content_es/locales/fr/LC_MESSAGES/pyams_content_es.mo has changed
--- a/src/pyams_content_es/locales/fr/LC_MESSAGES/pyams_content_es.po Sat Jul 18 19:37:58 2020 +0200
+++ b/src/pyams_content_es/locales/fr/LC_MESSAGES/pyams_content_es.po Sun Jul 19 02:11:57 2020 +0200
@@ -5,7 +5,7 @@
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE 1.0\n"
-"POT-Creation-Date: 2019-03-01 13:28+0100\n"
+"POT-Creation-Date: 2020-07-18 20:14+0200\n"
"PO-Revision-Date: 2016-04-21 18:26+0200\n"
"Last-Translator: Thierry Florac <tflorac@ulthar.net>\n"
"Language-Team: French\n"
@@ -24,53 +24,53 @@
msgid "Name of ZODB connection defining indexer connection"
msgstr "Nom de la ZODB définissant les paramètres de connexion"
-#: src/pyams_content_es/zmi/db.py:53
+#: src/pyams_content_es/zmi/db.py:52
msgid "Check index contents..."
msgstr "Vérifier le contenu de l'index"
-#: src/pyams_content_es/zmi/db.py:79
+#: src/pyams_content_es/zmi/db.py:78
#: src/pyams_content_es/zmi/templates/index-ok.pt:9
msgid "Check index contents"
msgstr "Vérifier le contenu de l'index"
-#: src/pyams_content_es/zmi/db.py:63 src/pyams_content_es/zmi/__init__.py:82
+#: src/pyams_content_es/zmi/db.py:62 src/pyams_content_es/zmi/__init__.py:81
#: src/pyams_content_es/zmi/templates/index-ok.pt:19
#: src/pyams_content_es/zmi/templates/index-updater.pt:31
msgid "Close"
msgstr "Fermer"
-#: src/pyams_content_es/zmi/db.py:64
+#: src/pyams_content_es/zmi/db.py:63
#: src/pyams_content_es/zmi/templates/index-ok.pt:24
msgid "Check index"
msgstr "Vérifier l'index"
-#: src/pyams_content_es/zmi/db.py:185
+#: src/pyams_content_es/zmi/db.py:184
msgid "Requested contents have been re-indexed!"
msgstr "Les contenus indiqués ont été ré-indexés !"
-#: src/pyams_content_es/zmi/db.py:130
+#: src/pyams_content_es/zmi/db.py:129
#, python-format
msgid "Loading index data ({})..."
msgstr "Chargement de l'index ({})..."
-#: src/pyams_content_es/zmi/db.py:139
+#: src/pyams_content_es/zmi/db.py:138
#, python-format
msgid "Loading database contents ({})..."
msgstr "Chargement des contenus ({})..."
-#: src/pyams_content_es/zmi/__init__.py:53
+#: src/pyams_content_es/zmi/__init__.py:52
msgid "Update content indexer properties"
msgstr "Propriétés de l'indexeur de contenus"
-#: src/pyams_content_es/zmi/__init__.py:72
+#: src/pyams_content_es/zmi/__init__.py:71
msgid "Test process connection..."
msgstr "Tester la connexion"
-#: src/pyams_content_es/zmi/__init__.py:97
+#: src/pyams_content_es/zmi/__init__.py:96
msgid "Test content indexer process connection"
msgstr "Test de la connexion au processus d'indexation"
-#: src/pyams_content_es/zmi/__init__.py:83
+#: src/pyams_content_es/zmi/__init__.py:82
msgid "Test connection"
msgstr "Tester la connexion"
@@ -85,3 +85,64 @@
#: src/pyams_content_es/zmi/templates/index-updater.pt:35
msgid "Update index"
msgstr "Mettre l'index à jour"
+
+#: src/pyams_content_es/root/zmi/search.py:47
+#: src/pyams_content_es/shared/zmi/search.py:50
+msgid "Search query"
+msgstr "Texte recherché"
+
+#: src/pyams_content_es/root/zmi/search.py:48
+#: src/pyams_content_es/shared/zmi/search.py:51
+msgid ""
+"This query will applies only on title, short name, header and description; "
+"if fulltext search option is selected, this will activate search on all "
+"paragraphs, including attachments"
+msgstr ""
+"La requête s'applique par défaut uniquement au titre, au titre court, au chapô "
+"et à la description ; si l'option \"full-text\" est sélectionnée, cela activera "
+"également la recherche dans le corps des paragraphes, y compris les pièces jointes"
+
+#: src/pyams_content_es/root/zmi/search.py:54
+#: src/pyams_content_es/shared/zmi/search.py:57
+msgid "Fulltext search"
+msgstr "Recherche full-text"
+
+#: src/pyams_content_es/root/zmi/search.py:55
+#: src/pyams_content_es/shared/zmi/search.py:58
+msgid "Search terms in fulltext mode instead of only on contents titles"
+msgstr "Lancer la recherche en mode full-text et non uniquement sur les titres et les chapôs"
+
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:137
+#: src/pyams_content_es/shared/zmi/templates/advanced-search.pt:160
+msgid "Created between"
+msgstr "Créé entre le"
+
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:149
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:175
+#: src/pyams_content_es/shared/zmi/templates/advanced-search.pt:172
+#: src/pyams_content_es/shared/zmi/templates/advanced-search.pt:198
+msgid "and"
+msgstr "et le"
+
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:163
+#: src/pyams_content_es/shared/zmi/templates/advanced-search.pt:186
+msgid "Modified between"
+msgstr "Modifié entre le"
+
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:188
+msgid "Tags"
+msgstr "Tags"
+
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:195
+msgid "Collections"
+msgstr "Collections"
+
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:211
+#: src/pyams_content_es/shared/zmi/templates/advanced-search.pt:230
+msgid "Title"
+msgstr "Titre"
+
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:225
+#: src/pyams_content_es/shared/zmi/templates/advanced-search.pt:244
+msgid "Tab label"
+msgstr "Libellé"
--- a/src/pyams_content_es/locales/pyams_content_es.pot Sat Jul 18 19:37:58 2020 +0200
+++ b/src/pyams_content_es/locales/pyams_content_es.pot Sun Jul 19 02:11:57 2020 +0200
@@ -1,12 +1,12 @@
#
# SOME DESCRIPTIVE TITLE
# This file is distributed under the same license as the PACKAGE package.
-# FIRST AUTHOR <EMAIL@ADDRESS>, 2019.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2020.
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE 1.0\n"
-"POT-Creation-Date: 2019-03-01 13:28+0100\n"
+"POT-Creation-Date: 2020-07-18 20:14+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -24,54 +24,54 @@
msgid "Name of ZODB connection defining indexer connection"
msgstr ""
-#: ./src/pyams_content_es/zmi/db.py:53
+#: ./src/pyams_content_es/zmi/db.py:52
msgid "Check index contents..."
msgstr ""
-#: ./src/pyams_content_es/zmi/db.py:79
+#: ./src/pyams_content_es/zmi/db.py:78
#: ./src/pyams_content_es/zmi/templates/index-ok.pt:9
msgid "Check index contents"
msgstr ""
-#: ./src/pyams_content_es/zmi/db.py:63
-#: ./src/pyams_content_es/zmi/__init__.py:82
+#: ./src/pyams_content_es/zmi/db.py:62
+#: ./src/pyams_content_es/zmi/__init__.py:81
#: ./src/pyams_content_es/zmi/templates/index-ok.pt:19
#: ./src/pyams_content_es/zmi/templates/index-updater.pt:31
msgid "Close"
msgstr ""
-#: ./src/pyams_content_es/zmi/db.py:64
+#: ./src/pyams_content_es/zmi/db.py:63
#: ./src/pyams_content_es/zmi/templates/index-ok.pt:24
msgid "Check index"
msgstr ""
-#: ./src/pyams_content_es/zmi/db.py:185
+#: ./src/pyams_content_es/zmi/db.py:184
msgid "Requested contents have been re-indexed!"
msgstr ""
-#: ./src/pyams_content_es/zmi/db.py:130
+#: ./src/pyams_content_es/zmi/db.py:129
#, python-format
msgid "Loading index data ({})..."
msgstr ""
-#: ./src/pyams_content_es/zmi/db.py:139
+#: ./src/pyams_content_es/zmi/db.py:138
#, python-format
msgid "Loading database contents ({})..."
msgstr ""
-#: ./src/pyams_content_es/zmi/__init__.py:53
+#: ./src/pyams_content_es/zmi/__init__.py:52
msgid "Update content indexer properties"
msgstr ""
-#: ./src/pyams_content_es/zmi/__init__.py:72
+#: ./src/pyams_content_es/zmi/__init__.py:71
msgid "Test process connection..."
msgstr ""
-#: ./src/pyams_content_es/zmi/__init__.py:97
+#: ./src/pyams_content_es/zmi/__init__.py:96
msgid "Test content indexer process connection"
msgstr ""
-#: ./src/pyams_content_es/zmi/__init__.py:83
+#: ./src/pyams_content_es/zmi/__init__.py:82
msgid "Test connection"
msgstr ""
@@ -86,3 +86,61 @@
#: ./src/pyams_content_es/zmi/templates/index-updater.pt:35
msgid "Update index"
msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/search.py:47
+#: ./src/pyams_content_es/shared/zmi/search.py:50
+msgid "Search query"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/search.py:48
+#: ./src/pyams_content_es/shared/zmi/search.py:51
+msgid ""
+"This query will applies only on title, short name, header and description; if"
+" fulltext search option is selected, this will activate search on all "
+"paragraphs, including attachments"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/search.py:54
+#: ./src/pyams_content_es/shared/zmi/search.py:57
+msgid "Fulltext search"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/search.py:55
+#: ./src/pyams_content_es/shared/zmi/search.py:58
+msgid "Search terms in fulltext mode instead of only on contents titles"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:137
+#: ./src/pyams_content_es/shared/zmi/templates/advanced-search.pt:160
+msgid "Created between"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:149
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:175
+#: ./src/pyams_content_es/shared/zmi/templates/advanced-search.pt:172
+#: ./src/pyams_content_es/shared/zmi/templates/advanced-search.pt:198
+msgid "and"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:163
+#: ./src/pyams_content_es/shared/zmi/templates/advanced-search.pt:186
+msgid "Modified between"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:188
+msgid "Tags"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:195
+msgid "Collections"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:211
+#: ./src/pyams_content_es/shared/zmi/templates/advanced-search.pt:230
+msgid "Title"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:225
+#: ./src/pyams_content_es/shared/zmi/templates/advanced-search.pt:244
+msgid "Tab label"
+msgstr ""
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/root/__init__.py Sun Jul 19 02:11:57 2020 +0200
@@ -0,0 +1,19 @@
+#
+# Copyright (c) 2015-2020 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.
+#
+
+"""PyAMS_*** module
+
+"""
+
+__docformat__ = 'restructuredtext'
+
+
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/root/zmi/__init__.py Sun Jul 19 02:11:57 2020 +0200
@@ -0,0 +1,19 @@
+#
+# Copyright (c) 2015-2020 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.
+#
+
+"""PyAMS_*** module
+
+"""
+
+__docformat__ = 'restructuredtext'
+
+
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/root/zmi/search.py Sun Jul 19 02:11:57 2020 +0200
@@ -0,0 +1,154 @@
+#
+# Copyright (c) 2015-2020 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.
+#
+
+"""PyAMS_content_es.root.zmi.search module
+
+"""
+from elasticsearch_dsl import Q, Search
+from pyramid_es import get_client
+from z3c.form.browser.checkbox import SingleCheckBoxFieldWidget
+from zope.intid import IIntIds
+from zope.schema import Bool, TextLine
+
+from pyams_catalog.query import CatalogResultSet
+from pyams_content.root import ISiteRoot
+from pyams_content.root.zmi.search import ISiteRootAdvancedSearchFields, \
+ SiteRootAdvancedSearchForm, SiteRootAdvancedSearchResultsView, SiteRootAdvancedSearchView
+from pyams_content.shared.common import CONTENT_TYPES
+from pyams_form.interfaces.form import ISearchFormFactory
+from pyams_i18n.interfaces import INegotiator
+from pyams_sequence.interfaces import ISequentialIntIds
+from pyams_sequence.reference import get_last_version
+from pyams_skin.interfaces import IContentSearch
+from pyams_skin.layer import IPyAMSLayer
+from pyams_template.template import template_config
+from pyams_utils.adapter import ContextRequestViewAdapter, adapter_config
+from pyams_utils.list import unique
+from pyams_utils.registry import get_utility
+
+
+__docformat__ = 'restructuredtext'
+
+from pyams_content_es import _
+
+
+class IEsSiteRootAdvancedSearchFields(ISiteRootAdvancedSearchFields):
+ """Elasticsearch based site root advanced search fields"""
+
+ query = TextLine(title=_("Search query"),
+ description=_("This query will applies only on title, short name, header "
+ "and description; if fulltext search option is selected, this "
+ "will activate search on all paragraphs, including "
+ "attachments"),
+ required=False)
+
+ fulltext = Bool(title=_("Fulltext search"),
+ description=_("Search terms in fulltext mode instead of only on contents "
+ "titles"),
+ required=False,
+ default=False)
+
+
+@template_config(template='templates/advanced-search.pt', layer=IPyAMSLayer)
+class EsSiteRootAdvancedSearchForm(SiteRootAdvancedSearchForm):
+ """Elasticsearch site root advanced search form"""
+
+ fields_interface = IEsSiteRootAdvancedSearchFields
+
+ @property
+ def fields(self):
+ fields = super(EsSiteRootAdvancedSearchForm, self).fields
+ fields['fulltext'].widgetFactory = SingleCheckBoxFieldWidget
+ return fields
+
+
+@adapter_config(context=(ISiteRoot, IPyAMSLayer, SiteRootAdvancedSearchView),
+ provides=ISearchFormFactory)
+@adapter_config(context=(ISiteRoot, IPyAMSLayer, SiteRootAdvancedSearchResultsView),
+ provides=ISearchFormFactory)
+def site_root_advanced_search_form_factory(context, request, view):
+ """Elasticsearch site root advanced search form factory"""
+ return EsSiteRootAdvancedSearchForm(context, request)
+
+
+@adapter_config(context=(ISiteRoot, IPyAMSLayer, EsSiteRootAdvancedSearchForm),
+ provides=IContentSearch)
+class EsSharedToolAdvancedSearchFormSearchAdapter(ContextRequestViewAdapter):
+ """Elasticsearch site root advanced search form adapter"""
+
+ def get_search_results(self, data):
+ intids = get_utility(IIntIds)
+ params = Q('term', parent_ids=intids.register(self.context))
+ if data.get('content_type'):
+ params &= Q('terms', content_type=data['content_type'])
+ else:
+ params &= Q('terms', content_type=list(CONTENT_TYPES.keys()))
+ # check query settings
+ query = data.get('query')
+ if query:
+ sequence = get_utility(ISequentialIntIds)
+ if query.startswith('+'):
+ params &= Q('term', reference_id=sequence.get_full_oid(query))
+ else:
+ fulltext = data.get('fulltext', False)
+ if fulltext:
+ params &= Q('simple_query_string', query=query)
+ else:
+ negotiator = get_utility(INegotiator)
+ query_params = Q('bool')
+ for lang in {self.request.registry.settings.get('pyramid.default_locale_name',
+ 'en'),
+ self.request.locale_name,
+ negotiator.server_language} | negotiator.offered_languages:
+ query_params |= Q('match', **{'title.{}'.format(lang): query})
+ query_params |= Q('match', **{'short_name.{}'.format(lang): query})
+ query_params |= Q('match', **{'header.{}'.format(lang): query})
+ query_params |= Q('match', **{'description.{}'.format(lang): query})
+ params &= query_params
+ if data.get('owner'):
+ params &= Q('term', owner_id=data['owner'])
+ if data.get('created_after'):
+ params &= Q('range', workflow__created_date={'gte': data['created_after']})
+ if data.get('created_before'):
+ params &= Q('range', workflow__created_date={'lte': data['created_before']})
+ if data.get('modified_after'):
+ params &= Q('range', workflow__modified_date={'gte': data['modified_after']})
+ if data.get('modified_before'):
+ params &= Q('range', workflow__modified_date={'lte': data['modified_before']})
+ if data.get('tags'):
+ tags = [intids.register(term) for term in data['tags']]
+ params &= Q('terms', tags=tags)
+ if data.get('collections'):
+ collections = [intids.register(collection) for collection in data['collections']]
+ params &= Q('terms', collections=collections)
+ # get ES client and create search object
+ client = get_client(self.request)
+ search = Search(using=client.es, index=client.index) \
+ .params(request_timeout=30) \
+ .query(params) \
+ .source(['internal_id'])
+ sort_values = [{
+ 'workflow.modified_date': {
+ 'unmapped_type': 'date',
+ 'order': 'desc'
+ }
+ }]
+ if query:
+ sort_values.insert(0, {
+ '_score': {
+ 'order': 'desc'
+ }
+ })
+ search = search.sort(*sort_values)[:999]
+ # extract results
+ return unique(map(get_last_version,
+ CatalogResultSet([result.internal_id for result in search])))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/root/zmi/templates/advanced-search.pt Sun Jul 19 02:11:57 2020 +0200
@@ -0,0 +1,246 @@
+<div class="ams-widget" i18n:domain="pyams_content_es">
+ <header>
+ <span tal:condition="view.widget_icon_class | nothing"
+ class="widget-icon"><i tal:attributes="class view.widget_icon_class"></i>
+ </span>
+ <h2 tal:content="view.legend"></h2>
+ <tal:var content="structure provider:pyams.widget_title" />
+ <tal:var content="structure provider:pyams.toolbar" />
+ </header>
+ <div class="widget-body no-padding">
+ <div tal:define="prefix provider:form_prefix"
+ tal:replace="structure prefix">Form prefix</div>
+ <tal:var content="structure provider:content_help" />
+ <form method="post"
+ data-async
+ tal:attributes="id view.id;
+ name view.name;
+ action view.get_form_action();
+ method view.method;
+ enctype view.enctype;
+ acceptCharset view.acceptCharset;
+ accept view.accept;
+ autocomplete view.autocomplete;
+ class view.css_class;
+ data-ams-data tales:object_data(view);
+ data-ams-form-handler view.get_ajax_handler() | nothing;
+ data-ams-form-options view.get_form_options() | nothing;
+ data-ams-form-submit-target view.form_target | nothing;
+ data-ams-form-download-target view.download_target | nothing;
+ data-ams-warn-on-change view.warn_on_change;">
+ <div class="modal-viewport">
+ <fieldset>
+ <div class="widgets-prefix"
+ tal:define="prefix provider:widgets_prefix"
+ tal:condition="prefix"
+ tal:content="structure prefix">Widgets prefix</div>
+ <tal:loop repeat="group view.groups">
+ <fieldset tal:define="legend group.legend"
+ tal:omit-tag="not:legend"
+ tal:attributes="class 'bordered' if group.bordered else None">
+ <tal:if condition="group.checkbox_switch">
+ <legend data-ams-checker-value="selected"
+ tal:condition="legend"
+ tal:attributes="class group.css_class;
+ data-ams-checker-fieldname '{0}:list'.format(group.checkbox_widget.name);
+ data-ams-checker-readonly 'readonly' if group.checkbox_widget.mode == 'display' else None;
+ data-ams-checker-marker '{0}-empty-marker'.format(group.checkbox_widget.name);
+ data-ams-checker-state group.checker_state;">
+ <label tal:content="legend">Legend</label>
+ </legend>
+ </tal:if>
+ <tal:if condition="not:group.checkbox_switch">
+ <legend tal:condition="legend"
+ tal:content="legend"
+ tal:attributes="class group.css_class;
+ data-ams-switcher-state group.switcher_state;">Legend</legend>
+ </tal:if>
+ <tal:var define="help group.help" condition="help">
+ <div class=""
+ tal:define="html import:pyams_utils.text.text_to_html;
+ i18n_help html(request.localizer.translate(help));"
+ tal:content="structure i18n_help"></div>
+ </tal:var>
+ <div class="form-group" tal:define="widget view.widgets['query']">
+ <label class="control-label col-md-3">
+ <span>
+ <tal:var content="widget.label" />
+ <i class="fa fa-question-circle hint" title="Input hint"
+ tal:define="description widget.field.description"
+ tal:condition="description"
+ tal:attributes="title description;
+ data-ams-hint-html '<' in description;"></i>
+ </span>
+ </label>
+ <div class="col-md-9">
+ <div class="input"
+ tal:attributes="class widget.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ <div class="input padding-10"
+ tal:define="fulltext view.widgets['fulltext']"
+ tal:attributes="class fulltext.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(fulltext.field.getName())">
+ <input tal:replace="structure fulltext.render()" />
+ </div>
+ </div>
+ </div>
+ <div class="form-group">
+ <tal:var define="widget view.widgets['owner']">
+ <label class="control-label col-md-3">
+ <span>
+ <tal:var content="widget.label" />
+ <i class="fa fa-question-circle hint" title="Input hint"
+ tal:define="description widget.field.description"
+ tal:condition="description"
+ tal:attributes="title description;
+ data-ams-hint-html '<' in description;"></i>
+ </span>
+ </label>
+ <div class="col-md-4">
+ <div class="input"
+ tal:attributes="class widget.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </div>
+ </tal:var>
+ </div>
+ <div class="form-group">
+ <tal:var define="widget view.widgets['content_type']">
+ <label class="control-label col-md-3">
+ <span>
+ <tal:var content="widget.label" />
+ <i class="fa fa-question-circle hint" title="Input hint"
+ tal:define="description widget.field.description"
+ tal:condition="description"
+ tal:attributes="title description;
+ data-ams-hint-html '<' in description;"></i>
+ </span>
+ </label>
+ <div class="col-md-4">
+ <div class="input"
+ tal:attributes="class widget.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </div>
+ </tal:var>
+ </div>
+ <div class="form-group">
+ <label class="control-label col-md-3">
+ <span i18n:translate="">Created between</span>
+ </label>
+ <div class="col-md-4">
+ <div class="input"
+ tal:define="widget view.widgets['created_after']"
+ tal:attributes="class widget.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </div>
+ <div class="control-label col-md-1 text-align-center">
+ <i18n:var translate=""> and </i18n:var>
+ </div>
+ <div class="col-md-4">
+ <div class="input"
+ tal:define="widget view.widgets['created_before']"
+ tal:attributes="class widget.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </div>
+ </div>
+ <div class="form-group">
+ <label class="control-label col-md-3">
+ <span i18n:translate="">Modified between</span>
+ </label>
+ <div class="col-md-4">
+ <div class="input"
+ tal:define="widget view.widgets['modified_after']"
+ tal:attributes="class widget.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </div>
+ <div class="control-label col-md-1 text-align-center">
+ <i18n:var translate=""> and </i18n:var>
+ </div>
+ <div class="col-md-4">
+ <div class="input"
+ tal:define="widget view.widgets['modified_before']"
+ tal:attributes="class widget.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </div>
+ </div>
+ <fieldset tal:condition="'tags' in view.widgets">
+ <legend class="switcher" i18n:translate="">Tags</legend>
+ <div class="input"
+ tal:define="widget view.widgets['tags']">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </fieldset>
+ <fieldset tal:condition="'collections' in view.widgets">
+ <legend class="switcher" i18n:translate="">Collections</legend>
+ <div class="input"
+ tal:define="widget view.widgets['collections']">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </fieldset>
+ </fieldset>
+ </tal:loop>
+ <div class="widgets-suffix"
+ tal:define="suffix provider:widgets_suffix"
+ tal:condition="suffix"
+ tal:content="structure suffix">Widgets suffix</div>
+ <div class="subforms"
+ tal:condition="view.subforms">
+ <fieldset tal:define="title view.subforms_legend"
+ tal:omit-tag="not:title">
+ <legend tal:condition="title" tal:content="title" i18n:translate="">Title</legend>
+ <tal:loop repeat="subform view.subforms">
+ <tal:var replace="structure subform.render()" />
+ </tal:loop>
+ </fieldset>
+ </div>
+ <div class="tabforms"
+ tal:condition="view.tabforms">
+ <ul class="nav nav-tabs">
+ <li tal:repeat="tabform view.tabforms"
+ tal:attributes="class 'small {active} {errors}'.format(active='active' if repeat['tabform'].start() else '',
+ errors='state-error' if tabform.widgets.errors else '')">
+ <a data-toggle="tab"
+ tal:attributes="href string:#${tabform.id}"
+ tal:content="tabform.tab_label" i18n:translate="">Tab label</a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane fade in"
+ tal:repeat="tabform view.tabforms"
+ tal:attributes="id tabform.id;
+ class 'tab-pane {active} fade in'.format(active='active' if repeat['tabform'].start() else '');"
+ tal:content="structure tabform.render()"></div>
+ </div>
+ </div>
+ </fieldset>
+ </div>
+ <footer>
+ <button tal:repeat="action view.actions.values()"
+ tal:replace="structure action.render()">Action</button>
+ </footer>
+ </form>
+ <div tal:define="prefix provider:form_suffix"
+ tal:replace="structure prefix">Form suffix</div>
+ </div>
+</div>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/shared/zmi/__init__.py Sun Jul 19 02:11:57 2020 +0200
@@ -0,0 +1,13 @@
+#
+# Copyright (c) 2015-2020 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.
+#
+
+
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/shared/zmi/search.py Sun Jul 19 02:11:57 2020 +0200
@@ -0,0 +1,168 @@
+#
+# Copyright (c) 2015-2020 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.
+#
+
+"""PyAMS_content_es.zmi.search module
+
+"""
+
+from elasticsearch_dsl import Q, Search
+from pyramid_es import get_client
+from z3c.form.browser.checkbox import SingleCheckBoxFieldWidget
+from zope.dublincore.interfaces import IZopeDublinCore
+from zope.intid import IIntIds
+from zope.schema import Bool, TextLine
+
+from pyams_catalog.query import CatalogResultSet
+from pyams_content.shared.common import CONTENT_TYPES, IBaseSharedTool
+from pyams_content.shared.common.zmi.search import ISharedToolAdvancedSearchFields, \
+ SharedToolAdvancedSearchForm, SharedToolAdvancedSearchResultsView, \
+ SharedToolAdvancedSearchView
+from pyams_form.interfaces.form import ISearchFormFactory
+from pyams_i18n.interfaces import INegotiator
+from pyams_sequence.interfaces import ISequentialIntIds
+from pyams_sequence.reference import get_last_version
+from pyams_skin.interfaces import IContentSearch
+from pyams_skin.layer import IPyAMSLayer
+from pyams_template.template import template_config
+from pyams_utils.adapter import ContextRequestViewAdapter, adapter_config
+from pyams_utils.list import unique
+from pyams_utils.registry import get_utility
+from pyams_workflow.interfaces import IWorkflowVersions
+
+
+__docformat__ = 'restructuredtext'
+
+from pyams_content_es import _
+
+
+class IEsSharedToolAdvancedSearchFields(ISharedToolAdvancedSearchFields):
+ """Elasticsearch based shared tool advanced search fields"""
+
+ query = TextLine(title=_("Search query"),
+ description=_("This query will applies only on title, short name, header "
+ "and description; if fulltext search option is selected, this "
+ "will activate search on all paragraphs, including "
+ "attachments"),
+ required=False)
+
+ fulltext = Bool(title=_("Fulltext search"),
+ description=_("Search terms in fulltext mode instead of only on contents "
+ "titles"),
+ required=False,
+ default=False)
+
+
+@template_config(template='templates/advanced-search.pt', layer=IPyAMSLayer)
+class EsSharedToolAdvancedSearchForm(SharedToolAdvancedSearchForm):
+ """Elasticsearch shared tool advanced search form"""
+
+ fields_interface = IEsSharedToolAdvancedSearchFields
+
+ @property
+ def fields(self):
+ fields = super(EsSharedToolAdvancedSearchForm, self).fields
+ fields['fulltext'].widgetFactory = SingleCheckBoxFieldWidget
+ return fields
+
+
+@adapter_config(context=(IBaseSharedTool, IPyAMSLayer, SharedToolAdvancedSearchView),
+ provides=ISearchFormFactory)
+@adapter_config(context=(IBaseSharedTool, IPyAMSLayer, SharedToolAdvancedSearchResultsView),
+ provides=ISearchFormFactory)
+def shared_tool_advanced_search_form_factory(context, request, view):
+ """Elasticsearch advanced search form factory"""
+ return EsSharedToolAdvancedSearchForm(context, request)
+
+
+@adapter_config(context=(IBaseSharedTool, IPyAMSLayer, EsSharedToolAdvancedSearchForm),
+ provides=IContentSearch)
+class EsSharedToolAdvancedSearchFormSearchAdapter(ContextRequestViewAdapter):
+ """Elasticsearch shared tool advanced search form adapter"""
+
+ def get_search_results(self, data):
+ intids = get_utility(IIntIds)
+ params = Q('term', parent_ids=intids.register(self.context)) & \
+ Q('terms', content_type=list(CONTENT_TYPES.keys()))
+ # check query settings
+ query = data.get('query')
+ if query:
+ sequence = get_utility(ISequentialIntIds)
+ if query.startswith('+'):
+ params &= Q('term', reference_id=sequence.get_full_oid(query))
+ else:
+ fulltext = data.get('fulltext', False)
+ if fulltext:
+ params &= Q('simple_query_string', query=query)
+ else:
+ negotiator = get_utility(INegotiator)
+ query_params = Q('bool')
+ for lang in {self.request.registry.settings.get('pyramid.default_locale_name',
+ 'en'),
+ self.request.locale_name,
+ negotiator.server_language} | negotiator.offered_languages:
+ query_params |= Q('match', **{'title.{}'.format(lang): query})
+ query_params |= Q('match', **{'short_name.{}'.format(lang): query})
+ query_params |= Q('match', **{'header.{}'.format(lang): query})
+ query_params |= Q('match', **{'description.{}'.format(lang): query})
+ params &= query_params
+ if data.get('owner'):
+ params &= Q('term', owner_id=data['owner'])
+ if data.get('status'):
+ params &= Q('term', workflow__status=data['status'])
+ if data.get('data_type'):
+ params &= Q('term', data_type=data['data_type'])
+ if data.get('created_after'):
+ params &= Q('range', workflow__created_date={'gte': data['created_after']})
+ if data.get('created_before'):
+ params &= Q('range', workflow__created_date={'lte': data['created_before']})
+ if data.get('modified_after'):
+ params &= Q('range', workflow__modified_date={'gte': data['modified_after']})
+ if data.get('modified_before'):
+ params &= Q('range', workflow__modified_date={'lte': data['modified_before']})
+ if data.get('tags'):
+ tags = [intids.register(term) for term in data['tags']]
+ params &= Q('terms', tags=tags)
+ if data.get('themes'):
+ themes = [intids.register(term) for term in data['themes']]
+ params &= Q('terms', themes__terms=themes)
+ if data.get('collections'):
+ collections = [intids.register(collection) for collection in data['collections']]
+ params &= Q('terms', collections=collections)
+ # get ES client and create search object
+ client = get_client(self.request)
+ search = Search(using=client.es, index=client.index) \
+ .params(request_timeout=30) \
+ .query(params) \
+ .source(['internal_id'])
+ sort_values = [{
+ 'workflow.modified_date': {
+ 'unmapped_type': 'date',
+ 'order': 'desc'
+ }
+ }]
+ if query:
+ sort_values.insert(0, {
+ '_score': {
+ 'order': 'desc'
+ }
+ })
+ search = search.sort(*sort_values)[:999]
+ # extract results
+ if data.get('status'):
+ # if status is specified, only extract last version in given status
+ return unique(map(lambda x: sorted(IWorkflowVersions(x).get_versions(data['status']),
+ key=lambda y: IZopeDublinCore(y).modified)[0],
+ CatalogResultSet([result.internal_id for result in search])))
+ else:
+ # otherwise, extract last absolute version
+ return unique(map(get_last_version,
+ CatalogResultSet([result.internal_id for result in search])))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/shared/zmi/templates/advanced-search.pt Sun Jul 19 02:11:57 2020 +0200
@@ -0,0 +1,265 @@
+<div class="ams-widget" i18n:domain="pyams_content_es">
+ <header>
+ <span tal:condition="view.widget_icon_class | nothing"
+ class="widget-icon"><i tal:attributes="class view.widget_icon_class"></i>
+ </span>
+ <h2 tal:content="view.legend"></h2>
+ <tal:var content="structure provider:pyams.widget_title" />
+ <tal:var content="structure provider:pyams.toolbar" />
+ </header>
+ <div class="widget-body no-padding">
+ <div tal:define="prefix provider:form_prefix"
+ tal:replace="structure prefix">Form prefix</div>
+ <tal:var content="structure provider:content_help" />
+ <form method="post"
+ data-async
+ tal:attributes="id view.id;
+ name view.name;
+ action view.get_form_action();
+ method view.method;
+ enctype view.enctype;
+ acceptCharset view.acceptCharset;
+ accept view.accept;
+ autocomplete view.autocomplete;
+ class view.css_class;
+ data-ams-data tales:object_data(view);
+ data-ams-form-handler view.get_ajax_handler() | nothing;
+ data-ams-form-options view.get_form_options() | nothing;
+ data-ams-form-submit-target view.form_target | nothing;
+ data-ams-form-download-target view.download_target | nothing;
+ data-ams-warn-on-change view.warn_on_change;">
+ <div class="modal-viewport">
+ <fieldset>
+ <div class="widgets-prefix"
+ tal:define="prefix provider:widgets_prefix"
+ tal:condition="prefix"
+ tal:content="structure prefix">Widgets prefix</div>
+ <tal:loop repeat="group view.groups">
+ <fieldset tal:define="legend group.legend"
+ tal:omit-tag="not:legend"
+ tal:attributes="class 'bordered' if group.bordered else None">
+ <tal:if condition="group.checkbox_switch">
+ <legend data-ams-checker-value="selected"
+ tal:condition="legend"
+ tal:attributes="class group.css_class;
+ data-ams-checker-fieldname '{0}:list'.format(group.checkbox_widget.name);
+ data-ams-checker-readonly 'readonly' if group.checkbox_widget.mode == 'display' else None;
+ data-ams-checker-marker '{0}-empty-marker'.format(group.checkbox_widget.name);
+ data-ams-checker-state group.checker_state;">
+ <label tal:content="legend">Legend</label>
+ </legend>
+ </tal:if>
+ <tal:if condition="not:group.checkbox_switch">
+ <legend tal:condition="legend"
+ tal:content="legend"
+ tal:attributes="class group.css_class;
+ data-ams-switcher-state group.switcher_state;">Legend</legend>
+ </tal:if>
+ <tal:var define="help group.help" condition="help">
+ <div class=""
+ tal:define="html import:pyams_utils.text.text_to_html;
+ i18n_help html(request.localizer.translate(help));"
+ tal:content="structure i18n_help"></div>
+ </tal:var>
+ <div class="form-group" tal:define="widget view.widgets['query']">
+ <label class="control-label col-md-3">
+ <span>
+ <tal:var content="widget.label" />
+ <i class="fa fa-question-circle hint" title="Input hint"
+ tal:define="description widget.field.description"
+ tal:condition="description"
+ tal:attributes="title description;
+ data-ams-hint-html '<' in description;"></i>
+ </span>
+ </label>
+ <div class="col-md-9">
+ <div class="input"
+ tal:attributes="class widget.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ <div class="input padding-10"
+ tal:define="fulltext view.widgets['fulltext']"
+ tal:attributes="class fulltext.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(fulltext.field.getName())">
+ <input tal:replace="structure fulltext.render()" />
+ </div>
+ </div>
+ </div>
+ <div class="form-group">
+ <tal:var define="widget view.widgets['owner']">
+ <label class="control-label col-md-3">
+ <span>
+ <tal:var content="widget.label" />
+ <i class="fa fa-question-circle hint" title="Input hint"
+ tal:define="description widget.field.description"
+ tal:condition="description"
+ tal:attributes="title description;
+ data-ams-hint-html '<' in description;"></i>
+ </span>
+ </label>
+ <div class="col-md-4">
+ <div class="input"
+ tal:attributes="class widget.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </div>
+ </tal:var>
+ </div>
+ <div class="form-group">
+ <tal:var define="widget view.widgets['status']">
+ <label class="control-label col-md-3">
+ <span>
+ <tal:var content="widget.label" />
+ <i class="fa fa-question-circle hint" title="Input hint"
+ tal:define="description widget.field.description"
+ tal:condition="description"
+ tal:attributes="title description;
+ data-ams-hint-html '<' in description;"></i>
+ </span>
+ </label>
+ <div class="col-md-4">
+ <div class="input"
+ tal:attributes="class widget.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </div>
+ </tal:var>
+ <tal:if define="widget view.widgets.get('data_type')"
+ condition="widget">
+ <tal:var >
+ <label class="control-label col-md-1">
+ <span>
+ <tal:var content="widget.label" />
+ <i class="fa fa-question-circle hint" title="Input hint"
+ tal:define="description widget.field.description"
+ tal:condition="description"
+ tal:attributes="title description;
+ data-ams-hint-html '<' in description;"></i>
+ </span>
+ </label>
+ <div class="col-md-4">
+ <div class="input"
+ tal:attributes="class widget.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </div>
+ </tal:var>
+ </tal:if>
+ </div>
+ <div class="form-group">
+ <label class="control-label col-md-3">
+ <span i18n:translate="">Created between</span>
+ </label>
+ <div class="col-md-4">
+ <div class="input"
+ tal:define="widget view.widgets['created_after']"
+ tal:attributes="class widget.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </div>
+ <div class="control-label col-md-1 text-align-center">
+ <i18n:var translate=""> and </i18n:var>
+ </div>
+ <div class="col-md-4">
+ <div class="input"
+ tal:define="widget view.widgets['created_before']"
+ tal:attributes="class widget.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </div>
+ </div>
+ <div class="form-group">
+ <label class="control-label col-md-3">
+ <span i18n:translate="">Modified between</span>
+ </label>
+ <div class="col-md-4">
+ <div class="input"
+ tal:define="widget view.widgets['modified_after']"
+ tal:attributes="class widget.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </div>
+ <div class="control-label col-md-1 text-align-center">
+ <i18n:var translate=""> and </i18n:var>
+ </div>
+ <div class="col-md-4">
+ <div class="input"
+ tal:define="widget view.widgets['modified_before']"
+ tal:attributes="class widget.widget_css_class | default;
+ data-ams-data tales:object_data(widget);
+ data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </div>
+ </div>
+ <tal:loop repeat="fieldname ('tags', 'themes', 'collections')">
+ <fieldset tal:condition="fieldname in view.widgets">
+ <tal:var define="widget view.widgets[fieldname]">
+ <legend class="switcher">${widget.label}</legend>
+ <div class="input">
+ <input tal:replace="structure widget.render()" />
+ </div>
+ </tal:var>
+ </fieldset>
+ </tal:loop>
+ </fieldset>
+ </tal:loop>
+ <div class="widgets-suffix"
+ tal:define="suffix provider:widgets_suffix"
+ tal:condition="suffix"
+ tal:content="structure suffix">Widgets suffix</div>
+ <div class="subforms"
+ tal:condition="view.subforms">
+ <fieldset tal:define="title view.subforms_legend"
+ tal:omit-tag="not:title">
+ <legend tal:condition="title" tal:content="title" i18n:translate="">Title</legend>
+ <tal:loop repeat="subform view.subforms">
+ <tal:var replace="structure subform.render()" />
+ </tal:loop>
+ </fieldset>
+ </div>
+ <div class="tabforms"
+ tal:condition="view.tabforms">
+ <ul class="nav nav-tabs">
+ <li tal:repeat="tabform view.tabforms"
+ tal:attributes="class 'small {active} {errors}'.format(active='active' if repeat['tabform'].start() else '',
+ errors='state-error' if tabform.widgets.errors else '')">
+ <a data-toggle="tab"
+ tal:attributes="href string:#${tabform.id}"
+ tal:content="tabform.tab_label" i18n:translate="">Tab label</a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane fade in"
+ tal:repeat="tabform view.tabforms"
+ tal:attributes="id tabform.id;
+ class 'tab-pane {active} fade in'.format(active='active' if repeat['tabform'].start() else '');"
+ tal:content="structure tabform.render()"></div>
+ </div>
+ </div>
+ </fieldset>
+ </div>
+ <footer>
+ <button tal:repeat="action view.actions.values()"
+ tal:replace="structure action.render()">Action</button>
+ </footer>
+ </form>
+ <div tal:define="prefix provider:form_suffix"
+ tal:replace="structure prefix">Form suffix</div>
+ </div>
+</div>