From ad1f46549fc9aaf112e45ac96c09066230ae38f4 Mon Sep 17 00:00:00 2001 From: Franco Pellegrini Date: Sat, 30 May 2026 14:42:59 +0200 Subject: [PATCH] Move plone.app.content views to plone.app.layout. --- news/3953.breaking | 2 + setup.py | 11 + src/plone/app/layout/configure.zcml | 1 + src/plone/app/layout/content/__init__.py | 0 .../app/layout/content/browser/__init__.py | 0 .../app/layout/content/browser/actions.py | 368 +++++++++ .../app/layout/content/browser/adding.py | 22 + .../app/layout/content/browser/configure.zcml | 213 +++++ .../layout/content/browser/constraintypes.pt | 72 ++ .../layout/content/browser/constraintypes.py | 194 +++++ .../content/browser/content_status_history.py | 148 ++++ .../content/browser/content_status_modify.py | 165 ++++ .../content/browser/contents/__init__.py | 388 +++++++++ .../content/browser/contents/configure.zcml | 140 ++++ .../layout/content/browser/contents/copy.py | 56 ++ .../layout/content/browser/contents/cut.py | 74 ++ .../content/browser/contents/defaultpage.py | 22 + .../layout/content/browser/contents/delete.py | 94 +++ .../layout/content/browser/contents/paste.py | 66 ++ .../content/browser/contents/properties.py | 118 +++ .../content/browser/contents/rearrange.py | 88 +++ .../layout/content/browser/contents/rename.py | 114 +++ .../layout/content/browser/contents/tags.py | 56 ++ .../browser/contents/templates/delete.pt | 6 + .../contents/templates/folder_contents.pt | 28 + .../browser/contents/templates/properties.pt | 65 ++ .../browser/contents/templates/rename.pt | 20 + .../browser/contents/templates/tags.pt | 24 + .../browser/contents/templates/workflow.pt | 26 + .../content/browser/contents/workflow.py | 94 +++ src/plone/app/layout/content/browser/file.py | 207 +++++ .../layout/content/browser/folder_publish.py | 117 +++ .../layout/content/browser/folderfactories.pt | 104 +++ .../layout/content/browser/folderfactories.py | 147 ++++ .../content/browser/full_review_list.pt | 40 + src/plone/app/layout/content/browser/i18n.py | 48 ++ .../app/layout/content/browser/interfaces.py | 14 + src/plone/app/layout/content/browser/query.py | 16 + .../app/layout/content/browser/reviewlist.py | 176 +++++ .../app/layout/content/browser/selection.py | 134 ++++ src/plone/app/layout/content/browser/table.pt | 351 ++++++++ .../app/layout/content/browser/table.txt | 147 ++++ .../app/layout/content/browser/tableview.py | 200 +++++ .../templates/content_status_history.pt | 540 +++++++++++++ .../browser/templates/delete_confirmation.pt | 73 ++ .../browser/templates/object_rename.pt | 35 + .../browser/templates/select_default_page.pt | 123 +++ .../browser/templates/select_default_view.pt | 134 ++++ .../app/layout/content/browser/vocabulary.py | 394 +++++++++ src/plone/app/layout/content/configure.zcml | 7 + src/plone/app/layout/tests/image.png | Bin 0 -> 1185 bytes src/plone/app/layout/tests/test_actions.py | 474 +++++++++++ src/plone/app/layout/tests/test_adding.py | 23 + src/plone/app/layout/tests/test_constrains.py | 20 + .../tests/test_content_status_modify.py | 180 +++++ src/plone/app/layout/tests/test_contents.py | 658 +++++++++++++++ src/plone/app/layout/tests/test_folder.py | 588 ++++++++++++++ .../app/layout/tests/test_folder_publish.py | 128 +++ ..._non_ascii_characters_in_workflow_state.py | 53 ++ .../app/layout/tests/test_permissions.py | 263 ++++++ src/plone/app/layout/tests/test_reviewlist.py | 82 ++ .../layout/tests/test_selectdefaultpage.py | 167 ++++ src/plone/app/layout/tests/test_table.py | 17 + src/plone/app/layout/tests/test_widgets.py | 748 ++++++++++++++++++ 64 files changed, 9083 insertions(+) create mode 100644 news/3953.breaking create mode 100644 src/plone/app/layout/content/__init__.py create mode 100644 src/plone/app/layout/content/browser/__init__.py create mode 100644 src/plone/app/layout/content/browser/actions.py create mode 100644 src/plone/app/layout/content/browser/adding.py create mode 100644 src/plone/app/layout/content/browser/configure.zcml create mode 100644 src/plone/app/layout/content/browser/constraintypes.pt create mode 100644 src/plone/app/layout/content/browser/constraintypes.py create mode 100644 src/plone/app/layout/content/browser/content_status_history.py create mode 100644 src/plone/app/layout/content/browser/content_status_modify.py create mode 100644 src/plone/app/layout/content/browser/contents/__init__.py create mode 100644 src/plone/app/layout/content/browser/contents/configure.zcml create mode 100644 src/plone/app/layout/content/browser/contents/copy.py create mode 100644 src/plone/app/layout/content/browser/contents/cut.py create mode 100644 src/plone/app/layout/content/browser/contents/defaultpage.py create mode 100644 src/plone/app/layout/content/browser/contents/delete.py create mode 100644 src/plone/app/layout/content/browser/contents/paste.py create mode 100644 src/plone/app/layout/content/browser/contents/properties.py create mode 100644 src/plone/app/layout/content/browser/contents/rearrange.py create mode 100644 src/plone/app/layout/content/browser/contents/rename.py create mode 100644 src/plone/app/layout/content/browser/contents/tags.py create mode 100644 src/plone/app/layout/content/browser/contents/templates/delete.pt create mode 100644 src/plone/app/layout/content/browser/contents/templates/folder_contents.pt create mode 100644 src/plone/app/layout/content/browser/contents/templates/properties.pt create mode 100644 src/plone/app/layout/content/browser/contents/templates/rename.pt create mode 100644 src/plone/app/layout/content/browser/contents/templates/tags.pt create mode 100644 src/plone/app/layout/content/browser/contents/templates/workflow.pt create mode 100644 src/plone/app/layout/content/browser/contents/workflow.py create mode 100644 src/plone/app/layout/content/browser/file.py create mode 100644 src/plone/app/layout/content/browser/folder_publish.py create mode 100644 src/plone/app/layout/content/browser/folderfactories.pt create mode 100644 src/plone/app/layout/content/browser/folderfactories.py create mode 100644 src/plone/app/layout/content/browser/full_review_list.pt create mode 100644 src/plone/app/layout/content/browser/i18n.py create mode 100644 src/plone/app/layout/content/browser/interfaces.py create mode 100644 src/plone/app/layout/content/browser/query.py create mode 100644 src/plone/app/layout/content/browser/reviewlist.py create mode 100644 src/plone/app/layout/content/browser/selection.py create mode 100644 src/plone/app/layout/content/browser/table.pt create mode 100644 src/plone/app/layout/content/browser/table.txt create mode 100644 src/plone/app/layout/content/browser/tableview.py create mode 100644 src/plone/app/layout/content/browser/templates/content_status_history.pt create mode 100644 src/plone/app/layout/content/browser/templates/delete_confirmation.pt create mode 100644 src/plone/app/layout/content/browser/templates/object_rename.pt create mode 100644 src/plone/app/layout/content/browser/templates/select_default_page.pt create mode 100644 src/plone/app/layout/content/browser/templates/select_default_view.pt create mode 100644 src/plone/app/layout/content/browser/vocabulary.py create mode 100644 src/plone/app/layout/content/configure.zcml create mode 100644 src/plone/app/layout/tests/image.png create mode 100644 src/plone/app/layout/tests/test_actions.py create mode 100644 src/plone/app/layout/tests/test_adding.py create mode 100644 src/plone/app/layout/tests/test_constrains.py create mode 100644 src/plone/app/layout/tests/test_content_status_modify.py create mode 100644 src/plone/app/layout/tests/test_contents.py create mode 100644 src/plone/app/layout/tests/test_folder.py create mode 100644 src/plone/app/layout/tests/test_folder_publish.py create mode 100644 src/plone/app/layout/tests/test_non_ascii_characters_in_workflow_state.py create mode 100644 src/plone/app/layout/tests/test_permissions.py create mode 100644 src/plone/app/layout/tests/test_reviewlist.py create mode 100644 src/plone/app/layout/tests/test_selectdefaultpage.py create mode 100644 src/plone/app/layout/tests/test_table.py create mode 100644 src/plone/app/layout/tests/test_widgets.py diff --git a/news/3953.breaking b/news/3953.breaking new file mode 100644 index 000000000..1b7cbd088 --- /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 9bc78815b..ad554a05f 100644 --- a/setup.py +++ b/setup.py @@ -45,17 +45,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( @@ -63,11 +71,14 @@ "plone.app.contenttypes[test]", "plone.app.relationfield", "plone.app.testing", + "plone.app.z3cform", "plone.browserlayer", "plone.dexterity", "plone.locking", + "plone.namedfile", "plone.testing", "z3c.relationfield", + "zope.annotation", "zope.intid", ] ), diff --git a/src/plone/app/layout/configure.zcml b/src/plone/app/layout/configure.zcml index f1cefa5d7..732121052 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..6f16e535a --- /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 000000000..60f069898 --- /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 000000000..f2762e985 --- /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 000000000..2cb548d3f --- /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 000000000..046d3f714 --- /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 000000000..60e01ab49 --- /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 000000000..ae64bbe14 --- /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 000000000..10a9c5b9f --- /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 000000000..931d4b90a --- /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 000000000..76b0ffd71 --- /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 000000000..b3e54fe10 --- /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 000000000..006ecea14 --- /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 000000000..9a5f6e1a4 --- /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 000000000..e6cb307a5 --- /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 000000000..9d087a3c8 --- /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 000000000..e1e3ce3dd --- /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 000000000..fcc232ff2 --- /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 000000000..a61af2204 --- /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 000000000..9a3681912 --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/templates/delete.pt @@ -0,0 +1,6 @@ +
+ <% try{ %> + <%= data.html || '' %> + <% }catch(e){} %> + +
\ 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 000000000..485eaf0d9 --- /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 000000000..3da061fb2 --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/templates/properties.pt @@ -0,0 +1,65 @@ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ + +
+
+ + <% if (data.languages) { %> +
+ + +
+ <% } %> + +
+ + +

