Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add folder_publish browser view. #213

Merged
merged 4 commits into from
Sep 28, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions news/3057.breaking
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Added ``folder_publish`` browser view.
This replaces the ``folder_publish.cpy`` script from ``Products.CMFPlone``.
[maurits]
7 changes: 7 additions & 0 deletions plone/app/content/browser/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@
permission="cmf.ModifyPortalContent"
/>

<browser:page
for="*"
name="folder_publish"
class=".folder_publish.FolderPublishView"
permission="cmf.ModifyPortalContent"
/>

<!-- Folder factories -->
<browser:page
for="*"
Expand Down
110 changes: 110 additions & 0 deletions plone/app/content/browser/folder_publish.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from plone.protect import CheckAuthenticator
from plone.protect import PostOnly
from Products.CMFCore.utils import getToolByName
from Products.CMFPlone import PloneMessageFactory as _
from Products.CMFPlone.utils import transaction_note
from ZODB.POSException import ConflictError
from zope.publisher.browser import BrowserView

import transaction


class FolderPublishView(BrowserView):
"""Publish objects from a folder.

Originally: Products/CMFPlone/skins/plone_scripts/folder_publish.cpy
Called by content_status_history, in plone.app.content.
"""

def __call__(
self,
workflow_action=None,
paths=None,
comment="No comment",
expiration_date=None,
effective_date=None,
include_children=False,
):
# Use plone.protect.
PostOnly(self.request)
CheckAuthenticator(self.request)

plone_utils = getToolByName(self.context, "plone_utils")
if workflow_action is None:
plone_utils.addPortalMessage(
_(u"You must select a publishing action."), "error"
)
return self.redirect()
if not paths:
plone_utils.addPortalMessage(
_(u"You must select content to change."), "error"
)
return self.redirect()

self.transition_objects_by_paths(
workflow_action,
paths,
comment,
expiration_date,
effective_date,
include_children,
)

transaction_note(str(paths) + " transitioned " + workflow_action)
plone_utils.addPortalMessage(_(u"Item state changed."))
return self.redirect()

def transition_objects_by_paths(
self,
workflow_action,
paths,
comment="",
expiration_date=None,
effective_date=None,
include_children=False,
handle_errors=True,
):
"""Originally this was in plone_utils.transitionObjectsByPaths.

This was deprecated since 2015, so we copied it here.
"""
failure = {}
# use the portal for traversal in case we have relative paths
portal = getToolByName(self, "portal_url").getPortalObject()
traverse = portal.restrictedTraverse
for path in paths:
sp = transaction.savepoint(optimistic=True)
try:
obj = traverse(path, None)
if obj is not None:
obj.content_status_modify(
workflow_action,
comment,
effective_date=effective_date,
expiration_date=expiration_date,
)
except (ConflictError, KeyboardInterrupt):
raise
except Exception as e:
# skip this object but continue with sub-objects.
sp.rollback()
failure[path] = e
if getattr(obj, "isPrincipiaFolderish", None) and include_children:
subobject_paths = ["%s/%s" % (path, id) for id in obj]
self.transition_objects_by_paths(
workflow_action,
subobject_paths,
comment,
expiration_date,
effective_date,
include_children,
)
return failure

def redirect(self):
target = self.request.get("orig_template", "")
if target and not getToolByName(self.context, "portal_url").isURLInPortal(target):
target = ""
if not target:
target = self.context.absolute_url()
self.request.response.redirect(target)
125 changes: 125 additions & 0 deletions plone/app/content/tests/test_folder_publish.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
from plone.app.content.testing import PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
from plone.app.testing import login
from plone.app.testing import logout
from plone.app.testing import setRoles
from plone.app.testing import TEST_USER_ID
from plone.app.testing import TEST_USER_NAME
from Products.CMFPlone.utils import isExpired
from zExceptions import Forbidden
from zope.component import getMultiAdapter

import unittest


class TestContentPublishing(unittest.TestCase):
"""Test the recursive behaviour of folder_publish.

Adapted from CMFPlone/tests/testContentPublishing.py.
"""

layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING

def setUp(self):
self.portal = self.layer["portal"]
self.request = self.layer["request"]
self.workflow = self.portal.portal_workflow
# Make sure we can create and publish directly.
login(self.portal, TEST_USER_NAME)
setRoles(self.portal, TEST_USER_ID, ["Manager"])
# Prepare content.
self.portal.invokeFactory("Folder", id="folder")
self.folder = self.portal.folder
self.folder.invokeFactory("Document", id="d1", title="Doc 1")
self.folder.invokeFactory("Folder", id="f1", title="Folder 1")
self.folder.f1.invokeFactory("Document", id="d2", title="Doc 2")
self.folder.f1.invokeFactory("Folder", id="f2", title="Folder 2")

