diff --git a/.meta.toml b/.meta.toml index 1fc0d6d30f..a2c22a7b70 100644 --- a/.meta.toml +++ b/.meta.toml @@ -36,7 +36,7 @@ per-file-ignores = [pyproject] codespell_ignores = "checkin" codespell_skip = "*.resp,performance/*,src/plone/restapi/tests/performance.py,docs/styles/**" -dependencies_ignores = ['collective.relationhelpers', 'importlib_metadata', 'plone.app.caching', 'plone.app.controlpanel', 'plone.app.discussion', 'plone.app.iterate', 'plone.app.multilingual', 'plone.base'] +dependencies_ignores = ['collective.relationhelpers', 'importlib_metadata', 'plone.app.caching', 'plone.app.controlpanel', 'plone.app.discussion', 'plone.app.iterate', 'plone.app.layout', 'plone.app.multilingual', 'plone.base'] dependencies_mappings = [ "PyJWT = ['jwt']", "'Products.CMFPlone' = ['Products.CMFEditions', 'Products.DateRecurringIndex', 'Products.ExtendedPathIndex', 'Products.MimetypesRegistry', 'Products.PlonePAS', 'Products.PluggableAuthService', 'Products.PluginIndexes', 'Products.ZCTextIndex', 'Products.statusmessages', 'plone.app.content', 'plone.app.contentlisting', 'plone.app.contentrules', 'plone.app.contenttypes', 'plone.app.dexterity', 'plone.app.event', 'plone.app.linkintegrity', 'plone.app.querystring', 'plone.app.redirector', 'plone.app.textfield', 'plone.app.i18n', 'plone.app.users', 'plone.app.vocabularies', 'plone.app.uuid', 'plone.app.workflow', 'plone.autoform', 'plone.batching', 'plone.behavior', 'plone.browserlayer', 'plone.caching', 'plone.contentrules', 'plone.dexterity', 'plone.event', 'plone.folder', 'plone.i18n', 'plone.indexer', 'plone.keyring', 'plone.locking', 'plone.memoize', 'plone.namedfile', 'plone.protect', 'plone.registry', 'plone.rfc822', 'plone.scale', 'plone.supermodel', 'plone.uuid', 'Products.CMFDynamicViewFTI', 'z3c.caching', 'z3c.form', 'z3c.formwidget.query', 'z3c.relationfield', 'zc.relation', 'zope.componentvocabulary', 'zope.intid']", diff --git a/news/+dep-layout.internal b/news/+dep-layout.internal new file mode 100644 index 0000000000..c07c4f9e65 --- /dev/null +++ b/news/+dep-layout.internal @@ -0,0 +1 @@ +Refactor to avoid direct dependency on plone.app.layout. @davisagli diff --git a/pyproject.toml b/pyproject.toml index aac982e3a5..ef019e0729 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,7 +122,7 @@ Zope = [ ] python-dateutil = ['dateutil'] pytest-plone = ['pytest', 'zope.pytestlayer', 'plone.testing', 'plone.app.testing'] -ignore-packages = ['collective.relationhelpers', 'importlib_metadata', 'plone.app.caching', 'plone.app.controlpanel', 'plone.app.discussion', 'plone.app.iterate', 'plone.app.multilingual', 'plone.base'] +ignore-packages = ['collective.relationhelpers', 'importlib_metadata', 'plone.app.caching', 'plone.app.controlpanel', 'plone.app.discussion', 'plone.app.iterate', 'plone.app.layout', 'plone.app.multilingual', 'plone.base'] PyJWT = ['jwt'] 'Products.CMFPlone' = ['Products.CMFEditions', 'Products.DateRecurringIndex', 'Products.ExtendedPathIndex', 'Products.MimetypesRegistry', 'Products.PlonePAS', 'Products.PluggableAuthService', 'Products.PluginIndexes', 'Products.ZCTextIndex', 'Products.statusmessages', 'plone.app.content', 'plone.app.contentlisting', 'plone.app.contentrules', 'plone.app.contenttypes', 'plone.app.dexterity', 'plone.app.event', 'plone.app.linkintegrity', 'plone.app.querystring', 'plone.app.redirector', 'plone.app.textfield', 'plone.app.i18n', 'plone.app.users', 'plone.app.vocabularies', 'plone.app.uuid', 'plone.app.workflow', 'plone.autoform', 'plone.batching', 'plone.behavior', 'plone.browserlayer', 'plone.caching', 'plone.contentrules', 'plone.dexterity', 'plone.event', 'plone.folder', 'plone.i18n', 'plone.indexer', 'plone.keyring', 'plone.locking', 'plone.memoize', 'plone.namedfile', 'plone.protect', 'plone.registry', 'plone.rfc822', 'plone.scale', 'plone.supermodel', 'plone.uuid', 'Products.CMFDynamicViewFTI', 'z3c.caching', 'z3c.form', 'z3c.formwidget.query', 'z3c.relationfield', 'zc.relation', 'zope.componentvocabulary', 'zope.intid'] diff --git a/setup.py b/setup.py index 9d3b729615..6cf1fcee4d 100644 --- a/setup.py +++ b/setup.py @@ -72,7 +72,6 @@ "packaging", "python-dateutil", "plone.api", - "plone.app.layout", "plone.rest", # json renderer moved to plone.restapi "plone.schema>=1.2.1", # new/fixed json field "Products.CMFCore", diff --git a/src/plone/restapi/bbb.py b/src/plone/restapi/bbb.py index 54d4bd786f..e8a04d73d5 100644 --- a/src/plone/restapi/bbb.py +++ b/src/plone/restapi/bbb.py @@ -1,4 +1,5 @@ try: + from plone.base.defaultpage import check_default_page_via_view from plone.base.defaultpage import is_default_page from plone.base.interfaces import IConstrainTypes from plone.base.interfaces import IEditingSchema @@ -27,6 +28,7 @@ from plone.app.layout.navigation.root import ( getNavigationRoot as get_navigation_root, ) + from Products.CMFPlone.defaultpage import check_default_page_via_view from Products.CMFPlone.defaultpage import is_default_page from Products.CMFPlone.interfaces import IConstrainTypes from Products.CMFPlone.interfaces import IEditingSchema diff --git a/src/plone/restapi/services/contextnavigation/get.py b/src/plone/restapi/services/contextnavigation/get.py index d16c84f764..3309b7635d 100644 --- a/src/plone/restapi/services/contextnavigation/get.py +++ b/src/plone/restapi/services/contextnavigation/get.py @@ -5,10 +5,10 @@ from Acquisition import aq_parent from collections import UserDict from plone import api -from plone.app.layout.navigation.navtree import buildFolderTree from plone.i18n.normalizer.interfaces import IIDNormalizer from plone.memoize.instance import memoize from plone.registry.interfaces import IRegistry +from plone.restapi.bbb import check_default_page_via_view from plone.restapi.bbb import get_navigation_root from plone.restapi.bbb import INavigationRoot from plone.restapi.bbb import INavigationSchema @@ -316,9 +316,6 @@ def getNavTree(self, _marker=None): if _marker is None: _marker = [] context = aq_inner(self.context) - # queryBuilder = getMultiAdapter((context, self.data), INavigationQueryBuilder) - # strategy = getMultiAdapter((context, self.data), INavtreeStrategy) - # TODO: bring back the adapters queryBuilder = QueryBuilder(context, self.data) strategy = NavtreeStrategy(context, self.data) @@ -679,8 +676,6 @@ def __call__(self): class NavtreeStrategy(SitemapNavtreeStrategy): """The navtree strategy used for the default navigation portlet""" - viewActionTypes = [] # different from Plone - def __init__(self, context, portlet): SitemapNavtreeStrategy.__init__(self, context, portlet) @@ -707,6 +702,322 @@ def decoratorFactory(self, node): new_node["nav_title"] = new_node["item"].nav_title return new_node - # def nodeFilter(self, node): - # exclude = getattr(node["item"], "exclude_from_nav", False) - # return not exclude + +def buildFolderTree(context, obj=None, query={}, strategy=None): + """Create a tree structure representing a navigation tree. By default, + it will create a full "sitemap" tree, rooted at the portal, ordered + by explicit folder order. If the 'query' parameter contains a 'path' + key, this can be used to override this. To create a navtree rooted + at the portal root, set query['path'] to: + + {'query' : '/'.join(context.getPhysicalPath()), + 'navtree' : 1} + + to start this 1 level below the portal root, set query['path'] to: + + {'query' : '/'.join(obj.getPhysicalPath()), + 'navtree' : 1, + 'navtree_start' : 1} + + to create a sitemap with depth limit 3, rooted in the portal: + + {'query' : '/'.join(obj.getPhysicalPath()), + 'depth' : 3} + + The parameters: + + - 'context' is the acquisition context, from which tools will be acquired + - 'obj' is the current object being displayed. + - 'query' is a catalog query to apply to find nodes in the tree. + - 'strategy' is an object that can affect how the generation works. It + should be derived from NavtreeStrategyBase, if given, and contain: + + rootPath -- a string property; the physical path to the root node. + + If not given, it will default to any path set in the query, or the + portal root. Note that in a navtree query, the root path will + default to the portal only, possibly adjusted for any navtree_start + set. If rootPath points to something not returned by the query by + the query, a dummy node containing only an empty 'children' list + will be returned. + + showAllParents -- a boolean property; if true and obj is given, + ensure that all parents of the object, including any that would + normally be filtered out are included in the tree. + + supplimentQuery -- a dictionary property; provides + additional query terms which, if not already present + in the query, are added. Useful, for example, to + affect default sorting or default page behavior. + + nodeFilter(node) -- a method returning a boolean; if this returns + False, the given node will not be inserted in the tree + + subtreeFilter(node) -- a method returning a boolean; if this + returns False, the given (folderish) node will not be expanded + (its children will be pruned off) + + decoratorFactory(node) -- a method returning a dict; this can + inject additional keys in a node being inserted. + + showChildrenOf(object) -- a method returning True if children of + the given object (normally the root) should be returned + + Returns tree where each node is represented by a dict: + + item - A catalog brain of this item + depth - The depth of this item, relative to the startAt + level + currentItem - True if this is the current item + currentParent - True if this is a direct parent of the current item + children - A list of children nodes of this node + + Note: Any 'decoratorFactory' specified may modify this list, but + the 'children' property is guaranteed to be there. + + Note: If the query does not return the root node itself, the root + element of the tree may contain *only* the 'children' list. + + Note: Folder default-pages are not included in the returned result. + If the 'obj' passed in is a default-page, its parent folder will be + used for the purposes of selecting the 'currentItem'. + """ + + portal_url = getToolByName(context, "portal_url") + portal_catalog = getToolByName(context, "portal_catalog") + + rootPath = strategy.rootPath + + request = getattr(context, "REQUEST", {}) + + # Find the object's path. Use parent folder if context is a default-page + + objPath = None + objPhysicalPath = None + if obj is not None: + objPhysicalPath = obj.getPhysicalPath() + if check_default_page_via_view(obj, request): + objPhysicalPath = objPhysicalPath[:-1] + objPath = "/".join(objPhysicalPath) + + portalPath = portal_url.getPortalPath() + portalObject = portal_url.getPortalObject() + + # Calculate rootPath from the path query if not set. + + if "path" not in query: + if rootPath is None: + rootPath = portalPath + query["path"] = rootPath + elif rootPath is None: + pathQuery = query["path"] + if isinstance(pathQuery, str): + rootPath = pathQuery + else: + # Adjust for the fact that in a 'navtree' query, the actual path + # is the path of the current context + if pathQuery.get("navtree", False): + navtreeLevel = pathQuery.get("navtree_start", 1) + if navtreeLevel > 1: + navtreeContextPath = pathQuery["query"] + navtreeContextPathElements = navtreeContextPath[ + len(portalPath) + 1 : + ].split("/") + # Short-circuit if we won't be able to find this path + if len(navtreeContextPathElements) < (navtreeLevel - 1): + return {"children": []} + rootPath = ( + portalPath + + "/" + + "/".join(navtreeContextPathElements[: navtreeLevel - 1]) + ) + else: + rootPath = portalPath + else: + rootPath = pathQuery["query"] + + rootDepth = len(rootPath.split("/")) + + # Determine if we need to prune the root (but still force the path to) + # the parent if necessary + + pruneRoot = False + if strategy is not None: + rootObject = portalObject.unrestrictedTraverse(rootPath, None) + if rootObject is not None: + pruneRoot = not strategy.showChildrenOf(rootObject) + + # Allow the strategy to supplement the query for keys not already + # present in the query such as sorting and omitting default pages + for key, value in strategy.supplimentQuery.items(): + if key not in query: + query[key] = value + + results = portal_catalog.searchResults(query) + + # We keep track of a dict of item path -> node, so that we can easily + # find parents and attach children. If a child appears before its + # parent, we stub the parent node. + + # This is necessary because whilst the sort_on parameter will ensure + # that the objects in a folder are returned in the right order relative + # to each other, we don't know the relative order of objects from + # different folders. So, if /foo comes before /bar, and /foo/a comes + # before /foo/b, we may get a list like (/bar/x, /foo/a, /foo/b, /foo, + # /bar,). + + itemPaths = {} + + # Add an (initially empty) node for the root + itemPaths[rootPath] = {"children": []} + + # If we need to "prune" the parent (but still allow showAllParent to + # force some children), do so now + if pruneRoot: + itemPaths[rootPath]["_pruneSubtree"] = True + + def insertElement(itemPaths, item, forceInsert=False): + """Insert the given 'item' brain into the tree, which is kept in + 'itemPaths'. If 'forceInsert' is True, ignore node- and subtree- + filters, otherwise any node- or subtree-filter set will be allowed to + block the insertion of a node. + """ + itemPath = item.getPath() + itemInserted = itemPaths.get(itemPath, {}).get("item", None) is not None + + # Short-circuit if we already added this item. Don't short-circuit + # if we're forcing the insert, because we may have inserted but + # later pruned off the node + if not forceInsert and itemInserted: + return + + itemPhysicalPath = itemPath.split("/") + parentPath = "/".join(itemPhysicalPath[:-1]) + parentPruned = itemPaths.get(parentPath, {}).get("_pruneSubtree", False) + + # Short-circuit if we know we're pruning this item's parent + + # XXX: We could do this recursively, in case of parent of the + # parent was being pruned, but this may not be a great trade-off + + # There is scope for more efficiency improvement here: If we knew we + # were going to prune the subtree, we would short-circuit here each + # time. In order to know that, we'd have to make sure we inserted each + # parent before its children, by sorting the catalog result set + # (probably manually) to get a breadth-first search. + + if not forceInsert and parentPruned: + return + + isCurrent = isCurrentParent = False + if objPath is not None: + objpath_startswith_itempath = objPath.startswith(itemPath + "/") + objpath_bigger_than_itempath = len(objPhysicalPath) > len(itemPhysicalPath) + if objPath == itemPath: + isCurrent = True + elif objpath_startswith_itempath and objpath_bigger_than_itempath: + isCurrentParent = True + + relativeDepth = len(itemPhysicalPath) - rootDepth + + newNode = { + "item": item, + "depth": relativeDepth, + "currentItem": isCurrent, + "currentParent": isCurrentParent, + } + + insert = True + if not forceInsert and strategy is not None: + insert = strategy.nodeFilter(newNode) + if insert: + if strategy is not None: + newNode = strategy.decoratorFactory(newNode) + + # Tell parent about this item, unless an earlier subtree filter + # told us not to. If we're forcing the insert, ignore the + # pruning, but avoid inserting the node twice + if parentPath in itemPaths: + itemParent = itemPaths[parentPath] + if forceInsert: + nodeAlreadyInserted = False + for i in itemParent["children"]: + if i["item"].getPath() == itemPath: + nodeAlreadyInserted = True + break + if not nodeAlreadyInserted: + itemParent["children"].append(newNode) + elif not itemParent.get("_pruneSubtree", False): + itemParent["children"].append(newNode) + else: + itemPaths[parentPath] = {"children": [newNode]} + + # Ask the subtree filter (if any), if we should be expanding this + # node + if strategy.showAllParents and isCurrentParent: + # If we will be expanding this later, we can't prune off + # children now + expand = True + else: + expand = getattr(item, "is_folderish", True) + if expand and (not forceInsert and strategy is not None): + expand = strategy.subtreeFilter(newNode) + + children = newNode.setdefault("children", []) + if expand: + # If we had some orphaned children for this node, attach + # them + if itemPath in itemPaths: + children.extend(itemPaths[itemPath]["children"]) + else: + newNode["_pruneSubtree"] = True + + itemPaths[itemPath] = newNode + + # Add the results of running the query + for r in results: + insertElement(itemPaths, r) + + # If needed, inject additional nodes for the direct parents of the + # context. Note that we use an unrestricted query: things we don't normally + # have permission to see will be included in the tree. + if strategy.showAllParents and objPath is not None: + objSubPathElements = objPath[len(rootPath) + 1 :].split("/") + parentPaths = [] + + haveNode = itemPaths.get(rootPath, {}).get("item", None) is None + if not haveNode: + parentPaths.append(rootPath) + + parentPath = rootPath + for i in range(len(objSubPathElements)): + nodePath = rootPath + "/" + "/".join(objSubPathElements[: i + 1]) + node = itemPaths.get(nodePath, None) + + # If we don't have this node, we'll have to get it, if we have it + # but it wasn't connected, re-connect it + if node is None or "item" not in node: + parentPaths.append(nodePath) + else: + nodeParent = itemPaths.get(parentPath, None) + if nodeParent is not None: + nodeAlreadyInserted = False + for i in nodeParent["children"]: + if i["item"].getPath() == nodePath: + nodeAlreadyInserted = True + break + if not nodeAlreadyInserted: + nodeParent["children"].append(node) + + parentPath = nodePath + + # If we were outright missing some nodes, find them again + if len(parentPaths) > 0: + query = {"path": {"query": parentPaths, "depth": 0}} + results = portal_catalog.unrestrictedSearchResults(query) + + for r in results: + insertElement(itemPaths, r, forceInsert=True) + + # Return the tree starting at rootPath as the root node. + return itemPaths[rootPath] diff --git a/src/plone/restapi/services/history/get.py b/src/plone/restapi/services/history/get.py index 842785f849..ce3db67e0d 100644 --- a/src/plone/restapi/services/history/get.py +++ b/src/plone/restapi/services/history/get.py @@ -1,15 +1,169 @@ +from Acquisition import aq_inner from datetime import datetime as dt from datetime import timezone -from plone.app.layout.viewlets.content import ContentHistoryViewlet +from plone.memoize.instance import memoize from plone.restapi.bbb import safe_text from plone.restapi.interfaces import ISerializeToJson from plone.restapi.serializer.converters import json_compatible from plone.restapi.services import Service +from Products.CMFCore.utils import _checkPermission +from Products.CMFCore.utils import getToolByName +from Products.CMFCore.WorkflowCore import WorkflowException +from Products.CMFEditions.Permissions import AccessPreviousVersions from zope.component import queryMultiAdapter from zope.component.hooks import getSite +from zope.i18nmessageid import MessageFactory from zope.interface import implementer from zope.publisher.interfaces import IPublishTraverse +import logging + +_ = MessageFactory("plone") +logger = logging.getLogger(__file__) + + +class ContentHistory: + + def __init__(self, context, request, site_url): + self.context = context + self.request = request + self.site_url = site_url + + @memoize + def getUserInfo(self, userid): + actor = dict(fullname=userid) + mt = getToolByName(self.context, "portal_membership") + info = mt.getMemberInfo(userid) + if info is None: + return dict(actor=actor) + + fullname = info.get("fullname", None) + if fullname: + actor["fullname"] = fullname + + return dict(actor=actor) + + def workflowHistory(self, complete=True): + """Return workflow history of this context. + + Taken from plone_scripts/getWorkflowHistory.py + """ + context = aq_inner(self.context) + # check if the current user has the proper permissions + if not ( + _checkPermission("Request review", context) + or _checkPermission("Review portal content", context) + ): + return [] + + workflow = getToolByName(context, "portal_workflow") + review_history = [] + + try: + # Get total history. + # Note: expected variables like 'action' may not exist: + # the workflow may have started out without variables. + review_history = workflow.getInfoFor(context, "review_history") + + if not complete: + # filter out automatic transitions. + review_history = [r for r in review_history if r.get("action")] + else: + review_history = list(review_history) + + portal_type = context.portal_type + anon = _("label_anonymous_user", default="Anonymous User") + for r in review_history: + r["type"] = "workflow" + + # Get transition title. + transition_title = "" + action = r.get("action") + if action: + transition_title = workflow.getTitleForTransitionOnType( + action, portal_type + ) + if not transition_title: + transition_title = _("Create") + r["transition_title"] = transition_title + + # Get state title. + r["state_title"] = workflow.getTitleForStateOnType( + r.get("review_state", ""), portal_type + ) + + # Get actor. + actorid = r.get("actor") + r["actorid"] = actorid + if actorid is None: + # action performed by an anonymous user, or unknown + r["actor"] = {"username": anon, "fullname": anon} + else: + r.update(self.getUserInfo(actorid)) + review_history.reverse() + + except WorkflowException: + logger.debug( + "plone.app.layout.viewlets.content: %s has no associated workflow", + context.absolute_url(), + ) + + return review_history + + def revisionHistory(self): + context = aq_inner(self.context) + if not _checkPermission(AccessPreviousVersions, context): + return [] + + rt = getToolByName(context, "portal_repository", None) + if rt is None or not rt.isVersionable(context): + return [] + + history = rt.getHistoryMetadata(context) + may_revert = _checkPermission( + "CMFEditions: Revert to previous versions", context + ) + + def morphVersionDataToHistoryFormat(vdata, version_id): + meta = vdata["metadata"]["sys_metadata"] + userid = meta["principal"] + info = dict( + type="versioning", + action=_("Edited"), + transition_title=_("Edited"), + actorid=userid, + time=meta["timestamp"], + comments=meta["comment"], + may_revert=bool(may_revert), + version_id=version_id, + ) + info.update(self.getUserInfo(userid)) + return info + + # History may be an empty list + if not history: + return history + + version_history = [] + retrieve = history.retrieve + getId = history.getVersionId + # Count backwards from most recent to least recent + for i in range(history.getLength(countPurged=False) - 1, -1, -1): + version_history.append( + morphVersionDataToHistoryFormat( + retrieve(i, countPurged=False), getId(i, countPurged=False) + ) + ) + + return version_history + + def fullHistory(self): + history = self.workflowHistory() + self.revisionHistory() + if len(history) == 0: + return None + history.sort(key=lambda x: x.get("time", 0.0), reverse=True) + return history + @implementer(IPublishTraverse) class HistoryGet(Service): @@ -31,44 +185,26 @@ def reply(self): return data # Listing historical data - content_history_viewlet = ContentHistoryViewlet( - self.context, self.request, None, None - ) site_url = getSite().absolute_url() - content_history_viewlet.navigation_root_url = site_url - content_history_viewlet.site_url = site_url - history = content_history_viewlet.fullHistory() + content_history = ContentHistory(self.context, self.request, site_url) + history = content_history.fullHistory() if history is None: history = [] - unwanted_keys = [ - "diff_current_url", - "diff_previous_url", - "preview_url", - "actor_home", - "actorid", - "revert_url", - "version_id", - ] - for item in history: item["actor"] = { "@id": "{}/@users/{}".format(site_url, item["actorid"]), - "id": item["actorid"], + "id": item.pop("actorid"), "fullname": item["actor"].get("fullname"), "username": item["actor"].get("username"), } if item["type"] == "versioning": - item["version"] = item["version_id"] + item["version"] = item.pop("version_id") item["@id"] = "{}/@history/{}".format( self.context.absolute_url(), item["version"] ) - # If a revert_url is present, then CMFEditions has checked our - # permissions. - item["may_revert"] = bool(item.get("revert_url")) - # Versioning entries use a timestamp, # workflow ISO formatted string if not isinstance(item["time"], str): @@ -97,9 +233,4 @@ def reply(self): safe_text(item["action"]), context=self.request ) - # clean up - for key in unwanted_keys: - if key in item: - del item[key] - return json_compatible(history) diff --git a/src/plone/restapi/tests/statictime.py b/src/plone/restapi/tests/statictime.py index c5f76f20a2..470172688d 100644 --- a/src/plone/restapi/tests/statictime.py +++ b/src/plone/restapi/tests/statictime.py @@ -1,10 +1,10 @@ from datetime import datetime from datetime import timezone from DateTime import DateTime -from plone.app.layout.viewlets.content import ContentHistoryViewlet from plone.dexterity.content import DexterityContent from plone.locking.lockable import TTWLockable from plone.restapi.serializer.working_copy import WorkingCopyInfo +from plone.restapi.services.history.get import ContentHistory from Products.CMFCore.WorkflowTool import _marker from Products.CMFCore.WorkflowTool import WorkflowTool @@ -16,7 +16,7 @@ _originals = { "WorkflowTool.getInfoFor": WorkflowTool.getInfoFor, - "ContentHistoryViewlet.fullHistory": ContentHistoryViewlet.fullHistory, + "ContentHistory.fullHistory": ContentHistory.fullHistory, "TTWLockable.lock_info": TTWLockable.lock_info, "WorkingCopyInfo.created": WorkingCopyInfo.created, } @@ -58,7 +58,7 @@ class StaticTime: - WorkflowTool.getInfoFor - (if asked for 'review_history') - - ContentHistoryViewlet + - ContentHistory - fullHistory - TTWLockable @@ -128,9 +128,7 @@ def start(self): WorkflowTool.getInfoFor = static_get_info_for_factory(self.static_modified) - ContentHistoryViewlet.fullHistory = static_full_history_factory( - self.static_modified - ) + ContentHistory.fullHistory = static_full_history_factory(self.static_modified) TTWLockable.lock_info = static_lock_info_factory(self.static_modified) @@ -139,9 +137,7 @@ def start(self): def stop(self): """Undo all the patches.""" TTWLockable.lock_info = _originals["TTWLockable.lock_info"] - ContentHistoryViewlet.fullHistory = _originals[ - "ContentHistoryViewlet.fullHistory" - ] + ContentHistory.fullHistory = _originals["ContentHistory.fullHistory"] WorkflowTool.getInfoFor = _originals["WorkflowTool.getInfoFor"] if Comment is not None: @@ -179,7 +175,7 @@ def static_get_info_for(self, ob, name, default=_marker, wf_id=None, *args, **kw if name == "review_history": base_date = dt_value - # The ContentHistoryViewlet.fullHistory method assembles results + # The ContentHistory.fullHistory method assembles results # from both the review_history (i.e., this method's result) and # the revision history, and intertwines their elements in # chronological order. That doesn't work if we already return @@ -207,7 +203,7 @@ def static_get_info_for(self, ob, name, default=_marker, wf_id=None, *args, **kw def static_full_history_factory(dt_value): - """Returns a static time replacement for ContentHistoryViewlet.fullHistory + """Returns a static time replacement for ContentHistory.fullHistory configured with the given datetime value as a base. """ if isinstance(dt_value, datetime): @@ -225,7 +221,7 @@ def static_full_history(self): In other words, they will be stable (static), but different for each event, and should still reflect proper order of events. """ - actions = _originals["ContentHistoryViewlet.fullHistory"](self) + actions = _originals["ContentHistory.fullHistory"](self) base_date = dt_value for idx, action in enumerate(actions): diff --git a/src/plone/restapi/tests/test_statictime.py b/src/plone/restapi/tests/test_statictime.py index a586a9277a..d9587ae8ce 100644 --- a/src/plone/restapi/tests/test_statictime.py +++ b/src/plone/restapi/tests/test_statictime.py @@ -10,7 +10,6 @@ from plone.app.discussion.interfaces import IReplies from plone.app.event.base import default_timezone from plone.app.iterate.interfaces import ICheckinCheckoutPolicy -from plone.app.layout.viewlets.content import ContentHistoryViewlet from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID from plone.locking.interfaces import ILockable @@ -18,6 +17,7 @@ from plone.registry.interfaces import IRegistry from plone.restapi.serializer.converters import json_compatible from plone.restapi.serializer.working_copy import WorkingCopyInfo +from plone.restapi.services.history.get import ContentHistory from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING from plone.restapi.testing import PLONE_RESTAPI_ITERATE_FUNCTIONAL_TESTING from plone.restapi.tests.statictime import StaticTime @@ -232,8 +232,11 @@ def test_statictime_full_history(self): doc1 = self.create_document("doc1") doc1.setTitle("Current version") api.content.transition(doc1, "publish") - viewlet = ContentHistoryViewlet(doc1, doc1.REQUEST, None) - viewlet.update() + viewlet = ContentHistory( + doc1, + doc1.REQUEST, + self.portal.absolute_url(), + ) history = viewlet.fullHistory() @@ -251,8 +254,11 @@ def test_statictime_full_history(self): doc2 = self.create_document("doc2") doc2.setTitle("Current version") api.content.transition(doc2, "publish") - viewlet = ContentHistoryViewlet(doc2, doc2.REQUEST, None) - viewlet.update() + viewlet = ContentHistory( + doc2, + doc2.REQUEST, + self.portal.absolute_url(), + ) history = viewlet.fullHistory()