+ If checked, this will attempt to modify the status of all content in any selected folders and their subfolders. +

+
+ +
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 000000000..5ddb0751e --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/templates/rename.pt @@ -0,0 +1,20 @@ +
+<% _.each(items, function(item, index) { %> +
+ + +
+ + +
+ +
+ + +
+ + <% 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 000000000..f1a35b1ca --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/templates/tags.pt @@ -0,0 +1,24 @@ +
+
+ + +
+ +
+ + + +
+
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 000000000..e9a32cd6c --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/templates/workflow.pt @@ -0,0 +1,26 @@ +
+
+ + +
+ +
+ + +

Select the transition to be used for modifying the items state.

+
+
+ + +

+ If checked, this will attempt to modify the status of all content in any selected folders and their subfolders. +

+
+
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 000000000..1d4df1cb4 --- /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 000000000..ff1dca5ef --- /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 000000000..669fbf7ec --- /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 000000000..c2305f581 --- /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. +
+ +
+

+ + Click to configure what type of items can be added here… + +

+ + + +
+ +
    + +
  • + + + Type description +
  • +
    +
+ +
+ +
+
+
+
+ +
+ + + 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 000000000..c83022eeb --- /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 000000000..8fd2c8ece --- /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 000000000..ebabfc1a4 --- /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 000000000..5f300c3e5 --- /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 000000000..7defcf56e --- /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 000000000..9a6ca197f --- /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 000000000..ea7947707 --- /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 000000000..939d107e9 --- /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. +

+ + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Select: + All
+ + All + + items on this + page are selected. + + Select all + + items in this folder. +
+ + All + + items in this folder + are selected. + + Clear selection +
+   Title  Size  Modified  State 
+ + + ▲ + +   + + ▼ + + + + + + + + + + + + ■ + + + + expired + + +   + + + size + + + 08/19/2001 03:01 AM + + +   +
+ Show all items +
+ Show batched +
+
+ + + +
+
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 000000000..00e846082 --- /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 000000000..b3a1ee0c6 --- /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 000000000..c42567e84 --- /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. +
+ +
+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + Title + + Size + + Modified + + State +
+ + + + + + + + + + + + + expired + + +   + + + size + + + 08/19/2001 03:01 AM + + +   +
+
+ +
Validation error output
+
+ +
+
+ + + +
+ If checked, this will attempt to modify the status of all content + in any selected folders and their subfolders. +
+ +
+ +
+ +
+ + +
+ +
+ calendar pop-up +
+ +
+
+ The date when the item will be published. If no date is selected the + item will be published immediately. +
+
Validation error output
+ +
+ +
+ + +
+ +
+ calendar pop-up +
+
+ +
+ 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. +
+ +
Validation error output
+ +
+ +
+ + + +
+ Will be added to the publishing history. If multiple + items are selected, this comment will be attached to all + of them. +
+
+ +
+ + +
+ +
+ Error +
+
+ + + For usability we will want to signify what state we are + currently in. DCWorkflow only returns what transitions are + available. But we want to visually represent what *state* we + are currently in along with possible transitions. + + +
+ + +
+ +
+ + +
+ +
+ +
+ Select the transition to be used for modifying the items state. +
+ +
+ +
+ + + +
+ + + + +
+
+
+ + + 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 000000000..29241d534 --- /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? +

+
+ +
+ + + +
    +
  • The item title (ID)
  • +
+ +
+
+ +
+ + 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 000000000..e951e4f3a --- /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 000000000..d7b337947 --- /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. +
+
+
+ + + + + +
+ +
+ + +
+
+ Item Description +
+
+ +
+ +
+ + +
+ +
+ +
+ There are no items in this folder that can be selected as + a default view page. +
+
+ +
+
+
+ +
+
+ +
+ + + 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 000000000..b7ccdbb9a --- /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. +
+ +
+
+ + + +
+ + + + + (current)
+
+
+

+ + or + + Choose a content item… + + + + or + + Select a content item… + + +

+
+ +
+ + +
+ +
+
+ +
+ + + 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 000000000..23ab157ab --- /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 000000000..505360d99 --- /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 0000000000000000000000000000000000000000..4c109bab8dbadd00ceb082694f3416b715055935 GIT binary patch literal 1185 zcmV;S1YY}zP)=xnvsnabWDk;T{N>i~nkE||zor_QO-;(NTvH2D426-nUu@_7pLQpkYy_MZTyNstbb5ZZ zwqx{xEQCOPq!bL>d=r~XQr|(z$J{CUw@0HTe<-H69xKkDirw2{ZFwlK-*d1^i(#2< zZdBfJ$f$ydeoc4S)mON7eLJim#v9j1NI?#R@upr~6xDF?Osq{SklySZY|9{P>H((P z`_IIpd>5zt1#+K6%5}triu4b;lFhklRGZ(O-CA(TH(592LivBkRT?MLfBr3vORg5K z?&8YkTrRZ838i$k$XU2LZhDxqU(mI)Ok&p(Gt zFIg1VKIzh2-EAACm_>`mgctiiTzjxQ8leQa$z?AWkGY&3CS6N=JG6$2FugPF>rm?z z#BRDZf7!{U#i4-E)hd;v*qzu@>3yU1DP%$=aQWyzNrB+rxId8cvMiKsCmoEY4;7xsu7lJSs`q zq@vs!E~A?@hOhg}y0(AP4leWtZUTp!=Dl2b2y%%t*wjuiQ68@RUB}vp(xr0@t{_iO z*B;F~Xz(n4GRP$yz8}9-ajm^eR}XT(Bq=9wAr^Ake&kCZWGpys#Ta1r(SPlS`?+jT zE!UuJ-%kpRUyL;6;Ft8OxPdZBea3~RZ*#87Ww7yZAkIfUjq@OuJ*GLABpExoP}|q% zWxID??y8~(xcE5Nm-8rdB#%0uYnJzN&UT?kA;e+c`@eGGx&xOE{t4@p7k*zf+z`NZ zrGXGQUsoRIDu?5tP@u+j0ILtw0YJ~6zdQsx(70|B4%cl^`}2({R{$Ray`av98s>)v z^1~@t0&fDnsxFE`Q!m}%vwXW_E)`+2F*thMGAz%jW230=W!+5q+DMy2E-}RDDukQE z`wnZqi4JfHM!e9UdZ4e?%U;&Yb1rQk?@}t=m(#5G?h~2eKGA+I^aR>h-#{+uV$?F; z*K>BVuXmKoRb0b*)SOFs#42g94=Z&E>-CX+*nPY6G_D`cxp4WwdcJS2iAW#YH&-LA zw&POTz$W`XE=62Z#qg?g4uOA54W)zOs~pznqgkOo@0AE&?|jMd)1&87UZdm3RUAFz z9MR9Yub*>zKj$ic&cUM6_ASmW&(E2McZB){V+fV(8gbsY00000NkvXXu0mjf 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 000000000..d3e6373d8 --- /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 000000000..0d5768c5d --- /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 000000000..d9bf2a7f5 --- /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 000000000..e63b4a3b9 --- /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 000000000..8c16a2bce --- /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 000000000..9da4cc49e --- /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 000000000..8aef998aa --- /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()