From 20dc21a952669516cd7009d3a7aa06505e785e0d Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Thu, 11 Jun 2026 22:00:32 +0200 Subject: [PATCH 1/2] Use extended expression context for Control Panel actions. Fixes #3288 --- news/3288.feature | 1 + src/Products/CMFPlone/PloneControlPanel.py | 6 +- .../CMFPlone/tests/test_controlpanel_expr.py | 81 +++++++++++++++++++ src/Products/CMFPlone/tests/test_utils.py | 66 +++++++++++++++ src/Products/CMFPlone/utils.py | 32 ++++++++ 5 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 news/3288.feature create mode 100644 src/Products/CMFPlone/tests/test_controlpanel_expr.py diff --git a/news/3288.feature b/news/3288.feature new file mode 100644 index 0000000000..d3da1d6299 --- /dev/null +++ b/news/3288.feature @@ -0,0 +1 @@ +Use extended expression context for Control Panel actions @erral diff --git a/src/Products/CMFPlone/PloneControlPanel.py b/src/Products/CMFPlone/PloneControlPanel.py index 357ee8ece3..9994e41c80 100644 --- a/src/Products/CMFPlone/PloneControlPanel.py +++ b/src/Products/CMFPlone/PloneControlPanel.py @@ -7,7 +7,6 @@ from plone.base.interfaces import IControlPanel from Products.CMFCore.ActionInformation import ActionInformation from Products.CMFCore.ActionProviderBase import ActionProviderBase -from Products.CMFCore.Expression import createExprContext from Products.CMFCore.Expression import Expression from Products.CMFCore.permissions import ManagePortal from Products.CMFCore.permissions import View @@ -16,6 +15,7 @@ from Products.CMFCore.utils import registerToolInterface from Products.CMFCore.utils import UniqueObject from Products.CMFPlone.PloneBaseTool import PloneBaseTool +from Products.CMFPlone.utils import get_current_context from zope.component.hooks import getSite from zope.i18n import translate from zope.i18nmessageid import Message @@ -121,8 +121,10 @@ def maySeeSomeConfiglets(self): security.declarePublic("enumConfiglets") def enumConfiglets(self, group=None): + context = get_current_context() portal = getToolByName(self, "portal_url").getPortalObject() - context = createExprContext(self, portal, self) + + context = self._getExprContext(context or portal) res = [] for a in self.listActions(): verified = 0 diff --git a/src/Products/CMFPlone/tests/test_controlpanel_expr.py b/src/Products/CMFPlone/tests/test_controlpanel_expr.py new file mode 100644 index 0000000000..8379a26640 --- /dev/null +++ b/src/Products/CMFPlone/tests/test_controlpanel_expr.py @@ -0,0 +1,81 @@ +from Products.CMFCore.utils import getToolByName +from Products.CMFPlone.testing import PRODUCTS_CMFPLONE_INTEGRATION_TESTING +from zope.globalrequest import setRequest + +import unittest + + +class TestControlPanelExpressions(unittest.TestCase): + layer = PRODUCTS_CMFPLONE_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + self.cp = getToolByName(self.portal, "portal_controlpanel") + self.folder = self.portal["test-folder"] + + def tearDown(self): + pass + + def test_enum_configlets_uses_plone_context_state_on_portal(self): + # This condition uses plone_context_state which is provided by the new _getExprContext + # is_portal_root() will be true on portal root. + self.cp.addAction( + id="test-configlet", + name="Test Configlet", + action="string:test", + condition="python:plone_context_state.is_portal_root()", + permission="cmf.ManagePortal", + category="Plone", + visible=True, + ) + + self.request.set("PARENTS", [self.portal]) + setRequest(self.request) + + configlets = [c["id"] for c in self.cp.enumConfiglets(group="Plone")] + self.assertIn( + "test-configlet", configlets, "Configlet should be visible on portal root" + ) + + def test_enum_configlets_uses_plone_context_state_on_folder(self): + # This condition uses plone_context_state which is provided by the new _getExprContext + # is_portal_root() will be false on the folder. + self.cp.addAction( + id="test-configlet", + name="Test Configlet", + action="string:test", + condition="python:plone_context_state.is_portal_root()", + permission="cmf.ManagePortal", + category="Plone", + visible=True, + ) + + self.request.set("PARENTS", [self.folder, self.portal]) + setRequest(self.request) + + configlets = [c["id"] for c in self.cp.enumConfiglets(group="Plone")] + self.assertNotIn( + "test-configlet", configlets, "Configlet should NOT be visible on folder" + ) + + def test_enum_configlets_uses_plone_portal_state(self): + # This condition uses plone_portal_state + self.cp.addAction( + id="test-configlet-2", + name="Test Configlet 2", + action="string:test", + condition="python:plone_portal_state.portal_url() is not None", + permission="cmf.ManagePortal", + category="Plone", + visible=True, + ) + + self.request.set("PARENTS", [self.portal]) + setRequest(self.request) + configlets = [c["id"] for c in self.cp.enumConfiglets(group="Plone")] + self.assertIn( + "test-configlet-2", + configlets, + "Configlet should be visible as plone_portal_state is available", + ) diff --git a/src/Products/CMFPlone/tests/test_utils.py b/src/Products/CMFPlone/tests/test_utils.py index 919559ed8e..827e581be3 100644 --- a/src/Products/CMFPlone/tests/test_utils.py +++ b/src/Products/CMFPlone/tests/test_utils.py @@ -53,3 +53,69 @@ def test_getSiteLogo_with_no_setting(self): logo_url, logo_type = getSiteLogo(include_type=True) self.assertTrue("http://nohost/plone/++resource++plone-logo.svg" in logo_url) self.assertEqual("image/svg+xml", logo_type) + + +class GetCurrentContextTests(unittest.TestCase): + layer = PRODUCTS_CMFPLONE_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + + def tearDown(self): + pass + + def test_get_current_context_no_request(self): + from Products.CMFPlone.utils import get_current_context + from zope.globalrequest import clearRequest + + clearRequest() + self.assertIsNone(get_current_context()) + + def test_get_current_context_with_published_view(self): + from Products.CMFPlone.utils import get_current_context + from zope.globalrequest import setRequest + + class FakeView: + def __init__(self, context): + self.context = context + + view = FakeView(self.portal) + self.request.set("PUBLISHED", view) + setRequest(self.request) + + self.assertEqual(get_current_context(), self.portal) + + def test_get_current_context_with_published_view_parent(self): + from Products.CMFPlone.utils import get_current_context + from zope.globalrequest import setRequest + + class FakeView: + def __init__(self, context): + self.__parent__ = context + + view = FakeView(self.portal) + self.request.set("PUBLISHED", view) + setRequest(self.request) + + self.assertEqual(get_current_context(), self.portal) + + def test_get_current_context_from_parents(self): + from Products.CMFPlone.utils import get_current_context + from zope.globalrequest import setRequest + + self.request.set("PARENTS", [self.portal]) + setRequest(self.request) + + self.assertEqual(get_current_context(), self.portal) + + def test_get_current_context_non_contentish_ignored(self): + from Products.CMFPlone.utils import get_current_context + from zope.globalrequest import setRequest + + # Something that is not IContentish + non_content = object() + self.request.set("PARENTS", [non_content]) + setRequest(self.request) + + self.assertIsNone(get_current_context()) diff --git a/src/Products/CMFPlone/utils.py b/src/Products/CMFPlone/utils.py index b7168663b6..879a327498 100644 --- a/src/Products/CMFPlone/utils.py +++ b/src/Products/CMFPlone/utils.py @@ -20,6 +20,7 @@ from plone.base.interfaces.siteroot import IPloneSiteRoot from plone.i18n.normalizer.interfaces import IIDNormalizer from plone.registry.interfaces import IRegistry +from Products.CMFCore.interfaces import IContentish from Products.CMFCore.permissions import ManageUsers from Products.CMFCore.utils import getToolByName from Products.CMFCore.utils import ToolInit as CMFCoreToolInit @@ -35,6 +36,7 @@ from zope.deferredimport import deprecated as deprecated_import from zope.deprecation import deprecate from zope.deprecation import deprecated # noqa: F401 +from zope.globalrequest import getRequest from zope.interface import implementedBy import OFS @@ -514,3 +516,33 @@ def _safe_format(inst, method): as we do in CMFPlone/__init__.py. """ return SafeFormatter(inst).safe_format + + +def get_current_context(): + # 1. Fetch the global request + request = getRequest() + if not request: + return None + + # 2. Inspect what Zope is currently publishing + published = request.get("PUBLISHED", None) + + # If a BrowserView is being published, look for __parent__ or context + context = getattr(published, "__parent__", None) + if context is None: + context = getattr(published, "context", None) + + # 3. Fall back to the Zope traversal lineage (PARENTS) + if context is None: + parents = request.get("PARENTS", []) + if parents: + # The immediate parent of the published view/method is the context object + context = parents[0] + + # 4. Content verification (Optional but Recommended) + # This ensures you got actual Plone content, + # rather than a skin template or system resource + if context and IContentish.providedBy(context): + return context + + return None From 3f5b43ae5cf97329dede51aa7e5a74a7a939dd9f Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 12 Jun 2026 07:58:46 +0200 Subject: [PATCH 2/2] remove unneeded empty methods --- src/Products/CMFPlone/tests/test_controlpanel_expr.py | 3 --- src/Products/CMFPlone/tests/test_utils.py | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/Products/CMFPlone/tests/test_controlpanel_expr.py b/src/Products/CMFPlone/tests/test_controlpanel_expr.py index 8379a26640..cde530cbc0 100644 --- a/src/Products/CMFPlone/tests/test_controlpanel_expr.py +++ b/src/Products/CMFPlone/tests/test_controlpanel_expr.py @@ -14,9 +14,6 @@ def setUp(self): self.cp = getToolByName(self.portal, "portal_controlpanel") self.folder = self.portal["test-folder"] - def tearDown(self): - pass - def test_enum_configlets_uses_plone_context_state_on_portal(self): # This condition uses plone_context_state which is provided by the new _getExprContext # is_portal_root() will be true on portal root. diff --git a/src/Products/CMFPlone/tests/test_utils.py b/src/Products/CMFPlone/tests/test_utils.py index 827e581be3..4e8a15134c 100644 --- a/src/Products/CMFPlone/tests/test_utils.py +++ b/src/Products/CMFPlone/tests/test_utils.py @@ -62,9 +62,6 @@ def setUp(self): self.portal = self.layer["portal"] self.request = self.layer["request"] - def tearDown(self): - pass - def test_get_current_context_no_request(self): from Products.CMFPlone.utils import get_current_context from zope.globalrequest import clearRequest