diff --git a/news/3953.breaking b/news/3953.breaking
new file mode 100644
index 00000000..1b7cbd08
--- /dev/null
+++ b/news/3953.breaking
@@ -0,0 +1,2 @@
+Move plone.app.content views to plone.app.layout
+[frapell]
diff --git a/setup.py b/setup.py
index a7424451..af408f52 100644
--- a/setup.py
+++ b/setup.py
@@ -46,17 +46,25 @@
"plone.app.relationfield",
"plone.app.uuid",
"plone.app.viewletmanager >=1.2",
+ "plone.autoform",
"plone.base >=4.0.0a1",
+ "plone.batching",
"plone.dexterity",
+ "plone.folder",
"plone.formwidget.namedfile",
"plone.i18n",
+ "plone.locking",
"plone.memoize",
"plone.portlets",
"plone.protect",
+ "plone.registry",
+ "plone.supermodel",
+ "plone.uuid",
"Products.CMFEditions >=1.2.2",
"Products.GenericSetup",
"Products.statusmessages",
"Products.ZCatalog",
+ "z3c.form",
"Zope",
],
extras_require=dict(
@@ -66,13 +74,16 @@
"plone.app.relationfield",
"plone.app.robotframework",
"plone.app.testing",
+ "plone.app.z3cform",
"plone.app.textfield",
"plone.browserlayer",
"plone.dexterity",
"plone.locking",
+ "plone.namedfile",
"plone.testing",
"robotsuite",
"z3c.relationfield",
+ "zope.annotation",
"zc.relation",
"zope.intid",
]
diff --git a/src/plone/app/layout/configure.zcml b/src/plone/app/layout/configure.zcml
index f1cefa5d..73212105 100644
--- a/src/plone/app/layout/configure.zcml
+++ b/src/plone/app/layout/configure.zcml
@@ -21,6 +21,7 @@
directory="profiles/uninstall"
/>
+
diff --git a/src/plone/app/layout/content/__init__.py b/src/plone/app/layout/content/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/plone/app/layout/content/browser/__init__.py b/src/plone/app/layout/content/browser/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/plone/app/layout/content/browser/actions.py b/src/plone/app/layout/content/browser/actions.py
new file mode 100644
index 00000000..6f16e535
--- /dev/null
+++ b/src/plone/app/layout/content/browser/actions.py
@@ -0,0 +1,368 @@
+from AccessControl import getSecurityManager
+from Acquisition import aq_inner
+from Acquisition import aq_parent
+from OFS.CopySupport import CopyError
+from plone.base import PloneMessageFactory as _
+from plone.base.utils import get_user_friendly_types
+from plone.base.utils import safe_text
+from plone.locking.interfaces import ILockable
+from Products.CMFCore.utils import getToolByName
+from Products.Five.browser import BrowserView
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from Products.statusmessages.interfaces import IStatusMessage
+from z3c.form import button
+from z3c.form import field
+from z3c.form import form
+from z3c.form.widget import ComputedWidgetAttribute
+from zExceptions import Unauthorized
+from ZODB.POSException import ConflictError
+from zope import schema
+from zope.component import getMultiAdapter
+from zope.component import queryMultiAdapter
+from zope.container.interfaces import INameChooser
+from zope.event import notify
+from zope.interface import Interface
+from zope.lifecycleevent import ObjectModifiedEvent
+
+import transaction
+
+
+class LockingBase(BrowserView):
+ @property
+ def is_locked(self):
+ locking_view = queryMultiAdapter(
+ (self.context, self.request), name="plone_lock_info"
+ )
+
+ return locking_view and locking_view.is_locked_for_current_user()
+
+
+class DeleteConfirmationForm(form.Form, LockingBase):
+ fields = field.Fields()
+ template = ViewPageTemplateFile("templates/delete_confirmation.pt")
+ enableCSRFProtection = True
+
+ def view_url(self):
+ """Facade to the homonymous plone_context_state method"""
+ context_state = getMultiAdapter(
+ (self.context, self.request), name="plone_context_state"
+ )
+ return context_state.view_url()
+
+ def more_info(self):
+ adapter = queryMultiAdapter(
+ (self.context, self.request), name="delete_confirmation_info"
+ )
+ if adapter:
+ return adapter()
+ return ""
+
+ @property
+ def items_to_delete(self):
+ catalog = getToolByName(self.context, "portal_catalog")
+ results = catalog(
+ {
+ "path": "/".join(self.context.getPhysicalPath()),
+ "portal_type": get_user_friendly_types(),
+ }
+ )
+ return len(results)
+
+ @button.buttonAndHandler(_("Delete"), name="Delete")
+ def handle_delete(self, action):
+ title = safe_text(self.context.Title())
+ parent = aq_parent(aq_inner(self.context))
+
+ # has the context object been acquired from a place it should not have
+ # been?
+ if self.context.aq_chain == self.context.aq_inner.aq_chain:
+ try:
+ lock_info = self.context.restrictedTraverse("@@plone_lock_info")
+ except AttributeError:
+ lock_info = None
+ if lock_info is not None:
+ if lock_info.is_locked() and not lock_info.is_locked_for_current_user():
+ # unlock object as it is locked by current user
+ ILockable(self.context).unlock()
+ parent.manage_delObjects(self.context.getId())
+ IStatusMessage(self.request).add(
+ _("${title} has been deleted.", mapping={"title": title})
+ )
+ else:
+ IStatusMessage(self.request).add(
+ _('"${title}" has already been deleted', mapping={"title": title})
+ )
+
+ self.request.response.redirect(parent.absolute_url())
+
+ @button.buttonAndHandler(_("label_cancel", default="Cancel"), name="Cancel")
+ def handle_cancel(self, action):
+ target = self.view_url()
+ return self.request.response.redirect(target)
+
+ def updateActions(self):
+ super().updateActions()
+ if self.actions and "Delete" in self.actions:
+ self.actions["Delete"].addClass("btn-danger")
+ if self.actions and "Cancel" in self.actions:
+ self.actions["Cancel"].addClass("btn-secondary")
+
+
+def valid_id(self):
+ # TODO: Do we need an validator here or use the same that's used in
+ # plone.app.dexterity.behaviors.id.IShortName
+ return True
+
+
+class IRenameForm(Interface):
+ new_id = schema.ASCIILine(
+ title=_("label_new_short_name", default="New Short Name"),
+ description=_(
+ "help_short_name_url",
+ default="Short name is the part that shows up in the URL " + "of the item.",
+ ),
+ constraint=valid_id,
+ )
+
+ new_title = schema.TextLine(
+ title=_("label_new_title", default="New Title"),
+ )
+
+
+default_new_id = ComputedWidgetAttribute(
+ lambda form: form.context.getId(), field=IRenameForm["new_id"]
+)
+
+default_new_title = ComputedWidgetAttribute(
+ lambda form: form.context.Title(), field=IRenameForm["new_title"]
+)
+
+
+class RenameForm(form.Form):
+ fields = field.Fields(IRenameForm)
+ template = ViewPageTemplateFile("templates/object_rename.pt")
+ enableCSRFProtection = True
+ ignoreContext = True
+
+ label = _("heading_rename_item", default="Rename item")
+ description = _(
+ "description_rename_item",
+ default="Each item has a Short Name and a Title, which you can "
+ + "change by entering the new details below.",
+ )
+
+ def view_url(self):
+ context_state = getMultiAdapter(
+ (self.context, self.request), name="plone_context_state"
+ )
+ return context_state.view_url()
+
+ @button.buttonAndHandler(_("Rename"), name="Rename")
+ def handle_rename(self, action):
+ data, errors = self.extractData()
+ if errors:
+ return
+
+ parent = aq_parent(aq_inner(self.context))
+ sm = getSecurityManager()
+ if not sm.checkPermission("Copy or Move", parent):
+ raise Unauthorized(
+ _(
+ "Permission denied to rename ${title}.",
+ mapping={"title": self.context.title},
+ )
+ )
+
+ # Requires cmf.ModifyPortalContent permission
+ self.context.title = data["new_title"]
+
+ oldid = self.context.getId()
+ newid = data["new_id"]
+ if oldid != newid:
+ newid = INameChooser(parent).chooseName(newid, self.context)
+
+ # Requires zope2.CopyOrMove permission
+
+ # manage_renameObjects fires 3 events:
+ # 1. ObjectWillBeMovedEvent before anything happens
+ # 2. ObjectMovedEvent directly after rename
+ # 3. zope.container.contained.notifyContainerModified directly after 2
+ # for 2+3 there are subscribers in Products.CMFDynamicViewFTI
+ # responsible to change (2) or unset (3) the default_page.
+
+ parent.manage_renameObjects(
+ [
+ oldid,
+ ],
+ [
+ str(newid),
+ ],
+ )
+ else:
+ # Object is not reindex if manage_renameObjects is not called
+ self.context.reindexObject()
+
+ transaction.savepoint(optimistic=True)
+ notify(ObjectModifiedEvent(self.context))
+
+ IStatusMessage(self.request).add(
+ _(
+ "Renamed '${oldid}' to '${newid}'.",
+ mapping={"oldid": oldid, "newid": newid},
+ )
+ )
+
+ self.request.response.redirect(self.view_url())
+
+ @button.buttonAndHandler(_("label_cancel", default="Cancel"), name="Cancel")
+ def handle_cancel(self, action):
+ self.request.response.redirect(self.view_url())
+
+ def updateActions(self):
+ super().updateActions()
+ if self.actions and "Rename" in self.actions:
+ self.actions["Rename"].addClass("btn-primary")
+ if self.actions and "Cancel" in self.actions:
+ self.actions["Cancel"].addClass("btn-secondary")
+
+
+class ObjectCutView(BrowserView):
+ @property
+ def title(self):
+ return self.context.Title()
+
+ @property
+ def parent(self):
+ return aq_parent(aq_inner(self.context))
+
+ @property
+ def canonical_object_url(self):
+ context_state = getMultiAdapter(
+ (self.context, self.request), name="plone_context_state"
+ )
+ return context_state.canonical_object_url()
+
+ @property
+ def view_url(self):
+ context_state = getMultiAdapter(
+ (self.context, self.request), name="plone_context_state"
+ )
+ return context_state.view_url()
+
+ def do_redirect(self, url, message=None, message_type="info", raise_exception=None):
+ if message is not None:
+ IStatusMessage(self.request).add(message, type=message_type)
+
+ if raise_exception is None:
+ return self.request.response.redirect(url)
+ raise raise_exception
+
+ def do_action(self):
+ try:
+ lock_info = self.context.restrictedTraverse("@@plone_lock_info")
+ except AttributeError:
+ lock_info = None
+ if lock_info is not None:
+ if lock_info.is_locked_for_current_user():
+ return self.do_redirect(
+ self.view_url,
+ _(
+ "${title} is locked and cannot be cut.",
+ mapping={
+ "title": self.title,
+ },
+ ),
+ )
+ elif lock_info.is_locked():
+ # unlock object as it is locked by current user
+ ILockable(self.context).unlock()
+
+ try:
+ cp = self.parent.manage_cutObjects(self.context.getId())
+ except CopyError:
+ return self.do_redirect(
+ self.view_url,
+ _("${title} is not moveable.", mapping={"title": self.title}),
+ )
+ self.request.response.setCookie(
+ "__cp", cp, path=self.request["BASEPATH1"] or "/"
+ )
+ self.request["__cp"] = cp
+
+ return self.do_redirect(
+ self.view_url, _("${title} cut.", mapping={"title": self.title}), "info"
+ )
+
+ def __call__(self):
+ authenticator = getMultiAdapter(
+ (self.context, self.request), name="authenticator"
+ )
+
+ if not authenticator.verify():
+ raise Unauthorized
+
+ return self.do_action()
+
+
+class ObjectCopyView(ObjectCutView):
+ def do_action(self):
+ try:
+ cp = self.parent.manage_copyObjects(self.context.getId())
+ except CopyError:
+ return self.do_redirect(
+ self.view_url,
+ _("${title} is not copyable.", mapping={"title": self.title}),
+ )
+ self.request.response.setCookie(
+ "__cp", cp, path=self.request["BASEPATH1"] or "/"
+ )
+ self.request["__cp"] = cp
+
+ return self.do_redirect(
+ self.view_url, _("${title} copied.", mapping={"title": self.title})
+ )
+
+
+class ObjectDeleteView(ObjectCutView):
+ def do_action(self):
+ form = DeleteConfirmationForm(self.context, self.request)
+ form.update()
+
+ button = form.buttons["Delete"]
+ # delete by clicking the form button in delete_confirmation
+ form.handlers.getHandler(button)(form, button)
+
+
+class ObjectPasteView(ObjectCutView):
+ def do_action(self):
+ if not self.context.cb_dataValid():
+ return self.do_redirect(
+ self.canonical_object_url,
+ _("Copy or cut one or more items to paste."),
+ "error",
+ )
+ try:
+ self.context.manage_pasteObjects(self.request["__cp"])
+ except ConflictError:
+ raise
+ except Unauthorized as e:
+ self.do_redirect(
+ self.canonical_object_url, _("You are not authorized to paste here."), e
+ )
+ except CopyError as e:
+ error_string = str(e)
+ if "Item Not Found" in error_string:
+ return self.do_redirect(
+ self.canonical_object_url,
+ _(
+ "The item you are trying to paste could not be found. "
+ "It may have been moved or deleted after you copied or "
+ "cut it."
+ ),
+ "error",
+ )
+ except Exception as e:
+ if "__cp" in self.request:
+ self.do_redirect(self.canonical_object_url, e, "error", e)
+
+ return self.do_redirect(self.canonical_object_url, _("Item(s) pasted."))
diff --git a/src/plone/app/layout/content/browser/adding.py b/src/plone/app/layout/content/browser/adding.py
new file mode 100644
index 00000000..60f06989
--- /dev/null
+++ b/src/plone/app/layout/content/browser/adding.py
@@ -0,0 +1,22 @@
+from Acquisition import Implicit
+from Products.CMFCore.utils import getToolByName
+from Products.Five.browser.adding import ContentAdding
+
+
+class CMFAdding(Implicit, ContentAdding):
+ """An adding view with a less silly next-url"""
+
+ # We need to do this to get proper traversal URLs - otherwise, the
+ # tag is messed up.
+ id = "+"
+
+ def add(self, content):
+ content = super().add(content)
+ # We need to ensure that we finish type construction, not at least
+ # to set the correct permissions based on the workflow
+ getToolByName(content, "portal_types")
+
+ return content
+
+ def nextURL(self):
+ return f"{self.context.absolute_url()}/{self.contentName}/view"
diff --git a/src/plone/app/layout/content/browser/configure.zcml b/src/plone/app/layout/content/browser/configure.zcml
new file mode 100644
index 00000000..f2762e98
--- /dev/null
+++ b/src/plone/app/layout/content/browser/configure.zcml
@@ -0,0 +1,213 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/content/browser/constraintypes.pt b/src/plone/app/layout/content/browser/constraintypes.pt
new file mode 100644
index 00000000..2cb548d3
--- /dev/null
+++ b/src/plone/app/layout/content/browser/constraintypes.pt
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Title
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/content/browser/constraintypes.py b/src/plone/app/layout/content/browser/constraintypes.py
new file mode 100644
index 00000000..046d3f71
--- /dev/null
+++ b/src/plone/app/layout/content/browser/constraintypes.py
@@ -0,0 +1,194 @@
+from plone.autoform.form import AutoExtensibleForm
+from plone.base.interfaces import ISelectableConstrainTypes
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from z3c.form import button
+from z3c.form import form
+from z3c.form.browser.checkbox import CheckBoxFieldWidget
+from zope.globalrequest import getRequest
+from zope.i18n import translate
+from zope.i18nmessageid import MessageFactory
+from zope.interface import implementer
+from zope.interface import Interface
+from zope.interface import invariant
+from zope.interface.exceptions import Invalid
+from zope.schema import Choice
+from zope.schema import List
+from zope.schema.interfaces import IVocabularyFactory
+from zope.schema.vocabulary import SimpleTerm
+from zope.schema.vocabulary import SimpleVocabulary
+
+# XXX
+# acquire locallyAllowedTypes from parent (default)
+ACQUIRE = -1
+
+# use default behavior of PortalFolder which uses the FTI information
+DISABLED = 0
+
+# allow types from locallyAllowedTypes only
+ENABLED = 1
+
+
+def ST(key, title):
+ return SimpleTerm(value=key, title=title)
+
+
+# reuse the translations that we had in atcontenttypes
+_ = MessageFactory("plone")
+
+possible_constrain_types = SimpleVocabulary(
+ [
+ ST(
+ ACQUIRE,
+ _("constraintypes_acquire_label", default="Use parent folder settings"),
+ ),
+ ST(DISABLED, _("constraintypes_disable_label", default="Use portal default")),
+ ST(ENABLED, _("constraintypes_enable_label", default="Select manually")),
+ ]
+)
+
+
+@implementer(IVocabularyFactory)
+class ValidTypes:
+ def __call__(self, context):
+ constrain_aspect = context.context
+ request = getRequest()
+ items = []
+ for type_ in constrain_aspect.getDefaultAddableTypes():
+ items.append(SimpleTerm(value=type_.getId(), title=type_.Title()))
+
+ return SimpleVocabulary(
+ sorted(items, key=lambda x: translate(x.title, context=request).lower())
+ )
+
+
+ValidTypesFactory = ValidTypes()
+
+
+class IConstrainForm(Interface):
+ constrain_types_mode = Choice(
+ title=_("label_type_restrictions", default="Type restrictions"),
+ description=_(
+ "help_add_restriction_mode",
+ default="Select the restriction policy " "in this location",
+ ),
+ vocabulary=possible_constrain_types,
+ required=True,
+ default=ACQUIRE,
+ )
+
+ allowed_types = List(
+ title=_("label_immediately_addable_types", default="Allowed types"),
+ description=_(
+ "help_immediately_addable_types",
+ default="Controls what types are addable " "in this location",
+ ),
+ value_type=Choice(source="plone.app.content.ValidAddableTypes"),
+ required=False,
+ )
+
+ secondary_types = List(
+ title=_("label_locally_allowed_types", default="Secondary types"),
+ description=_(
+ "help_locally_allowed_types",
+ default=""
+ "Select which types should be available in the "
+ "'More…' submenu instead of in the "
+ "main pulldown. "
+ "This is useful to indicate that these are not the "
+ "preferred types "
+ "in this location, but are allowed if you really "
+ "need them.",
+ ),
+ value_type=Choice(source="plone.app.content.ValidAddableTypes"),
+ required=False,
+ )
+
+ @invariant
+ def legal_not_immediately_addable(data):
+ missing = []
+ for one_allowed in data.secondary_types:
+ if one_allowed not in data.allowed_types:
+ missing.append(one_allowed)
+ if missing:
+ raise Invalid(
+ _(
+ "You cannot have a type as a secondary type without "
+ "having it allowed. You have selected ${types}.",
+ mapping=dict(types=", ".join(missing)),
+ )
+ )
+ return True
+
+
+@implementer(IConstrainForm)
+class FormContentAdapter:
+ def __init__(self, context):
+ self.context = ISelectableConstrainTypes(context)
+
+ @property
+ def constrain_types_mode(self):
+ return self.context.getConstrainTypesMode()
+
+ @property
+ def allowed_types(self):
+ return self.context.getLocallyAllowedTypes()
+
+ @property
+ def secondary_types(self):
+ immediately_addable = self.context.getImmediatelyAddableTypes()
+ return [
+ t
+ for t in self.context.getLocallyAllowedTypes()
+ if t not in immediately_addable
+ ]
+
+
+class ConstrainsFormView(AutoExtensibleForm, form.EditForm):
+ schema = IConstrainForm
+ label = _(
+ "heading_set_content_type_restrictions",
+ default="Restrict what types of content can be added",
+ )
+ template = ViewPageTemplateFile("constraintypes.pt")
+
+ def getContent(self):
+ return FormContentAdapter(self.context)
+
+ def updateFields(self):
+ super().updateFields()
+ self.fields["allowed_types"].widgetFactory = CheckBoxFieldWidget
+ self.fields["secondary_types"].widgetFactory = CheckBoxFieldWidget
+
+ def updateWidgets(self):
+ super().updateWidgets()
+ self.widgets["allowed_types"].addClass("current_prefer_form")
+ self.widgets["secondary_types"].addClass("current_allow_form")
+ self.widgets["constrain_types_mode"].addClass("constrain_types_mode_form")
+
+ def updateActions(self):
+ super().updateActions()
+ self.actions["save"].addClass("btn btn-primary")
+
+ @button.buttonAndHandler(_("label_save", default="Save"), name="save")
+ def handleSave(self, action):
+ data, errors = self.extractData()
+ if errors:
+ self.status = self.formErrorsMessage
+ return
+
+ allowed_types = data["allowed_types"]
+ immediately_addable = [
+ t for t in allowed_types if t not in data["secondary_types"]
+ ]
+
+ aspect = ISelectableConstrainTypes(self.context)
+ aspect.setConstrainTypesMode(data["constrain_types_mode"])
+ aspect.setLocallyAllowedTypes(allowed_types)
+ aspect.setImmediatelyAddableTypes(immediately_addable)
+ contextURL = self.context.absolute_url()
+ self.request.response.redirect(contextURL)
+
+ @button.buttonAndHandler(_("label_cancel", default="Cancel"), name="cancel")
+ def handleCancel(self, action):
+ contextURL = self.context.absolute_url()
+ self.request.response.redirect(contextURL)
diff --git a/src/plone/app/layout/content/browser/content_status_history.py b/src/plone/app/layout/content/browser/content_status_history.py
new file mode 100644
index 00000000..60e01ab4
--- /dev/null
+++ b/src/plone/app/layout/content/browser/content_status_history.py
@@ -0,0 +1,148 @@
+from Acquisition import aq_parent
+from plone.base import PloneMessageFactory as _
+from plone.base.defaultpage import is_default_page
+from plone.base.utils import human_readable_size
+from plone.base.utils import is_expired
+from Products.CMFCore.utils import getToolByName
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from Products.statusmessages.interfaces import IStatusMessage
+from z3c.form import field
+from z3c.form import form
+from zope.deprecation.deprecation import deprecate
+from zope.interface import Interface
+from zope.publisher.browser import BrowserView
+from zope.schema import Datetime
+from zope.schema.fieldproperty import FieldProperty
+
+
+class IContentStatusHistoryDates(Interface):
+ """Interface for the two dates on content status history view"""
+
+ effective_date = Datetime(
+ title=_("label_effective_date", default="Publishing Date"),
+ description=_(
+ "help_effective_date_content_status_history",
+ default="The date when the item will be published. If no "
+ "date is selected the item will be published immediately.",
+ ),
+ required=False,
+ )
+
+ expiration_date = Datetime(
+ title=_("label_expiration_date", default="Expiration Date"),
+ description=_(
+ "help_expiration_date_content_status_history",
+ default="The date when the item expires. This will automatically "
+ "make the item invisible for others at the given date. "
+ "If no date is chosen, it will never expire.",
+ ),
+ required=False,
+ )
+
+
+class ContentStatusHistoryDatesForm(form.Form):
+ fields = field.Fields(IContentStatusHistoryDates)
+ ignoreContext = True
+ label = "Content status history dates"
+
+ effective_date = FieldProperty(IContentStatusHistoryDates["effective_date"])
+ expiration_date = FieldProperty(IContentStatusHistoryDates["expiration_date"])
+
+
+class ContentStatusHistoryView(BrowserView):
+ template = ViewPageTemplateFile("templates/content_status_history.pt")
+
+ def __init__(self, context, request):
+ super().__init__(context, request)
+
+ self.dates_form = ContentStatusHistoryDatesForm(context, request)
+ self.dates_form.updateWidgets()
+ self.errors = {}
+
+ def __call__(
+ self,
+ workflow_action=None,
+ paths=[],
+ comment="",
+ effective_date=None,
+ expiration_date=None,
+ include_children=False,
+ *args,
+ ):
+ data = self.dates_form.extractData()
+ if self.request.get("form.widgets.effective_date-calendar", None) and data:
+ effective_date = data[0]["effective_date"].strftime("%Y-%m-%d %H:%M")
+
+ if self.request.get("form.widgets.expiration_date-calendar", None) and data:
+ expiration_date = data[0]["expiration_date"].strftime("%Y-%m-%d %H:%M")
+
+ if self.request.get("form.button.Cancel", None):
+ return self.request.RESPONSE.redirect(
+ "%s/view" % self.context.absolute_url()
+ )
+
+ if self.request.get("form.submitted", None):
+ self.validate(workflow_action=workflow_action, paths=paths)
+ if self.errors:
+ IStatusMessage(self.request).add(
+ _("Please correct the indicated errors."), type="error"
+ )
+ return self.template()
+
+ if self.request.get("form.button.Publish", None):
+ return self.context.restrictedTraverse("content_status_modify")(
+ workflow_action=workflow_action,
+ comment=comment,
+ effective_date=effective_date,
+ expiration_date=expiration_date,
+ )
+
+ if self.request.get("form.button.FolderPublish", None):
+ self.context.restrictedTraverse("folder_publish")(
+ workflow_action=workflow_action,
+ paths=paths,
+ comment=comment,
+ expiration_date=expiration_date,
+ effective_date=effective_date,
+ include_children=include_children,
+ )
+
+ return self.template()
+
+ def validate(self, workflow_action=None, paths=[]):
+ if workflow_action is None:
+ self.errors["workflow_action"] = _("You must select a publishing action.")
+
+ if not paths:
+ self.errors["paths"] = _("You must select content to change.")
+ # If there are no paths, it's mostly a mistake
+ # Set paths using orgi_paths, otherwise users are getting confused
+ orig_paths = self.request.get("orig_paths")
+ self.request.set("paths", orig_paths)
+
+ def get_objects_from_path_list(self, paths=[]):
+ contents = []
+ portal = getToolByName(self.context, "portal_url").getPortalObject()
+ for path in paths:
+ obj = portal.restrictedTraverse(str(path), None)
+ if obj is not None:
+ contents.append(obj)
+ return contents
+
+ def redirect_to_referrer(self):
+ referer = self.request.get("HTTP_REFERER", "")
+ target_url = referer.split("?", 1)[0]
+ return self.request.RESPONSE.redirect(target_url)
+
+ def isExpired(self, content):
+ return is_expired(content)
+
+ def is_default_page(self, obj):
+ return is_default_page(aq_parent(self.context), obj)
+
+ @deprecate(
+ "This method is deprecated since Plone 6, "
+ "use the @@plone/human_readable_size method instead"
+ )
+ def human_readable_size(self, size):
+ return human_readable_size(size)
diff --git a/src/plone/app/layout/content/browser/content_status_modify.py b/src/plone/app/layout/content/browser/content_status_modify.py
new file mode 100644
index 00000000..ae64bbe1
--- /dev/null
+++ b/src/plone/app/layout/content/browser/content_status_modify.py
@@ -0,0 +1,165 @@
+from AccessControl import Unauthorized
+from Acquisition import aq_inner
+from Acquisition import aq_parent
+from DateTime import DateTime
+from plone.base import PloneMessageFactory as _
+from plone.base.defaultpage import check_default_page_via_view
+from plone.base.utils import transaction_note
+from plone.protect import CheckAuthenticator
+from plone.registry.interfaces import IRegistry
+from Products.CMFCore.utils import getToolByName
+from Products.Five import BrowserView
+from Products.statusmessages.interfaces import IStatusMessage
+from ZODB.POSException import ConflictError
+from zope.component import getMultiAdapter
+from zope.component import getUtility
+
+
+class ContentStatusModifyView(BrowserView):
+ """Handles the workflow transitions of objects.
+
+ Former Controller Python Script "content_status_modify".
+
+ [validators]
+ validators = validate_content_status_modify
+
+ [actions]
+ action.failure=traverse_to:string:content_status_history
+ action.success=redirect_to_action:string:view
+
+ """
+
+ def __call__(
+ self,
+ workflow_action=None,
+ comment="",
+ effective_date=None,
+ expiration_date=None,
+ **kwargs,
+ ):
+ """Do a workflow action.
+
+ The status dropdown in the toolbar has links to for example:
+ content_status_modify?workflow_action=reject&_authenticator=secret
+ That is the main entry into this browser view.
+
+ When you right-click the status menu and open in a new tab,
+ you end up on the content_status_history page.
+ This page contains a form which posts to this view.
+
+ In the form you can select a workflow action.
+ When you select "no change" the workflow_action parameter actually contains
+ the current status. This status is naturally not in the list of allowed transitions.
+ So we should be lenient here, and not complain much.
+
+ Also, when the view is called on a default page,
+ the code below tries to do the same transition on the parent folder,
+ by calling this view on the parent. This may easily fail.
+ This is yet another reason to be lenient.
+ Otherwise you may see both a successful portal status message
+ and one with an error.
+
+ In the form you can also add a comment and set an effective and/or expiration date.
+ """
+ context = aq_inner(self.context)
+ portal_workflow = getToolByName(context, "portal_workflow")
+ self.plone_utils = getToolByName(context, "plone_utils")
+ # First check if the main argument is given.
+ if not workflow_action:
+ IStatusMessage(self.request).add(
+ _("You must select a publishing action."), type="error"
+ )
+ url = f"{context.absolute_url()}/content_status_history"
+ return self.request.response.redirect(url)
+ # If a workflow action was specified, there must be a plone.protect authenticator.
+ CheckAuthenticator(self.request)
+
+ # Get effective and expiration dates from the form, if not set in the arguments.
+ form = self.request.form
+ if not effective_date:
+ effective_date = form.get("form.widgets.effective_date") or effective_date
+ if not expiration_date:
+ expiration_date = (
+ form.get("form.widgets.expiration_date") or expiration_date
+ )
+
+ # Make sure an effective date is always set when there is a valid workflow action.
+ transitions = portal_workflow.getTransitionsFor(context)
+ transition_ids = [t["id"] for t in transitions]
+ if (
+ workflow_action in transition_ids
+ and not effective_date
+ and context.EffectiveDate() == "None"
+ ):
+ effective_date = DateTime()
+
+ # You can transition content but not have the permission to ModifyPortalContent.
+ contentEditSuccess = 0
+ try:
+ self.editContent(context, effective_date, expiration_date)
+ contentEditSuccess = 1
+ except Unauthorized:
+ pass
+
+ # Create the note while we still have access to the original context
+ note = "Changed status of {} at {}".format(
+ context.title_or_id(),
+ context.absolute_url(),
+ )
+
+ if workflow_action in transition_ids:
+ # The action could result in a move or delete.
+ # In that case we get a new context as answer.
+ context = portal_workflow.doActionFor(
+ context, workflow_action, comment=comment
+ )
+ if context is None:
+ # the normal case
+ context = aq_inner(self.context)
+
+ # The object post-transition could now have ModifyPortalContent permission.
+ if not contentEditSuccess:
+ try:
+ self.editContent(context, effective_date, expiration_date)
+ except Unauthorized:
+ pass
+
+ transaction_note(note)
+
+ # If this item is the default page in its parent, attempt to publish that
+ # too. It may not be possible, of course
+ if check_default_page_via_view(context, self.request):
+ parent = aq_parent(context)
+ try:
+ parent_modify_view = getMultiAdapter(
+ (parent, self.request), name="content_status_modify"
+ )
+ parent_modify_view(
+ workflow_action,
+ comment,
+ effective_date=effective_date,
+ expiration_date=expiration_date,
+ )
+ except ConflictError:
+ raise
+ except Exception:
+ pass
+
+ IStatusMessage(self.request).add(_("Item state changed."))
+ registry = getUtility(IRegistry)
+ url = context.absolute_url()
+ if context.portal_type in registry.get(
+ "plone.types_use_view_action_in_listings", ()
+ ):
+ url += "/view"
+ return self.request.response.redirect(url)
+
+ def editContent(self, obj, effective, expiry):
+ kwargs = {}
+ # may contain the year
+ if effective and (isinstance(effective, DateTime) or len(effective) > 5):
+ kwargs["effective_date"] = effective
+ # may contain the year
+ if expiry and (isinstance(expiry, DateTime) or len(expiry) > 5):
+ kwargs["expiration_date"] = expiry
+ self.plone_utils.contentEdit(obj, **kwargs)
diff --git a/src/plone/app/layout/content/browser/contents/__init__.py b/src/plone/app/layout/content/browser/contents/__init__.py
new file mode 100644
index 00000000..10a9c5b9
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/__init__.py
@@ -0,0 +1,388 @@
+from AccessControl import Unauthorized
+from Acquisition import aq_inner
+from Acquisition import aq_parent
+from plone.app.layout.content.browser.file import TUS_ENABLED
+from plone.app.layout.content.browser.interfaces import IFolderContentsView
+from plone.app.content.interfaces import IStructureAction
+from plone.app.content.utils import json_dumps
+from plone.app.content.utils import json_loads
+from plone.app.uuid.utils import uuidToCatalogBrain
+from plone.base import PloneMessageFactory as _
+from plone.base import utils
+from plone.base.interfaces.controlpanel import ISiteSchema
+from plone.protect.postonly import check as checkpost
+from plone.registry.interfaces import IRegistry
+from plone.uuid.interfaces import IUUID
+from Products.CMFCore.utils import getToolByName
+from Products.Five import BrowserView
+from Products.PortalTransforms.transforms.safe_html import SafeHTML
+from zope.browsermenu.interfaces import IBrowserMenu
+from zope.component import getMultiAdapter
+from zope.component import getUtilitiesFor
+from zope.component import getUtility
+from zope.i18n import translate
+from zope.interface import implementer
+from zope.schema.interfaces import IVocabularyFactory
+
+
+class ContentsBaseAction(BrowserView):
+ success_msg = _("Success")
+ failure_msg = _("Failure")
+ required_obj_permission = None
+
+ @property
+ def site(self):
+ return utils.get_top_site_from_url(self.context, self.request)
+
+ def objectTitle(self, obj):
+ context = aq_inner(obj)
+ title = utils.pretty_title_or_id(context, context)
+ return utils.safe_text(title)
+
+ def protect(self):
+ authenticator = getMultiAdapter(
+ (self.context, self.request), name="authenticator"
+ )
+ if not authenticator.verify():
+ raise Unauthorized
+ checkpost(self.request)
+
+ def json(self, data):
+ self.request.response.setHeader(
+ "Content-Type", "application/json; charset=utf-8"
+ )
+ return json_dumps(data)
+
+ def get_selection(self):
+ selection = self.request.form.get("selection", "[]")
+ return json_loads(selection)
+
+ def action(self, obj):
+ """
+ fill in this method to do action against each item in the selection
+ """
+ pass
+
+ def finish(self):
+ pass
+
+ def __call__(self, keep_selection_order=False):
+ self.protect()
+ self.errors = []
+ context = aq_inner(self.context)
+ selection = self.get_selection()
+
+ parts = str(self.request.form.get("folder", "").lstrip("/")).split("/")
+ if parts:
+ parent = self.site.unrestrictedTraverse("/".join(parts[:-1]))
+ self.dest = parent.restrictedTraverse(parts[-1])
+
+ self.catalog = getToolByName(context, "portal_catalog")
+ self.mtool = getToolByName(self.context, "portal_membership")
+
+ brains = []
+ if keep_selection_order:
+ brains = [uuidToCatalogBrain(uid) for uid in selection]
+ else:
+ brains = self.catalog(UID=selection, show_inactive=True)
+
+ for brain in brains:
+ if not brain:
+ continue
+ # remove everyone so we know if we missed any
+ selection.remove(brain.UID)
+ obj = brain.getObject()
+ if self.required_obj_permission and not self.mtool.checkPermission(
+ self.required_obj_permission, obj
+ ):
+ self.errors.append(
+ _(
+ 'Permission denied for "${title}"',
+ mapping={"title": self.objectTitle(obj)},
+ )
+ )
+ continue
+ self.action(obj)
+
+ self.finish()
+ return self.message(selection)
+
+ def message(self, missing=[]):
+ if len(missing) > 0:
+ self.errors.append(
+ _("${items} could not be found", mapping={"items": str(len(missing))})
+ )
+ if self.errors:
+ msg = self.failure_msg
+ else:
+ msg = self.success_msg
+
+ translated_msg = translate(msg, context=self.request)
+ if self.errors:
+ translated_errors = [
+ translate(error, context=self.request) for error in self.errors
+ ]
+ translated_msg = "{:s}: {:s}".format(
+ translated_msg, "\n".join(translated_errors)
+ )
+
+ return self.json(
+ {"status": "warning" if self.errors else "success", "msg": translated_msg}
+ )
+
+
+@implementer(IFolderContentsView)
+class FolderContentsView(BrowserView):
+ def get_actions(self):
+ actions = []
+ for name, Utility in getUtilitiesFor(IStructureAction):
+ utility = Utility(self.context, self.request)
+ actions.append(utility)
+ actions.sort(key=lambda a: a.order)
+ return [a.get_options() for a in actions]
+
+ @property
+ def ignored_columns(self):
+ """Return columns, which should be ignored in folder contents."""
+ # These columns either have alternatives or are probably not useful
+ ignored = [
+ "Date",
+ "Title",
+ "author_name",
+ "cmf_uid",
+ "commentators",
+ "created",
+ "effective",
+ "expires",
+ "getIcon",
+ "getMimeIcon",
+ "getId",
+ "getRemoteUrl",
+ "in_response_to",
+ "listCreators",
+ "meta_type",
+ "modified",
+ "portal_type",
+ "sync_uid",
+ ]
+ return ignored
+
+ def get_columns(self):
+ columns = {}
+ voc = getUtility(IVocabularyFactory, "plone.app.vocabularies.MetadataFields")(
+ self.context
+ )
+ for term in voc:
+ if term.value not in self.ignored_columns:
+ columns[term.value] = translate(term.title, context=self.request)
+
+ return columns
+
+ def get_thumb_scale(self):
+ registry = getUtility(IRegistry)
+ settings = registry.forInterface(ISiteSchema, prefix="plone", check=False)
+ if settings.no_thumbs_tables:
+ # thumbs to be suppressed
+ return None
+ thumb_scale_table = settings.thumb_scale_table
+ return thumb_scale_table
+
+ def default_page_types(self):
+ registry = getUtility(IRegistry)
+ return registry.get("plone.default_page_types", [])
+
+ @property
+ def ignored_indexes(self):
+ ignored = [
+ "Date",
+ "Description",
+ "Title",
+ "allowedRolesAndUsers",
+ "author_name",
+ "cmf_uid",
+ "commentators",
+ "effectiveRange",
+ "getId",
+ "getObjectPositionInParent",
+ "getRawRelatedItems",
+ "in_reply_to",
+ "meta_type",
+ "object_provides",
+ "portal_type",
+ "SearchableText",
+ "sync_uid",
+ ]
+ return ignored
+
+ def get_indexes(self):
+ # Base set of indexes
+ indexes = {
+ "created": translate(_("Created on"), context=self.request),
+ "Creator": translate(_("Creator"), context=self.request),
+ "effective": translate(_("Publication date"), context=self.request), # noqa
+ "end": translate(_("End Date"), context=self.request),
+ "expires": translate(_("Expiration date"), context=self.request),
+ "id": translate(_("ID"), context=self.request),
+ "is_folderish": translate(_("Folder"), context=self.request),
+ "modified": translate(_("Last modified"), context=self.request), # noqa
+ "review_state": translate(_("Review state"), context=self.request),
+ "sortable_title": translate(_("Title"), context=self.request),
+ "start": translate(_("Start Date"), context=self.request),
+ "Subject": translate(_("Tags"), context=self.request),
+ "total_comments": translate(
+ _("Total comments"), context=self.request
+ ), # noqa
+ "Type": translate(_("Type"), context=self.request),
+ }
+ # Filter out ignored
+ indexes = {k: v for k, v in indexes.items() if k not in self.ignored_indexes}
+ # Add in extra metadata indexes
+ catalog = getToolByName(self.context, "portal_catalog")
+ cat_indexes = [idx for idx in catalog.indexes()]
+ for index in cat_indexes:
+ if index not in indexes and index not in self.ignored_indexes:
+ indexes[index] = translate(_(index), context=self.request)
+ return indexes
+
+ def get_options(self):
+ site = utils.get_top_site_from_url(self.context, self.request)
+ base_url = site.absolute_url()
+ base_vocabulary = "%s/@@getVocabulary?name=" % base_url
+ site_path = site.getPhysicalPath()
+ context_path = self.context.getPhysicalPath()
+ columns = self.get_columns()
+ options = {
+ "vocabularyUrl": "%splone.app.vocabularies.Catalog" % (base_vocabulary),
+ "urlStructure": {"base": base_url, "appended": "/folder_contents"},
+ "moveUrl": "%s{path}/fc-itemOrder" % base_url,
+ "indexOptionsUrl": "%s/@@qsOptions" % base_url,
+ "contextInfoUrl": "%s{path}/@@fc-contextInfo" % base_url,
+ "setDefaultPageUrl": "%s{path}/@@fc-setDefaultPage" % base_url,
+ "defaultPageTypes": self.default_page_types(),
+ "searchParam": "Title",
+ "availableColumns": columns,
+ "attributes": [
+ "Title",
+ "path",
+ "getURL",
+ "getIcon",
+ "getMimeIcon",
+ "portal_type",
+ ]
+ + list(columns.keys()), # noqa
+ "buttons": self.get_actions(),
+ "rearrange": {
+ "properties": self.get_indexes(),
+ "url": "%s{path}/@@fc-rearrange" % base_url,
+ },
+ "basePath": "/" + "/".join(context_path[len(site_path) :]),
+ "upload": {
+ "relativePath": "@@fileUpload",
+ "baseUrl": base_url,
+ "initialFolder": IUUID(self.context, None),
+ "useTus": TUS_ENABLED,
+ },
+ "thumb_scale": self.get_thumb_scale(),
+ }
+ return options
+
+ def __call__(self):
+ self.options = json_dumps(self.get_options())
+ return super().__call__()
+
+
+class ContextInfo(BrowserView):
+ attributes = [
+ "CreationDate",
+ "Creator",
+ "Description",
+ "EffectiveDate",
+ "end",
+ "exclude_from_nav",
+ "getObjSize",
+ "getURL",
+ "id",
+ "is_folderish",
+ "last_comment_date",
+ "location",
+ "ModificationDate",
+ "path",
+ "portal_type",
+ "review_state",
+ "start",
+ "Subject",
+ "Title",
+ "total_comments",
+ "Type",
+ "UID",
+ ]
+
+ def __call__(self):
+ factories_menu = getUtility(
+ IBrowserMenu, name="plone_contentmenu_factory", context=self.context
+ ).getMenuItems(self.context, self.request)
+ factories = []
+ for item in factories_menu:
+ if item.get("title") == "folder_add_settings":
+ continue
+ title = item.get("title", "")
+ factories.append(
+ {
+ "id": item.get("id"),
+ "title": title
+ and translate(title, context=self.request)
+ or "", # noqa
+ "action": item.get("action"),
+ }
+ )
+
+ context = aq_inner(self.context)
+ transform = SafeHTML()
+ crumbs = []
+ top_site = utils.get_top_site_from_url(self.context, self.request)
+ while not context == top_site:
+ crumbs.append(
+ {
+ "id": context.getId(),
+ "title": transform.scrub_html(
+ utils.pretty_title_or_id(context, context)
+ ),
+ }
+ )
+ context = aq_parent(aq_inner(context))
+
+ catalog = getToolByName(self.context, "portal_catalog")
+ try:
+ brains = catalog(UID=IUUID(self.context), show_inactive=True)
+ except TypeError:
+ brains = []
+ item = None
+ if len(brains) > 0:
+ obj = brains[0]
+ # context here should be site root
+ base_path = "/".join(context.getPhysicalPath())
+ item = {}
+ for attr in self.attributes:
+ key = attr
+ if key == "path":
+ attr = "getPath"
+ val = getattr(obj, attr, None)
+ if callable(val):
+ val = val()
+ if key == "path":
+ val = val[len(base_path) :]
+ if isinstance(val, (bytes, str)):
+ val = transform.scrub_html(val)
+ item[key] = val
+
+ self.request.response.setHeader(
+ "Content-Type", "application/json; charset=utf-8"
+ )
+ return json_dumps(
+ {
+ "addButtons": factories,
+ "defaultPage": self.context.getDefaultPage(),
+ "breadcrumbs": [c for c in reversed(crumbs)],
+ "object": item,
+ }
+ )
diff --git a/src/plone/app/layout/content/browser/contents/configure.zcml b/src/plone/app/layout/content/browser/contents/configure.zcml
new file mode 100644
index 00000000..931d4b90
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/configure.zcml
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/content/browser/contents/copy.py b/src/plone/app/layout/content/browser/contents/copy.py
new file mode 100644
index 00000000..76b0ffd7
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/copy.py
@@ -0,0 +1,56 @@
+from OFS.CopySupport import _cb_encode
+from OFS.CopySupport import cookie_path
+from OFS.Moniker import Moniker
+from plone.app.layout.content.browser.contents import ContentsBaseAction
+from plone.app.content.interfaces import IStructureAction
+from plone.base import PloneMessageFactory as _
+from zope.i18n import translate
+from zope.interface import implementer
+
+
+@implementer(IStructureAction)
+class CopyAction:
+ order = 2
+
+ def __init__(self, context, request):
+ self.context = context
+ self.request = request
+
+ def get_options(self):
+ return {
+ "tooltip": translate(_("Copy"), context=self.request),
+ "id": "copy",
+ "icon": "plone-copy",
+ "url": self.context.absolute_url() + "/@@fc-copy",
+ }
+
+
+class CopyActionView(ContentsBaseAction):
+ success_msg = _("Successfully copied items")
+ failure_msg = _("Failed to copy items")
+
+ def action(self, obj):
+ self.oblist.append(obj)
+
+ def finish(self):
+ oblist = []
+ for ob in self.oblist:
+ if not ob.cb_isCopyable():
+ self.errors.append(
+ _(
+ "${title} cannot be copied.",
+ mapping={"title": self.objectTitle(ob)},
+ )
+ )
+ continue
+ m = Moniker(ob)
+ oblist.append(m.dump())
+ cp = (0, oblist)
+ cp = _cb_encode(cp)
+ resp = self.request.response
+ resp.setCookie("__cp", cp, path="%s" % cookie_path(self.request))
+ self.request["__cp"] = cp
+
+ def __call__(self):
+ self.oblist = []
+ return super().__call__(keep_selection_order=True)
diff --git a/src/plone/app/layout/content/browser/contents/cut.py b/src/plone/app/layout/content/browser/contents/cut.py
new file mode 100644
index 00000000..b3e54fe1
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/cut.py
@@ -0,0 +1,74 @@
+from OFS.CopySupport import _cb_encode
+from OFS.CopySupport import cookie_path
+from OFS.Moniker import Moniker
+from plone.app.layout.content.browser.contents import ContentsBaseAction
+from plone.app.content.interfaces import IStructureAction
+from plone.base import PloneMessageFactory as _
+from plone.locking.interfaces import ILockable
+from zope.i18n import translate
+from zope.interface import implementer
+
+
+@implementer(IStructureAction)
+class CutAction:
+ order = 1
+
+ def __init__(self, context, request):
+ self.context = context
+ self.request = request
+
+ def get_options(self):
+ return {
+ "tooltip": translate(_("Cut"), context=self.request),
+ "id": "cut",
+ "icon": "plone-cut",
+ "url": self.context.absolute_url() + "/@@fc-cut",
+ }
+
+
+class CutActionView(ContentsBaseAction):
+ success_msg = _("Successfully cut items")
+ failure_msg = _("Failed to cut items")
+
+ def action(self, obj):
+ self.oblist.append(obj)
+
+ def finish(self):
+ oblist = []
+ for ob in self.oblist:
+ try:
+ lock_info = ob.restrictedTraverse("@@plone_lock_info")
+ except AttributeError:
+ lock_info = None
+ if lock_info is not None:
+ if lock_info.is_locked_for_current_user():
+ self.errors.append(
+ _(
+ "${title} is being edited and cannot be cut.",
+ mapping={"title": self.objectTitle(ob)},
+ )
+ )
+ continue
+ elif lock_info.is_locked():
+ # unlock object as it is locked by current user
+ ILockable(ob).unlock()
+
+ if not ob.cb_isMoveable():
+ self.errors.append(
+ _(
+ "${title} is being edited and cannot be cut.",
+ mapping={"title": self.objectTitle(ob)},
+ )
+ )
+ continue
+ m = Moniker(ob)
+ oblist.append(m.dump())
+ cp = (1, oblist)
+ cp = _cb_encode(cp)
+ resp = self.request.response
+ resp.setCookie("__cp", cp, path="%s" % cookie_path(self.request))
+ self.request["__cp"] = cp
+
+ def __call__(self):
+ self.oblist = []
+ return super().__call__()
diff --git a/src/plone/app/layout/content/browser/contents/defaultpage.py b/src/plone/app/layout/content/browser/contents/defaultpage.py
new file mode 100644
index 00000000..006ecea1
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/defaultpage.py
@@ -0,0 +1,22 @@
+from plone.app.layout.content.browser.contents import ContentsBaseAction
+from plone.base import PloneMessageFactory as _
+
+
+class SetDefaultPageActionView(ContentsBaseAction):
+ success_msg = _("Default page set successfully")
+ failure_msg = _("Failed to set default page")
+
+ def __call__(self):
+ cid = self.request.form.get("id")
+ self.errors = []
+
+ if cid not in self.context.objectIds():
+ self.errors.append(
+ _(
+ "There is no object with short name " "${name} in this folder.",
+ mapping={"name": cid},
+ )
+ )
+ else:
+ self.context.setDefaultPage(cid)
+ return self.message()
diff --git a/src/plone/app/layout/content/browser/contents/delete.py b/src/plone/app/layout/content/browser/contents/delete.py
new file mode 100644
index 00000000..9a5f6e1a
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/delete.py
@@ -0,0 +1,94 @@
+from AccessControl import Unauthorized
+from AccessControl.Permissions import delete_objects
+from plone.app.layout.content.browser.contents import ContentsBaseAction
+from plone.app.content.interfaces import IStructureAction
+from plone.base import PloneMessageFactory as _
+from plone.locking.interfaces import ILockable
+from Products.CMFCore.utils import getToolByName
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from zope.component import getMultiAdapter
+from zope.component.hooks import getSite
+from zope.i18n import translate
+from zope.interface import implementer
+
+import json
+
+
+@implementer(IStructureAction)
+class DeleteAction:
+ template = ViewPageTemplateFile("templates/delete.pt")
+ order = 4
+
+ def __init__(self, context, request):
+ self.context = context
+ self.request = request
+
+ def get_options(self):
+ return {
+ "tooltip": translate(_("Delete"), context=self.request),
+ "id": "delete",
+ "icon": "plone-delete",
+ "context": "danger",
+ "url": self.context.absolute_url() + "/@@fc-delete",
+ "form": {
+ "title": translate(_("Delete selected items"), context=self.request),
+ "submitText": translate(_("Yes"), context=self.request),
+ "submitContext": "danger",
+ "template": self.template(),
+ "closeText": translate(_("No"), context=self.request),
+ "dataUrl": self.context.absolute_url() + "/@@fc-delete",
+ },
+ }
+
+
+class DeleteActionView(ContentsBaseAction):
+ required_obj_permission = delete_objects
+ success_msg = _("Successfully delete items")
+ failure_msg = _("Failed to delete items")
+
+ def __call__(self):
+ if self.request.form.get("render") == "yes":
+ confirm_view = getMultiAdapter(
+ (getSite(), self.request), name="delete_confirmation_info"
+ )
+ selection = self.get_selection()
+ catalog = getToolByName(self.context, "portal_catalog")
+ brains = catalog(UID=selection, show_inactive=True)
+ items = [i.getObject() for i in brains]
+ self.request.response.setHeader(
+ "Content-Type", "application/json; charset=utf-8"
+ )
+ return json.dumps({"html": confirm_view(items)})
+ else:
+ return super().__call__()
+
+ def action(self, obj):
+ parent = obj.aq_inner.aq_parent
+ title = self.objectTitle(obj)
+
+ try:
+ lock_info = obj.restrictedTraverse("@@plone_lock_info")
+ except AttributeError:
+ lock_info = None
+ if lock_info is not None:
+ if lock_info.is_locked_for_current_user():
+ self.errors.append(
+ _(
+ "${title} is locked and cannot be deleted.",
+ mapping={"title": title},
+ )
+ )
+ return
+ elif lock_info.is_locked():
+ # unlock object as it is locked by current user
+ ILockable(obj).unlock()
+
+ try:
+ parent.manage_delObjects(obj.getId())
+ except Unauthorized:
+ self.errors.append(
+ _(
+ "You are not authorized to delete ${title}.",
+ mapping={"title": self.objectTitle(self.dest)},
+ )
+ )
diff --git a/src/plone/app/layout/content/browser/contents/paste.py b/src/plone/app/layout/content/browser/contents/paste.py
new file mode 100644
index 00000000..e6cb307a
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/paste.py
@@ -0,0 +1,66 @@
+from AccessControl import Unauthorized
+from plone.app.layout.content.browser.contents import ContentsBaseAction
+from plone.app.content.interfaces import IStructureAction
+from plone.base import PloneMessageFactory as _
+from ZODB.POSException import ConflictError
+from zope.i18n import translate
+from zope.interface import implementer
+
+
+@implementer(IStructureAction)
+class PasteAction:
+ order = 3
+
+ def __init__(self, context, request):
+ self.context = context
+ self.request = request
+
+ def get_options(self):
+ return {
+ "tooltip": translate(_("Paste"), context=self.request),
+ "id": "paste",
+ "icon": "plone-paste",
+ "url": self.context.absolute_url() + "/@@fc-paste",
+ }
+
+
+class PasteActionView(ContentsBaseAction):
+ required_obj_permission = "Copy or Move"
+ success_msg = _("Successfully pasted items")
+ failure_msg = _("Failed to paste items")
+
+ def __call__(self):
+ self.protect()
+ self.errors = []
+
+ parts = str(self.request.form["folder"].lstrip("/")).split("/")
+ parent = self.site.unrestrictedTraverse("/".join(parts[:-1]))
+ self.dest = parent.restrictedTraverse(parts[-1])
+
+ try:
+ self.dest.manage_pasteObjects(self.request["__cp"])
+ except ConflictError:
+ raise
+ except Unauthorized:
+ # avoid this unfriendly exception text:
+ # "You are not allowed to access 'manage_pasteObjects' in this
+ # context"
+ self.errors.append(
+ _(
+ "You are not authorized to paste ${title} here.",
+ mapping={"title": self.objectTitle(self.dest)},
+ )
+ )
+ except ValueError as e:
+ if "Disallowed subobject type: " in e.args[0]:
+ msg_parts = e.args[0].split(":")
+ self.errors.append(
+ _(
+ 'Disallowed subobject type "${type}"',
+ mapping={"type": msg_parts[1].strip()},
+ )
+ )
+ else:
+ raise e
+
+ return self.message()
diff --git a/src/plone/app/layout/content/browser/contents/properties.py b/src/plone/app/layout/content/browser/contents/properties.py
new file mode 100644
index 00000000..9d087a3c
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/properties.py
@@ -0,0 +1,118 @@
+from DateTime import DateTime
+from plone.app.layout.content.browser.contents import ContentsBaseAction
+from plone.app.content.interfaces import IStructureAction
+from plone.app.dexterity.behaviors.metadata import ICategorization
+from plone.app.z3cform.widgets.datetime import get_date_options
+from plone.base import PloneMessageFactory as _
+from plone.base.defaultpage import check_default_page_via_view
+from Products.CMFCore.interfaces._content import IFolderish
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from zope.component import getUtility
+from zope.component.hooks import getSite
+from zope.i18n import translate
+from zope.interface import implementer
+from zope.schema.interfaces import IVocabularyFactory
+
+import json
+
+
+@implementer(IStructureAction)
+class PropertiesAction:
+ template = ViewPageTemplateFile("templates/properties.pt")
+ order = 8
+
+ def __init__(self, context, request):
+ self.context = context
+ self.request = request
+
+ def get_options(self):
+ base_vocabulary = "%s/@@getVocabulary?name=" % getSite().absolute_url()
+ return {
+ "tooltip": translate(_("Properties"), context=self.request),
+ "id": "properties",
+ "icon": "plone-edit",
+ "url": self.context.absolute_url() + "/@@fc-properties",
+ "form": {
+ "title": translate(
+ _("Modify properties on items"), context=self.request
+ ),
+ "template": self.template(
+ vocabulary_url="%splone.app.vocabularies.Users" % (base_vocabulary),
+ pattern_options=json.dumps(get_date_options(self.request)),
+ ),
+ "dataUrl": self.context.absolute_url() + "/@@fc-properties",
+ },
+ }
+
+
+class PropertiesActionView(ContentsBaseAction):
+ success_msg = _("Successfully updated metadata")
+ failure_msg = _("Failure updating metadata")
+ required_obj_permission = "Modify portal content"
+
+ def __call__(self):
+ if self.request.form.get("render") == "yes":
+ lang_factory = getUtility(
+ IVocabularyFactory, "plone.app.vocabularies.SupportedContentLanguages"
+ )
+ lang_vocabulary = lang_factory(self.context)
+ languages = [
+ {"title": term.title, "value": term.value} for term in lang_vocabulary
+ ]
+ return self.json(
+ {
+ "languages": [
+ {
+ "title": translate(
+ _("label_no_change", default="No change"),
+ context=self.request,
+ ),
+ "value": "",
+ }
+ ]
+ + languages
+ }
+ )
+
+ self.effectiveDate = self.request.form.get("effectiveDate")
+ self.expirationDate = self.request.form.get("expirationDate")
+ self.copyright = self.request.form.get("copyright")
+ self.contributors = self.request.form.get("contributors")
+ if self.contributors:
+ self.contributors = self.contributors.split(",")
+ else:
+ self.contributors = []
+ self.creators = self.request.form.get("creators", "")
+ if self.creators:
+ self.creators = self.creators.split(",")
+ self.exclude = self.request.form.get("exclude-from-nav")
+ self.language = self.request.form.get("language")
+ self.recurse = self.request.form.get("recurse", "no") == "yes"
+ return super().__call__()
+
+ def action(self, obj, bypass_recurse=False):
+ if check_default_page_via_view(obj, self.request):
+ self.action(obj.aq_parent, bypass_recurse=True)
+ recurse = self.recurse and not bypass_recurse
+ if recurse and IFolderish.providedBy(obj):
+ for sub in obj.values():
+ self.action(sub)
+
+ if self.effectiveDate and hasattr(obj, "effective_date"):
+ obj.effective_date = DateTime(self.effectiveDate)
+ if self.expirationDate and hasattr(obj, "expiration_date"):
+ obj.expiration_date = DateTime(self.expirationDate)
+ if self.copyright and hasattr(obj, "rights"):
+ obj.rights = self.copyright
+ if self.contributors and hasattr(obj, "contributors"):
+ obj.contributors = tuple(self.contributors)
+ if self.creators and hasattr(obj, "creators"):
+ obj.creators = tuple(self.creators)
+ if self.exclude and hasattr(obj, "exclude_from_nav"):
+ obj.exclude_from_nav = self.exclude == "yes"
+
+ behavior_categorization = ICategorization(obj)
+ if self.language and behavior_categorization:
+ behavior_categorization.language = self.language
+
+ obj.reindexObject()
diff --git a/src/plone/app/layout/content/browser/contents/rearrange.py b/src/plone/app/layout/content/browser/contents/rearrange.py
new file mode 100644
index 00000000..e1e3ce3d
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/rearrange.py
@@ -0,0 +1,88 @@
+from OFS.interfaces import IOrderedContainer
+from plone.app.layout.content.browser.contents import ContentsBaseAction
+from plone.app.content.utils import json_loads
+from plone.base import PloneMessageFactory as _
+from plone.base.interfaces import IPloneSiteRoot
+from plone.folder.interfaces import IExplicitOrdering
+from Products.CMFCore.utils import getToolByName
+
+
+class OrderContentsBaseAction(ContentsBaseAction):
+ def getOrdering(self):
+ if IPloneSiteRoot.providedBy(self.context):
+ return self.context
+ try:
+ if self.context.aq_base.getOrdering():
+ ordering = self.context.getOrdering()
+ else:
+ return None
+ except AttributeError:
+ if IOrderedContainer.providedBy(self.context):
+ # Archetype
+ return IOrderedContainer(self.context)
+ return None
+ if not IExplicitOrdering.providedBy(ordering):
+ return None
+ return ordering
+
+
+class ItemOrderActionView(OrderContentsBaseAction):
+ success_msg = _("Successfully moved item")
+ failure_msg = _("Error moving item")
+
+ def __call__(self):
+ self.errors = []
+ self.protect()
+ id = self.request.form.get("id")
+ ordering = self.getOrdering()
+
+ if ordering is None:
+ self.errors.append(_("This folder does not support ordering"))
+ return self.message()
+
+ delta = self.request.form["delta"]
+
+ if delta == "top":
+ ordering.moveObjectsToTop([id])
+ return self.message()
+
+ if delta == "bottom":
+ ordering.moveObjectsToBottom([id])
+ return self.message()
+
+ delta = int(delta)
+ subset_ids = json_loads(self.request.form.get("subsetIds", "null"))
+ if subset_ids:
+ position_id = [(ordering.getObjectPosition(i), i) for i in subset_ids]
+ position_id.sort()
+ if subset_ids != [i for position, i in position_id]:
+ self.errors.append(_("Client/server ordering mismatch"))
+ return self.message()
+
+ ordering.moveObjectsByDelta([id], delta, subset_ids)
+ return self.message()
+
+
+class RearrangeActionView(OrderContentsBaseAction):
+ success_msg = _("Successfully rearranged folder")
+ failure_msg = _("Can not rearrange folder")
+
+ def __call__(self):
+ self.protect()
+ self.errors = []
+ ordering = self.getOrdering()
+ if ordering:
+ catalog = getToolByName(self.context, "portal_catalog")
+ query = {
+ "path": {"query": "/".join(self.context.getPhysicalPath()), "depth": 1},
+ "sort_on": self.request.form.get("rearrange_on"),
+ "show_inactive": True,
+ }
+ brains = catalog(**query)
+ if self.request.form.get("reversed") == "true":
+ brains = [b for b in reversed(brains)]
+ for idx, brain in enumerate(brains):
+ ordering.moveObjectToPosition(brain.id, idx)
+ else:
+ self.errors.append(_("Not explicit orderable"))
+ return self.message()
diff --git a/src/plone/app/layout/content/browser/contents/rename.py b/src/plone/app/layout/content/browser/contents/rename.py
new file mode 100644
index 00000000..fcc232ff
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/rename.py
@@ -0,0 +1,114 @@
+from AccessControl import getSecurityManager
+from Acquisition import aq_inner
+from Acquisition import aq_parent
+from plone.app.layout.content.browser.contents import ContentsBaseAction
+from plone.app.content.interfaces import IStructureAction
+from plone.base import PloneMessageFactory as _
+from Products.CMFCore.utils import getToolByName
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from ZODB.POSException import ConflictError
+from zope.component import getMultiAdapter
+from zope.container.interfaces import INameChooser
+from zope.event import notify
+from zope.i18n import translate
+from zope.interface import implementer
+from zope.lifecycleevent import ObjectModifiedEvent
+
+import logging
+import transaction
+
+logger = logging.getLogger("plone.app.content")
+
+
+@implementer(IStructureAction)
+class RenameAction:
+ template = ViewPageTemplateFile("templates/rename.pt")
+ order = 5
+
+ def __init__(self, context, request):
+ self.context = context
+ self.request = request
+
+ def get_options(self):
+ return {
+ "tooltip": translate(_("Rename"), context=self.request),
+ "id": "rename",
+ "icon": "plone-rename",
+ "url": self.context.absolute_url() + "/@@fc-rename",
+ "form": {
+ "title": translate(_("Rename"), context=self.request),
+ "template": self.template(),
+ },
+ }
+
+
+class RenameActionView(ContentsBaseAction):
+ success_msg = _("Items renamed")
+ failure_msg = _("Failed to rename all items")
+
+ def __call__(self):
+ self.errors = []
+ self.protect()
+ context = aq_inner(self.context)
+
+ catalog = getToolByName(context, "portal_catalog")
+ mtool = getToolByName(context, "portal_membership")
+
+ missing = []
+ for key in self.request.form.keys():
+ if not key.startswith("UID_"):
+ continue
+ index = key.split("_")[-1]
+ uid = self.request.form[key]
+ brains = catalog(UID=uid, show_inactive=True)
+ if len(brains) == 0:
+ missing.append(uid)
+ continue
+ obj = brains[0].getObject()
+ title = self.objectTitle(obj)
+ if not mtool.checkPermission("Copy or Move", obj):
+ self.errors.append(
+ _("Permission denied to rename ${title}.", mapping={"title": title})
+ )
+ continue
+
+ sp = transaction.savepoint(optimistic=True)
+
+ newid = self.request.form["newid_" + index]
+ newtitle = self.request.form["newtitle_" + index]
+ try:
+ obid = obj.getId()
+ title = obj.Title()
+ change_title = newtitle and title != newtitle
+ if change_title:
+ getSecurityManager().validate(obj, obj, "setTitle", obj.setTitle)
+ obj.setTitle(newtitle)
+ notify(ObjectModifiedEvent(obj))
+ if newid and obid != newid:
+ parent = aq_parent(aq_inner(obj))
+ # Make sure newid is safe
+ newid = INameChooser(parent).chooseName(newid, obj)
+ # Update the default_page on the parent.
+ context_state = getMultiAdapter(
+ (obj, self.request), name="plone_context_state"
+ )
+ if context_state.is_default_page():
+ parent.setDefaultPage(newid)
+ parent.manage_renameObjects((obid,), (newid,))
+ elif change_title:
+ # the rename will have already triggered a reindex
+ obj.reindexObject()
+ except ConflictError:
+ raise
+ except Exception as e:
+ sp.rollback()
+ logger.error(
+ 'Error renaming "{title}": "{exception}"'.format(
+ title=title, exception=e
+ )
+ )
+ self.errors.append(
+ _("Error renaming ${title}", mapping={"title": title})
+ )
+
+ return self.message(missing)
diff --git a/src/plone/app/layout/content/browser/contents/tags.py b/src/plone/app/layout/content/browser/contents/tags.py
new file mode 100644
index 00000000..a61af220
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/tags.py
@@ -0,0 +1,56 @@
+from plone.app.layout.content.browser.contents import ContentsBaseAction
+from plone.app.content.interfaces import IStructureAction
+from plone.base import PloneMessageFactory as _
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from zope.component.hooks import getSite
+from zope.i18n import translate
+from zope.interface import implementer
+
+
+@implementer(IStructureAction)
+class TagsAction:
+ template = ViewPageTemplateFile("templates/tags.pt")
+ order = 6
+
+ def __init__(self, context, request):
+ self.context = context
+ self.request = request
+
+ def get_options(self):
+ base_vocabulary = "%s/@@getVocabulary?name=" % getSite().absolute_url()
+ return {
+ "tooltip": translate(_("Tags"), context=self.request),
+ "id": "tags",
+ "icon": "tags",
+ "url": self.context.absolute_url() + "/@@fc-tags",
+ "form": {
+ "title": translate(_("Tags"), context=self.request),
+ "template": self.template(
+ vocabulary_url="%splone.app.vocabularies.Keywords"
+ % (base_vocabulary)
+ ),
+ },
+ }
+
+
+class TagsActionView(ContentsBaseAction):
+ required_obj_permission = "Modify portal content"
+ success_msg = _("Successfully updated tags on items")
+ failure_msg = _("Failed to modify tags on items")
+
+ def action(self, obj):
+ toadd = self.request.form.get("toadd")
+ if toadd:
+ toadd = set(toadd.split(","))
+ else:
+ toadd = set()
+ toremove = self.request.get("toremove")
+ if toremove:
+ toremove = set(toremove.split(","))
+ else:
+ toremove = set()
+ tags = set(obj.Subject())
+ tags = tags - toremove
+ tags = tags | toadd
+ obj.setSubject(list(tags))
+ obj.reindexObject()
diff --git a/src/plone/app/layout/content/browser/contents/templates/delete.pt b/src/plone/app/layout/content/browser/contents/templates/delete.pt
new file mode 100644
index 00000000..9a368191
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/templates/delete.pt
@@ -0,0 +1,6 @@
+
+ <% try{ %>
+ <%= data.html || '' %>
+ <% }catch(e){} %>
+ Are you certain you want to delete the selected items?
+
\ No newline at end of file
diff --git a/src/plone/app/layout/content/browser/contents/templates/folder_contents.pt b/src/plone/app/layout/content/browser/contents/templates/folder_contents.pt
new file mode 100644
index 00000000..485eaf0d
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/templates/folder_contents.pt
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/content/browser/contents/templates/properties.pt b/src/plone/app/layout/content/browser/contents/templates/properties.pt
new file mode 100644
index 00000000..3da061fb
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/templates/properties.pt
@@ -0,0 +1,65 @@
+
+
+
+ Publication Date
+
+
+
+
+ Expiration Date
+
+
+
+
+ Copyright
+
+
+
+
+ Creators
+
+
+
+
+ Contributors
+
+
+
+
+
Exclude from navigation
+
+
+ Yes
+
+
+
+ No
+
+
+
+ <% if (data.languages) { %>
+
+ Language
+
+ <% _.each(data.languages, function (lang) { %>
+ <%= lang.title %>
+ <% }); %>
+
+
+ <% } %>
+
+
+
+
diff --git a/src/plone/app/layout/content/browser/contents/templates/rename.pt b/src/plone/app/layout/content/browser/contents/templates/rename.pt
new file mode 100644
index 00000000..5ddb0751
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/templates/rename.pt
@@ -0,0 +1,20 @@
+
+<% _.each(items, function(item, index) { %>
+
+
+
+
+ Title
+
+
+
+
+ Short name
+
+
+
+ <% if(item.getIcon ){ %>
<% } %>
+
+
+<% }) %>
+
diff --git a/src/plone/app/layout/content/browser/contents/templates/tags.pt b/src/plone/app/layout/content/browser/contents/templates/tags.pt
new file mode 100644
index 00000000..f1a35b1c
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/templates/tags.pt
@@ -0,0 +1,24 @@
+
+
+ Tags to remove
+
+ <% var tags = [];
+ _.each(items, function(item, index) {
+ _.each(item.Subject, function(tag) {
+ if(tags.indexOf(tag) === -1){
+ tags.push(tag);
+ %><%- tag %>
+ <%
+ }
+ });
+ }); %>
+
+
+
+
+ Tags to add
+
+
+
+
diff --git a/src/plone/app/layout/content/browser/contents/templates/workflow.pt b/src/plone/app/layout/content/browser/contents/templates/workflow.pt
new file mode 100644
index 00000000..e9a32cd6
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/templates/workflow.pt
@@ -0,0 +1,26 @@
+
+
+ Comments
+
+
+
+
+
Change State
+
+ <% if(data.transitions){
+ _.each(data.transitions, function(transition){
+ %><%= transition.title %>
+ <%
+ });
+ } %>
+
+
Select the transition to be used for modifying the items state.
+
+
+
diff --git a/src/plone/app/layout/content/browser/contents/workflow.py b/src/plone/app/layout/content/browser/contents/workflow.py
new file mode 100644
index 00000000..1d4df1cb
--- /dev/null
+++ b/src/plone/app/layout/content/browser/contents/workflow.py
@@ -0,0 +1,94 @@
+from DateTime import DateTime
+from plone.app.layout.content.browser.contents import ContentsBaseAction
+from plone.app.content.interfaces import IStructureAction
+from plone.base import PloneMessageFactory as _
+from plone.base.defaultpage import check_default_page_via_view
+from plone.base.utils import safe_text
+from Products.CMFCore.interfaces._content import IFolderish
+from Products.CMFCore.utils import getToolByName
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from ZODB.POSException import ConflictError
+from zope.i18n import translate
+from zope.interface import implementer
+
+
+@implementer(IStructureAction)
+class WorkflowAction:
+ template = ViewPageTemplateFile("templates/workflow.pt")
+ order = 7
+
+ def __init__(self, context, request):
+ self.context = context
+ self.request = request
+
+ def get_options(self):
+ return {
+ "tooltip": translate(_("State"), context=self.request),
+ "id": "workflow",
+ "icon": "plone-lock",
+ "url": self.context.absolute_url() + "/@@fc-workflow",
+ "form": {
+ "title": translate(
+ _("Change workflow of selected items"), context=self.request
+ ),
+ "template": self.template(),
+ "dataUrl": self.context.absolute_url() + "/@@fc-workflow",
+ },
+ }
+
+
+class WorkflowActionView(ContentsBaseAction):
+ required_obj_permission = "Modify portal content"
+ success_msg = _("Successfully modified items")
+ failure_msg = _("Failed to modify items")
+
+ def __call__(self):
+ self.pworkflow = getToolByName(self.context, "portal_workflow")
+ self.transition_id = self.request.form.get("transition", None)
+ self.comments = self.request.form.get("comments", "")
+ self.recurse = self.request.form.get("recurse", "no") == "yes"
+ if self.request.form.get("render") == "yes":
+ # asking for render information
+ selection = self.get_selection()
+ catalog = getToolByName(self.context, "portal_catalog")
+ brains = catalog(UID=selection, show_inactive=True)
+ transitions = []
+ for brain in brains:
+ obj = brain.getObject()
+ for transition in self.pworkflow.getTransitionsFor(obj):
+ tdata = {
+ "id": transition["id"],
+ "title": self.context.translate(safe_text(transition["name"])),
+ }
+ if tdata not in transitions:
+ transitions.append(tdata)
+ return self.json({"transitions": transitions})
+ return super().__call__()
+
+ def action(self, obj, bypass_recurse=False):
+ transitions = self.pworkflow.getTransitionsFor(obj)
+ if self.transition_id in [t["id"] for t in transitions]:
+ try:
+ # set effective date if not already set
+ if obj.EffectiveDate() == "None":
+ obj.setEffectiveDate(DateTime())
+
+ self.pworkflow.doActionFor(
+ obj, self.transition_id, comment=self.comments
+ )
+ if check_default_page_via_view(obj, self.request):
+ self.action(obj.aq_parent, bypass_recurse=True)
+ recurse = self.recurse and not bypass_recurse
+ if recurse and IFolderish.providedBy(obj):
+ for sub in obj.values():
+ self.action(sub)
+ obj.reindexObject()
+ except ConflictError:
+ raise
+ except Exception:
+ self.errors.append(
+ _(
+ "Could not transition: ${title}",
+ mapping={"title": self.objectTitle(obj)},
+ )
+ )
diff --git a/src/plone/app/layout/content/browser/file.py b/src/plone/app/layout/content/browser/file.py
new file mode 100644
index 00000000..ff1dca5e
--- /dev/null
+++ b/src/plone/app/layout/content/browser/file.py
@@ -0,0 +1,207 @@
+from AccessControl import getSecurityManager
+from OFS.interfaces import IFolder
+from plone.app.dexterity.interfaces import IDXFileFactory
+from plone.base.permissions import AddPortalContent
+from plone.uuid.interfaces import IUUID
+from Products.CMFCore.utils import getToolByName
+from Products.Five.browser import BrowserView
+
+import json
+import logging
+import mimetypes
+import os
+
+logger = logging.getLogger("plone")
+
+
+def _bool(val):
+ if val.lower() in ("t", "true", "1", "on"):
+ return True
+ return False
+
+
+def _tus_int(val):
+ try:
+ return int(val)
+ except (ValueError, TypeError, AttributeError):
+ return 60 * 60 # default here...
+
+
+possible_tus_options = {
+ "tmp_file_dir": str,
+ "send_file": _bool,
+ "upload_valid_duration": _tus_int,
+}
+
+TUS_ENABLED = False
+if os.environ.get("TUS_ENABLED"):
+ # tus resumable upload standard, see http://tus.io
+ try:
+ from tus import Tus
+ from tus import Zope2RequestAdapter
+
+ tus_settings = {}
+ for option, converter in possible_tus_options.items():
+ name = "TUS_%s" % option.upper()
+ if name in os.environ:
+ tus_settings[option] = converter(os.environ[name])
+
+ tmp_file_dir = tus_settings.get("tmp_file_dir")
+ if tmp_file_dir is None:
+ logger.warn(
+ "You are trying to enable tus but no"
+ "TUS_TMP_FILE_DIR environment setting is set."
+ )
+ elif not os.path.exists(tmp_file_dir) or not os.path.isdir(tmp_file_dir):
+ logger.warn(
+ "The TUS_TMP_FILE_DIR does not point to a valid " "directory."
+ )
+ elif not os.access(tmp_file_dir, os.W_OK):
+ logger.warn("The TUS_TMP_FILE_DIR is not writable")
+ else:
+ TUS_ENABLED = True
+ logger.info("tus file upload support is successfully " "configured")
+ except ImportError:
+ logger.warn(
+ "TUS_ENABLED is set; however, tus python package is " "not installed"
+ )
+else:
+ try:
+ import tus
+
+ tus # pyflakes
+ except ImportError:
+ pass
+ else:
+ logger.warn(
+ "You have the tus python package installed but it is "
+ "not configured for this plone client"
+ )
+
+
+class FileUploadView(BrowserView):
+ """
+ Handle file uploads with potential
+ special handling of TUS resumable uploads
+ """
+
+ tus_uid = None
+
+ def __contains__(self, uid):
+ return self.tus_uid and self.tus_uid == uid
+
+ def __getitem__(self, uid):
+ if self.tus_uid is None:
+ self.tus_uid = uid
+ self.__doc__ = "foobar" # why is this necessary?
+ return self
+ else:
+ raise KeyError
+
+ def __call__(self):
+ # Check if user has permission to add content here
+ sm = getSecurityManager()
+ if not sm.checkPermission(AddPortalContent, self.context):
+ response = self.request.RESPONSE
+ response.setStatus(403)
+ return "You are not authorized to add content to this folder."
+
+ req = self.request
+ tusrequest = False
+ if TUS_ENABLED:
+ adapter = Zope2RequestAdapter(req)
+ tus = Tus(adapter, **tus_settings)
+ if tus.valid:
+ tusrequest = True
+ tus.handle()
+ if not tus.upload_finished:
+ return
+ else:
+ filename = req.getHeader("FILENAME")
+ if tus.send_file:
+ filedata = req._file
+ filedata.filename = filename
+ else:
+ filepath = req._file.read()
+ filedata = open(filepath)
+ if not tusrequest:
+ if req.REQUEST_METHOD != "POST":
+ return
+ filedata = self.request.form.get("file", None)
+ if filedata is None:
+ return
+ filename = filedata.filename
+ content_type = mimetypes.guess_type(filename)[0] or ""
+
+ if not filedata:
+ return
+
+ ctr = getToolByName(self.context, "content_type_registry")
+ type_ = ctr.findTypeName(filename.lower(), content_type, "") or "File"
+
+ # Now check that the object is not restricted to be added in the
+ # current context
+ allowed_ids = [fti.getId() for fti in self.context.allowedContentTypes()]
+ if type_ not in allowed_ids:
+ response = self.request.RESPONSE
+ response.setStatus(403)
+ if type_ == "File":
+ return "You cannot add a File to this folder, try another one"
+ if type_ == "Image":
+ return "You cannot add an Image to this folder, " "try another one"
+
+ factory = IDXFileFactory(self.context)
+ obj = factory(filename, content_type, filedata)
+
+ result = {"type": "", "size": 0}
+
+ if "File" in obj.portal_type:
+ result["size"] = obj.file.getSize()
+ result["type"] = obj.file.contentType
+ elif "Image" in obj.portal_type:
+ result["size"] = obj.image.getSize()
+ result["type"] = obj.image.contentType
+
+ if tusrequest:
+ tus.cleanup_file()
+ result.update(
+ {
+ "url": obj.absolute_url(),
+ "name": obj.getId(),
+ "UID": IUUID(obj),
+ "filename": filename,
+ }
+ )
+
+ self.request.response.setHeader(
+ "Content-Type", "application/json; charset=utf-8"
+ )
+ return json.dumps(result)
+
+
+class AllowUploadView(BrowserView):
+ def __call__(self):
+ """Return JSON structure to indicate if File or Image uploads are
+ allowed in the current container.
+ """
+ self.request.response.setHeader(
+ "Content-Type", "application/json; charset=utf-8"
+ )
+ context = self.context
+ if self.request.form.get("path"):
+ context = context.unrestrictedTraverse(self.request.form.get("path"))
+
+ allow_images = False
+ allow_files = False
+ if IFolder.providedBy(context):
+ allowed_types = [t.getId() for t in context.allowedContentTypes()]
+ allow_images = "Image" in allowed_types
+ allow_files = "File" in allowed_types
+
+ return json.dumps(
+ {
+ "allowUpload": allow_images or allow_files,
+ "allowImages": allow_images,
+ "allowFiles": allow_files,
+ }
+ )
diff --git a/src/plone/app/layout/content/browser/folder_publish.py b/src/plone/app/layout/content/browser/folder_publish.py
new file mode 100644
index 00000000..669fbf7e
--- /dev/null
+++ b/src/plone/app/layout/content/browser/folder_publish.py
@@ -0,0 +1,117 @@
+from plone.base import PloneMessageFactory as _
+from plone.base.utils import transaction_note
+from plone.protect import CheckAuthenticator
+from plone.protect import PostOnly
+from Products.CMFCore.utils import getToolByName
+from Products.statusmessages.interfaces import IStatusMessage
+from ZODB.POSException import ConflictError
+from zope.component import getMultiAdapter
+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)
+
+ if workflow_action is None:
+ IStatusMessage(self.request).add(
+ _("You must select a publishing action."), type="error"
+ )
+ return self.redirect()
+ if not paths:
+ IStatusMessage(self.request).add(
+ _("You must select content to change."), type="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)
+
+ IStatusMessage(self.request).add(_("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:
+ view = getMultiAdapter(
+ (obj, self.request), name="content_status_modify"
+ )
+ view(
+ 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 = [f"{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)
diff --git a/src/plone/app/layout/content/browser/folderfactories.pt b/src/plone/app/layout/content/browser/folderfactories.pt
new file mode 100644
index 00000000..c2305f58
--- /dev/null
+++ b/src/plone/app/layout/content/browser/folderfactories.pt
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+ Add new item
+
+
+ Select the type of item you want to add to your folder.
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/content/browser/folderfactories.py b/src/plone/app/layout/content/browser/folderfactories.py
new file mode 100644
index 00000000..c83022ee
--- /dev/null
+++ b/src/plone/app/layout/content/browser/folderfactories.py
@@ -0,0 +1,147 @@
+from Acquisition import aq_inner
+from Acquisition import aq_parent
+from plone.app.layout.content.browser.interfaces import IFolderContentsView
+from plone.base.interfaces.constrains import ISelectableConstrainTypes
+from plone.i18n.normalizer.interfaces import IIDNormalizer
+from plone.memoize.instance import memoize
+from plone.memoize.request import memoize_diy_request
+from plone.protect.authenticator import createToken
+from Products.CMFCore.Expression import createExprContext
+from Products.CMFCore.utils import getToolByName
+from urllib.parse import quote_plus
+from zope.component import getMultiAdapter
+from zope.component import queryUtility
+from zope.i18n import translate
+from zope.publisher.browser import BrowserView
+
+
+@memoize_diy_request(arg=0)
+def _allowedTypes(request, context):
+ return context.allowedContentTypes()
+
+
+class FolderFactoriesView(BrowserView):
+ """The folder_factories view - show addable types"""
+
+ def __call__(self):
+ if "form.button.Add" in self.request.form:
+ urltool = getToolByName(self.context, "portal_url")
+ url = self.request.form.get("url")
+ if not urltool.isURLInPortal(url):
+ url = self.context.absolute_url()
+ self.request.response.redirect(url)
+ return ""
+ else:
+ return self.index()
+
+ def can_constrain_types(self):
+ aspect = ISelectableConstrainTypes(self.add_context(), None)
+ return aspect.canSetConstrainTypes() if aspect else False
+
+ @memoize
+ def add_context(self):
+ context = self.context
+ context_state = getMultiAdapter(
+ (context, self.request), name="plone_context_state"
+ )
+ context = aq_inner(context)
+ try:
+ published = self.request.PUBLISHED
+ except AttributeError:
+ published = context
+ if context_state.is_structural_folder():
+ if context_state.is_default_page():
+ is_folder_contents_view = IFolderContentsView.providedBy(published)
+ if is_folder_contents_view or self == published:
+ # on the folder_contents view and factories view,
+ # show the actual context object's addable types
+ return context
+ else:
+ return aq_parent(context)
+ else:
+ return context
+ else:
+ return aq_parent(context)
+
+ # NOTE: This is also used by plone.app.contentmenu.menu.FactoriesMenu.
+ # The return value is somewhat dictated by the menu infrastructure, so
+ # be careful if you change it
+
+ def addable_types(self, include=None):
+ """Return menu item entries in a TAL-friendly form.
+
+ Pass a list of type ids to 'include' to explicitly allow a list of
+ types.
+ """
+
+ context = aq_inner(self.context)
+ request = self.request
+
+ results = []
+
+ idnormalizer = queryUtility(IIDNormalizer)
+ portal_state = getMultiAdapter((context, request), name="plone_portal_state")
+
+ addContext = self.add_context()
+ baseUrl = addContext.absolute_url()
+ token = createToken()
+
+ allowedTypes = _allowedTypes(request, addContext)
+
+ types_tool = getToolByName(context, "portal_types")
+
+ # Note: we don't check 'allowed' or 'available' here, because these are
+ # slow. We assume the 'allowedTypes' list has already performed the
+ # necessary calculations
+ actions = types_tool.listActionInfos(
+ object=addContext,
+ check_permissions=False,
+ check_condition=False,
+ category="folder/add",
+ )
+ addActionsById = {a["id"]: a for a in actions}
+
+ expr_context = createExprContext(
+ aq_parent(addContext), portal_state.portal(), addContext
+ )
+ for t in allowedTypes:
+ typeId = t.getId()
+ if include is None or typeId in include:
+ cssId = idnormalizer.normalize(typeId)
+ cssClass = "contenttype-%s" % cssId
+
+ url = None
+ addAction = addActionsById.get(typeId, None)
+ if addAction is not None:
+ url = addAction["url"]
+
+ if not url:
+ url = "{}/createObject?type_name={}&_authenticator={}".format(
+ baseUrl, quote_plus(typeId), token
+ )
+
+ icon = t.getIconExprObject()
+ if icon:
+ icon = icon(expr_context)
+
+ results.append(
+ {
+ "id": typeId,
+ "title": t.Title(),
+ "description": t.Description(),
+ "action": url,
+ "selected": False,
+ "icon": icon,
+ "extra": {"id": cssId, "separator": None, "class": cssClass},
+ "submenu": None,
+ }
+ )
+
+ # Sort the addable content types based on their translated title
+ results = [
+ (translate(ctype["title"], context=request), ctype) for ctype in results
+ ]
+ results = sorted(results, key=lambda tp: tp[0])
+ results = [ctype[-1] for ctype in results]
+
+ return results
diff --git a/src/plone/app/layout/content/browser/full_review_list.pt b/src/plone/app/layout/content/browser/full_review_list.pt
new file mode 100644
index 00000000..8fd2c8ec
--- /dev/null
+++ b/src/plone/app/layout/content/browser/full_review_list.pt
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+ Full review list:
+
+
+
+
+
+
diff --git a/src/plone/app/layout/content/browser/i18n.py b/src/plone/app/layout/content/browser/i18n.py
new file mode 100644
index 00000000..ebabfc1a
--- /dev/null
+++ b/src/plone/app/layout/content/browser/i18n.py
@@ -0,0 +1,48 @@
+from plone.memoize import ram
+from Products.Five.browser import BrowserView
+from zope.component import queryUtility
+from zope.i18n.interfaces import ITranslationDomain
+
+import json
+
+
+def _cache_key(method, self, domain, language):
+ return (
+ domain,
+ language,
+ )
+
+
+class i18njs(BrowserView):
+ @ram.cache(_cache_key)
+ def _gettext_catalog(self, domain, language):
+ td = queryUtility(ITranslationDomain, domain)
+ if td is None:
+ return
+ if language not in td._catalogs:
+ baselanguage = language.split("-")[0]
+ if baselanguage not in td._catalogs:
+ return
+ else:
+ language = baselanguage
+ _catalog = {}
+ for mo_path in reversed(td._catalogs[language]):
+ catalog = td._data[mo_path]._catalog
+ if catalog is None:
+ td._data[mo_path].reload()
+ catalog = td._data[mo_path]._catalog
+ _catalog.update(catalog._catalog)
+ return _catalog
+
+ def __call__(self, domain=None, language=None):
+ if domain is None:
+ catalog = {}
+ else:
+ if language is None:
+ language = self.request["LANGUAGE"]
+ catalog = self._gettext_catalog(domain, language)
+
+ response = self.request.response
+ response.setHeader("Content-Type", "application/json; charset=utf-8")
+ response.setBody(json.dumps(catalog))
+ return response
diff --git a/src/plone/app/layout/content/browser/interfaces.py b/src/plone/app/layout/content/browser/interfaces.py
new file mode 100644
index 00000000..5f300c3e
--- /dev/null
+++ b/src/plone/app/layout/content/browser/interfaces.py
@@ -0,0 +1,14 @@
+from zope.interface import Interface
+
+
+class IFolderContentsView(Interface):
+ """Interface, which provides methods for folder contents"""
+
+ def test(a, b, c):
+ """A simple replacement of python's test."""
+
+ def getAllowedTypes():
+ """Returns allowed types for context."""
+
+ def title():
+ """Returns the title for the template."""
diff --git a/src/plone/app/layout/content/browser/query.py b/src/plone/app/layout/content/browser/query.py
new file mode 100644
index 00000000..7defcf56
--- /dev/null
+++ b/src/plone/app/layout/content/browser/query.py
@@ -0,0 +1,16 @@
+from plone.app.querystring.interfaces import IQuerystringRegistryReader
+from plone.registry.interfaces import IRegistry
+from Products.Five import BrowserView
+from zope.component import getUtility
+
+import json
+
+
+class QueryStringIndexOptions(BrowserView):
+ def __call__(self):
+ registry = getUtility(IRegistry)
+ config = IQuerystringRegistryReader(registry)()
+ self.request.response.setHeader(
+ "Content-Type", "application/json; charset=utf-8"
+ )
+ return json.dumps(config)
diff --git a/src/plone/app/layout/content/browser/reviewlist.py b/src/plone/app/layout/content/browser/reviewlist.py
new file mode 100644
index 00000000..9a6ca197
--- /dev/null
+++ b/src/plone/app/layout/content/browser/reviewlist.py
@@ -0,0 +1,176 @@
+from Acquisition import aq_inner
+from Acquisition import aq_parent
+from plone.app.layout.content.browser.tableview import Table
+from plone.app.layout.content.browser.tableview import TableBrowserView
+from plone.base.defaultpage import is_default_page
+from plone.base.utils import human_readable_size
+from plone.base.utils import is_expired
+from plone.base.utils import safe_text
+from plone.i18n.normalizer.interfaces import IIDNormalizer
+from plone.registry.interfaces import IRegistry
+from Products.CMFCore.utils import getToolByName
+from urllib.parse import quote_plus
+from zope.component import getMultiAdapter
+from zope.component import getUtility
+from zope.i18n import translate
+from zope.publisher.browser import BrowserView
+
+
+class FullReviewListView(BrowserView):
+ def revlist(self):
+ portal_membership = getToolByName(self.context, "portal_membership")
+ portal_workflow = getToolByName(self.context, "portal_workflow")
+ if portal_membership.isAnonymousUser():
+ return []
+
+ return portal_workflow.getWorklistsResults()
+
+ def url(self):
+ return self.context.absolute_url() + "/full_review_list"
+
+ def review_table(self):
+ table = ReviewListTable(self.context, self.request)
+ return table.render()
+
+
+class ReviewListTable:
+ """
+ The review list table renders the table and its actions.
+ """
+
+ def __init__(self, context, request, **kwargs):
+ self.context = context
+ self.request = request
+
+ url = self.context.absolute_url()
+ view_url = url + "/full_review_list"
+ self.table = Table(request, url, view_url, self.items, buttons=self.buttons)
+
+ def render(self):
+ return self.table.render()
+
+ @property
+ def items(self):
+ portal_url = getToolByName(self.context, "portal_url")
+ plone_view = getMultiAdapter((self.context, self.request), name="plone")
+ portal_workflow = getToolByName(self.context, "portal_workflow")
+ portal_types = getToolByName(self.context, "portal_types")
+ portal_membership = getToolByName(self.context, "portal_membership")
+ registry = getUtility(IRegistry)
+ use_view_action = registry.get("plone.types_use_view_action_in_listings", ())
+
+ results = []
+ if portal_membership.isAnonymousUser():
+ worklist = []
+ else:
+ worklist = portal_workflow.getWorklistsResults()
+
+ for i, obj in enumerate(worklist):
+ if i % 2 == 0:
+ table_row_class = "even"
+ else:
+ table_row_class = "odd"
+
+ url = obj.absolute_url()
+ path = "/".join(obj.getPhysicalPath())
+ normalizer = getUtility(IIDNormalizer)
+ type_class = "contenttype-" + normalizer.normalize(obj.portal_type)
+
+ review_state = portal_workflow.getInfoFor(obj, "review_state", "")
+
+ state_class = "state-" + normalizer.normalize(review_state)
+ relative_url = portal_url.getRelativeContentURL(obj)
+
+ type_title_msgid = portal_types[obj.portal_type].Title()
+ url_href_title = "{}: {}".format(
+ translate(type_title_msgid, context=self.request),
+ safe_text(obj.Description()),
+ )
+ getMember = getToolByName(obj, "portal_membership").getMemberById
+ creator_id = obj.Creator()
+ creator = getMember(creator_id)
+ if creator:
+ creator_name = creator.getProperty("fullname", "") or creator_id
+ else:
+ creator_name = creator_id
+ modified = "".join(
+ map(
+ safe_text,
+ [
+ creator_name,
+ " - ",
+ plone_view.toLocalizedTime(
+ obj.ModificationDate(), long_format=1
+ ),
+ ],
+ )
+ )
+ is_structural_folder = obj.restrictedTraverse(
+ "@@plone_context_state"
+ ).is_structural_folder()
+
+ if obj.portal_type in use_view_action:
+ view_url = url + "/view"
+ elif is_structural_folder:
+ view_url = url + "/folder_contents"
+ else:
+ view_url = url
+
+ # Is this object the default page of its container?
+ is_browser_default = is_default_page(aq_parent(aq_inner(obj)), obj)
+
+ results.append(
+ dict(
+ url=url,
+ url_href_title=url_href_title,
+ id=obj.getId(),
+ quoted_id=quote_plus(obj.getId()),
+ path=path,
+ title_or_id=obj.pretty_title_or_id(),
+ description=obj.Description(),
+ obj_type=obj.Type,
+ size=human_readable_size(obj.get_size()),
+ modified=modified,
+ type_class=type_class,
+ wf_state=review_state,
+ state_title=portal_workflow.getTitleForStateOnType(
+ review_state, obj.portal_type
+ ),
+ state_class=state_class,
+ is_browser_default=is_browser_default,
+ folderish=is_structural_folder,
+ relative_url=relative_url,
+ view_url=view_url,
+ table_row_class=table_row_class,
+ is_expired=is_expired(obj),
+ )
+ )
+ return results
+
+ @property
+ def show_sort_column(self):
+ return False
+
+ def buttons(self):
+ buttons = []
+ portal_actions = getToolByName(self.context, "portal_actions")
+ button_actions = portal_actions.listActionInfos(
+ object=aq_inner(self.context), categories=("folder_buttons",)
+ )
+
+ for button in button_actions:
+ # Make proper classes for our buttons
+ if button["id"] != "paste" or self.context.cb_dataValid():
+ buttons.append(self.setbuttonclass(button))
+ return buttons
+
+ def setbuttonclass(self, button):
+ if button["id"] == "paste":
+ button["cssclass"] = "btn btn-secondary"
+ else:
+ button["cssclass"] = "btn btn-primary"
+ return button
+
+
+class ReviewListBrowserView(TableBrowserView):
+ table = ReviewListTable
diff --git a/src/plone/app/layout/content/browser/selection.py b/src/plone/app/layout/content/browser/selection.py
new file mode 100644
index 00000000..ea794770
--- /dev/null
+++ b/src/plone/app/layout/content/browser/selection.py
@@ -0,0 +1,134 @@
+from Acquisition import aq_inner
+from plone.base import PloneMessageFactory as _
+from plone.registry.interfaces import IRegistry
+from Products.CMFCore.utils import getToolByName
+from Products.Five.browser import BrowserView
+from Products.statusmessages.interfaces import IStatusMessage
+from zope.component import getMultiAdapter
+from zope.component import getUtility
+
+
+class DefaultViewSelectionView(BrowserView):
+ def isValidTemplate(self, templateId):
+ return templateId in [a[0] for a in self.vocab]
+
+ def canSelectDefaultPage(self):
+ return self.context.isPrincipiaFolderish and self.context.canSetDefaultPage()
+
+ @property
+ def vocab(self):
+ return self.context.getAvailableLayouts()
+
+ @property
+ def selectedLayout(self):
+ if not self.context_state.is_default_page():
+ return self.context.getLayout()
+ return ""
+
+ def selectViewTemplate(self):
+ templateId = self.request.get("templateId")
+
+ if self.isValidTemplate(templateId):
+ self.context.setLayout(templateId)
+
+ self.request.response.redirect(self.context.absolute_url() + "/view")
+
+ @property
+ def action_url(self):
+ return f"{self.context_state.object_url():s}/select_default_view"
+
+ def __call__(self):
+ self.context_state = getMultiAdapter(
+ (self.context, self.request), name="plone_context_state"
+ )
+
+ template_id = self.request.form.get("templateId")
+ context_view_url = self.context_state.object_url() + "/view"
+
+ if self.request.form.get("form.button.Cancel"):
+ self.request.response.redirect(context_view_url)
+
+ if self.request.form.get("form.button.Save") and not template_id:
+ IStatusMessage(self.request).add(
+ "Please select a template.", type="warning"
+ )
+
+ if self.request.form.get("form.button.Save") and template_id:
+ # Make sure this is a valid template
+ if not self.isValidTemplate(template_id):
+ IStatusMessage(self.request).add("Invalid view.", type="error")
+ return self.index()
+ # Update the template
+ self.context.setLayout(template_id)
+ IStatusMessage(self.request).add("View changed.")
+ # Redirect to context view
+ self.request.response.redirect(context_view_url)
+
+ return self.index()
+
+
+class DefaultPageSelectionView(BrowserView):
+ def __call__(self):
+ if "form.buttons.Save" in self.request.form:
+ if "objectId" not in self.request.form:
+ message = _("Please select an item to use.")
+ msgtype = "error"
+ else:
+ objectId = self.request.form["objectId"]
+
+ if objectId not in self.context.objectIds():
+ message = _(
+ "There is no object with short name ${name} " "in this folder.",
+ mapping={"name": objectId},
+ )
+ msgtype = "error"
+ else:
+ self.context.setDefaultPage(objectId)
+ message = _("View changed.")
+ msgtype = "info"
+ self.request.response.redirect(self.context.absolute_url())
+ IStatusMessage(self.request).add(message, msgtype)
+ elif "form.buttons.Cancel" in self.request.form:
+ self.request.response.redirect(self.context.absolute_url())
+
+ return self.index()
+
+ def get_selectable_items(self):
+ """Return brains in this container that can be used as default_pages"""
+ context = aq_inner(self.context)
+ registry = getUtility(IRegistry)
+ view_types = registry.get("plone.types_use_view_action_in_listings", [])
+ default_page_types = registry.get("plone.default_page_types", [])
+ portal_types = getToolByName(self.context, "portal_types")
+ portal_catalog = getToolByName(self.context, "portal_catalog")
+
+ results = []
+ for brain in portal_catalog(
+ path={"query": "/".join(context.getPhysicalPath()), "depth": 1},
+ sort_on="getObjPositionInParent",
+ ):
+ portal_type = brain.portal_type
+ if portal_type in view_types:
+ # Skip files and images
+ continue
+
+ if portal_type in default_page_types:
+ # Allow types that are explicitly in default_page_types
+ results.append(brain)
+ continue
+
+ if brain.is_folderish:
+ fti = portal_types.get(portal_type)
+ if not fti:
+ continue
+ if (
+ fti.filter_content_types
+ and fti.allowed_content_types
+ or not fti.filter_content_types
+ ):
+ # Disallow folderish types if you can't add any content.
+ # To override you have to add type to default_page_types
+ continue
+
+ results.append(brain)
+ return results
diff --git a/src/plone/app/layout/content/browser/table.pt b/src/plone/app/layout/content/browser/table.pt
new file mode 100644
index 00000000..939d107e
--- /dev/null
+++ b/src/plone/app/layout/content/browser/table.pt
@@ -0,0 +1,351 @@
+
+
+
+
+
+
+
+
+
+
+
+ This folder has no visible items. To add content, press the
+ add button, or paste content from another location.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/content/browser/table.txt b/src/plone/app/layout/content/browser/table.txt
new file mode 100644
index 00000000..00e84608
--- /dev/null
+++ b/src/plone/app/layout/content/browser/table.txt
@@ -0,0 +1,147 @@
+=====
+Table
+=====
+
+The table class can render a table like used in the folder contents view. It is
+abstracted into a separate class for reuse with other views like the review
+list.
+
+A table can be parameterized at creation time.
+
+ >>> from plone.app.layout.content.browser.tableview import Table
+ >>> view_url = 'http://plone/view'
+ >>> base_url = 'http://plone'
+ >>> table = Table(request={}, base_url=base_url, view_url=view_url,
+ ... items=[], show_sort_column=False, buttons=[])
+
+The most important argument is items. This must provide a list of dictionaries
+because the table will add more information to them for rendering.
+
+
+Batching and selecting
+----------------------
+
+One of the table's main virtues is that it batches. A template can use the
+`batch` attribute for accessing the current batch.
+
+ >>> items = [{'id': i} for i in range(100)]
+ >>> table = Table({}, base_url, view_url, items=items)
+ >>> from plone.batching.interfaces import IBatch
+ >>> IBatch.providedBy(table.batch)
+ True
+
+The table automatically adds keys for making layout easier. One of these is
+indication whether an item is `checked`.
+
+ >>> list(table.batch)[0]['checked']
+
+Whether or not an item is checked depends on request variables. If either the
+whole batch or the current screen is selected the item we be marked as checked.
+Below we will demonstrate this by selecting the items on screen.
+
+ >>> table = Table({'select': 'screen'}, base_url, view_url, items=items)
+ >>> list(table.batch)[0]['checked']
+ 'checked'
+
+Another is the `table_row_class`. The table will append `selected` to it if it
+already exists, otherwise it will create it.
+
+ >>> items = [{'id': i} for i in range(100)]
+ >>> table = Table({}, base_url, view_url, items=items)
+ >>> list(table.batch)[0]['table_row_class']
+ ''
+
+If the row is `checked` this will also be reflected in the row class.
+
+ >>> table = Table({'select': 'screen'}, base_url, view_url, items=items)
+ >>> list(table.batch)[0]['table_row_class']
+ ' selected'
+
+The table will indicate in which selection mode it is in. You can use two
+interlinked variables for this. The `selectcurrentbatch` attribute will be true
+in case the current screen is selected or the whole batch is selected.
+
+ >>> table = Table({}, base_url, view_url, items=items)
+ >>> table.selectcurrentbatch
+ False
+
+ >>> table = Table({'select': 'screen'}, base_url, view_url, items=items)
+ >>> table.selectcurrentbatch
+ True
+
+ >>> table = Table({'select': 'all'}, base_url, view_url, items=items)
+ >>> table.selectcurrentbatch
+ True
+
+You can also check specifically if the whole batch has been selected.
+
+ >>> table = Table({}, base_url, view_url, items=items)
+ >>> table.selectall
+ False
+
+ >>> table = Table({'select': 'all'}, base_url, view_url, items=items)
+ >>> table.selectall
+ True
+
+When you select all items on the screen and there are no further pages it will
+set both the whole `selectcurrentbatch` and `selectall` properties.
+
+ >>> small_numnber_of_items = [{'id': i} for i in range(3)]
+ >>> table = Table({'select': 'screen'}, base_url, view_url, items=small_numnber_of_items)
+ >>> table.selectcurrentbatch
+ True
+ >>> table.selectall
+ True
+
+
+Sort column
+-----------
+
+Some tables may be made sortable by indicating so at table creation time. This
+will toggle a boolean which will be read by the rendering template. By default
+sorting is turned off.
+
+ >>> table = Table({}, base_url, view_url, items=items)
+ >>> table.show_sort_column
+ False
+
+ >>> table = Table({}, base_url, view_url, items=items, show_sort_column=True)
+ >>> table.show_sort_column
+ True
+
+Selection urls
+--------------
+
+The table provides the template with urls for selecting or deselecting items in
+the batch. These urls are based on the url passed in at creation time.
+
+ >>> table.selectnone_url
+ 'http://plone/view?pagenumber=1&pagesize=20'
+
+You can see how it automatically adds the current page to the url.
+
+ >>> table.selectscreen_url
+ 'http://plone/view?pagenumber=1&pagesize=20&select=screen'
+
+The same goes for selecting the screen and the whole batch.
+
+ >>> table.selectall_url
+ 'http://plone/view?pagenumber=1&pagesize=20&select=all'
+
+A template may want to display only one of these urls at the same time.
+Therefore the table has a boolean property to query whether or not to show the
+select all option. The display of this depends on whether the whole batch is
+selected (don't show) or the screen has not been selected (don't show then).
+
+ >>> table = Table({}, base_url, view_url, items=items)
+ >>> table.show_select_all_items
+ False
+
+ >>> table = Table({'select': 'screen'}, base_url, view_url, items=items)
+ >>> table.show_select_all_items
+ True
+
+ >>> table = Table({'select': 'all'}, base_url, view_url, items=items)
+ >>> table.show_select_all_items
+ False
+
diff --git a/src/plone/app/layout/content/browser/tableview.py b/src/plone/app/layout/content/browser/tableview.py
new file mode 100644
index 00000000..b3a1ee0c
--- /dev/null
+++ b/src/plone/app/layout/content/browser/tableview.py
@@ -0,0 +1,200 @@
+from plone.base.utils import safe_text
+from plone.batching import Batch
+from plone.batching.browser import BatchView
+from plone.memoize import instance
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from urllib.parse import quote_plus
+from zope.i18nmessageid import MessageFactory
+from zope.publisher.browser import BrowserView
+from ZTUtils import make_query
+
+_ = MessageFactory("plone")
+
+
+class TableBatchView(BatchView):
+ def make_link(self, pagenumber):
+ batchlinkparams = self.request.form.copy()
+ return "{}?{}".format(
+ self.request.ACTUAL_URL,
+ make_query(batchlinkparams, {"pagenumber": pagenumber}),
+ )
+
+
+class Table:
+ """
+ The table renders a table with sortable columns etc.
+
+ It is meant to be subclassed to provide methods for getting specific table
+ info.
+ """
+
+ def __init__(
+ self,
+ request,
+ base_url,
+ view_url,
+ items,
+ show_sort_column=False,
+ buttons=None,
+ pagesize=20,
+ show_select_column=True,
+ show_size_column=True,
+ show_modified_column=True,
+ show_status_column=True,
+ ):
+ if buttons is None:
+ buttons = []
+ self.request = request
+ self.context = None # Need for view pagetemplate
+
+ self.base_url = base_url
+ self.view_url = view_url
+ self.url = view_url
+ self.items = items
+ self.show_sort_column = show_sort_column
+ self.show_select_column = show_select_column
+ self.show_size_column = show_size_column
+ self.show_modified_column = show_modified_column
+ self.show_status_column = show_status_column
+ self.buttons = buttons
+ self.default_page_size = 20
+ self.pagesize = pagesize
+ self.show_all = request.get("show_all", "").lower() == "true"
+
+ selection = request.get("select")
+ if selection == "screen":
+ self.selectcurrentbatch = True
+ elif selection == "all":
+ self.selectall = True
+
+ self.pagenumber = int(request.get("pagenumber", 1))
+
+ def msg_select_item(self, item):
+ title_or_id = (
+ item.get("title_or_id")
+ or item.get("title")
+ or item.get("Title")
+ or item.get("id")
+ or item["getId"]
+ )
+ return _(
+ "checkbox_select_item",
+ default="Select ${title}",
+ mapping={"title": safe_text(title_or_id)},
+ )
+
+ @property
+ def within_batch_size(self):
+ return len(self.items) <= self.pagesize
+
+ def set_checked(self, item):
+ selected = self.selected(item)
+ item["checked"] = selected and "checked" or None
+ item["table_row_class"] = item.get("table_row_class", "")
+ if selected:
+ item["table_row_class"] += " selected"
+
+ @property
+ @instance.memoize
+ def batch(self):
+ pagesize = self.pagesize
+ if self.show_all:
+ pagesize = len(self.items)
+ b = Batch.fromPagenumber(
+ self.items, pagesize=pagesize, pagenumber=self.pagenumber
+ )
+ list(map(self.set_checked, b))
+ return b
+
+ render = ViewPageTemplateFile("table.pt")
+
+ def batching(self):
+ return TableBatchView(self.context, self.request)(self.batch)
+
+ # options
+ _selectcurrentbatch = False
+ _select_all = False
+
+ def _get_select_currentbatch(self):
+ return self._selectcurrentbatch
+
+ def _set_select_currentbatch(self, value):
+ self._selectcurrentbatch = value
+ if (
+ self._selectcurrentbatch
+ and self.show_all
+ or (len(self.items) <= self.pagesize)
+ ):
+ self.selectall = True
+
+ selectcurrentbatch = property(_get_select_currentbatch, _set_select_currentbatch)
+
+ def _get_select_all(self):
+ return self._select_all
+
+ def _set_select_all(self, value):
+ self._select_all = bool(value)
+ if self._select_all:
+ self._selectcurrentbatch = True
+
+ selectall = property(_get_select_all, _set_select_all)
+
+ @property
+ def show_select_all_items(self):
+ return self.selectcurrentbatch and not self.selectall
+
+ def get_nosort_class(self):
+ """ """
+ return "nosort"
+
+ @property
+ def selectall_url(self):
+ return self.selectnone_url + "&select=all"
+
+ @property
+ def selectscreen_url(self):
+ return self.selectnone_url + "&select=screen"
+
+ @property
+ def selectnone_url(self):
+ base = self.view_url + "?pagenumber={}&pagesize={}".format(
+ self.pagenumber, self.pagesize
+ )
+ if self.show_all:
+ base += "&show_all=true"
+ return base
+
+ @property
+ def show_all_url(self):
+ return self.view_url + "?show_all=true"
+
+ def selected(self, item):
+ if self.selectcurrentbatch:
+ return True
+ return False
+
+ @property
+ def viewname(self):
+ return self.view_url.split("?")[0].split("/")[-1]
+
+ def quote_plus(self, string):
+ return quote_plus(string)
+
+
+class TableBrowserView(BrowserView):
+ """Base class which can be used from a AJAX view
+
+ Subclasses only need to set the table property to a different
+ class."""
+
+ table = None
+
+ def update_table(
+ self, pagenumber="1", sort_on="getObjPositionInParent", show_all=False
+ ):
+ self.request.set("sort_on", sort_on)
+ self.request.set("pagenumber", pagenumber)
+ table = self.table(
+ self.context, self.request, contentFilter={"sort_on": sort_on}
+ )
+ return table.render()
diff --git a/src/plone/app/layout/content/browser/templates/content_status_history.pt b/src/plone/app/layout/content/browser/templates/content_status_history.pt
new file mode 100644
index 00000000..c42567e8
--- /dev/null
+++ b/src/plone/app/layout/content/browser/templates/content_status_history.pt
@@ -0,0 +1,540 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Note
+ This form is used in two different ways - from folder_contents,
+ allowing you to publish several things at once, and from the state
+ drop-down. In the first case, the 'paths' request parameter is set;
+ in the second case, giving the relative paths to the content object
+ to manipulate; in the second case, this parameter is omitted and the
+ path of the context is used.
+
+
+
+
+
+
+ Publishing process
+
+
+ An item's status (also called its review state) determines who
+ can see it. Another way to control the visibility of an item is
+ with its
+ Publishing Date . An item is not publicly
+ searchable before its publishing date. This will prevent the item
+ from showing up in portlets and folder listings, although the item
+ will still be available if accessed directly via its URL.
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/content/browser/templates/delete_confirmation.pt b/src/plone/app/layout/content/browser/templates/delete_confirmation.pt
new file mode 100644
index 00000000..29241d53
--- /dev/null
+++ b/src/plone/app/layout/content/browser/templates/delete_confirmation.pt
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+ This item can not be deleted because it is currently locked by another user.
+
+
+
+
+
+ Do you really want to delete this folder and all its contents?
+
+
+ (This will delete a total of
+ 22
+ items.)
+
+
+
+
+ Do you really want to delete this item?
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/content/browser/templates/object_rename.pt b/src/plone/app/layout/content/browser/templates/object_rename.pt
new file mode 100644
index 00000000..e951e4f3
--- /dev/null
+++ b/src/plone/app/layout/content/browser/templates/object_rename.pt
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
Rename item
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/content/browser/templates/select_default_page.pt b/src/plone/app/layout/content/browser/templates/select_default_page.pt
new file mode 100644
index 00000000..d7b33794
--- /dev/null
+++ b/src/plone/app/layout/content/browser/templates/select_default_page.pt
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+ Select default page
+
+
+ Please select item which will be displayed as the default page of the
+ folder.
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/content/browser/templates/select_default_view.pt b/src/plone/app/layout/content/browser/templates/select_default_view.pt
new file mode 100644
index 00000000..b7ccdbb9
--- /dev/null
+++ b/src/plone/app/layout/content/browser/templates/select_default_view.pt
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+
+
+ Select default view
+
+
+ Please select which template should be used as the default view of the
+ folder.
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/content/browser/vocabulary.py b/src/plone/app/layout/content/browser/vocabulary.py
new file mode 100644
index 00000000..23ab157a
--- /dev/null
+++ b/src/plone/app/layout/content/browser/vocabulary.py
@@ -0,0 +1,394 @@
+from AccessControl import getSecurityManager
+from Acquisition import aq_base
+from html import unescape
+from logging import getLogger
+from plone.app.content.utils import json_dumps
+from plone.app.content.utils import json_loads
+from plone.app.querystring import queryparser
+from plone.app.z3cform.interfaces import IFieldPermissionChecker
+from plone.autoform.interfaces import WRITE_PERMISSIONS_KEY
+from plone.base import PloneMessageFactory as _
+from plone.base.interfaces.siteroot import INavigationRoot
+from plone.base.navigationroot import get_navigation_root
+from plone.base.utils import safe_text
+from plone.memoize.view import memoize
+from plone.supermodel.utils import mergedTaggedValueDict
+from Products.CMFCore.utils import getToolByName
+from Products.Five import BrowserView
+from Products.MimetypesRegistry.MimeTypeItem import guess_icon_path
+from Products.MimetypesRegistry.MimeTypeItem import PREFIX
+from Products.PortalTransforms.transforms.safe_html import SafeHTML
+from types import FunctionType
+from z3c.form.browser.object import ObjectWidget
+from z3c.form.interfaces import IAddForm
+from z3c.form.interfaces import ISubForm
+from zope.component import getUtility
+from zope.component import queryAdapter
+from zope.component import queryUtility
+from zope.deprecation import deprecated
+from zope.i18n import translate
+from zope.schema.interfaces import ICollection
+from zope.schema.interfaces import IVocabularyFactory
+from zope.security.interfaces import IPermission
+
+import inspect
+import itertools
+
+logger = getLogger(__name__)
+
+MAX_BATCH_SIZE = 500 # prevent overloading server
+
+DEFAULT_PERMISSION = "View"
+DEFAULT_PERMISSION_SECURE = "Modify portal content"
+PERMISSIONS = {
+ "plone.app.vocabularies.Catalog": "View",
+ "plone.app.vocabularies.Keywords": "Modify portal content",
+ "plone.app.vocabularies.SyndicatableFeedItems": "Modify portal content",
+ "plone.app.vocabularies.Users": "Modify portal content",
+ "plone.app.multilingual.RootCatalog": "View",
+}
+TRANSLATED_IGNORED = [
+ "author_name",
+ "cmf_uid",
+ "commentators",
+ "created",
+ "CreationDate",
+ "Creator",
+ "Date",
+ "Description",
+ "effective",
+ "EffectiveDate",
+ "end",
+ "exclude_from_nav",
+ "ExpirationDate",
+ "expires",
+ "getIcon",
+ "getMimeIcon",
+ "getId",
+ "getObjSize",
+ "getRemoteUrl",
+ "getURL",
+ "id",
+ "in_response_to",
+ "is_folderish",
+ "last_comment_date",
+ "listCreators",
+ "location",
+ "meta_type",
+ "ModificationDate",
+ "modified",
+ "path",
+ "portal_type",
+ "review_state",
+ "start",
+ "Subject",
+ "sync_uid",
+ "Title",
+ "total_comments",
+ "UID",
+]
+
+_permissions = PERMISSIONS
+deprecated("_permissions", "Use PERMISSIONS variable instead.")
+
+
+def _parseJSON(s):
+ # XXX this should be changed to a try loads except return s
+ if isinstance(s, str):
+ s = s.strip()
+ if (s.startswith("{") and s.endswith("}")) or (
+ s.startswith("[") and s.endswith("]")
+ ): # detect if json
+ return json_loads(s)
+ return s
+
+
+_unsafe_metadata = [
+ "author_name",
+ "commentors",
+ "Creator",
+ "listCreators",
+]
+_safe_callable_metadata = [
+ "getIcon",
+ "getPath",
+ "getURL",
+ "is_folderish",
+ "review_state",
+]
+
+
+class VocabLookupException(Exception):
+ pass
+
+
+class BaseVocabularyView(BrowserView):
+ def get_translated_ignored(self):
+ return TRANSLATED_IGNORED
+
+ def get_base_path(self, context):
+ return get_navigation_root(context)
+
+ def __call__(self):
+ """
+ Accepts GET parameters of:
+ name: Name of the vocabulary
+ field: Name of the field the vocabulary is being retrieved for
+ query: string or json object of criteria and options.
+ json value consists of a structure:
+ {
+ criteria: object,
+ sort_on: index,
+ sort_order: (asc|reversed)
+ }
+ attributes: comma separated, or json object list
+ batch: {
+ page: 1-based page of results,
+ size: size of paged results
+ }
+ """
+ context = self.get_context()
+ self.request.response.setHeader(
+ "Content-Type", "application/json; charset=utf-8"
+ )
+
+ try:
+ vocabulary = self.get_vocabulary()
+ except VocabLookupException as e:
+ return json_dumps({"error": e.args[0]})
+
+ results_are_brains = False
+ if hasattr(vocabulary, "search_catalog"):
+ query = self.parsed_query()
+ results = vocabulary.search_catalog(query)
+ results_are_brains = True
+ elif hasattr(vocabulary, "search"):
+ try:
+ query = self.parsed_query()["SearchableText"]["query"]
+ except KeyError:
+ results = iter(vocabulary)
+ else:
+ results = vocabulary.search(query)
+ else:
+ results = vocabulary
+
+ try:
+ total = len(results)
+ except TypeError:
+ # do not error if object does not support __len__
+ # we'll check again later if we can figure some size
+ # out
+ total = 0
+
+ # get batch
+ batch = _parseJSON(self.request.get("batch", ""))
+ if batch and ("size" not in batch or "page" not in batch):
+ batch = None # batching not providing correct options
+ if batch:
+ # must be sliceable for batching support
+ page = int(batch["page"])
+ size = int(batch["size"])
+ if size > MAX_BATCH_SIZE:
+ raise Exception("Max batch size is 500")
+ # page is being passed in is 1-based
+ start = (max(page - 1, 0)) * size
+ end = start + size
+ # Try __getitem__-based slice, then iterator slice.
+ # The iterator slice has to consume the iterator through
+ # to the desired slice, but that shouldn't be the end
+ # of the world because at some point the user will hopefully
+ # give up scrolling and search instead.
+ try:
+ results = results[start:end]
+ except TypeError:
+ results = itertools.islice(results, start, end)
+
+ # build result items
+ items = []
+
+ attributes = _parseJSON(self.request.get("attributes", ""))
+ if isinstance(attributes, str) and attributes:
+ attributes = attributes.split(",")
+
+ translate_ignored = self.get_translated_ignored()
+ transform = SafeHTML()
+ if attributes:
+ base_path = self.get_base_path(context)
+ sm = getSecurityManager()
+ can_edit = sm.checkPermission(DEFAULT_PERMISSION_SECURE, context)
+ mtt = getToolByName(self.context, "mimetypes_registry")
+ for vocab_item in results:
+ if not results_are_brains:
+ vocab_item = vocab_item.value
+ item = {}
+ for attr in attributes:
+ key = attr
+ if ":" in attr:
+ key, attr = attr.split(":", 1)
+ if attr in _unsafe_metadata and not can_edit:
+ continue
+ if key == "path":
+ attr = "getPath"
+ val = getattr(vocab_item, attr, None)
+ if callable(val):
+ if attr in _safe_callable_metadata:
+ val = val()
+ else:
+ continue
+ if key == "path" and val is not None:
+ val = val[len(base_path) :]
+ if key not in translate_ignored and isinstance(val, str):
+ val = translate(_(safe_text(val)), context=self.request)
+ item[key] = val
+ if key == "getMimeIcon":
+ item[key] = None
+ # get mime type icon url from mimetype registry'
+ contenttype = aq_base(getattr(vocab_item, "mime_type", None))
+ if contenttype:
+ ctype = mtt.lookup(contenttype)
+ if ctype:
+ item[key] = "/".join(
+ [base_path, guess_icon_path(ctype[0])]
+ )
+ else:
+ item[key] = "/".join(
+ [
+ base_path,
+ PREFIX.rstrip("/"),
+ "unknown.png",
+ ]
+ )
+ items.append(item)
+ else:
+ items = [
+ {
+ "id": unescape(transform.scrub_html(item.value)),
+ "text": (
+ unescape(transform.scrub_html(item.title)) if item.title else ""
+ ),
+ }
+ for item in results
+ ]
+
+ if total == 0:
+ total = len(items)
+
+ return json_dumps({"results": items, "total": total})
+
+ def parsed_query(
+ self,
+ ):
+ query = _parseJSON(self.request.get("query", ""))
+ if isinstance(query, str):
+ query = {"SearchableText": {"query": query}}
+ elif query:
+ parsed = queryparser.parseFormquery(self.get_context(), query["criteria"])
+ if "sort_on" in query:
+ parsed["sort_on"] = query["sort_on"]
+ if "sort_order" in query:
+ parsed["sort_order"] = str(query["sort_order"])
+ query = parsed
+ else:
+ query = {}
+ return query
+
+
+class VocabularyView(BaseVocabularyView):
+ """Queries a named vocabulary and returns JSON-formatted results."""
+
+ def get_context(self):
+ return self.context
+
+ def get_vocabulary(self):
+ # Look up named vocabulary and check permission.
+
+ context = self.context
+ factory_name = self.request.get("name", None)
+ field_name = self.request.get("field", None)
+ if not factory_name:
+ raise VocabLookupException("No factory provided.")
+ authorized = None
+ sm = getSecurityManager()
+ if factory_name not in PERMISSIONS or not INavigationRoot.providedBy(context):
+ # Check field specific permission
+ if field_name:
+ permission_checker = queryAdapter(context, IFieldPermissionChecker)
+ if permission_checker is not None:
+ authorized = permission_checker.validate(field_name, factory_name)
+ elif sm.checkPermission(
+ PERMISSIONS.get(factory_name, DEFAULT_PERMISSION), context
+ ):
+ # If no checker, fall back to checking the global registry
+ authorized = True
+
+ if not authorized:
+ raise VocabLookupException("Vocabulary lookup not allowed")
+
+ # Short circuit if we are on the site root and permission is
+ # in global registry
+ elif not sm.checkPermission(
+ PERMISSIONS.get(factory_name, DEFAULT_PERMISSION), context
+ ):
+ raise VocabLookupException("Vocabulary lookup not allowed")
+
+ factory = queryUtility(IVocabularyFactory, factory_name)
+ if not factory:
+ raise VocabLookupException(
+ 'No factory with name "%s" exists.' % factory_name
+ )
+
+ # This part is for backwards-compatibility with the first
+ # generation of vocabularies created for plone.app.widgets,
+ # which take the (unparsed) query as a parameter of the vocab
+ # factory rather than as a separate search method.
+ if isinstance(factory, FunctionType):
+ factory_spec = inspect.getfullargspec(factory)
+ else:
+ factory_spec = inspect.getfullargspec(factory.__call__)
+ query = _parseJSON(self.request.get("query", ""))
+ if query and "query" in factory_spec.args:
+ vocabulary = factory(context, query=query)
+ else:
+ # This is what is reached for non-legacy vocabularies.
+ vocabulary = factory(context)
+
+ return vocabulary
+
+
+class SourceView(BaseVocabularyView):
+ """Queries a field's source and returns JSON-formatted results."""
+
+ def get_context(self):
+ if ISubForm.providedBy(self.context.form):
+ context = self.context.form.parentForm.context
+ elif isinstance(self.context.form, ObjectWidget):
+ context = self.context.form.form.context
+ else:
+ context = self.context.context
+ return context
+
+ @property
+ @memoize
+ def default_permission(self):
+ if IAddForm.providedBy(self.context.form):
+ return "cmf.AddPortalContent"
+ return "cmf.ModifyPortalContent"
+
+ def get_vocabulary(self):
+ widget = self.context
+ field = widget.field.bind(widget.context)
+
+ # check field's write permission
+ info = mergedTaggedValueDict(field.interface, WRITE_PERMISSIONS_KEY)
+ permission_name = info.get(field.__name__, self.default_permission)
+ permission = queryUtility(IPermission, name=permission_name)
+ if permission is None:
+ permission = getUtility(IPermission, name=self.default_permission)
+ if not getSecurityManager().checkPermission(
+ permission.title, self.get_context()
+ ):
+ raise VocabLookupException("Vocabulary lookup not allowed.")
+
+ if ICollection.providedBy(field):
+ return field.value_type.vocabulary
+ return field.vocabulary
diff --git a/src/plone/app/layout/content/configure.zcml b/src/plone/app/layout/content/configure.zcml
new file mode 100644
index 00000000..505360d9
--- /dev/null
+++ b/src/plone/app/layout/content/configure.zcml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/src/plone/app/layout/tests/image.png b/src/plone/app/layout/tests/image.png
new file mode 100644
index 00000000..4c109bab
Binary files /dev/null and b/src/plone/app/layout/tests/image.png differ
diff --git a/src/plone/app/layout/tests/test_actions.py b/src/plone/app/layout/tests/test_actions.py
new file mode 100644
index 00000000..6962b7c5
--- /dev/null
+++ b/src/plone/app/layout/tests/test_actions.py
@@ -0,0 +1,474 @@
+from plone.app.content.testing import PLONE_APP_CONTENT_DX_FUNCTIONAL_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 plone.app.testing import TEST_USER_PASSWORD
+from plone.locking.interfaces import ILockable
+from plone.testing.zope import Browser
+from z3c.form.interfaces import IFormLayer
+from zExceptions import NotFound
+from zExceptions import Unauthorized
+from zope.component import getMultiAdapter
+from zope.interface import alsoProvides
+
+import transaction
+import unittest
+
+
+class ActionsDXTestCase(unittest.TestCase):
+ layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ self.request = self.layer["request"]
+
+ self.portal.acl_users.userFolderAddUser(
+ "editor", TEST_USER_PASSWORD, ["Editor"], []
+ )
+
+ # For z3c.forms request must provide IFormLayer
+ alsoProvides(self.request, IFormLayer)
+
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+ self.portal.invokeFactory(type_name="Folder", id="f1", title="A Tést Folder")
+
+ transaction.commit()
+ self.browser = Browser(self.layer["app"])
+ self.browser.handleErrors = False
+ self.browser.addHeader(
+ "Authorization", f"Basic {TEST_USER_NAME}:{TEST_USER_PASSWORD}"
+ )
+
+ def tearDown(self):
+ if "f1" in self.portal.objectIds():
+ self.portal.manage_delObjects(ids="f1")
+ transaction.commit()
+
+ def test_delete_confirmation(self):
+ folder = self.portal["f1"]
+
+ form = getMultiAdapter((folder, self.request), name="delete_confirmation")
+ form.update()
+
+ cancel = form.buttons["Cancel"]
+ form.handlers.getHandler(cancel)(form, form)
+
+ self.assertFalse(form.is_locked)
+
+ def test_delete_confirmation_if_locked(self):
+ folder = self.portal["f1"]
+ lockable = ILockable.providedBy(folder)
+
+ form = getMultiAdapter((folder, self.request), name="delete_confirmation")
+ form.update()
+
+ self.assertFalse(form.is_locked)
+
+ if lockable:
+ lockable.lock()
+
+ form = getMultiAdapter((folder, self.request), name="delete_confirmation")
+ form.update()
+
+ self.assertFalse(form.is_locked)
+
+ # After switching the user it should not be possible to delete the
+ # object. Of course this is only possible if our context provides
+ # ILockable interface.
+ if lockable:
+ logout()
+ login(self.portal, "editor")
+
+ form = getMultiAdapter((folder, self.request), name="delete_confirmation")
+ form.update()
+ self.assertTrue(form.is_locked)
+
+ logout()
+ login(self.portal, TEST_USER_NAME)
+
+ ILockable(folder).unlock()
+
+ def test_delete_confirmation_cancel(self):
+ folder = self.portal["f1"]
+
+ self.browser.open(folder.absolute_url() + "/delete_confirmation")
+ self.browser.getControl(name="form.buttons.Cancel").click()
+ context_state = getMultiAdapter(
+ (folder, self.request), name="plone_context_state"
+ )
+ self.assertEqual(self.browser.url, context_state.view_url())
+
+ def prepare_for_acquisition_tests(self):
+ """create content and an alternate authenticated browser session
+
+ creates the following content structure:
+
+ |-- f1
+ | |-- test
+ |-- test
+ """
+ # create a page at the root and one nested with the same id.
+ p1 = self.portal.invokeFactory(
+ type_name="Document", id="test", title="Test Page at Root"
+ )
+ folder_1 = self.portal["f1"]
+ p2 = folder_1.invokeFactory(
+ type_name="Document", id="test", title="Test Page in Folder"
+ )
+ contained_test_page = folder_1[p2]
+
+ transaction.commit()
+
+ # create an alternate browser also logged in with manager
+ browser_2 = Browser(self.layer["app"])
+ browser_2.handleErrors = False
+ browser_2.addHeader(
+ "Authorization", f"Basic {TEST_USER_NAME}:{TEST_USER_PASSWORD}"
+ )
+
+ # return the id of the root page, the nested page itself, and the
+ # alternate browser
+ return p1, contained_test_page, browser_2
+
+ def test_delete_wrong_object_by_acquisition_with_action(self):
+ """exposes delete-by-acquisition bug using the delete action
+
+ see https://github.com/plone/Products.CMFPlone/issues/383
+ """
+ p1_id, page_2, browser_2 = self.prepare_for_acquisition_tests()
+
+ # open two different browsers to the 'delete confirmation' view
+ delete_url = page_2.absolute_url() + "/delete_confirmation"
+ self.browser.open(delete_url)
+ browser_2.open(delete_url)
+ self.assertTrue(p1_id in self.portal)
+
+ # Try to delete in both browsers
+ self.browser.getControl(name="form.buttons.Delete").click()
+ try:
+ browser_2.getControl(name="form.buttons.Delete").click()
+ except NotFound:
+ pass
+
+ # the nested folder should be gone, but the one at the root should
+ # remain.
+ self.assertFalse(page_2.id in self.portal["f1"])
+ self.assertTrue(p1_id in self.portal)
+
+ def test_rename_form(self):
+ logout()
+ folder = self.portal["f1"]
+
+ # We need zope2.CopyOrMove permission to rename content
+ self.browser.open(folder.absolute_url() + "/folder_rename")
+ self.browser.getControl(name="form.widgets.new_id").value = "f2"
+ self.browser.getControl(name="form.widgets.new_title").value = "F2"
+ self.browser.getControl(name="form.buttons.Rename").click()
+ self.assertEqual(folder.getId(), "f2")
+ self.assertEqual(folder.Title(), "F2")
+ self.assertEqual(self.browser.url, folder.absolute_url())
+
+ login(self.portal, TEST_USER_NAME)
+ self.portal.manage_delObjects(ids="f2")
+ transaction.commit()
+
+ def test_rename_form_with_view_action(self):
+ # can't be bothered to register blobs, instead we add documents to
+ # typesUseViewActionInListings
+ registry = self.portal.portal_registry
+ registry["plone.types_use_view_action_in_listings"] = [
+ "Image",
+ "File",
+ "Document",
+ ]
+
+ folder = self.portal["f1"]
+ folder.invokeFactory("Document", "document1")
+ document1 = folder["document1"]
+ transaction.commit()
+ logout()
+
+ # We need zope2.CopyOrMove permission to rename content
+ self.browser.open(document1.absolute_url() + "/object_rename")
+ self.browser.getControl(name="form.widgets.new_id").value = "f2"
+ self.browser.getControl(name="form.widgets.new_title").value = "F2"
+ self.browser.getControl(name="form.buttons.Rename").click()
+ self.assertEqual(document1.getId(), "f2")
+ self.assertEqual(document1.Title(), "F2")
+ self.assertEqual(self.browser.url, document1.absolute_url() + "/view")
+
+ login(self.portal, TEST_USER_NAME)
+ self.portal.manage_delObjects(ids="f1")
+ transaction.commit()
+
+ def test_create_safe_id_on_renaming(self):
+ logout()
+ folder = self.portal["f1"]
+
+ # We need zope2.CopyOrMove permission to rename content
+ self.browser.open(folder.absolute_url() + "/folder_rename")
+ self.browser.getControl(name="form.widgets.new_id").value = " ? f4 4 "
+ self.browser.getControl(name="form.widgets.new_title").value = " F2 "
+ self.browser.getControl(name="form.buttons.Rename").click()
+ self.assertEqual(folder.getId(), "f4-4")
+ self.assertEqual(folder.Title(), "F2")
+ self.assertEqual(self.browser.url, folder.absolute_url())
+
+ login(self.portal, TEST_USER_NAME)
+ self.portal.manage_delObjects(ids="f4-4")
+ transaction.commit()
+
+ def test_default_page_updated_on_rename(self):
+ login(self.portal, TEST_USER_NAME)
+ folder = self.portal["f1"]
+ folder.invokeFactory(type_name="Document", id="d1", title="A Doc")
+ doc = folder["d1"]
+ folder.setDefaultPage("d1")
+ transaction.commit()
+ self.assertEqual(folder.getDefaultPage(), "d1")
+
+ # We need zope2.CopyOrMove permission to rename content
+ self.browser.open(doc.absolute_url() + "/object_rename")
+ self.browser.getControl(name="form.widgets.new_id").value = " ?renamed"
+ self.browser.getControl(name="form.widgets.new_title").value = "Doc"
+ self.browser.getControl(name="form.buttons.Rename").click()
+ self.assertEqual(folder.contentIds()[0], "renamed")
+ self.assertEqual(folder.getDefaultPage(), "renamed")
+
+ def test_rename_form_cancel(self):
+ folder = self.portal["f1"]
+
+ _id = folder.getId()
+ _title = folder.Title()
+
+ self.browser.open(folder.absolute_url() + "/folder_rename")
+ self.browser.getControl(name="form.buttons.Cancel").click()
+ transaction.commit()
+
+ self.assertEqual(self.browser.url, folder.absolute_url())
+ self.assertEqual(folder.getId(), _id)
+ self.assertEqual(folder.Title(), _title)
+
+ def test_rename_form_cancel_with_view_action(self):
+ # can't be bothered to register blobs, instead we add documents to
+ # typesUseViewActionInListings
+ registry = self.portal.portal_registry
+ registry["plone.types_use_view_action_in_listings"] = [
+ "Image",
+ "File",
+ "Document",
+ ]
+ folder = self.portal["f1"]
+ folder.invokeFactory("Document", "document1")
+ document1 = folder["document1"]
+ transaction.commit()
+
+ _id = document1.getId()
+ _title = document1.Title()
+
+ self.browser.open(document1.absolute_url() + "/object_rename")
+ self.browser.getControl(name="form.buttons.Cancel").click()
+ transaction.commit()
+
+ self.assertEqual(self.browser.url, document1.absolute_url() + "/view")
+ self.assertEqual(document1.getId(), _id)
+ self.assertEqual(document1.Title(), _title)
+
+ def _get_token(self, context):
+ authenticator = getMultiAdapter((context, self.request), name="authenticator")
+
+ return authenticator.token()
+
+ def test_object_cut_view(self):
+ folder = self.portal["f1"]
+
+ # We need pass an authenticator token to prevent Unauthorized
+ self.assertRaises(
+ Unauthorized, self.browser.open, f"{folder.absolute_url():s}/object_cut"
+ )
+
+ # We need to have Copy or Move permission to cut an object
+ self.browser.open(
+ "{:s}/object_cut?_authenticator={:s}".format(
+ folder.absolute_url(), self._get_token(folder)
+ )
+ )
+
+ self.assertIn("__cp", self.browser.cookies)
+ self.assertIn(f"{folder.Title():s} cut.", self.browser.contents)
+
+ def test_object_cut_view_with_view_action(self):
+ # can't be bothered to register blobs, instead we add documents to
+ # typesUseViewActionInListings
+ registry = self.portal.portal_registry
+ registry["plone.types_use_view_action_in_listings"] = [
+ "Image",
+ "File",
+ "Document",
+ ]
+ folder = self.portal["f1"]
+ folder.invokeFactory("Document", "document1")
+ document1 = folder["document1"]
+ transaction.commit()
+
+ # We need pass an authenticator token to prevent Unauthorized
+ self.assertRaises(
+ Unauthorized, self.browser.open, f"{document1.absolute_url():s}/object_cut"
+ )
+
+ # We need to have Copy or Move permission to cut an object
+ self.browser.open(
+ "{:s}/object_cut?_authenticator={:s}".format(
+ document1.absolute_url(), self._get_token(document1)
+ )
+ )
+
+ self.assertIn("__cp", self.browser.cookies)
+ self.assertIn(f"{document1.Title():s} cut.", self.browser.contents)
+ self.assertEqual(document1.absolute_url() + "/view", self.browser.url)
+
+ def test_object_copy_view(self):
+ folder = self.portal["f1"]
+
+ # We need pass an authenticator token to prevent Unauthorized
+ self.assertRaises(
+ Unauthorized, self.browser.open, f"{folder.absolute_url():s}/object_copy"
+ )
+
+ self.browser.open(
+ "{:s}/object_copy?_authenticator={:s}".format(
+ folder.absolute_url(), self._get_token(folder)
+ )
+ )
+
+ self.assertIn("__cp", self.browser.cookies)
+ self.assertIn(f"{folder.Title():s} copied.", self.browser.contents)
+
+ def test_object_copy_with_view_action(self):
+ # can't be bothered to register blobs, instead we add documents to
+ # typesUseViewActionInListings
+ registry = self.portal.portal_registry
+ registry["plone.types_use_view_action_in_listings"] = [
+ "Image",
+ "File",
+ "Document",
+ ]
+
+ folder = self.portal["f1"]
+ folder.invokeFactory("Document", "document1")
+ document1 = folder["document1"]
+ transaction.commit()
+
+ # We need pass an authenticator token to prevent Unauthorized
+ self.assertRaises(
+ Unauthorized, self.browser.open, f"{document1.absolute_url():s}/object_copy"
+ )
+
+ self.browser.open(
+ "{:s}/object_copy?_authenticator={:s}".format(
+ document1.absolute_url(), self._get_token(document1)
+ )
+ )
+
+ self.assertIn("__cp", self.browser.cookies)
+ self.assertIn(f"{document1.Title():s} copied.", self.browser.contents)
+ self.assertEqual(document1.absolute_url() + "/view", self.browser.url)
+
+ def test_object_cut_and_paste(self):
+ folder = self.portal["f1"]
+ self.portal.invokeFactory(type_name="Document", id="d1", title="A Doc")
+ doc = self.portal["d1"]
+ transaction.commit()
+
+ self.browser.open(
+ "{:s}/object_cut?_authenticator={:s}".format(
+ doc.absolute_url(), self._get_token(doc)
+ )
+ )
+
+ self.assertIn("__cp", self.browser.cookies)
+ self.assertIn("d1", self.portal.objectIds())
+ self.assertIn("f1", self.portal.objectIds())
+
+ # We need pass an authenticator token to prevent Unauthorized
+ self.assertRaises(
+ Unauthorized, self.browser.open, f"{folder.absolute_url():s}/object_paste"
+ )
+
+ self.browser.open(
+ "{:s}/object_paste?_authenticator={:s}".format(
+ folder.absolute_url(), self._get_token(doc)
+ )
+ )
+
+ self.assertIn("__cp", self.browser.cookies)
+ transaction.commit()
+
+ self.assertNotIn("d1", self.portal.objectIds())
+ self.assertIn("d1", folder.objectIds())
+ self.assertIn("Item(s) pasted.", self.browser.contents)
+
+ def test_object_copy_and_paste(self):
+ folder = self.portal["f1"]
+ folder.invokeFactory(type_name="Document", id="d1", title="A Doc")
+ doc = folder["d1"]
+ transaction.commit()
+
+ self.browser.open(
+ "{:s}/object_copy?_authenticator={:s}".format(
+ doc.absolute_url(), self._get_token(doc)
+ )
+ )
+
+ self.assertIn("__cp", self.browser.cookies)
+
+ # We need pass an authenticator token to prevent Unauthorized
+ self.assertRaises(
+ Unauthorized, self.browser.open, f"{folder.absolute_url():s}/object_paste"
+ )
+
+ self.browser.open(
+ "{:s}/object_paste?_authenticator={:s}".format(
+ folder.absolute_url(), self._get_token(folder)
+ )
+ )
+ transaction.commit()
+
+ self.assertIn("f1", self.portal.objectIds())
+ self.assertIn("d1", folder.objectIds())
+ self.assertIn("copy_of_d1", folder.objectIds())
+ self.assertIn("Item(s) pasted.", self.browser.contents)
+
+ def test_object_copy_and_paste_multiple_times(self):
+ folder = self.portal["f1"]
+ folder.invokeFactory(type_name="Document", id="d1", title="A Doc")
+ doc = folder["d1"]
+ transaction.commit()
+
+ self.browser.open(
+ "{:s}/object_copy?_authenticator={:s}".format(
+ doc.absolute_url(), self._get_token(doc)
+ )
+ )
+
+ self.assertIn("__cp", self.browser.cookies)
+ self.browser.open(
+ "{:s}/object_paste?_authenticator={:s}".format(
+ folder.absolute_url(), self._get_token(folder)
+ )
+ )
+ self.browser.open(
+ "{:s}/object_paste?_authenticator={:s}".format(
+ folder.absolute_url(), self._get_token(folder)
+ )
+ )
+
+ # Cookie should persist, because you can paste the item multiple times
+ self.assertIn("__cp", self.browser.cookies)
+ self.assertIn("f1", self.portal.objectIds())
+ self.assertIn("d1", folder.objectIds())
+ self.assertIn("copy_of_d1", folder.objectIds())
+ self.assertIn("copy2_of_d1", folder.objectIds())
+ self.assertIn("Item(s) pasted.", self.browser.contents)
diff --git a/src/plone/app/layout/tests/test_adding.py b/src/plone/app/layout/tests/test_adding.py
new file mode 100644
index 00000000..b813c5e8
--- /dev/null
+++ b/src/plone/app/layout/tests/test_adding.py
@@ -0,0 +1,23 @@
+from Acquisition import aq_get
+from plone.app.content.testing import PLONE_APP_CONTENT_INTEGRATION_TESTING
+
+import unittest
+
+
+class AddingTests(unittest.TestCase):
+ layer = PLONE_APP_CONTENT_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+
+ def test_adding_acquisition(self):
+ adding = self.portal.unrestrictedTraverse("+")
+ # Check explicit Acquisition
+ template = aq_get(adding, "portal_skins")
+ self.assertTrue(template)
+ # Check implicit Acquisition, unfortunately the CMF skins machinery
+ # depends on this
+ template = getattr(adding, "portal_skins")
+ self.assertTrue(template)
+ # Check traversal
+ self.assertTrue(self.portal.unrestrictedTraverse("+/main_template"))
diff --git a/src/plone/app/layout/tests/test_constrains.py b/src/plone/app/layout/tests/test_constrains.py
new file mode 100644
index 00000000..0f8fbe2c
--- /dev/null
+++ b/src/plone/app/layout/tests/test_constrains.py
@@ -0,0 +1,20 @@
+from plone.app.layout.content.browser.constraintypes import IConstrainForm
+from zope.interface.exceptions import Invalid
+
+import unittest
+
+
+class DocumentIntegrationTest(unittest.TestCase):
+ def test_formschemainvariants(self):
+ class Data:
+ allowed_types = []
+ secondary_types = []
+
+ bad = Data()
+ bad.allowed_types = []
+ bad.secondary_types = ["1"]
+ good = Data()
+ good.allowed_types = ["1"]
+ good.secondary_types = []
+ self.assertTrue(IConstrainForm.validateInvariants(good) is None)
+ self.assertRaises(Invalid, IConstrainForm.validateInvariants, bad)
diff --git a/src/plone/app/layout/tests/test_content_status_modify.py b/src/plone/app/layout/tests/test_content_status_modify.py
new file mode 100644
index 00000000..7b53a48c
--- /dev/null
+++ b/src/plone/app/layout/tests/test_content_status_modify.py
@@ -0,0 +1,180 @@
+from plone.app.content.testing import PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING
+from plone.app.content.testing import PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+from plone.app.testing import login
+from plone.app.testing import setRoles
+from plone.app.testing import TEST_USER_ID
+from plone.app.testing import TEST_USER_NAME
+from plone.app.testing import TEST_USER_PASSWORD
+from plone.base.utils import is_expired
+from plone.namedfile.file import NamedBlobFile
+from plone.namedfile.file import NamedBlobImage
+from plone.testing.zope import Browser
+from zope.component import getMultiAdapter
+
+import os
+import transaction
+import unittest
+
+
+class TestContentStatusModifyIntegration(unittest.TestCase):
+ """The the content_status_modify view.
+
+ Until and including Plone 5.2, this was a skin script in Products.CMFPlone.
+ Tests 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")
+
+ def setup_authenticator(self):
+ from plone.protect.authenticator import createToken
+
+ self.request.form["_authenticator"] = createToken()
+
+ def get_content_status_modify_view(self, obj):
+ self.setup_authenticator()
+ view = getMultiAdapter((obj, self.request), name="content_status_modify")
+ return view
+
+ # Test the recursive behaviour of content_status_modify:
+
+ def testPublishingNonDefaultPageLeavesFolderAlone(self):
+ view = self.get_content_status_modify_view(self.folder.d1)
+ view("publish")
+ self.assertEqual(
+ self.workflow.getInfoFor(self.folder, "review_state"), "private"
+ )
+ self.assertEqual(
+ self.workflow.getInfoFor(self.folder.d1, "review_state"), "published"
+ )
+
+ def testPublishingDefaultPagePublishesFolder(self):
+ self.folder.setDefaultPage("d1")
+ view = self.get_content_status_modify_view(self.folder.d1)
+ view("publish")
+ self.assertEqual(
+ self.workflow.getInfoFor(self.folder, "review_state"), "published"
+ )
+ self.assertEqual(
+ self.workflow.getInfoFor(self.folder.d1, "review_state"), "published"
+ )
+
+ def testPublishingDefaultPageWhenFolderCannotBePublished(self):
+ self.folder.setDefaultPage("d1")
+ # make parent be published already when publishing its default document
+ # results in an attempt to do it again
+ view = self.get_content_status_modify_view(self.folder)
+ view("publish")
+ self.assertEqual(
+ self.workflow.getInfoFor(self.folder, "review_state"), "published"
+ )
+ view = self.get_content_status_modify_view(self.folder.d1)
+ view("publish")
+ self.assertEqual(
+ self.workflow.getInfoFor(self.folder, "review_state"), "published"
+ )
+ self.assertEqual(
+ self.workflow.getInfoFor(self.folder.d1, "review_state"), "published"
+ )
+
+ # test setting effective/expiration date and isExpired method
+
+ def testIsExpiredWithExplicitExpiredContent(self):
+ view = self.get_content_status_modify_view(self.folder.d1)
+ view(
+ workflow_action="publish",
+ effective_date="1/1/2001",
+ expiration_date="1/2/2001",
+ )
+ self.assertTrue(is_expired(self.folder.d1))
+
+ def testIsExpiredWithExplicitNonExpiredContent(self):
+ view = self.get_content_status_modify_view(self.folder.d1)
+ view(workflow_action="publish")
+ self.assertFalse(is_expired(self.folder.d1))
+
+ def testEditorCanSubmitButNotPublish(self):
+ setRoles(self.portal, TEST_USER_ID, ["Contributor"])
+ self.folder.invokeFactory("Document", id="d2", title="Doc 2")
+ view = self.get_content_status_modify_view(self.folder.d2)
+ view(workflow_action="submit")
+ self.assertEqual(
+ self.workflow.getInfoFor(self.folder.d2, "review_state"), "pending"
+ )
+
+ # Now try publishing.
+ # For various reasons, there are no complaints/errors when trying
+ # a transition that you are not allowed to do.
+ view = self.get_content_status_modify_view(self.folder.d2)
+ view(workflow_action="publish")
+ self.assertEqual(
+ self.workflow.getInfoFor(self.folder.d2, "review_state"), "pending"
+ )
+
+
+class TestContentStatusModifyFunctional(unittest.TestCase):
+ layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ # Set workflow for files and images.
+ wf_tool = self.portal.portal_workflow
+ wf_tool.setChainForPortalTypes(["File"], "simple_publication_workflow")
+ wf_tool.setChainForPortalTypes(["Image"], "simple_publication_workflow")
+
+ # Create content.
+ filename = os.path.join(os.path.dirname(__file__), "image.png")
+ with open(filename, "rb") as f:
+ FILE_DATA = f.read()
+ self.portal.invokeFactory("Document", id="d1", title="Doc 1")
+ self.portal.invokeFactory(
+ "File",
+ id="f1",
+ title="File 1",
+ file=NamedBlobFile(data=FILE_DATA, filename="image.png"),
+ )
+ self.portal.invokeFactory(
+ "Image",
+ id="i1",
+ title="Image 1",
+ image=NamedBlobImage(data=FILE_DATA, filename="image.png"),
+ )
+ self.portal_url = self.portal.absolute_url()
+ transaction.commit()
+
+ # Setup the test browser.
+ self.browser = Browser(self.layer["app"])
+ self.browser.handleErrors = False
+ self.browser.raiseHttpErrors = False
+ self.browser.addHeader(
+ "Authorization", f"Basic {TEST_USER_NAME}:{TEST_USER_PASSWORD}"
+ )
+
+ def test_history_doc(self):
+ self.browser.open(f"{self.portal_url}/d1/content_status_history")
+ self.browser.getControl("Save").click()
+ self.assertEqual(self.browser.url, f"{self.portal_url}/d1")
+
+ def test_history_file(self):
+ self.browser.open(f"{self.portal_url}/f1/content_status_history")
+ self.browser.getControl("Save").click()
+ self.assertEqual(self.browser.url, f"{self.portal_url}/f1/view")
+
+ def test_history_image(self):
+ self.browser.open(f"{self.portal_url}/i1/content_status_history")
+ self.browser.getControl("Save").click()
+ self.assertEqual(self.browser.url, f"{self.portal_url}/i1/view")
diff --git a/src/plone/app/layout/tests/test_contents.py b/src/plone/app/layout/tests/test_contents.py
new file mode 100644
index 00000000..2ec41e1a
--- /dev/null
+++ b/src/plone/app/layout/tests/test_contents.py
@@ -0,0 +1,658 @@
+from datetime import datetime
+from datetime import timedelta
+from plone.app.content.testing import PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING
+from plone.app.content.testing import PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+from plone.app.testing import login
+from plone.app.testing import setRoles
+from plone.app.testing import SITE_OWNER_NAME
+from plone.app.testing import SITE_OWNER_PASSWORD
+from plone.app.testing import TEST_USER_ID
+from plone.app.testing import TEST_USER_NAME
+from plone.dexterity.fti import DexterityFTI
+from plone.protect.authenticator import createToken
+from plone.registry.interfaces import IRegistry
+from plone.testing.zope import Browser
+from plone.uuid.interfaces import IUUID
+from unittest import mock
+from zope.component import getMultiAdapter
+from zope.component import getUtility
+
+import json
+import transaction
+import unittest
+
+
+class ContentsCopyTests(unittest.TestCase):
+ layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ self.request = self.layer["request"]
+
+ # TYPE 1
+ type1_fti = DexterityFTI("type1")
+ type1_fti.klass = "plone.dexterity.content.Container"
+ type1_fti.filter_content_types = True
+ type1_fti.allowed_content_types = ["type1"]
+ type1_fti.behaviors = (
+ "plone.constraintypes",
+ "plone.basic",
+ )
+ self.portal.portal_types._setObject("type1", type1_fti)
+ self.type1_fti = type1_fti
+
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ @mock.patch(
+ "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True
+ ) # noqa
+ def test_keep_selection_order(self):
+ """Keep the order of items the same as they were selected."""
+ self.portal.invokeFactory("type1", id="f1", title="Folder 1")
+ f1 = self.portal.f1
+ f1.invokeFactory("type1", id="it1", title="Item 1")
+ f1.invokeFactory("type1", id="it2", title="Item 2")
+ f1.invokeFactory("type1", id="it3", title="Item 3")
+
+ def _test_order(sel):
+ self.request.form["selection"] = json.dumps([IUUID(f1[id_]) for id_ in sel])
+ view = f1.restrictedTraverse("@@fc-copy")
+ view()
+ self.assertEqual([ob.id for ob in view.oblist], sel)
+
+ _test_order(["it1", "it2", "it3"])
+ _test_order(["it3", "it1", "it2"])
+
+
+class ContentsDeleteTests(unittest.TestCase):
+ layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ self.request = self.layer["request"]
+
+ # TYPE 1
+ type1_fti = DexterityFTI("type1")
+ type1_fti.klass = "plone.dexterity.content.Container"
+ type1_fti.filter_content_types = True
+ type1_fti.allowed_content_types = ["type1"]
+ type1_fti.behaviors = (
+ "plone.constraintypes",
+ "plone.basic",
+ )
+ self.portal.portal_types._setObject("type1", type1_fti)
+ self.type1_fti = type1_fti
+
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ @mock.patch(
+ "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True
+ ) # noqa
+ def test_delete_success_with_private_ancestor(self):
+ """Delete content item from a folder with private ancestor"""
+ # Create test content /it1/it2/it3
+ self.portal.invokeFactory("type1", id="it1", title="Item 1")
+ self.portal.it1.invokeFactory("type1", id="it2", title="Item 2")
+ self.portal.it1.it2.invokeFactory("type1", id="it3", title="Item 3")
+ self.assertEqual(len(self.portal.it1.it2.contentIds()), 1)
+
+ # Block user access to it1m but leave access to its children
+ self.portal.it1.__ac_local_roles_block__ = True
+ del self.portal.it1.__ac_local_roles__[TEST_USER_ID]
+ self.portal.it1.reindexObjectSecurity()
+ self.portal.it1.it2.reindexObjectSecurity()
+
+ # Remove test user global roles (leaving only local owner roles on it2)
+ setRoles(self.portal, TEST_USER_ID, [])
+
+ # Execute delete request
+ selection = [self.portal.it1.it2.it3.UID()]
+ self.request.form["folder"] = "/it1/it2"
+ self.request.form["selection"] = json.dumps(selection)
+ res = self.portal.it1.it2.restrictedTraverse("@@fc-delete")()
+
+ # Check for successful deletion
+ res = json.loads(res)
+ self.assertEqual(res["status"], "success")
+ self.assertEqual(len(self.portal.it1.it2.contentIds()), 0)
+
+ @mock.patch(
+ "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True
+ ) # noqa
+ def test_delete_success_on_inactive_content(self):
+ """Delete an expired content item from a folder."""
+ # Create content
+ self.portal.invokeFactory("type1", id="it1", title="Item 1")
+ self.portal.it1.invokeFactory("type1", id="it2", title="Item 2")
+
+ # Expire it2
+ exp = datetime.now() - timedelta(days=10)
+ self.portal.it1.it2.expiration_date = exp
+ self.portal.it1.it2.reindexObject()
+
+ # Remove test user global roles (leaving only local owner roles on it1
+ # and below)
+ setRoles(self.portal, TEST_USER_ID, [])
+
+ # Execute delete request
+ selection = [self.portal.it1.it2.UID()]
+ self.request.form["folder"] = "/it1"
+ self.request.form["selection"] = json.dumps(selection)
+ res = self.portal.it1.restrictedTraverse("@@fc-delete")()
+
+ # Check for successful deletion
+ res = json.loads(res)
+ self.assertEqual(res["status"], "success")
+ self.assertEqual(len(self.portal.it1.contentIds()), 0)
+
+
+class ContentsPasteTests(unittest.TestCase):
+ layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ self.request = self.layer["request"]
+
+ # TYPE 1
+ type1_fti = DexterityFTI("type1")
+ type1_fti.klass = "plone.dexterity.content.Container"
+ type1_fti.filter_content_types = True
+ type1_fti.allowed_content_types = ["type1"]
+ type1_fti.behaviors = (
+ "plone.constraintypes",
+ "plone.basic",
+ )
+ self.portal.portal_types._setObject("type1", type1_fti)
+ self.type1_fti = type1_fti
+
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ self.portal.invokeFactory("type1", id="it1", title="Item 1")
+
+ @mock.patch(
+ "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True
+ ) # noqa
+ def test_paste_success(self):
+ """Copy content item and paste in portal root."""
+ # # setup copying via @@fc-copy
+ # from plone.uuid.interfaces import IUUID
+ # self.request['selection'] = [IUUID(self.portal.it1)]
+ # self.portal.restrictedTraverse('@@fc-copy')()
+
+ self.request["__cp"] = self.portal.manage_copyObjects(["it1"])
+ self.request.form["folder"] = "/"
+ res = self.portal.restrictedTraverse("@@fc-paste")()
+
+ res = json.loads(res)
+ self.assertEqual(res["status"], "success")
+ self.assertEqual(len(self.portal.contentIds()), 2)
+
+ @mock.patch(
+ "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True
+ ) # noqa
+ def test_paste_success_paste_in_itself(self):
+ """Copy content item and paste in itself. Because we can."""
+ self.request["__cp"] = self.portal.manage_copyObjects(["it1"])
+ self.request.form["folder"] = "/it1"
+ res = self.portal.it1.restrictedTraverse("@@fc-paste")()
+
+ res = json.loads(res)
+ self.assertEqual(res["status"], "success")
+ self.assertEqual(len(self.portal.it1.contentIds()), 1)
+
+ @mock.patch(
+ "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True
+ ) # noqa
+ def test_paste_fail_constraint(self):
+ """Fail pasting content item in itself when folder constraints don't
+ allow to.
+ """
+ self.type1_fti.allowed_content_types = [] # set folder constraints
+ self.request["__cp"] = self.portal.manage_copyObjects(["it1"])
+ self.request.form["folder"] = "/it1"
+ res = self.portal.it1.restrictedTraverse("@@fc-paste")()
+
+ res = json.loads(res)
+ self.assertEqual(res["status"], "warning")
+ self.assertEqual(len(self.portal.it1.contentIds()), 0)
+
+ @mock.patch(
+ "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True
+ ) # noqa
+ def test_paste_success_with_private_ancestor(self):
+ """Copy content item and paste into a folder with private ancestor"""
+ # Create test content /it2/it3
+ self.portal.invokeFactory("type1", id="it2", title="Item 2")
+ self.portal.it2.invokeFactory("type1", id="it3", title="Item 3")
+ self.assertEqual(len(self.portal.it2.it3.contentIds()), 0)
+
+ # Block user access to it2, but leave access to its children
+ self.portal.it2.__ac_local_roles_block__ = True
+ del self.portal.it2.__ac_local_roles__[TEST_USER_ID]
+ self.portal.it2.reindexObjectSecurity()
+ self.portal.it2.it3.reindexObjectSecurity()
+
+ # Remove test user global roles (leaving only local owner roles on it2)
+ setRoles(self.portal, TEST_USER_ID, [])
+
+ # Execute paste
+ self.request["__cp"] = self.portal.manage_copyObjects(["it1"])
+ self.request.form["folder"] = "/it2/it3"
+ res = self.portal.it2.it3.restrictedTraverse("@@fc-paste")()
+
+ # Check for successful paste
+ res = json.loads(res)
+ self.assertEqual(res["status"], "success")
+ self.assertEqual(len(self.portal.it2.it3.contentIds()), 1)
+
+
+class ContentsRenameTests(unittest.TestCase):
+ layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ self.request = self.layer["request"]
+
+ # TYPE 1
+ type1_fti = DexterityFTI("type1")
+ type1_fti.klass = "plone.dexterity.content.Container"
+ type1_fti.filter_content_types = True
+ type1_fti.allowed_content_types = ["type1"]
+ type1_fti.behaviors = (
+ "plone.constraintypes",
+ "plone.basic",
+ )
+ self.portal.portal_types._setObject("type1", type1_fti)
+ self.type1_fti = type1_fti
+
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ @mock.patch(
+ "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True
+ ) # noqa
+ def test_rename_success_with_private_ancestor(self):
+ """Rename content item from a folder with private ancestor"""
+ # Create test content /it1/it2/it3
+ self.portal.invokeFactory("type1", id="it1", title="Item 1")
+ self.portal.it1.invokeFactory("type1", id="it2", title="Item 2")
+ self.portal.it1.it2.invokeFactory("type1", id="it3", title="Item 3")
+ self.assertEqual(len(self.portal.it1.it2.contentIds()), 1)
+
+ # Block user access to it1m but leave access to its children
+ self.portal.it1.__ac_local_roles_block__ = True
+ del self.portal.it1.__ac_local_roles__[TEST_USER_ID]
+ self.portal.it1.reindexObjectSecurity()
+ self.portal.it1.it2.reindexObjectSecurity()
+
+ # Remove test user global roles (leaving only local owner roles on it2)
+ setRoles(self.portal, TEST_USER_ID, [])
+
+ # Execute rename request
+ self.request.form["UID_1"] = self.portal.it1.it2.it3.UID()
+ self.request.form["newid_1"] = "it3bak"
+ self.request.form["newtitle_1"] = "Item 3 BAK"
+ res = self.portal.it1.it2.restrictedTraverse("@@fc-rename")()
+
+ # Check for successful deletion
+ res = json.loads(res)
+ self.assertEqual(res["status"], "success")
+ self.assertEqual(self.portal.it1.it2.it3bak.id, "it3bak")
+ self.assertEqual(self.portal.it1.it2.it3bak.title, "Item 3 BAK")
+
+ @mock.patch(
+ "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True
+ ) # noqa
+ def test_rename_success_on_inactive_content(self):
+ """Rename an expired content item from a folder."""
+ # Create content
+ self.portal.invokeFactory("type1", id="it1", title="Item 1")
+ self.portal.it1.invokeFactory("type1", id="it2", title="Item 2")
+
+ # Expire it2
+ exp = datetime.now() - timedelta(days=10)
+ self.portal.it1.it2.expiration_date = exp
+ self.portal.it1.it2.reindexObject()
+
+ # Remove test user global roles (leaving only local owner roles on it1
+ # and below)
+ setRoles(self.portal, TEST_USER_ID, [])
+
+ # Execute rename request
+ self.request.form["UID_1"] = self.portal.it1.it2.UID()
+ self.request.form["newid_1"] = "it2bak"
+ self.request.form["newtitle_1"] = "Item 2 BAK"
+ res = self.portal.it1.restrictedTraverse("@@fc-rename")()
+
+ # Check for successful deletion
+ res = json.loads(res)
+ self.assertEqual(res["status"], "success")
+ self.assertEqual(self.portal.it1.it2bak.id, "it2bak")
+ self.assertEqual(self.portal.it1.it2bak.title, "Item 2 BAK")
+
+
+class AllowUploadViewTests(unittest.TestCase):
+ layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ self.request = self.layer["request"]
+
+ # TYPE 1
+ type1_fti = DexterityFTI("type1")
+ type1_fti.klass = "plone.dexterity.content.Container"
+ type1_fti.filter_content_types = True
+ type1_fti.allowed_content_types = []
+ type1_fti.behaviors = ("plone.basic",)
+ self.portal.portal_types._setObject("type1", type1_fti)
+ self.type1_fti = type1_fti
+
+ # TYPE 2
+ type2_fti = DexterityFTI("type1")
+ type2_fti.klass = "plone.dexterity.content.Item"
+ type2_fti.filter_content_types = True
+ type2_fti.allowed_content_types = []
+ type2_fti.behaviors = ("plone.basic",)
+ self.portal.portal_types._setObject("type2", type2_fti)
+ self.type2_fti = type2_fti
+
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ self.portal.invokeFactory("type1", id="it1", title="Item 1")
+ self.portal.invokeFactory("type2", id="it2", title="Item 2")
+
+ def test_allow_upload(self):
+ """Test, if file or images are allowed in a container in different FTI
+ configurations.
+ """
+
+ # Test non-container, none allowed
+ allow_upload = self.portal.it2.restrictedTraverse("@@allow_upload")
+ allow_upload = json.loads(allow_upload())
+
+ self.assertEqual(allow_upload["allowUpload"], False)
+ self.assertEqual(allow_upload["allowImages"], False)
+ self.assertEqual(allow_upload["allowFiles"], False)
+
+ # Test none allowed
+ self.type1_fti.allowed_content_types = []
+ allow_upload = self.portal.it1.restrictedTraverse("@@allow_upload")
+ allow_upload = json.loads(allow_upload())
+
+ self.assertEqual(allow_upload["allowUpload"], False)
+ self.assertEqual(allow_upload["allowImages"], False)
+ self.assertEqual(allow_upload["allowFiles"], False)
+
+ # Test images allowed
+ self.type1_fti.allowed_content_types = ["Image"]
+ allow_upload = self.portal.it1.restrictedTraverse("@@allow_upload")
+ allow_upload = json.loads(allow_upload())
+
+ self.assertEqual(allow_upload["allowUpload"], True)
+ self.assertEqual(allow_upload["allowImages"], True)
+ self.assertEqual(allow_upload["allowFiles"], False)
+
+ # Test files allowed
+ self.type1_fti.allowed_content_types = ["File"]
+ allow_upload = self.portal.it1.restrictedTraverse("@@allow_upload")
+ allow_upload = json.loads(allow_upload())
+
+ self.assertEqual(allow_upload["allowUpload"], True)
+ self.assertEqual(allow_upload["allowImages"], False)
+ self.assertEqual(allow_upload["allowFiles"], True)
+
+ # Test images and files allowed
+ self.type1_fti.allowed_content_types = ["Image", "File"]
+ allow_upload = self.portal.it1.restrictedTraverse("@@allow_upload")
+ allow_upload = json.loads(allow_upload())
+
+ self.assertEqual(allow_upload["allowUpload"], True)
+ self.assertEqual(allow_upload["allowImages"], True)
+ self.assertEqual(allow_upload["allowFiles"], True)
+
+ # Test files allowed, path via request variable
+ self.type1_fti.allowed_content_types = ["File"]
+ # First, test on Portal root to see the difference
+ allow_upload = self.portal.restrictedTraverse("@@allow_upload")
+ allow_upload = json.loads(allow_upload())
+ self.assertEqual(allow_upload["allowUpload"], True)
+ self.assertEqual(allow_upload["allowImages"], True)
+ self.assertEqual(allow_upload["allowFiles"], True)
+ # Then, with path set to sub item
+ allow_upload = self.portal.restrictedTraverse("@@allow_upload")
+ allow_upload.request.form["path"] = "/plone/it1"
+ allow_upload = json.loads(allow_upload())
+ self.assertEqual(allow_upload["allowUpload"], True)
+ self.assertEqual(allow_upload["allowImages"], False)
+ self.assertEqual(allow_upload["allowFiles"], True)
+
+
+class FCPropertiesTests(unittest.TestCase):
+ layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ self.request = self.layer["request"]
+ # Disable plone.protect for these tests
+ self.request.environ["REQUEST_METHOD"] = "POST"
+ self.request.form["_authenticator"] = createToken()
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ # set available languages
+ registry = getUtility(IRegistry)
+ registry["plone.available_languages"] = ["en", "de"]
+
+ self.portal.invokeFactory("Folder", "main1")
+ self.portal.main1.invokeFactory("Folder", "sub1")
+ self.portal.main1.sub1.invokeFactory("Folder", "subsub1")
+ self.portal.main1.invokeFactory("Document", "sub2")
+ self.portal.invokeFactory("Document", "main2")
+
+ self.setup_initial()
+
+ def setup_initial(self):
+ # Initial Settings
+ self.portal.main1.exclude_from_nav = True
+ self.portal.main1.sub1.exclude_from_nav = True
+ self.portal.main1.sub1.subsub1.exclude_from_nav = True
+ self.portal.main1.sub2.exclude_from_nav = True
+ self.portal.main2.exclude_from_nav = True
+
+ self.portal.main1.language = "en"
+ self.portal.main1.sub1.language = "en"
+ self.portal.main1.sub1.subsub1.language = "en"
+ self.portal.main1.sub2.language = "en"
+ self.portal.main2.language = "en"
+
+ def test_fc_properties__changes__no_recurse(self):
+ """Test changing properties without recursion."""
+ req = self.request
+ req.form["language"] = "de"
+ req.form["exclude-from-nav"] = "no"
+ req.form["selection"] = '["{}", "{}"]'.format(
+ IUUID(self.portal.main1), IUUID(self.portal.main2)
+ )
+
+ view = getMultiAdapter((self.portal, req), name="fc-properties")
+
+ # Call the view and execute the actions
+ view()
+
+ self.assertEqual(self.portal.main1.language, "de")
+ self.assertEqual(self.portal.main2.language, "de")
+ self.assertEqual(self.portal.main1.sub1.language, "en")
+ self.assertEqual(self.portal.main1.sub1.subsub1.language, "en")
+ self.assertEqual(self.portal.main1.sub2.language, "en")
+
+ self.assertEqual(self.portal.main1.exclude_from_nav, False)
+ self.assertEqual(self.portal.main2.exclude_from_nav, False)
+ self.assertEqual(self.portal.main1.sub1.exclude_from_nav, True)
+ self.assertEqual(self.portal.main1.sub1.subsub1.exclude_from_nav, True)
+ self.assertEqual(self.portal.main1.sub2.exclude_from_nav, True)
+
+ def test_fc_properties__changes__with_recurse(self):
+ """Test changing properties without recursion."""
+ req = self.request
+ req.form["language"] = "de"
+ req.form["exclude-from-nav"] = "no"
+ req.form["recurse"] = "yes"
+ req.form["selection"] = '["{}", "{}"]'.format(
+ IUUID(self.portal.main1), IUUID(self.portal.main2)
+ )
+
+ view = getMultiAdapter((self.portal, req), name="fc-properties")
+
+ # Call the view and execute the actions
+ view()
+
+ self.assertEqual(self.portal.main1.language, "de")
+ self.assertEqual(self.portal.main2.language, "de")
+ self.assertEqual(self.portal.main1.sub1.language, "de")
+ self.assertEqual(self.portal.main1.sub1.subsub1.language, "de")
+ self.assertEqual(self.portal.main1.sub2.language, "de")
+
+ self.assertEqual(self.portal.main1.exclude_from_nav, False)
+ self.assertEqual(self.portal.main2.exclude_from_nav, False)
+ self.assertEqual(self.portal.main1.sub1.exclude_from_nav, False)
+ self.assertEqual(self.portal.main1.sub1.subsub1.exclude_from_nav, False) # noqa
+ self.assertEqual(self.portal.main1.sub2.exclude_from_nav, False)
+
+
+# We want to avoid hackers getting script tags inserted.
+# But for example an ampersand is okay as long as it is escaped,
+# although it should not be doubly escaped, because that looks wrong.
+NORMAL_TEXT = "Smith & Jones"
+ESCAPED_TEXT = "Smith & Jones"
+DOUBLY_ESCAPED_TEXT = "Smith & Jones"
+# For script tags, safest is to filter them using the safe html filter.
+HACKED = 'The was here.'
+
+
+class TestSafeHtmlInFolderContents(unittest.TestCase):
+ """Test that the title in the folder contents is safe.
+
+ From PloneHotfix20200121, see
+ https://plone.org/security/hotfix/20200121/xss-in-the-title-field-on-plone-5-0-and-higher
+
+ Same for other fields, from PloneHotfix20210518, see
+ https://plone.org/security/hotfix/20210518/stored-xss-in-folder-contents
+ """
+
+ layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ def get_browser(self):
+ browser = Browser(self.layer["app"])
+ browser.handleErrors = False
+ browser.addHeader(
+ "Authorization",
+ f"Basic {SITE_OWNER_NAME}:{SITE_OWNER_PASSWORD}",
+ )
+ return browser
+
+ def test_ampersand(self):
+ self.portal.invokeFactory(
+ "Folder",
+ id="folder1",
+ title=NORMAL_TEXT,
+ description=NORMAL_TEXT,
+ creators=(NORMAL_TEXT,),
+ contributors=(NORMAL_TEXT,),
+ )
+ folder1 = self.portal.folder1
+ self.assertEqual(folder1.Title(), NORMAL_TEXT)
+ self.assertEqual(folder1.Description(), NORMAL_TEXT)
+ folder1.invokeFactory(
+ "Document",
+ id="page1",
+ title=NORMAL_TEXT,
+ description=NORMAL_TEXT,
+ creators=(NORMAL_TEXT,),
+ contributors=(NORMAL_TEXT,),
+ )
+ page1 = folder1.page1
+ self.assertEqual(page1.Title(), NORMAL_TEXT)
+ self.assertEqual(page1.Description(), NORMAL_TEXT)
+ transaction.commit()
+
+ # Check the output.
+ browser = self.get_browser()
+ browser.open(folder1.absolute_url())
+ self.assert_only_escaped_text(browser)
+ browser.open(page1.absolute_url())
+ self.assert_only_escaped_text(browser)
+ browser.open(folder1.absolute_url() + "/folder_contents")
+ self.assert_only_escaped_text(browser)
+
+ browser.open(folder1.absolute_url() + "/@@fc-contextInfo")
+ self.assert_only_escaped_text(browser)
+
+ def test_xss(self):
+ self.portal.invokeFactory(
+ "Folder",
+ id="folder1",
+ title=HACKED,
+ description=HACKED,
+ creators=(HACKED,),
+ contributors=(HACKED,),
+ )
+ folder1 = self.portal.folder1
+ self.assertEqual(folder1.Title(), HACKED)
+ # With good old Archetypes the description gets cleaned up to
+ # 'The alert("hacker") was here.'
+ # self.assertEqual(folder1.Description(), HACKED)
+ folder1.invokeFactory(
+ "Document",
+ id="page1",
+ title=HACKED,
+ description=HACKED,
+ creators=(HACKED,),
+ contributors=(HACKED,),
+ )
+ page1 = folder1.page1
+ self.assertEqual(page1.Title(), HACKED)
+ # self.assertEqual(page1.Description(), HACKED)
+ transaction.commit()
+
+ # Check the output.
+ browser = self.get_browser()
+ browser.open(folder1.absolute_url())
+ self.assert_not_in(HACKED, browser.contents)
+ browser.open(page1.absolute_url())
+ self.assert_not_in(HACKED, browser.contents)
+ browser.open(folder1.absolute_url() + "/folder_contents")
+ self.assert_not_in(HACKED, browser.contents)
+
+ browser.open(folder1.absolute_url() + "/@@fc-contextInfo")
+ self.assert_not_in(HACKED, browser.contents)
+
+ def assert_only_escaped_text(self, browser):
+ body = browser.contents
+ # The escaped version of the text text should be in the response text.
+ self.assertIn(ESCAPED_TEXT, body)
+ # The normal version should not.
+ self.assert_not_in(NORMAL_TEXT, body)
+ # We should avoid escaping twice.
+ self.assert_not_in(DOUBLY_ESCAPED_TEXT, body)
+
+ def assert_not_in(self, target, body):
+ # This gives a too verbose error message, showing the entire body:
+ # self.assertNotIn("x", body)
+ # So we roll our own less verbose version.
+ if target not in body:
+ return
+ index = body.index(target)
+ start = max(0, index - 50)
+ end = min(index + len(target) + 50, len(body))
+ assert False, "Text '{}' unexpectedly found in body: ... {} ...".format(
+ target, body[start:end]
+ )
diff --git a/src/plone/app/layout/tests/test_folder.py b/src/plone/app/layout/tests/test_folder.py
new file mode 100644
index 00000000..435eba8f
--- /dev/null
+++ b/src/plone/app/layout/tests/test_folder.py
@@ -0,0 +1,588 @@
+from DateTime import DateTime
+from plone.app.content.testing import PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING
+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 plone.app.testing import TEST_USER_PASSWORD
+from plone.dexterity.fti import DexterityFTI
+from plone.locking.interfaces import IRefreshableLockable
+from plone.protect.authenticator import createToken
+from plone.uuid.interfaces import IUUID
+from Products.CMFCore.utils import getToolByName
+from Testing.makerequest import makerequest
+from transaction import commit
+from urllib.parse import urlparse
+from zope.annotation.interfaces import IAttributeAnnotatable
+from zope.interface import alsoProvides
+from zope.publisher.browser import TestRequest
+
+import json
+import unittest
+
+
+class BaseTest(unittest.TestCase):
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ self.portal.invokeFactory("Document", id="page", title="page")
+ self.portal.page.reindexObject()
+
+ self.request = TestRequest(
+ environ={"HTTP_ACCEPT_LANGUAGE": "en", "REQUEST_METHOD": "POST"},
+ form={
+ "selection": '["' + IUUID(self.portal.page) + '"]',
+ "_authenticator": createToken(),
+ "folder": "/",
+ },
+ )
+ self.request.REQUEST_METHOD = "POST"
+ # Mock physicalPathFromURL
+ # NOTE: won't return the right path in virtual hosting environments
+ self.request.physicalPathFromURL = lambda url: urlparse(url).path.split(
+ "/"
+ ) # noqa
+ alsoProvides(self.request, IAttributeAnnotatable)
+ self.userList = "one,two"
+
+
+class DXBaseTest(BaseTest):
+ layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ portal_types = getToolByName(self.portal, "portal_types")
+ if "Document" not in portal_types.objectIds():
+ fti = DexterityFTI("Document")
+ portal_types._setObject("Document", fti)
+ super().setUp()
+
+
+class PropertiesDXTest(DXBaseTest):
+ def testEffective(self):
+ from plone.app.layout.content.browser.contents.properties import ( # noqa
+ PropertiesActionView,
+ )
+
+ self.request.form["effectiveDate"] = "1999/01/01 09:00"
+ view = PropertiesActionView(self.portal.page, self.request)
+ view()
+ self.assertEqual(self.portal.page.effective_date, DateTime("1999/01/01 09:00"))
+
+ def testExpires(self):
+ from plone.app.layout.content.browser.contents.properties import ( # noqa
+ PropertiesActionView,
+ )
+
+ self.request.form["expirationDate"] = "1999/01/01 09:00"
+ view = PropertiesActionView(self.portal.page, self.request)
+ view()
+ self.assertEqual(self.portal.page.expiration_date, DateTime("1999/01/01 09:00"))
+
+ def testSetDexterityExcludeFromNav(self):
+ from plone.app.layout.content.browser.contents.properties import ( # noqa
+ PropertiesActionView,
+ )
+
+ self.request.form["exclude-from-nav"] = "yes"
+ view = PropertiesActionView(self.portal.page, self.request)
+ view()
+ self.assertEqual(self.portal.page.exclude_from_nav, True)
+
+ def testRights(self):
+ from plone.app.layout.content.browser.contents.properties import ( # noqa
+ PropertiesActionView,
+ )
+
+ self.request.form["copyright"] = "foobar"
+ view = PropertiesActionView(self.portal.page, self.request)
+ view()
+ self.assertEqual(self.portal.page.rights, "foobar")
+
+ def testContributors(self):
+ from plone.app.layout.content.browser.contents.properties import ( # noqa
+ PropertiesActionView,
+ )
+
+ self.request.form["contributors"] = self.userList
+ view = PropertiesActionView(self.portal.page, self.request)
+ view()
+ self.assertEqual(self.portal.page.contributors, ("one", "two"))
+
+ def testCreators(self):
+ from plone.app.layout.content.browser.contents.properties import ( # noqa
+ PropertiesActionView,
+ )
+
+ self.request.form["creators"] = self.userList
+ view = PropertiesActionView(self.portal.page, self.request)
+ view()
+ self.assertEqual(self.portal.page.creators, ("one", "two"))
+
+
+class WorkflowTest(BaseTest):
+ layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING
+
+ def convertDateTimeToIndexRepr(self, date):
+ t_tup = date.toZone("UTC").parts()
+ yr = t_tup[0]
+ mo = t_tup[1]
+ dy = t_tup[2]
+ hr = t_tup[3]
+ mn = t_tup[4]
+
+ return (((yr * 12 + mo) * 31 + dy) * 24 + hr) * 60 + mn
+
+ def testStateChange(self):
+ from plone.app.layout.content.browser.contents.workflow import ( # noqa
+ WorkflowActionView,
+ )
+
+ self.request.form["transition"] = "publish"
+ default_effective = DateTime(f"1969/12/31 00:00:00 {DateTime().timezone()}")
+ default_effective_index = self.convertDateTimeToIndexRepr(default_effective)
+ pc = getToolByName(self.portal, "portal_catalog")
+ # i need to call it, to populate catalog indexes
+ pc()
+ self.assertEqual(pc.uniqueValuesFor("effective"), (default_effective_index,))
+ view = WorkflowActionView(self.portal.page, self.request)
+ view()
+ workflowTool = getToolByName(self.portal, "portal_workflow")
+ self.assertEqual(
+ workflowTool.getInfoFor(self.portal.page, "review_state"), "published"
+ )
+ # commit to update indexes in catalog
+ commit()
+ effective_index = self.convertDateTimeToIndexRepr(
+ self.portal.page.effective_date
+ )
+ self.assertIn(effective_index, pc.uniqueValuesFor("effective"))
+
+
+class RenameTest(BaseTest):
+ layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+
+ def test_folder_rename_objects(self):
+ from plone.app.layout.content.browser.contents.rename import RenameActionView
+
+ uid = IUUID(self.portal.page)
+ self.portal.invokeFactory("Document", id="page2", title="2nd page")
+ uid2 = IUUID(self.portal.page2)
+ self.request.form.update(
+ {
+ "UID_0": uid,
+ "newid_0": "I am UnSafe! ",
+ "newtitle_0": "New!",
+ "UID_1": uid2,
+ "newid_1": ". ,;new id : _! ",
+ "newtitle_1": "Newer!",
+ }
+ )
+ view = RenameActionView(self.portal, self.request)
+ view()
+ self.assertEqual(self.portal["i-am-unsafe"].title, "New!")
+ self.assertEqual(self.portal["new-id-_"].title, "Newer!")
+
+ def test_default_page_updated_on_rename_objects(self):
+ from plone.app.layout.content.browser.contents.rename import RenameActionView
+
+ self.portal.setDefaultPage("page")
+ uid = IUUID(self.portal.page)
+ self.request.form.update(
+ {"UID_0": uid, "newid_0": "page-renamed", "newtitle_0": "Page"}
+ )
+ view = RenameActionView(self.portal, self.request)
+ view()
+ self.assertEqual(self.portal.getDefaultPage(), "page-renamed")
+
+
+class ContextInfoTest(BaseTest):
+ layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+
+ def testStateChange(self):
+ from plone.app.layout.content.browser.contents import ContextInfo
+
+ view = ContextInfo(self.portal.page, self.request)
+ result = json.loads(view())
+ self.assertEqual(result["object"]["Title"], "page")
+ self.assertTrue(len(result["breadcrumbs"]) > 0)
+
+
+class CutCopyLockedTest(BaseTest):
+ """in folder contents"""
+
+ layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ self.portal.acl_users.userFolderAddUser(
+ "editor", TEST_USER_PASSWORD, ["Editor"], []
+ )
+
+ self.portal.invokeFactory("Document", id="page", title="page")
+ self.portal.page.reindexObject()
+
+ self.env = {"HTTP_ACCEPT_LANGUAGE": "en", "REQUEST_METHOD": "POST"}
+ self.request = makerequest(self.layer["app"]).REQUEST
+ self.request.environ.update(self.env)
+ self.request.form = {
+ "selection": '["' + IUUID(self.portal.page) + '"]',
+ "_authenticator": createToken(),
+ "folder": "/",
+ }
+ self.request.REQUEST_METHOD = "POST"
+
+ def test_cut_object_when_locked_by_current_user(self):
+ from plone.app.layout.content.browser.contents.cut import CutActionView
+
+ plone_lock_info = self.portal.page.restrictedTraverse("@@plone_lock_info")
+ lockable = IRefreshableLockable(self.portal.page)
+ lockable.lock()
+ self.assertTrue(plone_lock_info.is_locked())
+ view = CutActionView(self.portal, self.request)
+ view()
+ self.assertEqual(len(view.errors), 0)
+ self.assertFalse(plone_lock_info.is_locked())
+
+ def test_cut_object_when_locked_by_other_user(self):
+ from plone.app.layout.content.browser.contents.cut import CutActionView
+
+ plone_lock_info = self.portal.page.restrictedTraverse("@@plone_lock_info")
+ lockable = IRefreshableLockable(self.portal.page)
+ lockable.lock()
+ logout()
+
+ login(self.portal, "editor")
+ self.assertTrue(plone_lock_info.is_locked())
+ self.request.form["_authenticator"] = createToken()
+ view = CutActionView(self.portal, self.request)
+ view()
+ self.assertEqual(len(view.errors), 1)
+ self.assertTrue(plone_lock_info.is_locked())
+
+
+class DeleteDXTest(BaseTest):
+ """Verify delete behavior from the folder contents view"""
+
+ layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ self.portal.acl_users.userFolderAddUser(
+ "editor", TEST_USER_PASSWORD, ["Editor"], []
+ )
+
+ self.portal.invokeFactory("Document", id="page", title="page")
+ self.portal.page.reindexObject()
+
+ self.env = {"HTTP_ACCEPT_LANGUAGE": "en", "REQUEST_METHOD": "POST"}
+ self.request = makerequest(self.layer["app"]).REQUEST
+ self.request.environ.update(self.env)
+ self.request.form = {
+ "selection": '["' + IUUID(self.portal.page) + '"]',
+ "_authenticator": createToken(),
+ "folder": "/",
+ }
+ self.request.REQUEST_METHOD = "POST"
+
+ def make_request(self):
+ request = makerequest(self.layer["app"], environ=self.env).REQUEST
+ self.request.environ.update(self.env)
+ request.REQUEST_METHOD = "POST"
+ return request
+
+ def test_delete_object(self):
+ from plone.app.layout.content.browser.contents.delete import DeleteActionView
+
+ page_id = self.portal.page.id
+ self.assertTrue(page_id in self.portal)
+ view = DeleteActionView(self.portal, self.request)
+ view()
+ self.assertTrue(page_id not in self.portal)
+
+ def test_delete_object_when_locked_by_current_user(self):
+ from plone.app.layout.content.browser.contents.delete import DeleteActionView
+
+ page_id = self.portal.page.id
+ lockable = IRefreshableLockable(self.portal.page)
+ lockable.lock()
+ view = DeleteActionView(self.portal, self.request)
+ view()
+ self.assertEqual(len(view.errors), 0)
+ self.assertTrue(page_id not in self.portal)
+
+ def test_delete_object_when_locked_by_other_user(self):
+ from plone.app.layout.content.browser.contents.delete import DeleteActionView
+
+ plone_lock_info = self.portal.page.restrictedTraverse("@@plone_lock_info")
+ lockable = IRefreshableLockable(self.portal.page)
+ lockable.lock()
+ logout()
+
+ login(self.portal, "editor")
+ self.assertTrue(plone_lock_info.is_locked())
+ self.request.form["_authenticator"] = createToken()
+ view = DeleteActionView(self.portal, self.request)
+ view()
+ self.assertEqual(len(view.errors), 1)
+ self.assertTrue(plone_lock_info.is_locked())
+
+ def test_delete_wrong_object_by_acquisition(self):
+ page_id = self.portal.page.id
+ f1 = self.portal.invokeFactory("Folder", id="f1", title="folder one")
+ # created a nested page with the same id as the one at the site root
+ p1 = self.portal[f1].invokeFactory("Document", id=page_id, title="page")
+ self.assertEqual(p1, page_id)
+ request2 = self.make_request()
+
+ # both pages exist before we delete on
+ for location in [self.portal, self.portal[f1]]:
+ self.assertTrue(p1 in location)
+
+ # instantiate two different views and delete the same object with each
+ from plone.app.layout.content.browser.contents.delete import DeleteActionView
+
+ object_uuid = IUUID(self.portal[f1][p1])
+ for req in [self.request, request2]:
+ req.form = {
+ "selection": f'["{object_uuid}"]',
+ "_authenticator": createToken(),
+ "folder": f"/{f1}/",
+ }
+ view = DeleteActionView(self.portal, req)
+ view()
+
+ # the root page exists, the nested one is gone
+ self.assertTrue(p1 in self.portal)
+ self.assertFalse(p1 in self.portal[f1])
+
+
+class RearrangeDXTest(BaseTest):
+ """Verify rearrange feature from the folder contents view"""
+
+ layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ self.portal.invokeFactory("Folder", id="basefolder", title="Folder Base")
+ self.bf = self.portal.basefolder
+ self.bf.reindexObject()
+ for idx in range(0, 5):
+ newid = f"f{idx}"
+ self.bf.invokeFactory(
+ "Folder",
+ id=newid,
+ # title in reverse order
+ title=f"Folder {4 - idx}",
+ )
+ self.bf[newid].reindexObject()
+
+ # create 3 documents in plone root
+ for idx in range(0, 3):
+ _id = f"page_{idx}"
+ self.portal.invokeFactory("Document", id=_id, title=f"Page {idx}")
+ self.portal[_id].reindexObject()
+
+ self.env = {"HTTP_ACCEPT_LANGUAGE": "en", "REQUEST_METHOD": "POST"}
+ self.request = makerequest(self.layer["app"]).REQUEST
+ self.request.environ.update(self.env)
+ self.request.form = {
+ "selection": '["' + IUUID(self.bf) + '"]',
+ "_authenticator": createToken(),
+ "folder": "/basefolder",
+ }
+ self.request.REQUEST_METHOD = "POST"
+
+ def test_initial_order(self):
+ # just to be sure preconditions are fine
+ #
+ # initial ids are forward
+ # and titles are set reversed!
+ self.assertEqual(
+ [(c[0], c[1].Title()) for c in self.bf.contentItems()],
+ [
+ ("f0", "Folder 4"),
+ ("f1", "Folder 3"),
+ ("f2", "Folder 2"),
+ ("f3", "Folder 1"),
+ ("f4", "Folder 0"),
+ ],
+ )
+
+ def test_rearrange_by_title(self):
+ from plone.app.layout.content.browser.contents.rearrange import ( # noqa
+ RearrangeActionView,
+ )
+
+ self.request.form.update(
+ {
+ "rearrange_on": "sortable_title",
+ }
+ )
+ view = RearrangeActionView(self.bf, self.request)
+ view()
+ self.assertEqual(
+ [(c[0], c[1].Title()) for c in self.bf.contentItems()],
+ [
+ ("f4", "Folder 0"),
+ ("f3", "Folder 1"),
+ ("f2", "Folder 2"),
+ ("f1", "Folder 3"),
+ ("f0", "Folder 4"),
+ ],
+ )
+
+ def test_item_order_move_to_top(self):
+ from plone.app.layout.content.browser.contents.rearrange import ( # noqa
+ ItemOrderActionView,
+ )
+
+ self.request.form.update(
+ {
+ "id": "f2",
+ "delta": "top",
+ }
+ )
+ view = ItemOrderActionView(self.bf, self.request)
+ view()
+ self.assertEqual(
+ [(c[0], c[1].Title()) for c in self.bf.contentItems()],
+ [
+ ("f2", "Folder 2"),
+ ("f0", "Folder 4"),
+ ("f1", "Folder 3"),
+ ("f3", "Folder 1"),
+ ("f4", "Folder 0"),
+ ],
+ )
+
+ def test_item_order_move_to_bottom(self):
+ from plone.app.layout.content.browser.contents.rearrange import ( # noqa
+ ItemOrderActionView,
+ )
+
+ self.request.form.update(
+ {
+ "id": "f2",
+ "delta": "bottom",
+ }
+ )
+ view = ItemOrderActionView(self.bf, self.request)
+ view()
+ self.assertEqual(
+ [(c[0], c[1].Title()) for c in self.bf.contentItems()],
+ [
+ ("f0", "Folder 4"),
+ ("f1", "Folder 3"),
+ ("f3", "Folder 1"),
+ ("f4", "Folder 0"),
+ ("f2", "Folder 2"),
+ ],
+ )
+
+ def test_item_order_move_by_delta(self):
+ from plone.app.layout.content.browser.contents.rearrange import ( # noqa
+ ItemOrderActionView,
+ )
+
+ self.request.form.update(
+ {
+ "id": "f2",
+ "delta": "-1",
+ }
+ )
+ view = ItemOrderActionView(self.bf, self.request)
+ view()
+ self.assertEqual(
+ [(c[0], c[1].Title()) for c in self.bf.contentItems()],
+ [
+ ("f0", "Folder 4"),
+ ("f2", "Folder 2"),
+ ("f1", "Folder 3"),
+ ("f3", "Folder 1"),
+ ("f4", "Folder 0"),
+ ],
+ )
+
+ def test_item_order_move_by_delta_in_plone_root(self):
+ from plone.app.layout.content.browser.contents.rearrange import ( # noqa
+ ItemOrderActionView,
+ )
+
+ # first move the 'basefolder' to the top
+ self.request.form.update(
+ {
+ "id": "basefolder",
+ "delta": "top",
+ }
+ )
+ view = ItemOrderActionView(self.portal, self.request)
+ view()
+
+ # move 'basefolder' two positions down
+ self.request.form.update(
+ {
+ "id": "basefolder",
+ "delta": "2",
+ "subsetIds": '["basefolder", "page_0", "page_1", "page_2"]',
+ }
+ )
+ view = ItemOrderActionView(self.portal, self.request)
+ view()
+
+ self.assertEqual(
+ [(c[0], c[1].Title()) for c in self.portal.contentItems()],
+ [
+ ("page_0", "Page 0"),
+ ("page_1", "Page 1"),
+ ("basefolder", "Folder Base"),
+ ("page_2", "Page 2"),
+ ],
+ )
+
+
+class FolderFactoriesTest(unittest.TestCase):
+ layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ self.request = self.layer["request"]
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ def test_folder_factories_regression(self):
+ from plone.app.layout.content.browser.folderfactories import FolderFactoriesView as FFV
+
+ view = FFV(self.portal, self.request)
+ self.request.form.update(
+ {"form.button.Add": "yes", "url": self.portal.absolute_url()}
+ )
+ view()
+ self.assertEqual(
+ self.request.response.headers.get("location"), self.portal.absolute_url()
+ )
+
+ def test_folder_factories(self):
+ from plone.app.layout.content.browser.folderfactories import FolderFactoriesView as FFV
+
+ view = FFV(self.portal, self.request)
+ self.request.form.update(
+ {"form.button.Add": "yes", "url": "http://www.foobar.com"}
+ )
+ view()
+ self.assertNotEqual(
+ self.request.response.headers.get("location"), "http://www.foobar.com"
+ )
diff --git a/src/plone/app/layout/tests/test_folder_publish.py b/src/plone/app/layout/tests/test_folder_publish.py
new file mode 100644
index 00000000..d3e6373d
--- /dev/null
+++ b/src/plone/app/layout/tests/test_folder_publish.py
@@ -0,0 +1,128 @@
+from plone.app.content.testing import PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+from plone.app.testing import login
+from plone.app.testing import setRoles
+from plone.app.testing import TEST_USER_ID
+from plone.app.testing import TEST_USER_NAME
+from plone.base.utils import is_expired
+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 the Plone version,
+ # 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(is_expired(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()
+ )
diff --git a/src/plone/app/layout/tests/test_non_ascii_characters_in_workflow_state.py b/src/plone/app/layout/tests/test_non_ascii_characters_in_workflow_state.py
new file mode 100644
index 00000000..0d5768c5
--- /dev/null
+++ b/src/plone/app/layout/tests/test_non_ascii_characters_in_workflow_state.py
@@ -0,0 +1,53 @@
+from plone.app.content.testing import PLONE_APP_CONTENT_NON_ASCII_INTEGRATION_TESTING
+from plone.app.testing import login
+from plone.app.testing import setRoles
+from plone.app.testing import TEST_USER_ID
+from plone.app.testing import TEST_USER_NAME
+from plone.uuid.interfaces import IUUID
+from Products.CMFCore.utils import getToolByName
+
+import json
+import unittest
+
+
+class TestNonAsciiCharactersWorkflow(unittest.TestCase):
+ layer = PLONE_APP_CONTENT_NON_ASCII_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ self.request = self.layer["request"]
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ # set non-ascii-workflow for Documents
+ wf_tool = getToolByName(self, "portal_workflow")
+ wf_tool.setChainForPortalTypes(["Document"], "non-ascii-workflow")
+
+ # create an object having the non-ascii-workflow assigned
+ self.portal.invokeFactory("Document", "doc")
+
+ def test_non_ascii_characters_in_workflow_title(self):
+ wf_tool = getToolByName(self, "portal_workflow")
+ workflow_matching_id = list(
+ filter(
+ lambda workflow: ("non-ascii-workflow" in workflow.id),
+ wf_tool.getWorkflowsFor(self.portal.doc),
+ )
+ )
+
+ # Make sure that the non-ascii-workflow was assigned to the Document
+ self.assertTrue(workflow_matching_id)
+
+ # Build POST request to get state title for the Document
+ documents_uid = IUUID(self.portal.doc)
+ self.request.form["selection"] = json.dumps(documents_uid)
+ self.request.form["transitions"] = True
+ self.request.form["render"] = "yes"
+
+ try:
+ # try to do the json request which among other things returns
+ # the workflow state title containing non-ascii characters.
+ self.portal.unrestrictedTraverse("@@fc-workflow")()
+ except UnicodeDecodeError:
+ self.fail("Calling @@fc-workflow raised UnicodeDecodeError \
+ unexpectedly.")
diff --git a/src/plone/app/layout/tests/test_permissions.py b/src/plone/app/layout/tests/test_permissions.py
new file mode 100644
index 00000000..d9bf2a7f
--- /dev/null
+++ b/src/plone/app/layout/tests/test_permissions.py
@@ -0,0 +1,263 @@
+from plone.app.layout.content.browser.vocabulary import VocabularyView
+from plone.app.testing import login
+from plone.app.testing import setRoles
+from plone.app.testing import TEST_USER_ID
+from plone.app.testing import TEST_USER_NAME
+from plone.app.z3cform.interfaces import IPloneFormLayer
+from plone.app.z3cform.tests.layer import PAZ3CForm_INTEGRATION_TESTING
+from plone.autoform.interfaces import WIDGETS_KEY
+from plone.autoform.interfaces import WRITE_PERMISSIONS_KEY
+from plone.dexterity.browser.add import DefaultAddForm
+from plone.dexterity.browser.add import DefaultAddView
+from plone.dexterity.fti import DexterityFTI
+from z3c.form.interfaces import IFieldWidget
+from z3c.form.util import getSpecification
+from z3c.form.widget import FieldWidget
+from zope import schema
+from zope.component import provideAdapter
+from zope.component.globalregistry import base
+from zope.globalrequest import setRequest
+from zope.interface import Interface
+from zope.publisher.browser import TestRequest
+
+import json
+import unittest
+
+
+def add_mock_fti(portal):
+ # Fake DX Type
+ fti = DexterityFTI("dx_mock")
+ portal.portal_types._setObject("dx_mock", fti)
+ fti.klass = "plone.dexterity.content.Item"
+ fti.schema = "plone.app.layout.tests.test_permissions.IMockSchema"
+ fti.filter_content_types = False
+ fti.behaviors = ("plone.app.dexterity.behaviors.metadata.IBasic",)
+
+
+def _custom_field_widget(field, request):
+ from plone.app.z3cform.widgets.select import AjaxSelectWidget
+
+ widget = FieldWidget(field, AjaxSelectWidget(request))
+ widget.vocabulary = "plone.app.vocabularies.PortalTypes"
+ return widget
+
+
+class IMockSchema(Interface):
+ allowed_field = schema.Choice(vocabulary="plone.app.vocabularies.PortalTypes")
+ disallowed_field = schema.Choice(vocabulary="plone.app.vocabularies.PortalTypes")
+ default_field = schema.Choice(vocabulary="plone.app.vocabularies.PortalTypes")
+ custom_widget_field = schema.TextLine()
+ adapted_widget_field = schema.TextLine()
+
+
+IMockSchema.setTaggedValue(
+ WRITE_PERMISSIONS_KEY,
+ {
+ "allowed_field": "zope2.View",
+ "disallowed_field": "zope2.ViewManagementScreens",
+ "custom_widget_field": "zope2.View",
+ "adapted_widget_field": "zope2.View",
+ },
+)
+IMockSchema.setTaggedValue(
+ WIDGETS_KEY,
+ {
+ "custom_widget_field": _custom_field_widget,
+ },
+)
+
+
+def _enable_custom_widget(field):
+ provideAdapter(
+ _custom_field_widget,
+ adapts=(getSpecification(field), IPloneFormLayer),
+ provides=IFieldWidget,
+ )
+
+
+def _disable_custom_widget(field):
+ base.unregisterAdapter(
+ required=(
+ getSpecification(field),
+ IPloneFormLayer,
+ ),
+ provided=IFieldWidget,
+ )
+
+
+class DexterityVocabularyPermissionTests(unittest.TestCase):
+ layer = PAZ3CForm_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.request = TestRequest(environ={"HTTP_ACCEPT_LANGUAGE": "en"})
+ setRequest(self.request)
+ self.portal = self.layer["portal"]
+
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ add_mock_fti(self.portal)
+ self.portal.invokeFactory("dx_mock", "test_dx")
+
+ self.portal.test_dx.manage_permission("View", ("Anonymous",), acquire=False)
+ self.portal.test_dx.manage_permission(
+ "View management screens", (), acquire=False
+ )
+ self.portal.test_dx.manage_permission(
+ "Modify portal content",
+ ("Editor", "Manager", "Site Adiminstrator"),
+ acquire=False,
+ )
+
+ def test_vocabulary_field_allowed(self):
+ view = VocabularyView(self.portal.test_dx, self.request)
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.PortalTypes",
+ "field": "allowed_field",
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(
+ len(data["results"]),
+ len(self.portal.portal_types.objectIds()),
+ )
+
+ def test_vocabulary_field_wrong_vocabulary_disallowed(self):
+ view = VocabularyView(self.portal.test_dx, self.request)
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.Fake",
+ "field": "allowed_field",
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(data["error"], "Vocabulary lookup not allowed")
+
+ def test_vocabulary_field_disallowed(self):
+ view = VocabularyView(self.portal.test_dx, self.request)
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.PortalTypes",
+ "field": "disallowed_field",
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(data["error"], "Vocabulary lookup not allowed")
+
+ def test_vocabulary_field_default_permission(self):
+ view = VocabularyView(self.portal.test_dx, self.request)
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.PortalTypes",
+ "field": "default_field",
+ }
+ )
+ # If the field is does not have a security declaration, the
+ # default edit permission is tested (Modify portal content)
+ setRoles(self.portal, TEST_USER_ID, ["Member"])
+ data = json.loads(view())
+ self.assertEqual(data["error"], "Vocabulary lookup not allowed")
+
+ setRoles(self.portal, TEST_USER_ID, ["Editor"])
+ # Now access should be allowed, but the vocabulary does not exist
+ data = json.loads(view())
+ self.assertEqual(
+ len(data["results"]),
+ len(self.portal.portal_types.objectIds()),
+ )
+
+ def test_vocabulary_field_default_permission_wrong_vocab(self):
+ view = VocabularyView(self.portal.test_dx, self.request)
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.Fake",
+ "field": "default_field",
+ }
+ )
+ setRoles(self.portal, TEST_USER_ID, ["Editor"])
+ # Now access should be allowed, but the vocabulary does not exist
+ data = json.loads(view())
+ self.assertEqual(data["error"], "Vocabulary lookup not allowed")
+
+ def test_vocabulary_missing_field(self):
+ view = VocabularyView(self.portal.test_dx, self.request)
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.PortalTypes",
+ "field": "missing_field",
+ }
+ )
+ setRoles(self.portal, TEST_USER_ID, ["Member"])
+ with self.assertRaises(AttributeError):
+ view()
+
+ def test_vocabulary_on_widget(self):
+ view = VocabularyView(self.portal.test_dx, self.request)
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.PortalTypes",
+ "field": "custom_widget_field",
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(
+ len(data["results"]),
+ len(self.portal.portal_types.objectIds()),
+ )
+ self.request.form["name"] = "plone.app.vocabularies.Fake"
+ data = json.loads(view())
+ self.assertEqual(data["error"], "Vocabulary lookup not allowed")
+
+ def test_vocabulary_on_adapted_widget(self):
+ _enable_custom_widget(IMockSchema["adapted_widget_field"])
+ view = VocabularyView(self.portal.test_dx, self.request)
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.PortalTypes",
+ "field": "adapted_widget_field",
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(
+ len(data["results"]),
+ len(self.portal.portal_types.objectIds()),
+ )
+
+ self.request.form["name"] = "plone.app.vocabularies.Fake"
+ data = json.loads(view())
+ self.assertEqual(data["error"], "Vocabulary lookup not allowed")
+ _disable_custom_widget(IMockSchema["adapted_widget_field"])
+
+ def test_vocabulary_field_allowed_from_add_view(self):
+ add_view = DefaultAddView(
+ self.portal, self.request, self.portal.portal_types["dx_mock"]
+ )
+ view = VocabularyView(add_view, self.request)
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.PortalTypes",
+ "field": "allowed_field",
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(
+ len(data["results"]),
+ len(self.portal.portal_types.objectIds()),
+ )
+
+ def test_vocabulary_field_allowed_from_add_form(self):
+ add_form = DefaultAddForm(self.portal, self.request)
+ add_form.portal_type = "dx_mock"
+ view = VocabularyView(add_form, self.request)
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.PortalTypes",
+ "field": "allowed_field",
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(
+ len(data["results"]),
+ len(self.portal.portal_types.objectIds()),
+ )
diff --git a/src/plone/app/layout/tests/test_reviewlist.py b/src/plone/app/layout/tests/test_reviewlist.py
new file mode 100644
index 00000000..e63b4a3b
--- /dev/null
+++ b/src/plone/app/layout/tests/test_reviewlist.py
@@ -0,0 +1,82 @@
+from plone.app.content.testing import PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING
+from plone.app.testing import setRoles
+from plone.app.testing import TEST_USER_ID
+from plone.app.testing import TEST_USER_PASSWORD
+from plone.testing.zope import Browser
+from Products.CMFCore.utils import getToolByName
+
+import transaction
+import unittest
+
+
+class ReviewListTestCase(unittest.TestCase):
+ """dsfsdaf"""
+
+ layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ self.uf = self.portal.acl_users
+ self.uf.userFolderAddUser("reviewer", TEST_USER_PASSWORD, ["Reviewer"], [])
+ transaction.commit()
+ self.browser = Browser(self.layer["app"])
+ self.browser.handleErrors = True
+ self.wftool = getToolByName(self.portal, "portal_workflow")
+
+ def createDocument(self, id, title, description):
+ setRoles(
+ self.portal,
+ TEST_USER_ID,
+ [
+ "Manager",
+ ],
+ )
+ self.portal.invokeFactory(id=id, type_name="Document")
+ doc = getattr(self.portal, id)
+ doc.title = title
+ doc.description = description
+ # we don't want it in the navigation
+ doc.exclude_from_nav = True
+ doc.reindexObject()
+ transaction.commit()
+ return doc
+
+ def submitToReview(self, obj):
+ """call the workflow action 'submit' for an object"""
+ self.wftool.doActionFor(obj, "submit")
+
+ def test_unauthenticated(self):
+ """
+ unauthenticated users do not have the necessary permissions to view
+ the review list
+ """
+ self.browser.open("http://nohost/plone/full_review_list")
+ self.assertTrue("Login Name" in self.browser.contents)
+
+ def test_authenticated(self):
+ """
+ authenticated users do have the necessary permissions to view
+ the review list
+ """
+ self.browser.addHeader(
+ "Authorization", "Basic {}:{}".format("reviewer", TEST_USER_PASSWORD)
+ )
+ self.browser.open("http://nohost/plone/full_review_list")
+ self.assertTrue("Full review list:" in self.browser.contents)
+
+ def test_with_content(self):
+ """
+ authenticated users do have the necessary permissions to view
+ the review list
+ """
+ doc = self.createDocument("testdoc", "Test Document", "Test Description")
+ self.wftool.doActionFor(doc, "submit")
+ transaction.commit()
+
+ self.browser.addHeader(
+ "Authorization", "Basic {}:{}".format("reviewer", TEST_USER_PASSWORD)
+ )
+ self.browser.open("http://nohost/plone/full_review_list")
+ self.assertTrue("Full review list:" in self.browser.contents)
+ # test if the table with review items contains an entry for testdoc
+ self.assertTrue('value="/plone/testdoc"' in self.browser.contents)
diff --git a/src/plone/app/layout/tests/test_selectdefaultpage.py b/src/plone/app/layout/tests/test_selectdefaultpage.py
new file mode 100644
index 00000000..8c16a2bc
--- /dev/null
+++ b/src/plone/app/layout/tests/test_selectdefaultpage.py
@@ -0,0 +1,167 @@
+from plone.app.content.testing import PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING
+from plone.app.testing import setRoles
+from plone.app.testing import TEST_USER_ID
+from plone.app.testing import TEST_USER_PASSWORD
+from plone.testing.zope import Browser
+
+import transaction
+import unittest
+
+FOLDER = {
+ "id": "testfolder",
+ "title": "Test Folder",
+ "description": "Test Folder Description",
+}
+
+DOCUMENT = {
+ "id": "testdoc",
+ "title": "Test Document",
+ "description": "Test Document Description",
+}
+
+NEWSITEM = {
+ "id": "testnews",
+ "title": "Test News Item",
+ "description": "Test News Item Description",
+}
+
+
+class SelectDefaultPageDXTestCase(unittest.TestCase):
+ layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING
+
+ def setUp(self):
+ self.app = self.layer["app"]
+ self.portal = self.layer["portal"]
+ self.portal.acl_users.userFolderAddUser(
+ "editor", TEST_USER_PASSWORD, ["Editor"], []
+ )
+
+ self._create_structure()
+ transaction.commit()
+
+ self.browser = Browser(self.layer["app"])
+ self.browser.addHeader(
+ "Authorization", "Basic {}:{}".format("editor", TEST_USER_PASSWORD)
+ )
+
+ def tearDown(self):
+ self.portal.manage_delObjects(ids=FOLDER["id"])
+ transaction.commit()
+
+ def _createFolder(self):
+ self.portal.invokeFactory(id=FOLDER["id"], type_name="Folder")
+ folder = getattr(self.portal, FOLDER["id"])
+ folder.setTitle(FOLDER["title"])
+ folder.setDescription(FOLDER["description"])
+ folder.reindexObject()
+ # we don't want it in the navigation
+ # folder.setExcludeFromNav(True)
+ return folder
+
+ def _createDocument(self, context):
+ context.invokeFactory(id=DOCUMENT["id"], type_name="Document")
+ doc = getattr(context, DOCUMENT["id"])
+ doc.setTitle(DOCUMENT["title"])
+ doc.setDescription(DOCUMENT["description"])
+ doc.reindexObject()
+ # we don't want it in the navigation
+ # doc.setExcludeFromNav(True)
+ return doc
+
+ def _createNewsItem(self, context):
+ context.invokeFactory(id=NEWSITEM["id"], type_name="News Item")
+ doc = getattr(context, NEWSITEM["id"])
+ doc.setTitle(NEWSITEM["title"])
+ doc.setDescription(NEWSITEM["description"])
+ doc.reindexObject()
+ # we don't want it in the navigation
+ # doc.setExcludeFromNav(True)
+ return doc
+
+ def _create_structure(self):
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+ folder = self._createFolder()
+ self._createDocument(folder)
+ return folder
+
+ def test_select_default_page_view(self):
+ """Check that the form can be rendered."""
+ folder = self.portal.testfolder
+
+ self.browser.open("%s/@@select_default_page" % folder.absolute_url())
+
+ self.assertTrue("Select default page" in self.browser.contents)
+ self.assertTrue('id="testdoc"' in self.browser.contents)
+
+ def test_select_default_page_vhm_hosted(self):
+ # Install a Virtual Host Monster
+ if "virtual_hosting" not in self.app.objectIds():
+ # If ZopeLite was imported, we have no default virtual
+ # host monster
+ from Products.SiteAccess.VirtualHostMonster import (
+ manage_addVirtualHostMonster,
+ )
+
+ manage_addVirtualHostMonster(self.app, "virtual_hosting")
+ transaction.commit()
+
+ folder = self.portal.testfolder
+ folder_vhm_url = (
+ "{}/VirtualHostBase/http/plone.org/{}/VirtualHostRoot/{}".format(
+ self.app.absolute_url(),
+ self.portal.id,
+ folder.id,
+ )
+ )
+
+ self.browser.open(f"{folder_vhm_url}/@@select_default_page")
+
+ self.assertTrue("Select default page" in self.browser.contents)
+ self.assertTrue('id="testdoc"' in self.browser.contents)
+
+ def test_select_default_page_view_with_folderish_type(self):
+ """Check if folderish types are available."""
+ folder = self.portal.testfolder
+ folder.invokeFactory(id=FOLDER["id"], type_name="Folder")
+ folder2 = getattr(folder, FOLDER["id"])
+ folder.setTitle(FOLDER["title"])
+ folder2.reindexObject()
+ folder_fti = self.portal.portal_types["Folder"]
+ folder_fti.manage_changeProperties(
+ filter_content_types=True, allowed_content_types=[]
+ )
+ view = folder.restrictedTraverse("@@select_default_page")()
+
+ self.assertTrue('id="testdoc"' in view)
+ self.assertTrue('id="testfolder"' in view)
+
+ def test_default_page_action_cancel(self):
+ """Check the Cancel action."""
+ folder = self.portal.testfolder
+
+ self.browser.open("%s/@@select_default_page" % folder.absolute_url())
+ cancel_button = self.browser.getControl(name="form.buttons.Cancel")
+ cancel_button.click()
+
+ self.assertEqual(self.browser.url, folder.absolute_url())
+ self.assertIs(folder.getDefaultPage(), None)
+
+ def test_default_page_action_save(self):
+ """Check the Save action."""
+ folder = self.portal.testfolder
+ self.browser.open("%s/@@select_default_page" % folder.absolute_url())
+
+ submit_button = self.browser.getControl(name="form.buttons.Save")
+ submit_button.click()
+
+ self.assertEqual(self.browser.url, folder.absolute_url())
+ self.assertEqual(folder.getDefaultPage(), "testdoc")
+
+ def test_selectable_types_filter(self):
+ self.portal.portal_registry["plone.default_page_types"] = ["News Item"]
+ folder = self.portal.testfolder
+ self._createNewsItem(folder)
+
+ view = folder.restrictedTraverse("@@select_default_page")()
+ self.assertTrue('id="testdoc"' not in view)
+ self.assertTrue('id="testnews"' in view)
diff --git a/src/plone/app/layout/tests/test_table.py b/src/plone/app/layout/tests/test_table.py
new file mode 100644
index 00000000..9da4cc49
--- /dev/null
+++ b/src/plone/app/layout/tests/test_table.py
@@ -0,0 +1,17 @@
+from zope.component.testing import setUp
+from zope.component.testing import tearDown
+
+import doctest
+import unittest
+
+
+def test_suite():
+ return unittest.TestSuite(
+ doctest.DocFileSuite(
+ "table.txt",
+ package="plone.app.layout.content.browser",
+ optionflags=doctest.ELLIPSIS,
+ setUp=setUp,
+ tearDown=tearDown,
+ )
+ )
diff --git a/src/plone/app/layout/tests/test_widgets.py b/src/plone/app/layout/tests/test_widgets.py
new file mode 100644
index 00000000..8aef998a
--- /dev/null
+++ b/src/plone/app/layout/tests/test_widgets.py
@@ -0,0 +1,748 @@
+from plone.app.layout.content.browser import vocabulary
+from plone.app.layout.content.browser.file import FileUploadView
+from plone.app.layout.content.browser.query import QueryStringIndexOptions
+from plone.app.layout.content.browser.vocabulary import VocabularyView
+from plone.app.content.testing import ExampleFunctionVocabulary
+from plone.app.content.testing import ExampleVocabulary
+from plone.app.content.testing import PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING
+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 plone.app.testing import TEST_USER_PASSWORD
+from plone.app.z3cform.interfaces import IFieldPermissionChecker
+from Products.CMFCore.indexing import processQueue
+from unittest import mock
+from zope.component import getMultiAdapter
+from zope.component import provideAdapter
+from zope.component import provideUtility
+from zope.component.globalregistry import base
+from zope.globalrequest import setRequest
+from zope.interface import alsoProvides
+from zope.interface import Interface
+from zope.interface import noLongerProvides
+from zope.publisher.browser import TestRequest
+
+import json
+import operator
+import os
+import transaction
+import unittest
+
+_dir = os.path.dirname(__file__)
+
+
+class PermissionChecker:
+ def __init__(self, context):
+ pass
+
+ def validate(self, field_name, vocabulary_name=None):
+ if field_name == "allowed_field":
+ return True
+ elif field_name == "disallowed_field":
+ return False
+ else:
+ raise AttributeError("Missing Field")
+
+
+class ICustomPermissionProvider(Interface):
+ pass
+
+
+def _enable_permission_checker(context):
+ provideAdapter(
+ PermissionChecker,
+ adapts=(ICustomPermissionProvider,),
+ provides=IFieldPermissionChecker,
+ )
+ alsoProvides(context, ICustomPermissionProvider)
+
+
+def _disable_permission_checker(context):
+ noLongerProvides(context, ICustomPermissionProvider)
+ base.unregisterAdapter(
+ required=(ICustomPermissionProvider,), provided=IFieldPermissionChecker
+ )
+
+
+class BrowserTest(unittest.TestCase):
+ layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.request = TestRequest(environ={"HTTP_ACCEPT_LANGUAGE": "en"})
+ setRequest(self.request)
+ self.portal = self.layer["portal"]
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+ provideUtility(ExampleVocabulary(), name="vocab_class")
+ provideUtility(ExampleFunctionVocabulary, name="vocab_function")
+ vocabulary.PERMISSIONS.update(
+ {
+ "vocab_class": "Modify portal content",
+ "vocab_function": "Modify portal content",
+ }
+ )
+
+ def testVocabularyQueryString(self):
+ """Test querying a class based vocabulary with a search string."""
+ view = VocabularyView(self.portal, self.request)
+ self.request.form.update({"name": "vocab_class", "query": "three"})
+ data = json.loads(view())
+ self.assertEqual(len(data["results"]), 1)
+
+ def testVocabularyFunctionQueryString(self):
+ """Test querying a function based vocabulary with a search string."""
+ view = VocabularyView(self.portal, self.request)
+ self.request.form.update({"name": "vocab_function", "query": "third"})
+ data = json.loads(view())
+ self.assertEqual(len(data["results"]), 1)
+
+ def testVocabularyNoResults(self):
+ """Tests that the widgets displays correctly"""
+ view = VocabularyView(self.portal, self.request)
+ query = {
+ "criteria": [
+ {
+ "i": "path",
+ "o": "plone.app.querystring.operation.string.path",
+ "v": "/foo",
+ }
+ ]
+ }
+ self.request.form.update(
+ {"name": "plone.app.vocabularies.Catalog", "query": json.dumps(query)}
+ )
+ data = json.loads(view())
+ self.assertEqual(len(data["results"]), 0)
+
+ def testVocabularyCatalogResults(self):
+ self.portal.invokeFactory("Document", id="page", title="page")
+ self.portal.page.reindexObject()
+ view = VocabularyView(self.portal, self.request)
+ query = {
+ "criteria": [
+ {
+ "i": "path",
+ "o": "plone.app.querystring.operation.string.path",
+ "v": "/plone",
+ },
+ {
+ "i": "portal_type",
+ "o": "plone.app.querystring.operation.selection.any",
+ "v": "Document",
+ },
+ ]
+ }
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.Catalog",
+ "query": json.dumps(query),
+ "attributes": ["UID", "id", "title", "path"],
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(len(data["results"]), 1)
+
+ def testVocabularyCatalogUnsafeMetadataAllowed(self):
+ """Users with permission "Modify portal content" are allowed to see
+ ``_unsafe_metadata``.
+ """
+ self.portal.invokeFactory("Document", id="page", title="page")
+ self.portal.page.reindexObject()
+ view = VocabularyView(self.portal, self.request)
+ query = {
+ "criteria": [
+ {
+ "i": "path",
+ "o": "plone.app.querystring.operation.string.path",
+ "v": "/plone/page",
+ }
+ ]
+ }
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.Catalog",
+ "query": json.dumps(query),
+ "attributes": [
+ "id",
+ "commentors",
+ "Creator",
+ "listCreators",
+ ],
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(len(list(data["results"][0].keys())), 4)
+
+ def testVocabularyCatalogUnsafeMetadataDisallowed(self):
+ """Users without permission "Modify portal content" are not allowed to
+ see ``_unsafe_metadata``.
+ """
+ self.portal.invokeFactory("Document", id="page", title="page")
+ self.portal.page.reindexObject()
+ # Downgrade permissions
+ setRoles(self.portal, TEST_USER_ID, [])
+ view = VocabularyView(self.portal, self.request)
+ query = {
+ "criteria": [
+ {
+ "i": "path",
+ "o": "plone.app.querystring.operation.string.path",
+ "v": "/plone/page",
+ }
+ ]
+ }
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.Catalog",
+ "query": json.dumps(query),
+ "attributes": [
+ "id",
+ "commentors",
+ "Creator",
+ "listCreators",
+ ],
+ }
+ )
+ data = json.loads(view())
+ # Only one result key should be returned, as ``commentors``,
+ # ``Creator`` and ``listCreators`` is considered unsafe and thus
+ # skipped.
+ self.assertEqual(len(list(data["results"][0].keys())), 1)
+
+ def testVocabularyBatching(self):
+ amount = 30
+ for i in range(amount):
+ self.portal.invokeFactory(
+ "Document", id="page" + str(i), title="Page" + str(i)
+ )
+ self.portal["page" + str(i)].reindexObject()
+ view = VocabularyView(self.portal, self.request)
+ query = {
+ "criteria": [
+ {
+ "i": "path",
+ "o": "plone.app.querystring.operation.string.path",
+ "v": "/plone",
+ },
+ {
+ "i": "portal_type",
+ "o": "plone.app.querystring.operation.selection.any",
+ "v": "Document",
+ },
+ ]
+ }
+ # batch pages are 1-based
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.Catalog",
+ "query": json.dumps(query),
+ "attributes": ["UID", "id", "title", "path"],
+ "batch": {"page": "1", "size": "10"},
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(len(data["results"]), 10)
+ self.assertEqual(data["total"], amount)
+
+ def testVocabularyEncoding(self):
+ """The vocabulary should not return the binary encoded token
+ ("N=C3=A5=C3=B8=C3=AF"), but instead the value as the id in the result
+ set. Fixes an encoding problem. See:
+ https://github.com/plone/Products.CMFPlone/issues/650
+ """
+ test_val = "Nåøï"
+
+ self.portal.invokeFactory("Document", id="page", title="page")
+ self.portal.page.subject = (test_val,)
+ self.portal.page.reindexObject(idxs=["Subject"])
+ processQueue()
+
+ self.request.form["name"] = "plone.app.vocabularies.Keywords"
+ results = getMultiAdapter((self.portal, self.request), name="getVocabulary")()
+ results = json.loads(results)
+ result = results["results"][0]
+
+ self.assertEqual(result["text"], test_val)
+ self.assertEqual(result["id"], test_val)
+
+ def testVocabularyHtmlEntity(self):
+ """The vocabulary token should not convert to htmlentities.
+ See https://github.com/plone/Products.CMFPlone/issues/3874
+ """
+ test_val = "Question & Answer"
+
+ self.portal.invokeFactory("Document", id="page", title="page")
+ self.portal.page.subject = (test_val,)
+ self.portal.page.reindexObject(idxs=["Subject"])
+ processQueue()
+
+ self.request.form["name"] = "plone.app.vocabularies.Keywords"
+ results = getMultiAdapter((self.portal, self.request), name="getVocabulary")()
+ results = json.loads(results)
+ result = results["results"][0]
+
+ self.assertEqual(result["text"], test_val)
+ self.assertEqual(result["id"], test_val)
+
+ def testVocabularyUnauthorized(self):
+ setRoles(self.portal, TEST_USER_ID, [])
+ view = VocabularyView(self.portal, self.request)
+ self.request.form.update(
+ {"name": "plone.app.vocabularies.Users", "query": TEST_USER_NAME}
+ )
+ data = json.loads(view())
+ self.assertEqual(data["error"], "Vocabulary lookup not allowed")
+
+ def testVocabularyMissing(self):
+ view = VocabularyView(self.portal, self.request)
+ self.request.form.update(
+ {
+ "name": "vocabulary.that.does.not.exist",
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(data["error"], "Vocabulary lookup not allowed")
+
+ def testPermissionCheckerAllowed(self):
+ # Setup a custom permission checker on the portal
+ _enable_permission_checker(self.portal)
+ view = VocabularyView(self.portal, self.request)
+
+ # Allowed field is allowed
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.PortalTypes",
+ "field": "allowed_field",
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(
+ len(data["results"]), len(self.portal.portal_types.objectIds())
+ )
+ _disable_permission_checker(self.portal)
+
+ def testPermissionCheckerUnknownVocab(self):
+ _enable_permission_checker(self.portal)
+ view = VocabularyView(self.portal, self.request)
+ # Unknown vocabulary gives error
+ self.request.form.update(
+ {
+ "name": "vocab.does.not.exist",
+ "field": "allowed_field",
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(
+ data["error"],
+ 'No factory with name "{}" exists.'.format("vocab.does.not.exist"),
+ )
+ _disable_permission_checker(self.portal)
+
+ def testPermissionCheckerDisallowed(self):
+ _enable_permission_checker(self.portal)
+ view = VocabularyView(self.portal, self.request)
+ # Disallowed field is not allowed
+ # Allowed field is allowed
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.PortalTypes",
+ "field": "disallowed_field",
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(data["error"], "Vocabulary lookup not allowed")
+ _disable_permission_checker(self.portal)
+
+ def testPermissionCheckerShortCircuit(self):
+ _enable_permission_checker(self.portal)
+ view = VocabularyView(self.portal, self.request)
+ # Known vocabulary name short-circuits field permission check
+ # global permission
+ self.request.form["name"] = "plone.app.vocabularies.Users"
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.Users",
+ "field": "disallowed_field",
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(data["results"], [])
+ _disable_permission_checker(self.portal)
+
+ def testPermissionCheckerUnknownField(self):
+ _enable_permission_checker(self.portal)
+ view = VocabularyView(self.portal, self.request)
+ # Unknown field is raises error
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.PortalTypes",
+ "field": "missing_field",
+ }
+ )
+ with self.assertRaises(AttributeError):
+ view()
+ _disable_permission_checker(self.portal)
+
+ def testVocabularyUsers(self):
+ acl_users = self.portal.acl_users
+ membership = self.portal.portal_membership
+ amount = 10
+ # Let's test that safe html is used on the fullname,
+ # as alternative to the workaround in PloneHotfix20210518.
+ for i in range(amount):
+ id = "user" + str(i)
+ acl_users.userFolderAddUser(id, TEST_USER_PASSWORD, ["Member"], [])
+ member = membership.getMemberById(id)
+ # Make user0 the hacker.
+ if i == 0:
+ fullname = "user hacker"
+ else:
+ fullname = id
+ member.setMemberProperties(mapping={"fullname": fullname})
+ view = VocabularyView(self.portal, self.request)
+ self.request.form.update(
+ {"name": "plone.app.vocabularies.Users", "query": "user"}
+ )
+ data = json.loads(view())
+
+ self.assertEqual(len(data["results"]), amount)
+ # Let's sort, just to be sure.
+ results = sorted(data["results"], key=operator.itemgetter("id"))
+ # The first one is the hacker. The hack should have failed.
+ self.assertDictEqual(results[0], {"id": "user0", "text": "user hacker"})
+
+ def testSource(self):
+ from z3c.form.browser.text import TextWidget
+ from zope.interface import implementer
+ from zope.interface import Interface
+ from zope.schema import Choice
+ from zope.schema.interfaces import ISource
+
+ @implementer(ISource)
+ class DummyCatalogSource:
+ def search_catalog(self, query):
+ querytext = query["SearchableText"]["query"]
+ return [mock.Mock(id=querytext)]
+
+ widget = TextWidget(self.request)
+ widget.context = self.portal
+ widget.field = Choice(source=DummyCatalogSource())
+ widget.field.interface = Interface
+
+ from plone.app.layout.content.browser.vocabulary import SourceView
+
+ view = SourceView(widget, self.request)
+ query = {
+ "criteria": [
+ {
+ "i": "SearchableText",
+ "o": "plone.app.querystring.operation.string.is",
+ "v": "foo",
+ }
+ ]
+ }
+ self.request.form.update(
+ {
+ "query": json.dumps(query),
+ "attributes": "id",
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(len(data["results"]), 1)
+ self.assertEqual(data["results"][0]["id"], "foo")
+
+ def testSourceCollectionField(self):
+ # This test uses a collection field
+ # and a source providing the 'search' method
+ # to help achieve coverage.
+ from z3c.form.browser.text import TextWidget
+ from zope.interface import implementer
+ from zope.interface import Interface
+ from zope.schema import Choice
+ from zope.schema import List
+ from zope.schema.interfaces import ISource
+ from zope.schema.vocabulary import SimpleTerm
+
+ @implementer(ISource)
+ class DummySource:
+ def search(self, query):
+ terms = [SimpleTerm(query, query)]
+ return iter(terms)
+
+ widget = TextWidget(self.request)
+ widget.context = self.portal
+ widget.field = List(value_type=Choice(source=DummySource()))
+ widget.field.interface = Interface
+
+ from plone.app.layout.content.browser.vocabulary import SourceView
+
+ view = SourceView(widget, self.request)
+ query = {
+ "criteria": [
+ {
+ "i": "SearchableText",
+ "o": "plone.app.querystring.operation.string.is",
+ "v": "foo",
+ }
+ ],
+ "sort_on": "id",
+ "sort_order": "ascending",
+ }
+ self.request.form.update(
+ {
+ "query": json.dumps(query),
+ "batch": json.dumps({"size": 10, "page": 1}),
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(len(data["results"]), 1)
+ self.assertEqual(data["results"][0]["id"], "foo")
+
+ def testSourcePermissionDenied(self):
+ from z3c.form.browser.text import TextWidget
+ from zope.interface import implementer
+ from zope.interface import Interface
+ from zope.schema import Choice
+ from zope.schema.interfaces import ISource
+
+ @implementer(ISource)
+ class DummyCatalogSource:
+ def search_catalog(self, query):
+ querytext = query["SearchableText"]["query"]
+ return [mock.Mock(id=querytext)]
+
+ widget = TextWidget(self.request)
+ widget.context = self.portal
+ widget.field = Choice(source=DummyCatalogSource())
+ widget.field.interface = Interface
+
+ from plone.app.layout.content.browser.vocabulary import SourceView
+
+ view = SourceView(widget, self.request)
+ query = {
+ "criteria": [
+ {
+ "i": "SearchableText",
+ "o": "plone.app.querystring.operation.string.is",
+ "v": "foo",
+ }
+ ]
+ }
+ self.request.form.update(
+ {
+ "query": json.dumps(query),
+ }
+ )
+ logout()
+ data = json.loads(view())
+ self.assertEqual(data["error"], "Vocabulary lookup not allowed.")
+
+ def testSourceDefaultPermission(self):
+ from plone.app.layout.content.browser.vocabulary import SourceView
+ from z3c.form.browser.text import TextWidget
+
+ widget = TextWidget(self.request)
+ view = SourceView(widget, self.request)
+ self.assertEqual(view.default_permission, "cmf.ModifyPortalContent")
+
+ def testSourceDefaultPermissionOnAddForm(self):
+ from plone.app.layout.content.browser.vocabulary import SourceView
+ from z3c.form import form
+ from z3c.form.browser.text import TextWidget
+
+ widget = TextWidget(self.request)
+ widget.form = form.AddForm(self.portal, self.request)
+
+ view = SourceView(widget, self.request)
+ self.assertEqual(view.default_permission, "cmf.AddPortalContent")
+
+ def testSourceTextQuery(self):
+ from z3c.form.browser.text import TextWidget
+ from zope.interface import implementer
+ from zope.interface import Interface
+ from zope.schema import Choice
+ from zope.schema.interfaces import ISource
+
+ @implementer(ISource)
+ class DummyCatalogSource:
+ def search(self, query):
+ return [mock.Mock(value=mock.Mock(id=query))]
+
+ widget = TextWidget(self.request)
+ widget.context = self.portal
+ widget.field = Choice(source=DummyCatalogSource())
+ widget.field.interface = Interface
+
+ from plone.app.layout.content.browser.vocabulary import SourceView
+
+ view = SourceView(widget, self.request)
+ self.request.form.update(
+ {
+ "query": "foo",
+ "attributes": "id",
+ }
+ )
+ data = json.loads(view())
+ self.assertEqual(len(data["results"]), 1)
+ self.assertEqual(data["results"][0]["id"], "foo")
+
+ def testQueryStringConfiguration(self):
+ view = QueryStringIndexOptions(self.portal, self.request)
+ data = json.loads(view())
+ # just test one so we know it's working...
+ self.assertEqual(data["indexes"]["sortable_title"]["sortable"], True)
+
+ @mock.patch("zope.i18n.negotiate", new=lambda ctx: "de")
+ def testUntranslatableMetadata(self):
+ """Test translation of ``@@getVocabulary`` view results.
+ From the standard metadata columns, only ``Type`` is translated.
+ """
+ # Language is set via language negotiaton patch.
+
+ self.portal.invokeFactory("Document", id="page", title="page")
+ self.portal.page.reindexObject()
+ view = VocabularyView(self.portal, self.request)
+ query = {
+ "criteria": [
+ {
+ "i": "path",
+ "o": "plone.app.querystring.operation.string.path",
+ "v": "/plone/page",
+ }
+ ]
+ }
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.Catalog",
+ "query": json.dumps(query),
+ "attributes": [
+ "id",
+ "portal_type",
+ "Type",
+ ],
+ }
+ )
+
+ # data['results'] should return one item, which represents the document
+ # created before.
+ data = json.loads(view())
+
+ # Type is translated
+ self.assertEqual(data["results"][0]["Type"], "Seite")
+
+ # portal_type is never translated
+ self.assertEqual(data["results"][0]["portal_type"], "Document")
+
+ def testGetMimeIcon(self):
+ """Check if the returned icon is correct"""
+ query = {
+ "criteria": [
+ {
+ "i": "portal_type",
+ "o": "plone.app.querystring.operation.selection.any",
+ "v": "File",
+ },
+ ]
+ }
+ self.request.form.update(
+ {
+ "name": "plone.app.vocabularies.Catalog",
+ "attributes": ["getMimeIcon"],
+ "query": json.dumps(query),
+ }
+ )
+ view = VocabularyView(self.portal, self.request)
+
+ # Check an empty file
+ self.portal.invokeFactory("File", id="my-file", title="My file")
+ obj = self.portal["my-file"]
+ obj.reindexObject()
+
+ self.assertListEqual(json.loads(view())["results"], [{"getMimeIcon": None}])
+
+ # mock a pdf
+ obj.file = mock.Mock(contentType="application/pdf")
+ obj.reindexObject()
+ self.assertListEqual(
+ json.loads(view())["results"],
+ [{"getMimeIcon": "/plone/++resource++mimetype.icons/pdf.png"}],
+ )
+
+ # mock something unknown
+ obj.file = mock.Mock(contentType="x-foo/x-bar")
+ obj.reindexObject()
+ self.assertListEqual(
+ json.loads(view())["results"],
+ [{"getMimeIcon": "/plone/++resource++mimetype.icons/unknown.png"}],
+ )
+
+ def testGeneratesValidJson(self):
+ from zope.schema.vocabulary import SimpleTerm
+ from zope.schema.vocabulary import SimpleVocabulary
+
+ view = VocabularyView(self.portal, self.request)
+ vocab = SimpleVocabulary(
+ [
+ SimpleTerm(
+ token=f"term {idx} ",
+ value=f"term {idx} ",
+ title=f"term {idx} ",
+ )
+ for idx in range(3)
+ ]
+ )
+ with mock.patch.object(view, "get_vocabulary", return_value=vocab):
+ result = view()
+ # The above values could result in invalid json if there is an error in
+ # the code: the following call would give a json.decoder.JSONDecodeError.
+ # See https://github.com/plone/plone.app.content/pull/288
+ parsed = json.loads(result)
+ self.assertEqual(parsed["results"][0]["text"], "term 0 ")
+
+
+class FunctionalBrowserTest(unittest.TestCase):
+ layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING
+
+ def setUp(self):
+ self.request = TestRequest()
+ setRequest(self.request)
+ self.portal = self.layer["portal"]
+ login(self.portal, TEST_USER_NAME)
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ def testFileUpload(self):
+ view = FileUploadView(self.portal, self.request)
+ from plone.namedfile.file import FileChunk
+
+ chunk = FileChunk(b"foobar")
+ chunk.filename = "test.xml"
+ self.request.form["file"] = chunk
+ self.request.REQUEST_METHOD = "POST"
+ # the next calls plone.app.dexterity.factories and does a
+ # transaction.commit. Needs cleanup and FunctionalTesting layer.
+ data = json.loads(view())
+ self.assertEqual(data["url"], "http://nohost/plone/test.xml")
+ self.assertTrue(data["UID"] is not None)
+ # clean it up...
+ self.portal.manage_delObjects(["test.xml"])
+ transaction.commit()
+
+ def testFileUploadTxt(self):
+ view = FileUploadView(self.portal, self.request)
+ from plone.namedfile.file import FileChunk
+
+ chunk = FileChunk(b"foobar")
+ chunk.filename = "test.txt"
+ self.request.form["file"] = chunk
+ self.request.REQUEST_METHOD = "POST"
+ # the next calls plone.app.dexterity.factories and does a
+ # transaction.commit. Needs cleanup and FunctionalTesting layer.
+ data = json.loads(view())
+ self.assertEqual(data["url"], "http://nohost/plone/test.txt")
+ self.assertTrue(data["UID"] is not None)
+ # clean it up...
+ self.portal.manage_delObjects(["test.txt"])
+ transaction.commit()