def setup_authenticator(self):
from plone.protect.authenticator import createToken

self.request.form["_authenticator"] = createToken()

def test_folder_publish_get(self, **kwargs):
self.setup_authenticator()
view = getMultiAdapter((self.folder, self.request), name="folder_publish")
with self.assertRaises(Forbidden):
view(**kwargs)

def test_folder_publish_post_without_authenticator(self, **kwargs):
self.request.environ["REQUEST_METHOD"] = "POST"
# request.set("REQUEST_METHOD", "POST")
view = getMultiAdapter((self.folder, self.request), name="folder_publish")
with self.assertRaises(Forbidden):
view(**kwargs)

def folder_publish(self, **kwargs):
self.request.set("REQUEST_METHOD", "POST")
self.setup_authenticator()
view = getMultiAdapter((self.folder, self.request), name="folder_publish")
return view(**kwargs)

def test_initial_state(self):
# Depending on Plone version, dexterity, archetypes,
# the review state may be visible or private. Check which one it is.
for o in (self.folder.d1, self.folder.f1, self.folder.f1.d2, self.folder.f1.f2):
self.assertEqual(self.workflow.getInfoFor(o, "review_state"), "private")

def test_publishing_subobjects(self):
paths = []
for o in (self.folder.d1, self.folder.f1):
paths.append("/".join(o.getPhysicalPath()))

self.folder_publish(
workflow_action="publish", paths=paths, include_children=True
)
for o in (self.folder.d1, self.folder.f1, self.folder.f1.d2, self.folder.f1.f2):
self.assertEqual(self.workflow.getInfoFor(o, "review_state"), "published")
self.assertEqual(self.request.response.getStatus(), 302)
self.assertEqual(self.request.response.getHeader("Location"), self.folder.absolute_url())

def test_publishing_subobjects_and_expire_them(self):
paths = []
for o in (self.folder.d1, self.folder.f1):
paths.append("/".join(o.getPhysicalPath()))

self.folder_publish(
workflow_action="publish",
paths=paths,
effective_date="1/1/2001",
expiration_date="1/2/2001",
include_children=True,
)
for o in (self.folder.d1, self.folder.f1, self.folder.f1.d2, self.folder.f1.f2):
self.assertEqual(self.workflow.getInfoFor(o, "review_state"), "published")
self.assertTrue(isExpired(o))

def test_publishing_without_subobjects(self):
paths = []
for o in (self.folder.d1, self.folder.f1):
paths.append("/".join(o.getPhysicalPath()))

self.folder_publish(
workflow_action="publish", paths=paths, include_children=False
)
for o in (self.folder.d1, self.folder.f1):
self.assertEqual(self.workflow.getInfoFor(o, "review_state"), "published")
for o in (self.folder.f1.d2, self.folder.f1.f2):
self.assertEqual(self.workflow.getInfoFor(o, "review_state"), "private")

def test_publishing_orig_template_safe(self):
paths = []
for o in (self.folder.d1, self.folder.f1):
paths.append("/".join(o.getPhysicalPath()))

self.request.form["orig_template"] = "some_view"
self.folder_publish(workflow_action="publish", paths=paths)
self.assertEqual(self.request.response.getHeader("Location"), "some_view")

def test_publishing_orig_template_attacker(self):
paths = []
for o in (self.folder.d1, self.folder.f1):
paths.append("/".join(o.getPhysicalPath()))

self.request.form["orig_template"] = "https://attacker.com"
self.folder_publish(workflow_action="publish", paths=paths)
self.assertEqual(self.request.response.getHeader("Location"), self.folder.absolute_url())
6 changes: 2 additions & 4 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from setuptools import find_packages
from setuptools import setup

version = '3.8.8.dev0'
version = '4.0.0.dev0'

setup(
name='plone.app.content',
Expand All @@ -15,11 +15,10 @@
classifiers=[
"Development Status :: 5 - Production/Stable",
"Framework :: Plone",
"Framework :: Plone :: 5.2",
"Framework :: Plone :: 6.0",
"Framework :: Plone :: Core",
"License :: OSI Approved :: GNU General Public License v2 (GPLv2)",
"Programming Language :: Python",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
Expand All @@ -37,7 +36,6 @@
test=[
'plone.app.contenttypes',
'plone.app.testing',
'mock;python_version<"3.3"'
]
),
install_requires=[
Expand Down