From 87d3f8c3223c998584fd81fe590d715bb27ff775 Mon Sep 17 00:00:00 2001 From: Alec Mitchell Date: Sun, 31 Jan 2021 10:20:43 -0800 Subject: [PATCH 01/10] Add a StaticCatalogVocabularyFactory that allows creating simple vocabularies with preset queries to use with SelectWidget, AJAXSelectWidget, etc. --- plone/app/vocabularies/catalog.py | 68 +++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/plone/app/vocabularies/catalog.py b/plone/app/vocabularies/catalog.py index e279c5b..7adbca9 100644 --- a/plone/app/vocabularies/catalog.py +++ b/plone/app/vocabularies/catalog.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import json from BTrees.IIBTree import intersection from plone.app.layout.navigation.root import getNavigationRootObject from plone.app.vocabularies import SlicableVocabulary @@ -7,6 +8,7 @@ from plone.app.vocabularies.terms import safe_simplevocabulary_from_values from plone.app.vocabularies.utils import parseQueryString from plone.memoize.instance import memoize +from plone.memoize import request from plone.registry.interfaces import IRegistry from plone.uuid.interfaces import IUUID from Products.CMFCore.utils import getToolByName @@ -23,6 +25,12 @@ from zope.schema.vocabulary import SimpleVocabulary from zope.component.hooks import getSite +try: + from zope.globalrequest import getRequest +except ImportError: + def getRequest(): + return None + import itertools import os import six @@ -588,6 +596,21 @@ def __getitem__(self, index): else: return self.createTerm(self.brains[index], None) + def getTerm(self, value): + if not isinstance(value, six.string_types): + # here we have a content and fetch the uuid as hex value + value = IUUID(value) + query = {'UID': value} + brains = self.catalog(**query) + for b in brains: + return SimpleTerm( + title=u'{} ({})'.format(b.Title, b.getPath()), + token=b.UID, + value=b.UID, + ) + + getTermByToken = getTerm + @implementer(IVocabularyFactory) class CatalogVocabularyFactory(object): @@ -646,6 +669,51 @@ def __call__(self, context, query=None): return CatalogVocabulary.fromItems(parsed, context) +def request_query_cache_key(func, vocab): + return json.dumps((vocab.query, vocab.default_text_search_index)) + + +class StaticCatalogVocabulary(CatalogVocabulary): + """Catalog Vocabulary for static lists of content based on a fixed query. + """ + + def __init__(self, query, default_text_search_index='SearchableText'): + self.query = query + self.default_text_search_index = default_text_search_index + + @staticmethod + def get_request(): + return getRequest() + + @property + @request.cache(get_key=request_query_cache_key, get_request='self.get_request()') + def brains(self): + return self.catalog(**self.query) + + @classmethod + def createTerm(cls, brain, context): + return SimpleTerm( + value=brain.UID, token=brain.UID, + title='{} ({})'.format(brain.Title, brain.getPath()) + ) + + def search(self, query): + """Required by plone.app.content.browser.vocabulary for simple queryable + vocabs, e.g. for AJAXSelectWidget.""" + if not query.endswith(' '): + query += '*' + query = {self.default_text_search_index: query} + query.update(self.query) + brains = self.catalog(**query) + return SimpleVocabulary([ + SimpleTerm( + title=u'{} ({})'.format(b.Title, b.getPath()), + token=b.UID, + value=b.UID, + ) for b in brains + ]) + + @implementer(ISource) class CatalogSource(object): """Catalog source for use with Choice fields. From fdff0d446ca705f93ab5c276ee9675a968ab7836 Mon Sep 17 00:00:00 2001 From: Alec Mitchell Date: Sun, 31 Jan 2021 18:25:20 -0800 Subject: [PATCH 02/10] Document new vocabulary. Add vocabulary title customization. --- plone/app/vocabularies/catalog.py | 103 ++++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 21 deletions(-) diff --git a/plone/app/vocabularies/catalog.py b/plone/app/vocabularies/catalog.py index 7adbca9..11ce8e5 100644 --- a/plone/app/vocabularies/catalog.py +++ b/plone/app/vocabularies/catalog.py @@ -603,11 +603,7 @@ def getTerm(self, value): query = {'UID': value} brains = self.catalog(**query) for b in brains: - return SimpleTerm( - title=u'{} ({})'.format(b.Title, b.getPath()), - token=b.UID, - value=b.UID, - ) + return self.createTerm(b, None) getTermByToken = getTerm @@ -670,47 +666,112 @@ def __call__(self, context, query=None): def request_query_cache_key(func, vocab): - return json.dumps((vocab.query, vocab.default_text_search_index)) + return json.dumps([ + vocab.query, vocab.text_search_index, vocab.title_template + ]) class StaticCatalogVocabulary(CatalogVocabulary): - """Catalog Vocabulary for static lists of content based on a fixed query. + """Catalog Vocabulary for static queries of content based on a fixed query. + Intended for use in a zope.schema, e.g.: + + my_relation = RelationChoice( + title="Custom Relation", + vocabulary=StaticCatalogVocabulary({ + "portal_type": "Document", + "review_state": "published", + }) + ) + + Can be used with TextLine values (to store a UUID) or + Relation/RelationChoice values (to create a z3c.relationfield style + relation). This vocabulary will work with a variety of selection widgets, + and provides a text search method to work with the + plone.app.z3cform.widget.AjaxSelectWidget. + + This vocabulary can be used to make a named vocabulary with a factory + function: + + from zope.interface import provider + from zope.schema.interfaces import IVocabularyFactory + + + @provider(IVocabularyFactory) + def my_vocab_factory(context): + return StaticCatalogVocabulary({ + 'portal_type': 'Event', + 'path': '/'.join(context.getPhysicalPath()) + }) + + The default item title looks like "Object Title (/path/to/object)", but this + can be customized by passing a format string as the "title_template" + parameter. The format string has "brain" and "path" arguments available: + + MY_VOCABULARY = StaticCatalogVocabulary( + {'portal_type': 'Event'}, + title_template="{brain.Type}: {brain.Title} at {path}" + ) + + When using this vocabulary for dynamic queries, e.g. with the + AjaxSelectWidget, you can customize the index searched using the + "text_search_index" parameter. By default it uses the "SearchableText" + index, but you could have your vocabulary search on "Title" instead: + + from plone.autoform import directives + from plone.app.z3cform.widget import AjaxSelectFieldWidget + + + directives.widget( + 'my_relation', + AjaxSelectFieldWidget, + vocabulary=StaticCatalogVocabulary( + {'portal_type': 'Event'}, + text_search_index="Title", + title_template="{brain.Type}: {brain.Title} at {path}" + ) + ) + + This vocabulary lazily caches the result set for the base query on the + request to optimize performance. """ + title_template = "{brain.Title} ({path})" + text_search_index = "SearchableText" - def __init__(self, query, default_text_search_index='SearchableText'): + def __init__(self, query, text_search_index=None, + title_template=None): self.query = query - self.default_text_search_index = default_text_search_index + if text_search_index: + self.text_search_index = text_search_index + if title_template: + self.title_template = title_template @staticmethod def get_request(): return getRequest() @property - @request.cache(get_key=request_query_cache_key, get_request='self.get_request()') + @request.cache(get_key=request_query_cache_key, get_request="self.get_request()") def brains(self): return self.catalog(**self.query) - @classmethod - def createTerm(cls, brain, context): + def createTerm(self, brain, context=None): return SimpleTerm( value=brain.UID, token=brain.UID, - title='{} ({})'.format(brain.Title, brain.getPath()) + title=self.title_template.format( + brain=brain, path=brain.getPath() + ) ) def search(self, query): """Required by plone.app.content.browser.vocabulary for simple queryable vocabs, e.g. for AJAXSelectWidget.""" - if not query.endswith(' '): - query += '*' - query = {self.default_text_search_index: query} + if not query.endswith(" "): + query += "*" + query = {self.text_search_index: query} query.update(self.query) brains = self.catalog(**query) return SimpleVocabulary([ - SimpleTerm( - title=u'{} ({})'.format(b.Title, b.getPath()), - token=b.UID, - value=b.UID, - ) for b in brains + self.createTerm(b) for b in brains ]) From 224f178043bd70cd70abd91ca4168da8692cf17a Mon Sep 17 00:00:00 2001 From: Alec Mitchell Date: Sun, 28 Feb 2021 17:42:52 -0800 Subject: [PATCH 03/10] Strip site/nav_root path from display path in default StaticCatalogVocabulary title. Add tests. --- plone/app/vocabularies/catalog.py | 78 +++++++++++++++++++++++++++- plone/app/vocabularies/tests/base.py | 3 ++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/plone/app/vocabularies/catalog.py b/plone/app/vocabularies/catalog.py index 11ce8e5..538d506 100644 --- a/plone/app/vocabularies/catalog.py +++ b/plone/app/vocabularies/catalog.py @@ -14,6 +14,7 @@ from Products.CMFCore.utils import getToolByName from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile from Products.ZCTextIndex.ParseTree import ParseError +from z3c.formwidget.query.interfaces import IQuerySource from zope.browser.interfaces import ITerms from zope.component import queryUtility from zope.interface import implementer @@ -671,6 +672,7 @@ def request_query_cache_key(func, vocab): ]) +@implementer(IQuerySource, IVocabularyFactory) class StaticCatalogVocabulary(CatalogVocabulary): """Catalog Vocabulary for static queries of content based on a fixed query. Intended for use in a zope.schema, e.g.: @@ -733,6 +735,62 @@ def my_vocab_factory(context): This vocabulary lazily caches the result set for the base query on the request to optimize performance. + + Here are some doctests:: + + >>> from plone.app.vocabularies.tests.base import Brain + >>> from plone.app.vocabularies.tests.base import DummyCatalog + >>> from plone.app.vocabularies.tests.base import create_context + >>> from plone.app.vocabularies.tests.base import DummyTool + + >>> context = create_context() + + >>> catalog = DummyCatalog(('/1234', '/2345')) + >>> context.portal_catalog = catalog + + >>> tool = DummyTool('portal_url') + >>> def getPortalPath(): + ... return '/' + >>> tool.getPortalPath = getPortalPath + >>> context.portal_url = tool + + >>> vocab = StaticCatalogVocabulary({'portal_type': ['Document']}) + >>> vocab + + + >>> vocab.search('') + + >>> list(vocab.search('')) + [] + + >>> vocab.search('foo') + + + >>> [(t.title, t.value) for t in vocab.search('foo')] + [('BrainTitle (/1234)', '/1234'), ('BrainTitle (/2345)', '/2345')] + + We strip out the site path from the rendered path in the title template: + + >>> catalog = DummyCatalog(('/site/1234', '/site/2345')) + >>> context.portal_catalog = catalog + >>> vocab = StaticCatalogVocabulary({'portal_type': ['Document']}) + >>> [(t.title, t.value) for t in vocab.search('bar')] + [('BrainTitle (/site/1234)', '/site/1234'), + ('BrainTitle (/site/2345)', '/site/2345')] + + >>> context.__name__ = 'site' + >>> vocab = StaticCatalogVocabulary({'portal_type': ['Document']}) + >>> [(t.title, t.value) for t in vocab.search('bar')] + [('BrainTitle (/1234)', '/site/1234'), + ('BrainTitle (/2345)', '/site/2345')] + + The title template can be customized: + + >>> vocab.title_template = "{url} {brain.UID} - {brain.Title} {path}" + >>> [(t.title, t.value) for t in vocab.search('bar')] + [('proto:/site/1234 /site/1234 - BrainTitle /1234', '/site/1234'), + ('proto:/site/2345 /site/2345 - BrainTitle /2345', '/site/2345')] + """ title_template = "{brain.Title} ({path})" text_search_index = "SearchableText" @@ -745,6 +803,20 @@ def __init__(self, query, text_search_index=None, if title_template: self.title_template = title_template + @property + @memoize + def nav_root_path(self): + site = getSite() + nav_root = getNavigationRootObject(site, site) + return '/'.join(nav_root.getPhysicalPath()) + + def get_brain_path(self, brain): + nav_root_path = self.nav_root_path + path = brain.getPath() + if path.startswith(nav_root_path): + path = path[len(nav_root_path):] + return path + @staticmethod def get_request(): return getRequest() @@ -758,13 +830,17 @@ def createTerm(self, brain, context=None): return SimpleTerm( value=brain.UID, token=brain.UID, title=self.title_template.format( - brain=brain, path=brain.getPath() + brain=brain, path=self.get_brain_path(brain), + url=brain.getURL(), ) ) def search(self, query): """Required by plone.app.content.browser.vocabulary for simple queryable vocabs, e.g. for AJAXSelectWidget.""" + if not query: + return SimpleVocabulary([]) + if not query.endswith(" "): query += "*" query = {self.text_search_index: query} diff --git a/plone/app/vocabularies/tests/base.py b/plone/app/vocabularies/tests/base.py index aaec8f6..7c3136d 100644 --- a/plone/app/vocabularies/tests/base.py +++ b/plone/app/vocabularies/tests/base.py @@ -94,6 +94,9 @@ def __init__(self, rid): def getPath(self): return self.rid + def getURL(self): + return 'proto:' + self.rid + @property def UID(self): return self.rid From 72b4702f7e58d0a176dbde1949e3277afaf42766 Mon Sep 17 00:00:00 2001 From: Alec Mitchell Date: Sun, 28 Feb 2021 17:46:59 -0800 Subject: [PATCH 04/10] Update to use view.memoize. Thanks @ale-rt! --- plone/app/vocabularies/catalog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plone/app/vocabularies/catalog.py b/plone/app/vocabularies/catalog.py index 538d506..a66c66f 100644 --- a/plone/app/vocabularies/catalog.py +++ b/plone/app/vocabularies/catalog.py @@ -8,7 +8,7 @@ from plone.app.vocabularies.terms import safe_simplevocabulary_from_values from plone.app.vocabularies.utils import parseQueryString from plone.memoize.instance import memoize -from plone.memoize import request +from plone.memoize.view import memoize as view_memoize from plone.registry.interfaces import IRegistry from plone.uuid.interfaces import IUUID from Products.CMFCore.utils import getToolByName @@ -822,7 +822,7 @@ def get_request(): return getRequest() @property - @request.cache(get_key=request_query_cache_key, get_request="self.get_request()") + @view_memoize def brains(self): return self.catalog(**self.query) From 9e3b4f0f69b2150cea51ea5212cb0458762e62c8 Mon Sep 17 00:00:00 2001 From: Alec Mitchell Date: Sun, 28 Feb 2021 20:11:46 -0800 Subject: [PATCH 05/10] Add CHANGELOG entry. --- news/66.feature | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 news/66.feature diff --git a/news/66.feature b/news/66.feature new file mode 100644 index 0000000..41cf57f --- /dev/null +++ b/news/66.feature @@ -0,0 +1,3 @@ +Add new ``StaticCatalogVocabulary`` class providing a simplified mechanism for +creating queryable content vocabularies. Allows use of e.g. AJAXSelectWidget for +fields that store Relations or UUIDs. \ No newline at end of file From 092635e04b36c67fd76f08bbb39e7bdd75d8572d Mon Sep 17 00:00:00 2001 From: Alec Mitchell Date: Mon, 1 Mar 2021 08:41:39 -0800 Subject: [PATCH 06/10] Remove moribund code. --- plone/app/vocabularies/catalog.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/plone/app/vocabularies/catalog.py b/plone/app/vocabularies/catalog.py index a66c66f..e39caaa 100644 --- a/plone/app/vocabularies/catalog.py +++ b/plone/app/vocabularies/catalog.py @@ -25,13 +25,6 @@ from zope.schema.vocabulary import SimpleTerm from zope.schema.vocabulary import SimpleVocabulary from zope.component.hooks import getSite - -try: - from zope.globalrequest import getRequest -except ImportError: - def getRequest(): - return None - import itertools import os import six @@ -666,12 +659,6 @@ def __call__(self, context, query=None): return CatalogVocabulary.fromItems(parsed, context) -def request_query_cache_key(func, vocab): - return json.dumps([ - vocab.query, vocab.text_search_index, vocab.title_template - ]) - - @implementer(IQuerySource, IVocabularyFactory) class StaticCatalogVocabulary(CatalogVocabulary): """Catalog Vocabulary for static queries of content based on a fixed query. @@ -817,10 +804,6 @@ def get_brain_path(self, brain): path = path[len(nav_root_path):] return path - @staticmethod - def get_request(): - return getRequest() - @property @view_memoize def brains(self): From b13d32fea5645c89aa7c58e8d0f8a8739b9e9e50 Mon Sep 17 00:00:00 2001 From: Alec Mitchell Date: Sun, 4 Apr 2021 20:14:25 -0700 Subject: [PATCH 07/10] Revert "Remove moribund code." This reverts commit 8e71006291f186fffd9c66a7894855875ef49887. --- plone/app/vocabularies/catalog.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/plone/app/vocabularies/catalog.py b/plone/app/vocabularies/catalog.py index e39caaa..a66c66f 100644 --- a/plone/app/vocabularies/catalog.py +++ b/plone/app/vocabularies/catalog.py @@ -25,6 +25,13 @@ from zope.schema.vocabulary import SimpleTerm from zope.schema.vocabulary import SimpleVocabulary from zope.component.hooks import getSite + +try: + from zope.globalrequest import getRequest +except ImportError: + def getRequest(): + return None + import itertools import os import six @@ -659,6 +666,12 @@ def __call__(self, context, query=None): return CatalogVocabulary.fromItems(parsed, context) +def request_query_cache_key(func, vocab): + return json.dumps([ + vocab.query, vocab.text_search_index, vocab.title_template + ]) + + @implementer(IQuerySource, IVocabularyFactory) class StaticCatalogVocabulary(CatalogVocabulary): """Catalog Vocabulary for static queries of content based on a fixed query. @@ -804,6 +817,10 @@ def get_brain_path(self, brain): path = path[len(nav_root_path):] return path + @staticmethod + def get_request(): + return getRequest() + @property @view_memoize def brains(self): From 1b1208627d28fcb786d72fd08f21884ef7aefe83 Mon Sep 17 00:00:00 2001 From: Alec Mitchell Date: Sun, 4 Apr 2021 20:14:31 -0700 Subject: [PATCH 08/10] Revert "Update to use view.memoize. Thanks @ale-rt!" This reverts commit c79043d4314ee6eb6b155abb24ac1dde8593cac1. --- plone/app/vocabularies/catalog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plone/app/vocabularies/catalog.py b/plone/app/vocabularies/catalog.py index a66c66f..538d506 100644 --- a/plone/app/vocabularies/catalog.py +++ b/plone/app/vocabularies/catalog.py @@ -8,7 +8,7 @@ from plone.app.vocabularies.terms import safe_simplevocabulary_from_values from plone.app.vocabularies.utils import parseQueryString from plone.memoize.instance import memoize -from plone.memoize.view import memoize as view_memoize +from plone.memoize import request from plone.registry.interfaces import IRegistry from plone.uuid.interfaces import IUUID from Products.CMFCore.utils import getToolByName @@ -822,7 +822,7 @@ def get_request(): return getRequest() @property - @view_memoize + @request.cache(get_key=request_query_cache_key, get_request="self.get_request()") def brains(self): return self.catalog(**self.query) From b3c12f68a8cf83a01eeeccce60a6f0c84db2dc8e Mon Sep 17 00:00:00 2001 From: Alec Mitchell Date: Mon, 5 Apr 2021 09:46:16 -0700 Subject: [PATCH 09/10] Unicode fix and import reorder. --- plone/app/vocabularies/catalog.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plone/app/vocabularies/catalog.py b/plone/app/vocabularies/catalog.py index 538d506..455929b 100644 --- a/plone/app/vocabularies/catalog.py +++ b/plone/app/vocabularies/catalog.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -import json from BTrees.IIBTree import intersection from plone.app.layout.navigation.root import getNavigationRootObject from plone.app.vocabularies import SlicableVocabulary @@ -12,6 +11,7 @@ from plone.registry.interfaces import IRegistry from plone.uuid.interfaces import IUUID from Products.CMFCore.utils import getToolByName +from Products.CMFPlone.utils import safe_unicode from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile from Products.ZCTextIndex.ParseTree import ParseError from z3c.formwidget.query.interfaces import IQuerySource @@ -33,6 +33,7 @@ def getRequest(): return None import itertools +import json import os import six import warnings @@ -829,10 +830,10 @@ def brains(self): def createTerm(self, brain, context=None): return SimpleTerm( value=brain.UID, token=brain.UID, - title=self.title_template.format( + title=safe_unicode(self.title_template.format( brain=brain, path=self.get_brain_path(brain), url=brain.getURL(), - ) + )) ) def search(self, query): From 6ea116e7058251215165d9a8890a1d3f357b7409 Mon Sep 17 00:00:00 2001 From: Philip Bauer Date: Thu, 12 Aug 2021 08:11:42 +0200 Subject: [PATCH 10/10] fix doctests for python 2 --- plone/app/vocabularies/catalog.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plone/app/vocabularies/catalog.py b/plone/app/vocabularies/catalog.py index 455929b..a006e9e 100644 --- a/plone/app/vocabularies/catalog.py +++ b/plone/app/vocabularies/catalog.py @@ -768,7 +768,7 @@ def my_vocab_factory(context): >>> [(t.title, t.value) for t in vocab.search('foo')] - [('BrainTitle (/1234)', '/1234'), ('BrainTitle (/2345)', '/2345')] + [(u'BrainTitle (/1234)', '/1234'), (u'BrainTitle (/2345)', '/2345')] We strip out the site path from the rendered path in the title template: @@ -776,21 +776,21 @@ def my_vocab_factory(context): >>> context.portal_catalog = catalog >>> vocab = StaticCatalogVocabulary({'portal_type': ['Document']}) >>> [(t.title, t.value) for t in vocab.search('bar')] - [('BrainTitle (/site/1234)', '/site/1234'), - ('BrainTitle (/site/2345)', '/site/2345')] + [(u'BrainTitle (/site/1234)', '/site/1234'), + (u'BrainTitle (/site/2345)', '/site/2345')] >>> context.__name__ = 'site' >>> vocab = StaticCatalogVocabulary({'portal_type': ['Document']}) >>> [(t.title, t.value) for t in vocab.search('bar')] - [('BrainTitle (/1234)', '/site/1234'), - ('BrainTitle (/2345)', '/site/2345')] + [(u'BrainTitle (/1234)', '/site/1234'), + (u'BrainTitle (/2345)', '/site/2345')] The title template can be customized: >>> vocab.title_template = "{url} {brain.UID} - {brain.Title} {path}" >>> [(t.title, t.value) for t in vocab.search('bar')] - [('proto:/site/1234 /site/1234 - BrainTitle /1234', '/site/1234'), - ('proto:/site/2345 /site/2345 - BrainTitle /2345', '/site/2345')] + [(u'proto:/site/1234 /site/1234 - BrainTitle /1234', '/site/1234'), + (u'proto:/site/2345 /site/2345 - BrainTitle /2345', '/site/2345')] """ title_template = "{brain.Title} ({path})"