diff --git a/news/3953.breaking b/news/3953.breaking new file mode 100644 index 00000000..1b7cbd08 --- /dev/null +++ b/news/3953.breaking @@ -0,0 +1,2 @@ +Move plone.app.content views to plone.app.layout +[frapell] diff --git a/setup.py b/setup.py index a7424451..af408f52 100644 --- a/setup.py +++ b/setup.py @@ -46,17 +46,25 @@ "plone.app.relationfield", "plone.app.uuid", "plone.app.viewletmanager >=1.2", + "plone.autoform", "plone.base >=4.0.0a1", + "plone.batching", "plone.dexterity", + "plone.folder", "plone.formwidget.namedfile", "plone.i18n", + "plone.locking", "plone.memoize", "plone.portlets", "plone.protect", + "plone.registry", + "plone.supermodel", + "plone.uuid", "Products.CMFEditions >=1.2.2", "Products.GenericSetup", "Products.statusmessages", "Products.ZCatalog", + "z3c.form", "Zope", ], extras_require=dict( @@ -66,13 +74,16 @@ "plone.app.relationfield", "plone.app.robotframework", "plone.app.testing", + "plone.app.z3cform", "plone.app.textfield", "plone.browserlayer", "plone.dexterity", "plone.locking", + "plone.namedfile", "plone.testing", "robotsuite", "z3c.relationfield", + "zope.annotation", "zc.relation", "zope.intid", ] diff --git a/src/plone/app/layout/configure.zcml b/src/plone/app/layout/configure.zcml index f1cefa5d..73212105 100644 --- a/src/plone/app/layout/configure.zcml +++ b/src/plone/app/layout/configure.zcml @@ -21,6 +21,7 @@ directory="profiles/uninstall" /> + diff --git a/src/plone/app/layout/content/__init__.py b/src/plone/app/layout/content/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/plone/app/layout/content/browser/__init__.py b/src/plone/app/layout/content/browser/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/plone/app/layout/content/browser/actions.py b/src/plone/app/layout/content/browser/actions.py new file mode 100644 index 00000000..6f16e535 --- /dev/null +++ b/src/plone/app/layout/content/browser/actions.py @@ -0,0 +1,368 @@ +from AccessControl import getSecurityManager +from Acquisition import aq_inner +from Acquisition import aq_parent +from OFS.CopySupport import CopyError +from plone.base import PloneMessageFactory as _ +from plone.base.utils import get_user_friendly_types +from plone.base.utils import safe_text +from plone.locking.interfaces import ILockable +from Products.CMFCore.utils import getToolByName +from Products.Five.browser import BrowserView +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from Products.statusmessages.interfaces import IStatusMessage +from z3c.form import button +from z3c.form import field +from z3c.form import form +from z3c.form.widget import ComputedWidgetAttribute +from zExceptions import Unauthorized +from ZODB.POSException import ConflictError +from zope import schema +from zope.component import getMultiAdapter +from zope.component import queryMultiAdapter +from zope.container.interfaces import INameChooser +from zope.event import notify +from zope.interface import Interface +from zope.lifecycleevent import ObjectModifiedEvent + +import transaction + + +class LockingBase(BrowserView): + @property + def is_locked(self): + locking_view = queryMultiAdapter( + (self.context, self.request), name="plone_lock_info" + ) + + return locking_view and locking_view.is_locked_for_current_user() + + +class DeleteConfirmationForm(form.Form, LockingBase): + fields = field.Fields() + template = ViewPageTemplateFile("templates/delete_confirmation.pt") + enableCSRFProtection = True + + def view_url(self): + """Facade to the homonymous plone_context_state method""" + context_state = getMultiAdapter( + (self.context, self.request), name="plone_context_state" + ) + return context_state.view_url() + + def more_info(self): + adapter = queryMultiAdapter( + (self.context, self.request), name="delete_confirmation_info" + ) + if adapter: + return adapter() + return "" + + @property + def items_to_delete(self): + catalog = getToolByName(self.context, "portal_catalog") + results = catalog( + { + "path": "/".join(self.context.getPhysicalPath()), + "portal_type": get_user_friendly_types(), + } + ) + return len(results) + + @button.buttonAndHandler(_("Delete"), name="Delete") + def handle_delete(self, action): + title = safe_text(self.context.Title()) + parent = aq_parent(aq_inner(self.context)) + + # has the context object been acquired from a place it should not have + # been? + if self.context.aq_chain == self.context.aq_inner.aq_chain: + try: + lock_info = self.context.restrictedTraverse("@@plone_lock_info") + except AttributeError: + lock_info = None + if lock_info is not None: + if lock_info.is_locked() and not lock_info.is_locked_for_current_user(): + # unlock object as it is locked by current user + ILockable(self.context).unlock() + parent.manage_delObjects(self.context.getId()) + IStatusMessage(self.request).add( + _("${title} has been deleted.", mapping={"title": title}) + ) + else: + IStatusMessage(self.request).add( + _('"${title}" has already been deleted', mapping={"title": title}) + ) + + self.request.response.redirect(parent.absolute_url()) + + @button.buttonAndHandler(_("label_cancel", default="Cancel"), name="Cancel") + def handle_cancel(self, action): + target = self.view_url() + return self.request.response.redirect(target) + + def updateActions(self): + super().updateActions() + if self.actions and "Delete" in self.actions: + self.actions["Delete"].addClass("btn-danger") + if self.actions and "Cancel" in self.actions: + self.actions["Cancel"].addClass("btn-secondary") + + +def valid_id(self): + # TODO: Do we need an validator here or use the same that's used in + # plone.app.dexterity.behaviors.id.IShortName + return True + + +class IRenameForm(Interface): + new_id = schema.ASCIILine( + title=_("label_new_short_name", default="New Short Name"), + description=_( + "help_short_name_url", + default="Short name is the part that shows up in the URL " + "of the item.", + ), + constraint=valid_id, + ) + + new_title = schema.TextLine( + title=_("label_new_title", default="New Title"), + ) + + +default_new_id = ComputedWidgetAttribute( + lambda form: form.context.getId(), field=IRenameForm["new_id"] +) + +default_new_title = ComputedWidgetAttribute( + lambda form: form.context.Title(), field=IRenameForm["new_title"] +) + + +class RenameForm(form.Form): + fields = field.Fields(IRenameForm) + template = ViewPageTemplateFile("templates/object_rename.pt") + enableCSRFProtection = True + ignoreContext = True + + label = _("heading_rename_item", default="Rename item") + description = _( + "description_rename_item", + default="Each item has a Short Name and a Title, which you can " + + "change by entering the new details below.", + ) + + def view_url(self): + context_state = getMultiAdapter( + (self.context, self.request), name="plone_context_state" + ) + return context_state.view_url() + + @button.buttonAndHandler(_("Rename"), name="Rename") + def handle_rename(self, action): + data, errors = self.extractData() + if errors: + return + + parent = aq_parent(aq_inner(self.context)) + sm = getSecurityManager() + if not sm.checkPermission("Copy or Move", parent): + raise Unauthorized( + _( + "Permission denied to rename ${title}.", + mapping={"title": self.context.title}, + ) + ) + + # Requires cmf.ModifyPortalContent permission + self.context.title = data["new_title"] + + oldid = self.context.getId() + newid = data["new_id"] + if oldid != newid: + newid = INameChooser(parent).chooseName(newid, self.context) + + # Requires zope2.CopyOrMove permission + + # manage_renameObjects fires 3 events: + # 1. ObjectWillBeMovedEvent before anything happens + # 2. ObjectMovedEvent directly after rename + # 3. zope.container.contained.notifyContainerModified directly after 2 + # for 2+3 there are subscribers in Products.CMFDynamicViewFTI + # responsible to change (2) or unset (3) the default_page. + + parent.manage_renameObjects( + [ + oldid, + ], + [ + str(newid), + ], + ) + else: + # Object is not reindex if manage_renameObjects is not called + self.context.reindexObject() + + transaction.savepoint(optimistic=True) + notify(ObjectModifiedEvent(self.context)) + + IStatusMessage(self.request).add( + _( + "Renamed '${oldid}' to '${newid}'.", + mapping={"oldid": oldid, "newid": newid}, + ) + ) + + self.request.response.redirect(self.view_url()) + + @button.buttonAndHandler(_("label_cancel", default="Cancel"), name="Cancel") + def handle_cancel(self, action): + self.request.response.redirect(self.view_url()) + + def updateActions(self): + super().updateActions() + if self.actions and "Rename" in self.actions: + self.actions["Rename"].addClass("btn-primary") + if self.actions and "Cancel" in self.actions: + self.actions["Cancel"].addClass("btn-secondary") + + +class ObjectCutView(BrowserView): + @property + def title(self): + return self.context.Title() + + @property + def parent(self): + return aq_parent(aq_inner(self.context)) + + @property + def canonical_object_url(self): + context_state = getMultiAdapter( + (self.context, self.request), name="plone_context_state" + ) + return context_state.canonical_object_url() + + @property + def view_url(self): + context_state = getMultiAdapter( + (self.context, self.request), name="plone_context_state" + ) + return context_state.view_url() + + def do_redirect(self, url, message=None, message_type="info", raise_exception=None): + if message is not None: + IStatusMessage(self.request).add(message, type=message_type) + + if raise_exception is None: + return self.request.response.redirect(url) + raise raise_exception + + def do_action(self): + try: + lock_info = self.context.restrictedTraverse("@@plone_lock_info") + except AttributeError: + lock_info = None + if lock_info is not None: + if lock_info.is_locked_for_current_user(): + return self.do_redirect( + self.view_url, + _( + "${title} is locked and cannot be cut.", + mapping={ + "title": self.title, + }, + ), + ) + elif lock_info.is_locked(): + # unlock object as it is locked by current user + ILockable(self.context).unlock() + + try: + cp = self.parent.manage_cutObjects(self.context.getId()) + except CopyError: + return self.do_redirect( + self.view_url, + _("${title} is not moveable.", mapping={"title": self.title}), + ) + self.request.response.setCookie( + "__cp", cp, path=self.request["BASEPATH1"] or "/" + ) + self.request["__cp"] = cp + + return self.do_redirect( + self.view_url, _("${title} cut.", mapping={"title": self.title}), "info" + ) + + def __call__(self): + authenticator = getMultiAdapter( + (self.context, self.request), name="authenticator" + ) + + if not authenticator.verify(): + raise Unauthorized + + return self.do_action() + + +class ObjectCopyView(ObjectCutView): + def do_action(self): + try: + cp = self.parent.manage_copyObjects(self.context.getId()) + except CopyError: + return self.do_redirect( + self.view_url, + _("${title} is not copyable.", mapping={"title": self.title}), + ) + self.request.response.setCookie( + "__cp", cp, path=self.request["BASEPATH1"] or "/" + ) + self.request["__cp"] = cp + + return self.do_redirect( + self.view_url, _("${title} copied.", mapping={"title": self.title}) + ) + + +class ObjectDeleteView(ObjectCutView): + def do_action(self): + form = DeleteConfirmationForm(self.context, self.request) + form.update() + + button = form.buttons["Delete"] + # delete by clicking the form button in delete_confirmation + form.handlers.getHandler(button)(form, button) + + +class ObjectPasteView(ObjectCutView): + def do_action(self): + if not self.context.cb_dataValid(): + return self.do_redirect( + self.canonical_object_url, + _("Copy or cut one or more items to paste."), + "error", + ) + try: + self.context.manage_pasteObjects(self.request["__cp"]) + except ConflictError: + raise + except Unauthorized as e: + self.do_redirect( + self.canonical_object_url, _("You are not authorized to paste here."), e + ) + except CopyError as e: + error_string = str(e) + if "Item Not Found" in error_string: + return self.do_redirect( + self.canonical_object_url, + _( + "The item you are trying to paste could not be found. " + "It may have been moved or deleted after you copied or " + "cut it." + ), + "error", + ) + except Exception as e: + if "__cp" in self.request: + self.do_redirect(self.canonical_object_url, e, "error", e) + + return self.do_redirect(self.canonical_object_url, _("Item(s) pasted.")) diff --git a/src/plone/app/layout/content/browser/adding.py b/src/plone/app/layout/content/browser/adding.py new file mode 100644 index 00000000..60f06989 --- /dev/null +++ b/src/plone/app/layout/content/browser/adding.py @@ -0,0 +1,22 @@ +from Acquisition import Implicit +from Products.CMFCore.utils import getToolByName +from Products.Five.browser.adding import ContentAdding + + +class CMFAdding(Implicit, ContentAdding): + """An adding view with a less silly next-url""" + + # We need to do this to get proper traversal URLs - otherwise, the + # tag is messed up. + id = "+" + + def add(self, content): + content = super().add(content) + # We need to ensure that we finish type construction, not at least + # to set the correct permissions based on the workflow + getToolByName(content, "portal_types") + + return content + + def nextURL(self): + return f"{self.context.absolute_url()}/{self.contentName}/view" diff --git a/src/plone/app/layout/content/browser/configure.zcml b/src/plone/app/layout/content/browser/configure.zcml new file mode 100644 index 00000000..f2762e98 --- /dev/null +++ b/src/plone/app/layout/content/browser/configure.zcml @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/plone/app/layout/content/browser/constraintypes.pt b/src/plone/app/layout/content/browser/constraintypes.pt new file mode 100644 index 00000000..2cb548d3 --- /dev/null +++ b/src/plone/app/layout/content/browser/constraintypes.pt @@ -0,0 +1,72 @@ + + +
+ +
+ + + + + + + +

Title

+ + + +
+
+ + diff --git a/src/plone/app/layout/content/browser/constraintypes.py b/src/plone/app/layout/content/browser/constraintypes.py new file mode 100644 index 00000000..046d3f71 --- /dev/null +++ b/src/plone/app/layout/content/browser/constraintypes.py @@ -0,0 +1,194 @@ +from plone.autoform.form import AutoExtensibleForm +from plone.base.interfaces import ISelectableConstrainTypes +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from z3c.form import button +from z3c.form import form +from z3c.form.browser.checkbox import CheckBoxFieldWidget +from zope.globalrequest import getRequest +from zope.i18n import translate +from zope.i18nmessageid import MessageFactory +from zope.interface import implementer +from zope.interface import Interface +from zope.interface import invariant +from zope.interface.exceptions import Invalid +from zope.schema import Choice +from zope.schema import List +from zope.schema.interfaces import IVocabularyFactory +from zope.schema.vocabulary import SimpleTerm +from zope.schema.vocabulary import SimpleVocabulary + +# XXX +# acquire locallyAllowedTypes from parent (default) +ACQUIRE = -1 + +# use default behavior of PortalFolder which uses the FTI information +DISABLED = 0 + +# allow types from locallyAllowedTypes only +ENABLED = 1 + + +def ST(key, title): + return SimpleTerm(value=key, title=title) + + +# reuse the translations that we had in atcontenttypes +_ = MessageFactory("plone") + +possible_constrain_types = SimpleVocabulary( + [ + ST( + ACQUIRE, + _("constraintypes_acquire_label", default="Use parent folder settings"), + ), + ST(DISABLED, _("constraintypes_disable_label", default="Use portal default")), + ST(ENABLED, _("constraintypes_enable_label", default="Select manually")), + ] +) + + +@implementer(IVocabularyFactory) +class ValidTypes: + def __call__(self, context): + constrain_aspect = context.context + request = getRequest() + items = [] + for type_ in constrain_aspect.getDefaultAddableTypes(): + items.append(SimpleTerm(value=type_.getId(), title=type_.Title())) + + return SimpleVocabulary( + sorted(items, key=lambda x: translate(x.title, context=request).lower()) + ) + + +ValidTypesFactory = ValidTypes() + + +class IConstrainForm(Interface): + constrain_types_mode = Choice( + title=_("label_type_restrictions", default="Type restrictions"), + description=_( + "help_add_restriction_mode", + default="Select the restriction policy " "in this location", + ), + vocabulary=possible_constrain_types, + required=True, + default=ACQUIRE, + ) + + allowed_types = List( + title=_("label_immediately_addable_types", default="Allowed types"), + description=_( + "help_immediately_addable_types", + default="Controls what types are addable " "in this location", + ), + value_type=Choice(source="plone.app.content.ValidAddableTypes"), + required=False, + ) + + secondary_types = List( + title=_("label_locally_allowed_types", default="Secondary types"), + description=_( + "help_locally_allowed_types", + default="" + "Select which types should be available in the " + "'More…' submenu instead of in the " + "main pulldown. " + "This is useful to indicate that these are not the " + "preferred types " + "in this location, but are allowed if you really " + "need them.", + ), + value_type=Choice(source="plone.app.content.ValidAddableTypes"), + required=False, + ) + + @invariant + def legal_not_immediately_addable(data): + missing = [] + for one_allowed in data.secondary_types: + if one_allowed not in data.allowed_types: + missing.append(one_allowed) + if missing: + raise Invalid( + _( + "You cannot have a type as a secondary type without " + "having it allowed. You have selected ${types}.", + mapping=dict(types=", ".join(missing)), + ) + ) + return True + + +@implementer(IConstrainForm) +class FormContentAdapter: + def __init__(self, context): + self.context = ISelectableConstrainTypes(context) + + @property + def constrain_types_mode(self): + return self.context.getConstrainTypesMode() + + @property + def allowed_types(self): + return self.context.getLocallyAllowedTypes() + + @property + def secondary_types(self): + immediately_addable = self.context.getImmediatelyAddableTypes() + return [ + t + for t in self.context.getLocallyAllowedTypes() + if t not in immediately_addable + ] + + +class ConstrainsFormView(AutoExtensibleForm, form.EditForm): + schema = IConstrainForm + label = _( + "heading_set_content_type_restrictions", + default="Restrict what types of content can be added", + ) + template = ViewPageTemplateFile("constraintypes.pt") + + def getContent(self): + return FormContentAdapter(self.context) + + def updateFields(self): + super().updateFields() + self.fields["allowed_types"].widgetFactory = CheckBoxFieldWidget + self.fields["secondary_types"].widgetFactory = CheckBoxFieldWidget + + def updateWidgets(self): + super().updateWidgets() + self.widgets["allowed_types"].addClass("current_prefer_form") + self.widgets["secondary_types"].addClass("current_allow_form") + self.widgets["constrain_types_mode"].addClass("constrain_types_mode_form") + + def updateActions(self): + super().updateActions() + self.actions["save"].addClass("btn btn-primary") + + @button.buttonAndHandler(_("label_save", default="Save"), name="save") + def handleSave(self, action): + data, errors = self.extractData() + if errors: + self.status = self.formErrorsMessage + return + + allowed_types = data["allowed_types"] + immediately_addable = [ + t for t in allowed_types if t not in data["secondary_types"] + ] + + aspect = ISelectableConstrainTypes(self.context) + aspect.setConstrainTypesMode(data["constrain_types_mode"]) + aspect.setLocallyAllowedTypes(allowed_types) + aspect.setImmediatelyAddableTypes(immediately_addable) + contextURL = self.context.absolute_url() + self.request.response.redirect(contextURL) + + @button.buttonAndHandler(_("label_cancel", default="Cancel"), name="cancel") + def handleCancel(self, action): + contextURL = self.context.absolute_url() + self.request.response.redirect(contextURL) diff --git a/src/plone/app/layout/content/browser/content_status_history.py b/src/plone/app/layout/content/browser/content_status_history.py new file mode 100644 index 00000000..60e01ab4 --- /dev/null +++ b/src/plone/app/layout/content/browser/content_status_history.py @@ -0,0 +1,148 @@ +from Acquisition import aq_parent +from plone.base import PloneMessageFactory as _ +from plone.base.defaultpage import is_default_page +from plone.base.utils import human_readable_size +from plone.base.utils import is_expired +from Products.CMFCore.utils import getToolByName +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from Products.statusmessages.interfaces import IStatusMessage +from z3c.form import field +from z3c.form import form +from zope.deprecation.deprecation import deprecate +from zope.interface import Interface +from zope.publisher.browser import BrowserView +from zope.schema import Datetime +from zope.schema.fieldproperty import FieldProperty + + +class IContentStatusHistoryDates(Interface): + """Interface for the two dates on content status history view""" + + effective_date = Datetime( + title=_("label_effective_date", default="Publishing Date"), + description=_( + "help_effective_date_content_status_history", + default="The date when the item will be published. If no " + "date is selected the item will be published immediately.", + ), + required=False, + ) + + expiration_date = Datetime( + title=_("label_expiration_date", default="Expiration Date"), + description=_( + "help_expiration_date_content_status_history", + default="The date when the item expires. This will automatically " + "make the item invisible for others at the given date. " + "If no date is chosen, it will never expire.", + ), + required=False, + ) + + +class ContentStatusHistoryDatesForm(form.Form): + fields = field.Fields(IContentStatusHistoryDates) + ignoreContext = True + label = "Content status history dates" + + effective_date = FieldProperty(IContentStatusHistoryDates["effective_date"]) + expiration_date = FieldProperty(IContentStatusHistoryDates["expiration_date"]) + + +class ContentStatusHistoryView(BrowserView): + template = ViewPageTemplateFile("templates/content_status_history.pt") + + def __init__(self, context, request): + super().__init__(context, request) + + self.dates_form = ContentStatusHistoryDatesForm(context, request) + self.dates_form.updateWidgets() + self.errors = {} + + def __call__( + self, + workflow_action=None, + paths=[], + comment="", + effective_date=None, + expiration_date=None, + include_children=False, + *args, + ): + data = self.dates_form.extractData() + if self.request.get("form.widgets.effective_date-calendar", None) and data: + effective_date = data[0]["effective_date"].strftime("%Y-%m-%d %H:%M") + + if self.request.get("form.widgets.expiration_date-calendar", None) and data: + expiration_date = data[0]["expiration_date"].strftime("%Y-%m-%d %H:%M") + + if self.request.get("form.button.Cancel", None): + return self.request.RESPONSE.redirect( + "%s/view" % self.context.absolute_url() + ) + + if self.request.get("form.submitted", None): + self.validate(workflow_action=workflow_action, paths=paths) + if self.errors: + IStatusMessage(self.request).add( + _("Please correct the indicated errors."), type="error" + ) + return self.template() + + if self.request.get("form.button.Publish", None): + return self.context.restrictedTraverse("content_status_modify")( + workflow_action=workflow_action, + comment=comment, + effective_date=effective_date, + expiration_date=expiration_date, + ) + + if self.request.get("form.button.FolderPublish", None): + self.context.restrictedTraverse("folder_publish")( + workflow_action=workflow_action, + paths=paths, + comment=comment, + expiration_date=expiration_date, + effective_date=effective_date, + include_children=include_children, + ) + + return self.template() + + def validate(self, workflow_action=None, paths=[]): + if workflow_action is None: + self.errors["workflow_action"] = _("You must select a publishing action.") + + if not paths: + self.errors["paths"] = _("You must select content to change.") + # If there are no paths, it's mostly a mistake + # Set paths using orgi_paths, otherwise users are getting confused + orig_paths = self.request.get("orig_paths") + self.request.set("paths", orig_paths) + + def get_objects_from_path_list(self, paths=[]): + contents = [] + portal = getToolByName(self.context, "portal_url").getPortalObject() + for path in paths: + obj = portal.restrictedTraverse(str(path), None) + if obj is not None: + contents.append(obj) + return contents + + def redirect_to_referrer(self): + referer = self.request.get("HTTP_REFERER", "") + target_url = referer.split("?", 1)[0] + return self.request.RESPONSE.redirect(target_url) + + def isExpired(self, content): + return is_expired(content) + + def is_default_page(self, obj): + return is_default_page(aq_parent(self.context), obj) + + @deprecate( + "This method is deprecated since Plone 6, " + "use the @@plone/human_readable_size method instead" + ) + def human_readable_size(self, size): + return human_readable_size(size) diff --git a/src/plone/app/layout/content/browser/content_status_modify.py b/src/plone/app/layout/content/browser/content_status_modify.py new file mode 100644 index 00000000..ae64bbe1 --- /dev/null +++ b/src/plone/app/layout/content/browser/content_status_modify.py @@ -0,0 +1,165 @@ +from AccessControl import Unauthorized +from Acquisition import aq_inner +from Acquisition import aq_parent +from DateTime import DateTime +from plone.base import PloneMessageFactory as _ +from plone.base.defaultpage import check_default_page_via_view +from plone.base.utils import transaction_note +from plone.protect import CheckAuthenticator +from plone.registry.interfaces import IRegistry +from Products.CMFCore.utils import getToolByName +from Products.Five import BrowserView +from Products.statusmessages.interfaces import IStatusMessage +from ZODB.POSException import ConflictError +from zope.component import getMultiAdapter +from zope.component import getUtility + + +class ContentStatusModifyView(BrowserView): + """Handles the workflow transitions of objects. + + Former Controller Python Script "content_status_modify". + + [validators] + validators = validate_content_status_modify + + [actions] + action.failure=traverse_to:string:content_status_history + action.success=redirect_to_action:string:view + + """ + + def __call__( + self, + workflow_action=None, + comment="", + effective_date=None, + expiration_date=None, + **kwargs, + ): + """Do a workflow action. + + The status dropdown in the toolbar has links to for example: + content_status_modify?workflow_action=reject&_authenticator=secret + That is the main entry into this browser view. + + When you right-click the status menu and open in a new tab, + you end up on the content_status_history page. + This page contains a form which posts to this view. + + In the form you can select a workflow action. + When you select "no change" the workflow_action parameter actually contains + the current status. This status is naturally not in the list of allowed transitions. + So we should be lenient here, and not complain much. + + Also, when the view is called on a default page, + the code below tries to do the same transition on the parent folder, + by calling this view on the parent. This may easily fail. + This is yet another reason to be lenient. + Otherwise you may see both a successful portal status message + and one with an error. + + In the form you can also add a comment and set an effective and/or expiration date. + """ + context = aq_inner(self.context) + portal_workflow = getToolByName(context, "portal_workflow") + self.plone_utils = getToolByName(context, "plone_utils") + # First check if the main argument is given. + if not workflow_action: + IStatusMessage(self.request).add( + _("You must select a publishing action."), type="error" + ) + url = f"{context.absolute_url()}/content_status_history" + return self.request.response.redirect(url) + # If a workflow action was specified, there must be a plone.protect authenticator. + CheckAuthenticator(self.request) + + # Get effective and expiration dates from the form, if not set in the arguments. + form = self.request.form + if not effective_date: + effective_date = form.get("form.widgets.effective_date") or effective_date + if not expiration_date: + expiration_date = ( + form.get("form.widgets.expiration_date") or expiration_date + ) + + # Make sure an effective date is always set when there is a valid workflow action. + transitions = portal_workflow.getTransitionsFor(context) + transition_ids = [t["id"] for t in transitions] + if ( + workflow_action in transition_ids + and not effective_date + and context.EffectiveDate() == "None" + ): + effective_date = DateTime() + + # You can transition content but not have the permission to ModifyPortalContent. + contentEditSuccess = 0 + try: + self.editContent(context, effective_date, expiration_date) + contentEditSuccess = 1 + except Unauthorized: + pass + + # Create the note while we still have access to the original context + note = "Changed status of {} at {}".format( + context.title_or_id(), + context.absolute_url(), + ) + + if workflow_action in transition_ids: + # The action could result in a move or delete. + # In that case we get a new context as answer. + context = portal_workflow.doActionFor( + context, workflow_action, comment=comment + ) + if context is None: + # the normal case + context = aq_inner(self.context) + + # The object post-transition could now have ModifyPortalContent permission. + if not contentEditSuccess: + try: + self.editContent(context, effective_date, expiration_date) + except Unauthorized: + pass + + transaction_note(note) + + # If this item is the default page in its parent, attempt to publish that + # too. It may not be possible, of course + if check_default_page_via_view(context, self.request): + parent = aq_parent(context) + try: + parent_modify_view = getMultiAdapter( + (parent, self.request), name="content_status_modify" + ) + parent_modify_view( + workflow_action, + comment, + effective_date=effective_date, + expiration_date=expiration_date, + ) + except ConflictError: + raise + except Exception: + pass + + IStatusMessage(self.request).add(_("Item state changed.")) + registry = getUtility(IRegistry) + url = context.absolute_url() + if context.portal_type in registry.get( + "plone.types_use_view_action_in_listings", () + ): + url += "/view" + return self.request.response.redirect(url) + + def editContent(self, obj, effective, expiry): + kwargs = {} + # may contain the year + if effective and (isinstance(effective, DateTime) or len(effective) > 5): + kwargs["effective_date"] = effective + # may contain the year + if expiry and (isinstance(expiry, DateTime) or len(expiry) > 5): + kwargs["expiration_date"] = expiry + self.plone_utils.contentEdit(obj, **kwargs) diff --git a/src/plone/app/layout/content/browser/contents/__init__.py b/src/plone/app/layout/content/browser/contents/__init__.py new file mode 100644 index 00000000..10a9c5b9 --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/__init__.py @@ -0,0 +1,388 @@ +from AccessControl import Unauthorized +from Acquisition import aq_inner +from Acquisition import aq_parent +from plone.app.layout.content.browser.file import TUS_ENABLED +from plone.app.layout.content.browser.interfaces import IFolderContentsView +from plone.app.content.interfaces import IStructureAction +from plone.app.content.utils import json_dumps +from plone.app.content.utils import json_loads +from plone.app.uuid.utils import uuidToCatalogBrain +from plone.base import PloneMessageFactory as _ +from plone.base import utils +from plone.base.interfaces.controlpanel import ISiteSchema +from plone.protect.postonly import check as checkpost +from plone.registry.interfaces import IRegistry +from plone.uuid.interfaces import IUUID +from Products.CMFCore.utils import getToolByName +from Products.Five import BrowserView +from Products.PortalTransforms.transforms.safe_html import SafeHTML +from zope.browsermenu.interfaces import IBrowserMenu +from zope.component import getMultiAdapter +from zope.component import getUtilitiesFor +from zope.component import getUtility +from zope.i18n import translate +from zope.interface import implementer +from zope.schema.interfaces import IVocabularyFactory + + +class ContentsBaseAction(BrowserView): + success_msg = _("Success") + failure_msg = _("Failure") + required_obj_permission = None + + @property + def site(self): + return utils.get_top_site_from_url(self.context, self.request) + + def objectTitle(self, obj): + context = aq_inner(obj) + title = utils.pretty_title_or_id(context, context) + return utils.safe_text(title) + + def protect(self): + authenticator = getMultiAdapter( + (self.context, self.request), name="authenticator" + ) + if not authenticator.verify(): + raise Unauthorized + checkpost(self.request) + + def json(self, data): + self.request.response.setHeader( + "Content-Type", "application/json; charset=utf-8" + ) + return json_dumps(data) + + def get_selection(self): + selection = self.request.form.get("selection", "[]") + return json_loads(selection) + + def action(self, obj): + """ + fill in this method to do action against each item in the selection + """ + pass + + def finish(self): + pass + + def __call__(self, keep_selection_order=False): + self.protect() + self.errors = [] + context = aq_inner(self.context) + selection = self.get_selection() + + parts = str(self.request.form.get("folder", "").lstrip("/")).split("/") + if parts: + parent = self.site.unrestrictedTraverse("/".join(parts[:-1])) + self.dest = parent.restrictedTraverse(parts[-1]) + + self.catalog = getToolByName(context, "portal_catalog") + self.mtool = getToolByName(self.context, "portal_membership") + + brains = [] + if keep_selection_order: + brains = [uuidToCatalogBrain(uid) for uid in selection] + else: + brains = self.catalog(UID=selection, show_inactive=True) + + for brain in brains: + if not brain: + continue + # remove everyone so we know if we missed any + selection.remove(brain.UID) + obj = brain.getObject() + if self.required_obj_permission and not self.mtool.checkPermission( + self.required_obj_permission, obj + ): + self.errors.append( + _( + 'Permission denied for "${title}"', + mapping={"title": self.objectTitle(obj)}, + ) + ) + continue + self.action(obj) + + self.finish() + return self.message(selection) + + def message(self, missing=[]): + if len(missing) > 0: + self.errors.append( + _("${items} could not be found", mapping={"items": str(len(missing))}) + ) + if self.errors: + msg = self.failure_msg + else: + msg = self.success_msg + + translated_msg = translate(msg, context=self.request) + if self.errors: + translated_errors = [ + translate(error, context=self.request) for error in self.errors + ] + translated_msg = "{:s}: {:s}".format( + translated_msg, "\n".join(translated_errors) + ) + + return self.json( + {"status": "warning" if self.errors else "success", "msg": translated_msg} + ) + + +@implementer(IFolderContentsView) +class FolderContentsView(BrowserView): + def get_actions(self): + actions = [] + for name, Utility in getUtilitiesFor(IStructureAction): + utility = Utility(self.context, self.request) + actions.append(utility) + actions.sort(key=lambda a: a.order) + return [a.get_options() for a in actions] + + @property + def ignored_columns(self): + """Return columns, which should be ignored in folder contents.""" + # These columns either have alternatives or are probably not useful + ignored = [ + "Date", + "Title", + "author_name", + "cmf_uid", + "commentators", + "created", + "effective", + "expires", + "getIcon", + "getMimeIcon", + "getId", + "getRemoteUrl", + "in_response_to", + "listCreators", + "meta_type", + "modified", + "portal_type", + "sync_uid", + ] + return ignored + + def get_columns(self): + columns = {} + voc = getUtility(IVocabularyFactory, "plone.app.vocabularies.MetadataFields")( + self.context + ) + for term in voc: + if term.value not in self.ignored_columns: + columns[term.value] = translate(term.title, context=self.request) + + return columns + + def get_thumb_scale(self): + registry = getUtility(IRegistry) + settings = registry.forInterface(ISiteSchema, prefix="plone", check=False) + if settings.no_thumbs_tables: + # thumbs to be suppressed + return None + thumb_scale_table = settings.thumb_scale_table + return thumb_scale_table + + def default_page_types(self): + registry = getUtility(IRegistry) + return registry.get("plone.default_page_types", []) + + @property + def ignored_indexes(self): + ignored = [ + "Date", + "Description", + "Title", + "allowedRolesAndUsers", + "author_name", + "cmf_uid", + "commentators", + "effectiveRange", + "getId", + "getObjectPositionInParent", + "getRawRelatedItems", + "in_reply_to", + "meta_type", + "object_provides", + "portal_type", + "SearchableText", + "sync_uid", + ] + return ignored + + def get_indexes(self): + # Base set of indexes + indexes = { + "created": translate(_("Created on"), context=self.request), + "Creator": translate(_("Creator"), context=self.request), + "effective": translate(_("Publication date"), context=self.request), # noqa + "end": translate(_("End Date"), context=self.request), + "expires": translate(_("Expiration date"), context=self.request), + "id": translate(_("ID"), context=self.request), + "is_folderish": translate(_("Folder"), context=self.request), + "modified": translate(_("Last modified"), context=self.request), # noqa + "review_state": translate(_("Review state"), context=self.request), + "sortable_title": translate(_("Title"), context=self.request), + "start": translate(_("Start Date"), context=self.request), + "Subject": translate(_("Tags"), context=self.request), + "total_comments": translate( + _("Total comments"), context=self.request + ), # noqa + "Type": translate(_("Type"), context=self.request), + } + # Filter out ignored + indexes = {k: v for k, v in indexes.items() if k not in self.ignored_indexes} + # Add in extra metadata indexes + catalog = getToolByName(self.context, "portal_catalog") + cat_indexes = [idx for idx in catalog.indexes()] + for index in cat_indexes: + if index not in indexes and index not in self.ignored_indexes: + indexes[index] = translate(_(index), context=self.request) + return indexes + + def get_options(self): + site = utils.get_top_site_from_url(self.context, self.request) + base_url = site.absolute_url() + base_vocabulary = "%s/@@getVocabulary?name=" % base_url + site_path = site.getPhysicalPath() + context_path = self.context.getPhysicalPath() + columns = self.get_columns() + options = { + "vocabularyUrl": "%splone.app.vocabularies.Catalog" % (base_vocabulary), + "urlStructure": {"base": base_url, "appended": "/folder_contents"}, + "moveUrl": "%s{path}/fc-itemOrder" % base_url, + "indexOptionsUrl": "%s/@@qsOptions" % base_url, + "contextInfoUrl": "%s{path}/@@fc-contextInfo" % base_url, + "setDefaultPageUrl": "%s{path}/@@fc-setDefaultPage" % base_url, + "defaultPageTypes": self.default_page_types(), + "searchParam": "Title", + "availableColumns": columns, + "attributes": [ + "Title", + "path", + "getURL", + "getIcon", + "getMimeIcon", + "portal_type", + ] + + list(columns.keys()), # noqa + "buttons": self.get_actions(), + "rearrange": { + "properties": self.get_indexes(), + "url": "%s{path}/@@fc-rearrange" % base_url, + }, + "basePath": "/" + "/".join(context_path[len(site_path) :]), + "upload": { + "relativePath": "@@fileUpload", + "baseUrl": base_url, + "initialFolder": IUUID(self.context, None), + "useTus": TUS_ENABLED, + }, + "thumb_scale": self.get_thumb_scale(), + } + return options + + def __call__(self): + self.options = json_dumps(self.get_options()) + return super().__call__() + + +class ContextInfo(BrowserView): + attributes = [ + "CreationDate", + "Creator", + "Description", + "EffectiveDate", + "end", + "exclude_from_nav", + "getObjSize", + "getURL", + "id", + "is_folderish", + "last_comment_date", + "location", + "ModificationDate", + "path", + "portal_type", + "review_state", + "start", + "Subject", + "Title", + "total_comments", + "Type", + "UID", + ] + + def __call__(self): + factories_menu = getUtility( + IBrowserMenu, name="plone_contentmenu_factory", context=self.context + ).getMenuItems(self.context, self.request) + factories = [] + for item in factories_menu: + if item.get("title") == "folder_add_settings": + continue + title = item.get("title", "") + factories.append( + { + "id": item.get("id"), + "title": title + and translate(title, context=self.request) + or "", # noqa + "action": item.get("action"), + } + ) + + context = aq_inner(self.context) + transform = SafeHTML() + crumbs = [] + top_site = utils.get_top_site_from_url(self.context, self.request) + while not context == top_site: + crumbs.append( + { + "id": context.getId(), + "title": transform.scrub_html( + utils.pretty_title_or_id(context, context) + ), + } + ) + context = aq_parent(aq_inner(context)) + + catalog = getToolByName(self.context, "portal_catalog") + try: + brains = catalog(UID=IUUID(self.context), show_inactive=True) + except TypeError: + brains = [] + item = None + if len(brains) > 0: + obj = brains[0] + # context here should be site root + base_path = "/".join(context.getPhysicalPath()) + item = {} + for attr in self.attributes: + key = attr + if key == "path": + attr = "getPath" + val = getattr(obj, attr, None) + if callable(val): + val = val() + if key == "path": + val = val[len(base_path) :] + if isinstance(val, (bytes, str)): + val = transform.scrub_html(val) + item[key] = val + + self.request.response.setHeader( + "Content-Type", "application/json; charset=utf-8" + ) + return json_dumps( + { + "addButtons": factories, + "defaultPage": self.context.getDefaultPage(), + "breadcrumbs": [c for c in reversed(crumbs)], + "object": item, + } + ) diff --git a/src/plone/app/layout/content/browser/contents/configure.zcml b/src/plone/app/layout/content/browser/contents/configure.zcml new file mode 100644 index 00000000..931d4b90 --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/configure.zcml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/plone/app/layout/content/browser/contents/copy.py b/src/plone/app/layout/content/browser/contents/copy.py new file mode 100644 index 00000000..76b0ffd7 --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/copy.py @@ -0,0 +1,56 @@ +from OFS.CopySupport import _cb_encode +from OFS.CopySupport import cookie_path +from OFS.Moniker import Moniker +from plone.app.layout.content.browser.contents import ContentsBaseAction +from plone.app.content.interfaces import IStructureAction +from plone.base import PloneMessageFactory as _ +from zope.i18n import translate +from zope.interface import implementer + + +@implementer(IStructureAction) +class CopyAction: + order = 2 + + def __init__(self, context, request): + self.context = context + self.request = request + + def get_options(self): + return { + "tooltip": translate(_("Copy"), context=self.request), + "id": "copy", + "icon": "plone-copy", + "url": self.context.absolute_url() + "/@@fc-copy", + } + + +class CopyActionView(ContentsBaseAction): + success_msg = _("Successfully copied items") + failure_msg = _("Failed to copy items") + + def action(self, obj): + self.oblist.append(obj) + + def finish(self): + oblist = [] + for ob in self.oblist: + if not ob.cb_isCopyable(): + self.errors.append( + _( + "${title} cannot be copied.", + mapping={"title": self.objectTitle(ob)}, + ) + ) + continue + m = Moniker(ob) + oblist.append(m.dump()) + cp = (0, oblist) + cp = _cb_encode(cp) + resp = self.request.response + resp.setCookie("__cp", cp, path="%s" % cookie_path(self.request)) + self.request["__cp"] = cp + + def __call__(self): + self.oblist = [] + return super().__call__(keep_selection_order=True) diff --git a/src/plone/app/layout/content/browser/contents/cut.py b/src/plone/app/layout/content/browser/contents/cut.py new file mode 100644 index 00000000..b3e54fe1 --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/cut.py @@ -0,0 +1,74 @@ +from OFS.CopySupport import _cb_encode +from OFS.CopySupport import cookie_path +from OFS.Moniker import Moniker +from plone.app.layout.content.browser.contents import ContentsBaseAction +from plone.app.content.interfaces import IStructureAction +from plone.base import PloneMessageFactory as _ +from plone.locking.interfaces import ILockable +from zope.i18n import translate +from zope.interface import implementer + + +@implementer(IStructureAction) +class CutAction: + order = 1 + + def __init__(self, context, request): + self.context = context + self.request = request + + def get_options(self): + return { + "tooltip": translate(_("Cut"), context=self.request), + "id": "cut", + "icon": "plone-cut", + "url": self.context.absolute_url() + "/@@fc-cut", + } + + +class CutActionView(ContentsBaseAction): + success_msg = _("Successfully cut items") + failure_msg = _("Failed to cut items") + + def action(self, obj): + self.oblist.append(obj) + + def finish(self): + oblist = [] + for ob in self.oblist: + try: + lock_info = ob.restrictedTraverse("@@plone_lock_info") + except AttributeError: + lock_info = None + if lock_info is not None: + if lock_info.is_locked_for_current_user(): + self.errors.append( + _( + "${title} is being edited and cannot be cut.", + mapping={"title": self.objectTitle(ob)}, + ) + ) + continue + elif lock_info.is_locked(): + # unlock object as it is locked by current user + ILockable(ob).unlock() + + if not ob.cb_isMoveable(): + self.errors.append( + _( + "${title} is being edited and cannot be cut.", + mapping={"title": self.objectTitle(ob)}, + ) + ) + continue + m = Moniker(ob) + oblist.append(m.dump()) + cp = (1, oblist) + cp = _cb_encode(cp) + resp = self.request.response + resp.setCookie("__cp", cp, path="%s" % cookie_path(self.request)) + self.request["__cp"] = cp + + def __call__(self): + self.oblist = [] + return super().__call__() diff --git a/src/plone/app/layout/content/browser/contents/defaultpage.py b/src/plone/app/layout/content/browser/contents/defaultpage.py new file mode 100644 index 00000000..006ecea1 --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/defaultpage.py @@ -0,0 +1,22 @@ +from plone.app.layout.content.browser.contents import ContentsBaseAction +from plone.base import PloneMessageFactory as _ + + +class SetDefaultPageActionView(ContentsBaseAction): + success_msg = _("Default page set successfully") + failure_msg = _("Failed to set default page") + + def __call__(self): + cid = self.request.form.get("id") + self.errors = [] + + if cid not in self.context.objectIds(): + self.errors.append( + _( + "There is no object with short name " "${name} in this folder.", + mapping={"name": cid}, + ) + ) + else: + self.context.setDefaultPage(cid) + return self.message() diff --git a/src/plone/app/layout/content/browser/contents/delete.py b/src/plone/app/layout/content/browser/contents/delete.py new file mode 100644 index 00000000..9a5f6e1a --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/delete.py @@ -0,0 +1,94 @@ +from AccessControl import Unauthorized +from AccessControl.Permissions import delete_objects +from plone.app.layout.content.browser.contents import ContentsBaseAction +from plone.app.content.interfaces import IStructureAction +from plone.base import PloneMessageFactory as _ +from plone.locking.interfaces import ILockable +from Products.CMFCore.utils import getToolByName +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from zope.component import getMultiAdapter +from zope.component.hooks import getSite +from zope.i18n import translate +from zope.interface import implementer + +import json + + +@implementer(IStructureAction) +class DeleteAction: + template = ViewPageTemplateFile("templates/delete.pt") + order = 4 + + def __init__(self, context, request): + self.context = context + self.request = request + + def get_options(self): + return { + "tooltip": translate(_("Delete"), context=self.request), + "id": "delete", + "icon": "plone-delete", + "context": "danger", + "url": self.context.absolute_url() + "/@@fc-delete", + "form": { + "title": translate(_("Delete selected items"), context=self.request), + "submitText": translate(_("Yes"), context=self.request), + "submitContext": "danger", + "template": self.template(), + "closeText": translate(_("No"), context=self.request), + "dataUrl": self.context.absolute_url() + "/@@fc-delete", + }, + } + + +class DeleteActionView(ContentsBaseAction): + required_obj_permission = delete_objects + success_msg = _("Successfully delete items") + failure_msg = _("Failed to delete items") + + def __call__(self): + if self.request.form.get("render") == "yes": + confirm_view = getMultiAdapter( + (getSite(), self.request), name="delete_confirmation_info" + ) + selection = self.get_selection() + catalog = getToolByName(self.context, "portal_catalog") + brains = catalog(UID=selection, show_inactive=True) + items = [i.getObject() for i in brains] + self.request.response.setHeader( + "Content-Type", "application/json; charset=utf-8" + ) + return json.dumps({"html": confirm_view(items)}) + else: + return super().__call__() + + def action(self, obj): + parent = obj.aq_inner.aq_parent + title = self.objectTitle(obj) + + try: + lock_info = obj.restrictedTraverse("@@plone_lock_info") + except AttributeError: + lock_info = None + if lock_info is not None: + if lock_info.is_locked_for_current_user(): + self.errors.append( + _( + "${title} is locked and cannot be deleted.", + mapping={"title": title}, + ) + ) + return + elif lock_info.is_locked(): + # unlock object as it is locked by current user + ILockable(obj).unlock() + + try: + parent.manage_delObjects(obj.getId()) + except Unauthorized: + self.errors.append( + _( + "You are not authorized to delete ${title}.", + mapping={"title": self.objectTitle(self.dest)}, + ) + ) diff --git a/src/plone/app/layout/content/browser/contents/paste.py b/src/plone/app/layout/content/browser/contents/paste.py new file mode 100644 index 00000000..e6cb307a --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/paste.py @@ -0,0 +1,66 @@ +from AccessControl import Unauthorized +from plone.app.layout.content.browser.contents import ContentsBaseAction +from plone.app.content.interfaces import IStructureAction +from plone.base import PloneMessageFactory as _ +from ZODB.POSException import ConflictError +from zope.i18n import translate +from zope.interface import implementer + + +@implementer(IStructureAction) +class PasteAction: + order = 3 + + def __init__(self, context, request): + self.context = context + self.request = request + + def get_options(self): + return { + "tooltip": translate(_("Paste"), context=self.request), + "id": "paste", + "icon": "plone-paste", + "url": self.context.absolute_url() + "/@@fc-paste", + } + + +class PasteActionView(ContentsBaseAction): + required_obj_permission = "Copy or Move" + success_msg = _("Successfully pasted items") + failure_msg = _("Failed to paste items") + + def __call__(self): + self.protect() + self.errors = [] + + parts = str(self.request.form["folder"].lstrip("/")).split("/") + parent = self.site.unrestrictedTraverse("/".join(parts[:-1])) + self.dest = parent.restrictedTraverse(parts[-1]) + + try: + self.dest.manage_pasteObjects(self.request["__cp"]) + except ConflictError: + raise + except Unauthorized: + # avoid this unfriendly exception text: + # "You are not allowed to access 'manage_pasteObjects' in this + # context" + self.errors.append( + _( + "You are not authorized to paste ${title} here.", + mapping={"title": self.objectTitle(self.dest)}, + ) + ) + except ValueError as e: + if "Disallowed subobject type: " in e.args[0]: + msg_parts = e.args[0].split(":") + self.errors.append( + _( + 'Disallowed subobject type "${type}"', + mapping={"type": msg_parts[1].strip()}, + ) + ) + else: + raise e + + return self.message() diff --git a/src/plone/app/layout/content/browser/contents/properties.py b/src/plone/app/layout/content/browser/contents/properties.py new file mode 100644 index 00000000..9d087a3c --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/properties.py @@ -0,0 +1,118 @@ +from DateTime import DateTime +from plone.app.layout.content.browser.contents import ContentsBaseAction +from plone.app.content.interfaces import IStructureAction +from plone.app.dexterity.behaviors.metadata import ICategorization +from plone.app.z3cform.widgets.datetime import get_date_options +from plone.base import PloneMessageFactory as _ +from plone.base.defaultpage import check_default_page_via_view +from Products.CMFCore.interfaces._content import IFolderish +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from zope.component import getUtility +from zope.component.hooks import getSite +from zope.i18n import translate +from zope.interface import implementer +from zope.schema.interfaces import IVocabularyFactory + +import json + + +@implementer(IStructureAction) +class PropertiesAction: + template = ViewPageTemplateFile("templates/properties.pt") + order = 8 + + def __init__(self, context, request): + self.context = context + self.request = request + + def get_options(self): + base_vocabulary = "%s/@@getVocabulary?name=" % getSite().absolute_url() + return { + "tooltip": translate(_("Properties"), context=self.request), + "id": "properties", + "icon": "plone-edit", + "url": self.context.absolute_url() + "/@@fc-properties", + "form": { + "title": translate( + _("Modify properties on items"), context=self.request + ), + "template": self.template( + vocabulary_url="%splone.app.vocabularies.Users" % (base_vocabulary), + pattern_options=json.dumps(get_date_options(self.request)), + ), + "dataUrl": self.context.absolute_url() + "/@@fc-properties", + }, + } + + +class PropertiesActionView(ContentsBaseAction): + success_msg = _("Successfully updated metadata") + failure_msg = _("Failure updating metadata") + required_obj_permission = "Modify portal content" + + def __call__(self): + if self.request.form.get("render") == "yes": + lang_factory = getUtility( + IVocabularyFactory, "plone.app.vocabularies.SupportedContentLanguages" + ) + lang_vocabulary = lang_factory(self.context) + languages = [ + {"title": term.title, "value": term.value} for term in lang_vocabulary + ] + return self.json( + { + "languages": [ + { + "title": translate( + _("label_no_change", default="No change"), + context=self.request, + ), + "value": "", + } + ] + + languages + } + ) + + self.effectiveDate = self.request.form.get("effectiveDate") + self.expirationDate = self.request.form.get("expirationDate") + self.copyright = self.request.form.get("copyright") + self.contributors = self.request.form.get("contributors") + if self.contributors: + self.contributors = self.contributors.split(",") + else: + self.contributors = [] + self.creators = self.request.form.get("creators", "") + if self.creators: + self.creators = self.creators.split(",") + self.exclude = self.request.form.get("exclude-from-nav") + self.language = self.request.form.get("language") + self.recurse = self.request.form.get("recurse", "no") == "yes" + return super().__call__() + + def action(self, obj, bypass_recurse=False): + if check_default_page_via_view(obj, self.request): + self.action(obj.aq_parent, bypass_recurse=True) + recurse = self.recurse and not bypass_recurse + if recurse and IFolderish.providedBy(obj): + for sub in obj.values(): + self.action(sub) + + if self.effectiveDate and hasattr(obj, "effective_date"): + obj.effective_date = DateTime(self.effectiveDate) + if self.expirationDate and hasattr(obj, "expiration_date"): + obj.expiration_date = DateTime(self.expirationDate) + if self.copyright and hasattr(obj, "rights"): + obj.rights = self.copyright + if self.contributors and hasattr(obj, "contributors"): + obj.contributors = tuple(self.contributors) + if self.creators and hasattr(obj, "creators"): + obj.creators = tuple(self.creators) + if self.exclude and hasattr(obj, "exclude_from_nav"): + obj.exclude_from_nav = self.exclude == "yes" + + behavior_categorization = ICategorization(obj) + if self.language and behavior_categorization: + behavior_categorization.language = self.language + + obj.reindexObject() diff --git a/src/plone/app/layout/content/browser/contents/rearrange.py b/src/plone/app/layout/content/browser/contents/rearrange.py new file mode 100644 index 00000000..e1e3ce3d --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/rearrange.py @@ -0,0 +1,88 @@ +from OFS.interfaces import IOrderedContainer +from plone.app.layout.content.browser.contents import ContentsBaseAction +from plone.app.content.utils import json_loads +from plone.base import PloneMessageFactory as _ +from plone.base.interfaces import IPloneSiteRoot +from plone.folder.interfaces import IExplicitOrdering +from Products.CMFCore.utils import getToolByName + + +class OrderContentsBaseAction(ContentsBaseAction): + def getOrdering(self): + if IPloneSiteRoot.providedBy(self.context): + return self.context + try: + if self.context.aq_base.getOrdering(): + ordering = self.context.getOrdering() + else: + return None + except AttributeError: + if IOrderedContainer.providedBy(self.context): + # Archetype + return IOrderedContainer(self.context) + return None + if not IExplicitOrdering.providedBy(ordering): + return None + return ordering + + +class ItemOrderActionView(OrderContentsBaseAction): + success_msg = _("Successfully moved item") + failure_msg = _("Error moving item") + + def __call__(self): + self.errors = [] + self.protect() + id = self.request.form.get("id") + ordering = self.getOrdering() + + if ordering is None: + self.errors.append(_("This folder does not support ordering")) + return self.message() + + delta = self.request.form["delta"] + + if delta == "top": + ordering.moveObjectsToTop([id]) + return self.message() + + if delta == "bottom": + ordering.moveObjectsToBottom([id]) + return self.message() + + delta = int(delta) + subset_ids = json_loads(self.request.form.get("subsetIds", "null")) + if subset_ids: + position_id = [(ordering.getObjectPosition(i), i) for i in subset_ids] + position_id.sort() + if subset_ids != [i for position, i in position_id]: + self.errors.append(_("Client/server ordering mismatch")) + return self.message() + + ordering.moveObjectsByDelta([id], delta, subset_ids) + return self.message() + + +class RearrangeActionView(OrderContentsBaseAction): + success_msg = _("Successfully rearranged folder") + failure_msg = _("Can not rearrange folder") + + def __call__(self): + self.protect() + self.errors = [] + ordering = self.getOrdering() + if ordering: + catalog = getToolByName(self.context, "portal_catalog") + query = { + "path": {"query": "/".join(self.context.getPhysicalPath()), "depth": 1}, + "sort_on": self.request.form.get("rearrange_on"), + "show_inactive": True, + } + brains = catalog(**query) + if self.request.form.get("reversed") == "true": + brains = [b for b in reversed(brains)] + for idx, brain in enumerate(brains): + ordering.moveObjectToPosition(brain.id, idx) + else: + self.errors.append(_("Not explicit orderable")) + return self.message() diff --git a/src/plone/app/layout/content/browser/contents/rename.py b/src/plone/app/layout/content/browser/contents/rename.py new file mode 100644 index 00000000..fcc232ff --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/rename.py @@ -0,0 +1,114 @@ +from AccessControl import getSecurityManager +from Acquisition import aq_inner +from Acquisition import aq_parent +from plone.app.layout.content.browser.contents import ContentsBaseAction +from plone.app.content.interfaces import IStructureAction +from plone.base import PloneMessageFactory as _ +from Products.CMFCore.utils import getToolByName +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from ZODB.POSException import ConflictError +from zope.component import getMultiAdapter +from zope.container.interfaces import INameChooser +from zope.event import notify +from zope.i18n import translate +from zope.interface import implementer +from zope.lifecycleevent import ObjectModifiedEvent + +import logging +import transaction + +logger = logging.getLogger("plone.app.content") + + +@implementer(IStructureAction) +class RenameAction: + template = ViewPageTemplateFile("templates/rename.pt") + order = 5 + + def __init__(self, context, request): + self.context = context + self.request = request + + def get_options(self): + return { + "tooltip": translate(_("Rename"), context=self.request), + "id": "rename", + "icon": "plone-rename", + "url": self.context.absolute_url() + "/@@fc-rename", + "form": { + "title": translate(_("Rename"), context=self.request), + "template": self.template(), + }, + } + + +class RenameActionView(ContentsBaseAction): + success_msg = _("Items renamed") + failure_msg = _("Failed to rename all items") + + def __call__(self): + self.errors = [] + self.protect() + context = aq_inner(self.context) + + catalog = getToolByName(context, "portal_catalog") + mtool = getToolByName(context, "portal_membership") + + missing = [] + for key in self.request.form.keys(): + if not key.startswith("UID_"): + continue + index = key.split("_")[-1] + uid = self.request.form[key] + brains = catalog(UID=uid, show_inactive=True) + if len(brains) == 0: + missing.append(uid) + continue + obj = brains[0].getObject() + title = self.objectTitle(obj) + if not mtool.checkPermission("Copy or Move", obj): + self.errors.append( + _("Permission denied to rename ${title}.", mapping={"title": title}) + ) + continue + + sp = transaction.savepoint(optimistic=True) + + newid = self.request.form["newid_" + index] + newtitle = self.request.form["newtitle_" + index] + try: + obid = obj.getId() + title = obj.Title() + change_title = newtitle and title != newtitle + if change_title: + getSecurityManager().validate(obj, obj, "setTitle", obj.setTitle) + obj.setTitle(newtitle) + notify(ObjectModifiedEvent(obj)) + if newid and obid != newid: + parent = aq_parent(aq_inner(obj)) + # Make sure newid is safe + newid = INameChooser(parent).chooseName(newid, obj) + # Update the default_page on the parent. + context_state = getMultiAdapter( + (obj, self.request), name="plone_context_state" + ) + if context_state.is_default_page(): + parent.setDefaultPage(newid) + parent.manage_renameObjects((obid,), (newid,)) + elif change_title: + # the rename will have already triggered a reindex + obj.reindexObject() + except ConflictError: + raise + except Exception as e: + sp.rollback() + logger.error( + 'Error renaming "{title}": "{exception}"'.format( + title=title, exception=e + ) + ) + self.errors.append( + _("Error renaming ${title}", mapping={"title": title}) + ) + + return self.message(missing) diff --git a/src/plone/app/layout/content/browser/contents/tags.py b/src/plone/app/layout/content/browser/contents/tags.py new file mode 100644 index 00000000..a61af220 --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/tags.py @@ -0,0 +1,56 @@ +from plone.app.layout.content.browser.contents import ContentsBaseAction +from plone.app.content.interfaces import IStructureAction +from plone.base import PloneMessageFactory as _ +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from zope.component.hooks import getSite +from zope.i18n import translate +from zope.interface import implementer + + +@implementer(IStructureAction) +class TagsAction: + template = ViewPageTemplateFile("templates/tags.pt") + order = 6 + + def __init__(self, context, request): + self.context = context + self.request = request + + def get_options(self): + base_vocabulary = "%s/@@getVocabulary?name=" % getSite().absolute_url() + return { + "tooltip": translate(_("Tags"), context=self.request), + "id": "tags", + "icon": "tags", + "url": self.context.absolute_url() + "/@@fc-tags", + "form": { + "title": translate(_("Tags"), context=self.request), + "template": self.template( + vocabulary_url="%splone.app.vocabularies.Keywords" + % (base_vocabulary) + ), + }, + } + + +class TagsActionView(ContentsBaseAction): + required_obj_permission = "Modify portal content" + success_msg = _("Successfully updated tags on items") + failure_msg = _("Failed to modify tags on items") + + def action(self, obj): + toadd = self.request.form.get("toadd") + if toadd: + toadd = set(toadd.split(",")) + else: + toadd = set() + toremove = self.request.get("toremove") + if toremove: + toremove = set(toremove.split(",")) + else: + toremove = set() + tags = set(obj.Subject()) + tags = tags - toremove + tags = tags | toadd + obj.setSubject(list(tags)) + obj.reindexObject() diff --git a/src/plone/app/layout/content/browser/contents/templates/delete.pt b/src/plone/app/layout/content/browser/contents/templates/delete.pt new file mode 100644 index 00000000..9a368191 --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/templates/delete.pt @@ -0,0 +1,6 @@ +
+ <% try{ %> + <%= data.html || '' %> + <% }catch(e){} %> + +
\ No newline at end of file diff --git a/src/plone/app/layout/content/browser/contents/templates/folder_contents.pt b/src/plone/app/layout/content/browser/contents/templates/folder_contents.pt new file mode 100644 index 00000000..485eaf0d --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/templates/folder_contents.pt @@ -0,0 +1,28 @@ + + + + + + + + + + + +
+ + + + + + diff --git a/src/plone/app/layout/content/browser/contents/templates/properties.pt b/src/plone/app/layout/content/browser/contents/templates/properties.pt new file mode 100644 index 00000000..3da061fb --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/templates/properties.pt @@ -0,0 +1,65 @@ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ + +
+
+ + <% 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 00000000..5ddb0751 --- /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 00000000..f1a35b1c --- /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 00000000..e9a32cd6 --- /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 00000000..1d4df1cb --- /dev/null +++ b/src/plone/app/layout/content/browser/contents/workflow.py @@ -0,0 +1,94 @@ +from DateTime import DateTime +from plone.app.layout.content.browser.contents import ContentsBaseAction +from plone.app.content.interfaces import IStructureAction +from plone.base import PloneMessageFactory as _ +from plone.base.defaultpage import check_default_page_via_view +from plone.base.utils import safe_text +from Products.CMFCore.interfaces._content import IFolderish +from Products.CMFCore.utils import getToolByName +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from ZODB.POSException import ConflictError +from zope.i18n import translate +from zope.interface import implementer + + +@implementer(IStructureAction) +class WorkflowAction: + template = ViewPageTemplateFile("templates/workflow.pt") + order = 7 + + def __init__(self, context, request): + self.context = context + self.request = request + + def get_options(self): + return { + "tooltip": translate(_("State"), context=self.request), + "id": "workflow", + "icon": "plone-lock", + "url": self.context.absolute_url() + "/@@fc-workflow", + "form": { + "title": translate( + _("Change workflow of selected items"), context=self.request + ), + "template": self.template(), + "dataUrl": self.context.absolute_url() + "/@@fc-workflow", + }, + } + + +class WorkflowActionView(ContentsBaseAction): + required_obj_permission = "Modify portal content" + success_msg = _("Successfully modified items") + failure_msg = _("Failed to modify items") + + def __call__(self): + self.pworkflow = getToolByName(self.context, "portal_workflow") + self.transition_id = self.request.form.get("transition", None) + self.comments = self.request.form.get("comments", "") + self.recurse = self.request.form.get("recurse", "no") == "yes" + if self.request.form.get("render") == "yes": + # asking for render information + selection = self.get_selection() + catalog = getToolByName(self.context, "portal_catalog") + brains = catalog(UID=selection, show_inactive=True) + transitions = [] + for brain in brains: + obj = brain.getObject() + for transition in self.pworkflow.getTransitionsFor(obj): + tdata = { + "id": transition["id"], + "title": self.context.translate(safe_text(transition["name"])), + } + if tdata not in transitions: + transitions.append(tdata) + return self.json({"transitions": transitions}) + return super().__call__() + + def action(self, obj, bypass_recurse=False): + transitions = self.pworkflow.getTransitionsFor(obj) + if self.transition_id in [t["id"] for t in transitions]: + try: + # set effective date if not already set + if obj.EffectiveDate() == "None": + obj.setEffectiveDate(DateTime()) + + self.pworkflow.doActionFor( + obj, self.transition_id, comment=self.comments + ) + if check_default_page_via_view(obj, self.request): + self.action(obj.aq_parent, bypass_recurse=True) + recurse = self.recurse and not bypass_recurse + if recurse and IFolderish.providedBy(obj): + for sub in obj.values(): + self.action(sub) + obj.reindexObject() + except ConflictError: + raise + except Exception: + self.errors.append( + _( + "Could not transition: ${title}", + mapping={"title": self.objectTitle(obj)}, + ) + ) diff --git a/src/plone/app/layout/content/browser/file.py b/src/plone/app/layout/content/browser/file.py new file mode 100644 index 00000000..ff1dca5e --- /dev/null +++ b/src/plone/app/layout/content/browser/file.py @@ -0,0 +1,207 @@ +from AccessControl import getSecurityManager +from OFS.interfaces import IFolder +from plone.app.dexterity.interfaces import IDXFileFactory +from plone.base.permissions import AddPortalContent +from plone.uuid.interfaces import IUUID +from Products.CMFCore.utils import getToolByName +from Products.Five.browser import BrowserView + +import json +import logging +import mimetypes +import os + +logger = logging.getLogger("plone") + + +def _bool(val): + if val.lower() in ("t", "true", "1", "on"): + return True + return False + + +def _tus_int(val): + try: + return int(val) + except (ValueError, TypeError, AttributeError): + return 60 * 60 # default here... + + +possible_tus_options = { + "tmp_file_dir": str, + "send_file": _bool, + "upload_valid_duration": _tus_int, +} + +TUS_ENABLED = False +if os.environ.get("TUS_ENABLED"): + # tus resumable upload standard, see http://tus.io + try: + from tus import Tus + from tus import Zope2RequestAdapter + + tus_settings = {} + for option, converter in possible_tus_options.items(): + name = "TUS_%s" % option.upper() + if name in os.environ: + tus_settings[option] = converter(os.environ[name]) + + tmp_file_dir = tus_settings.get("tmp_file_dir") + if tmp_file_dir is None: + logger.warn( + "You are trying to enable tus but no" + "TUS_TMP_FILE_DIR environment setting is set." + ) + elif not os.path.exists(tmp_file_dir) or not os.path.isdir(tmp_file_dir): + logger.warn( + "The TUS_TMP_FILE_DIR does not point to a valid " "directory." + ) + elif not os.access(tmp_file_dir, os.W_OK): + logger.warn("The TUS_TMP_FILE_DIR is not writable") + else: + TUS_ENABLED = True + logger.info("tus file upload support is successfully " "configured") + except ImportError: + logger.warn( + "TUS_ENABLED is set; however, tus python package is " "not installed" + ) +else: + try: + import tus + + tus # pyflakes + except ImportError: + pass + else: + logger.warn( + "You have the tus python package installed but it is " + "not configured for this plone client" + ) + + +class FileUploadView(BrowserView): + """ + Handle file uploads with potential + special handling of TUS resumable uploads + """ + + tus_uid = None + + def __contains__(self, uid): + return self.tus_uid and self.tus_uid == uid + + def __getitem__(self, uid): + if self.tus_uid is None: + self.tus_uid = uid + self.__doc__ = "foobar" # why is this necessary? + return self + else: + raise KeyError + + def __call__(self): + # Check if user has permission to add content here + sm = getSecurityManager() + if not sm.checkPermission(AddPortalContent, self.context): + response = self.request.RESPONSE + response.setStatus(403) + return "You are not authorized to add content to this folder." + + req = self.request + tusrequest = False + if TUS_ENABLED: + adapter = Zope2RequestAdapter(req) + tus = Tus(adapter, **tus_settings) + if tus.valid: + tusrequest = True + tus.handle() + if not tus.upload_finished: + return + else: + filename = req.getHeader("FILENAME") + if tus.send_file: + filedata = req._file + filedata.filename = filename + else: + filepath = req._file.read() + filedata = open(filepath) + if not tusrequest: + if req.REQUEST_METHOD != "POST": + return + filedata = self.request.form.get("file", None) + if filedata is None: + return + filename = filedata.filename + content_type = mimetypes.guess_type(filename)[0] or "" + + if not filedata: + return + + ctr = getToolByName(self.context, "content_type_registry") + type_ = ctr.findTypeName(filename.lower(), content_type, "") or "File" + + # Now check that the object is not restricted to be added in the + # current context + allowed_ids = [fti.getId() for fti in self.context.allowedContentTypes()] + if type_ not in allowed_ids: + response = self.request.RESPONSE + response.setStatus(403) + if type_ == "File": + return "You cannot add a File to this folder, try another one" + if type_ == "Image": + return "You cannot add an Image to this folder, " "try another one" + + factory = IDXFileFactory(self.context) + obj = factory(filename, content_type, filedata) + + result = {"type": "", "size": 0} + + if "File" in obj.portal_type: + result["size"] = obj.file.getSize() + result["type"] = obj.file.contentType + elif "Image" in obj.portal_type: + result["size"] = obj.image.getSize() + result["type"] = obj.image.contentType + + if tusrequest: + tus.cleanup_file() + result.update( + { + "url": obj.absolute_url(), + "name": obj.getId(), + "UID": IUUID(obj), + "filename": filename, + } + ) + + self.request.response.setHeader( + "Content-Type", "application/json; charset=utf-8" + ) + return json.dumps(result) + + +class AllowUploadView(BrowserView): + def __call__(self): + """Return JSON structure to indicate if File or Image uploads are + allowed in the current container. + """ + self.request.response.setHeader( + "Content-Type", "application/json; charset=utf-8" + ) + context = self.context + if self.request.form.get("path"): + context = context.unrestrictedTraverse(self.request.form.get("path")) + + allow_images = False + allow_files = False + if IFolder.providedBy(context): + allowed_types = [t.getId() for t in context.allowedContentTypes()] + allow_images = "Image" in allowed_types + allow_files = "File" in allowed_types + + return json.dumps( + { + "allowUpload": allow_images or allow_files, + "allowImages": allow_images, + "allowFiles": allow_files, + } + ) diff --git a/src/plone/app/layout/content/browser/folder_publish.py b/src/plone/app/layout/content/browser/folder_publish.py new file mode 100644 index 00000000..669fbf7e --- /dev/null +++ b/src/plone/app/layout/content/browser/folder_publish.py @@ -0,0 +1,117 @@ +from plone.base import PloneMessageFactory as _ +from plone.base.utils import transaction_note +from plone.protect import CheckAuthenticator +from plone.protect import PostOnly +from Products.CMFCore.utils import getToolByName +from Products.statusmessages.interfaces import IStatusMessage +from ZODB.POSException import ConflictError +from zope.component import getMultiAdapter +from zope.publisher.browser import BrowserView + +import transaction + + +class FolderPublishView(BrowserView): + """Publish objects from a folder. + + Originally: Products/CMFPlone/skins/plone_scripts/folder_publish.cpy + Called by content_status_history, in plone.app.content. + """ + + def __call__( + self, + workflow_action=None, + paths=None, + comment="No comment", + expiration_date=None, + effective_date=None, + include_children=False, + ): + # Use plone.protect. + PostOnly(self.request) + CheckAuthenticator(self.request) + + if workflow_action is None: + IStatusMessage(self.request).add( + _("You must select a publishing action."), type="error" + ) + return self.redirect() + if not paths: + IStatusMessage(self.request).add( + _("You must select content to change."), type="error" + ) + return self.redirect() + + self.transition_objects_by_paths( + workflow_action, + paths, + comment, + expiration_date, + effective_date, + include_children, + ) + + transaction_note(str(paths) + " transitioned " + workflow_action) + + IStatusMessage(self.request).add(_("Item state changed.")) + return self.redirect() + + def transition_objects_by_paths( + self, + workflow_action, + paths, + comment="", + expiration_date=None, + effective_date=None, + include_children=False, + handle_errors=True, + ): + """Originally this was in plone_utils.transitionObjectsByPaths. + + This was deprecated since 2015, so we copied it here. + """ + failure = {} + # use the portal for traversal in case we have relative paths + portal = getToolByName(self, "portal_url").getPortalObject() + traverse = portal.restrictedTraverse + for path in paths: + sp = transaction.savepoint(optimistic=True) + try: + obj = traverse(path, None) + if obj is not None: + view = getMultiAdapter( + (obj, self.request), name="content_status_modify" + ) + view( + workflow_action, + comment, + effective_date=effective_date, + expiration_date=expiration_date, + ) + except (ConflictError, KeyboardInterrupt): + raise + except Exception as e: + # skip this object but continue with sub-objects. + sp.rollback() + failure[path] = e + if getattr(obj, "isPrincipiaFolderish", None) and include_children: + subobject_paths = [f"{path}/{id}" for id in obj] + self.transition_objects_by_paths( + workflow_action, + subobject_paths, + comment, + expiration_date, + effective_date, + include_children, + ) + return failure + + def redirect(self): + target = self.request.get("orig_template", "") + if target and not getToolByName(self.context, "portal_url").isURLInPortal( + target + ): + target = "" + if not target: + target = self.context.absolute_url() + self.request.response.redirect(target) diff --git a/src/plone/app/layout/content/browser/folderfactories.pt b/src/plone/app/layout/content/browser/folderfactories.pt new file mode 100644 index 00000000..c2305f58 --- /dev/null +++ b/src/plone/app/layout/content/browser/folderfactories.pt @@ -0,0 +1,104 @@ + + + + + + + + + + +

Add new item

+ +
+ Select the type of item you want to add to your folder. +
+ +
+

+ + 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 00000000..c83022ee --- /dev/null +++ b/src/plone/app/layout/content/browser/folderfactories.py @@ -0,0 +1,147 @@ +from Acquisition import aq_inner +from Acquisition import aq_parent +from plone.app.layout.content.browser.interfaces import IFolderContentsView +from plone.base.interfaces.constrains import ISelectableConstrainTypes +from plone.i18n.normalizer.interfaces import IIDNormalizer +from plone.memoize.instance import memoize +from plone.memoize.request import memoize_diy_request +from plone.protect.authenticator import createToken +from Products.CMFCore.Expression import createExprContext +from Products.CMFCore.utils import getToolByName +from urllib.parse import quote_plus +from zope.component import getMultiAdapter +from zope.component import queryUtility +from zope.i18n import translate +from zope.publisher.browser import BrowserView + + +@memoize_diy_request(arg=0) +def _allowedTypes(request, context): + return context.allowedContentTypes() + + +class FolderFactoriesView(BrowserView): + """The folder_factories view - show addable types""" + + def __call__(self): + if "form.button.Add" in self.request.form: + urltool = getToolByName(self.context, "portal_url") + url = self.request.form.get("url") + if not urltool.isURLInPortal(url): + url = self.context.absolute_url() + self.request.response.redirect(url) + return "" + else: + return self.index() + + def can_constrain_types(self): + aspect = ISelectableConstrainTypes(self.add_context(), None) + return aspect.canSetConstrainTypes() if aspect else False + + @memoize + def add_context(self): + context = self.context + context_state = getMultiAdapter( + (context, self.request), name="plone_context_state" + ) + context = aq_inner(context) + try: + published = self.request.PUBLISHED + except AttributeError: + published = context + if context_state.is_structural_folder(): + if context_state.is_default_page(): + is_folder_contents_view = IFolderContentsView.providedBy(published) + if is_folder_contents_view or self == published: + # on the folder_contents view and factories view, + # show the actual context object's addable types + return context + else: + return aq_parent(context) + else: + return context + else: + return aq_parent(context) + + # NOTE: This is also used by plone.app.contentmenu.menu.FactoriesMenu. + # The return value is somewhat dictated by the menu infrastructure, so + # be careful if you change it + + def addable_types(self, include=None): + """Return menu item entries in a TAL-friendly form. + + Pass a list of type ids to 'include' to explicitly allow a list of + types. + """ + + context = aq_inner(self.context) + request = self.request + + results = [] + + idnormalizer = queryUtility(IIDNormalizer) + portal_state = getMultiAdapter((context, request), name="plone_portal_state") + + addContext = self.add_context() + baseUrl = addContext.absolute_url() + token = createToken() + + allowedTypes = _allowedTypes(request, addContext) + + types_tool = getToolByName(context, "portal_types") + + # Note: we don't check 'allowed' or 'available' here, because these are + # slow. We assume the 'allowedTypes' list has already performed the + # necessary calculations + actions = types_tool.listActionInfos( + object=addContext, + check_permissions=False, + check_condition=False, + category="folder/add", + ) + addActionsById = {a["id"]: a for a in actions} + + expr_context = createExprContext( + aq_parent(addContext), portal_state.portal(), addContext + ) + for t in allowedTypes: + typeId = t.getId() + if include is None or typeId in include: + cssId = idnormalizer.normalize(typeId) + cssClass = "contenttype-%s" % cssId + + url = None + addAction = addActionsById.get(typeId, None) + if addAction is not None: + url = addAction["url"] + + if not url: + url = "{}/createObject?type_name={}&_authenticator={}".format( + baseUrl, quote_plus(typeId), token + ) + + icon = t.getIconExprObject() + if icon: + icon = icon(expr_context) + + results.append( + { + "id": typeId, + "title": t.Title(), + "description": t.Description(), + "action": url, + "selected": False, + "icon": icon, + "extra": {"id": cssId, "separator": None, "class": cssClass}, + "submenu": None, + } + ) + + # Sort the addable content types based on their translated title + results = [ + (translate(ctype["title"], context=request), ctype) for ctype in results + ] + results = sorted(results, key=lambda tp: tp[0]) + results = [ctype[-1] for ctype in results] + + return results diff --git a/src/plone/app/layout/content/browser/full_review_list.pt b/src/plone/app/layout/content/browser/full_review_list.pt new file mode 100644 index 00000000..8fd2c8ec --- /dev/null +++ b/src/plone/app/layout/content/browser/full_review_list.pt @@ -0,0 +1,40 @@ + + + + + + +

Full review list:

+ +
+
+
+ +
+
+ +
+ + diff --git a/src/plone/app/layout/content/browser/i18n.py b/src/plone/app/layout/content/browser/i18n.py new file mode 100644 index 00000000..ebabfc1a --- /dev/null +++ b/src/plone/app/layout/content/browser/i18n.py @@ -0,0 +1,48 @@ +from plone.memoize import ram +from Products.Five.browser import BrowserView +from zope.component import queryUtility +from zope.i18n.interfaces import ITranslationDomain + +import json + + +def _cache_key(method, self, domain, language): + return ( + domain, + language, + ) + + +class i18njs(BrowserView): + @ram.cache(_cache_key) + def _gettext_catalog(self, domain, language): + td = queryUtility(ITranslationDomain, domain) + if td is None: + return + if language not in td._catalogs: + baselanguage = language.split("-")[0] + if baselanguage not in td._catalogs: + return + else: + language = baselanguage + _catalog = {} + for mo_path in reversed(td._catalogs[language]): + catalog = td._data[mo_path]._catalog + if catalog is None: + td._data[mo_path].reload() + catalog = td._data[mo_path]._catalog + _catalog.update(catalog._catalog) + return _catalog + + def __call__(self, domain=None, language=None): + if domain is None: + catalog = {} + else: + if language is None: + language = self.request["LANGUAGE"] + catalog = self._gettext_catalog(domain, language) + + response = self.request.response + response.setHeader("Content-Type", "application/json; charset=utf-8") + response.setBody(json.dumps(catalog)) + return response diff --git a/src/plone/app/layout/content/browser/interfaces.py b/src/plone/app/layout/content/browser/interfaces.py new file mode 100644 index 00000000..5f300c3e --- /dev/null +++ b/src/plone/app/layout/content/browser/interfaces.py @@ -0,0 +1,14 @@ +from zope.interface import Interface + + +class IFolderContentsView(Interface): + """Interface, which provides methods for folder contents""" + + def test(a, b, c): + """A simple replacement of python's test.""" + + def getAllowedTypes(): + """Returns allowed types for context.""" + + def title(): + """Returns the title for the template.""" diff --git a/src/plone/app/layout/content/browser/query.py b/src/plone/app/layout/content/browser/query.py new file mode 100644 index 00000000..7defcf56 --- /dev/null +++ b/src/plone/app/layout/content/browser/query.py @@ -0,0 +1,16 @@ +from plone.app.querystring.interfaces import IQuerystringRegistryReader +from plone.registry.interfaces import IRegistry +from Products.Five import BrowserView +from zope.component import getUtility + +import json + + +class QueryStringIndexOptions(BrowserView): + def __call__(self): + registry = getUtility(IRegistry) + config = IQuerystringRegistryReader(registry)() + self.request.response.setHeader( + "Content-Type", "application/json; charset=utf-8" + ) + return json.dumps(config) diff --git a/src/plone/app/layout/content/browser/reviewlist.py b/src/plone/app/layout/content/browser/reviewlist.py new file mode 100644 index 00000000..9a6ca197 --- /dev/null +++ b/src/plone/app/layout/content/browser/reviewlist.py @@ -0,0 +1,176 @@ +from Acquisition import aq_inner +from Acquisition import aq_parent +from plone.app.layout.content.browser.tableview import Table +from plone.app.layout.content.browser.tableview import TableBrowserView +from plone.base.defaultpage import is_default_page +from plone.base.utils import human_readable_size +from plone.base.utils import is_expired +from plone.base.utils import safe_text +from plone.i18n.normalizer.interfaces import IIDNormalizer +from plone.registry.interfaces import IRegistry +from Products.CMFCore.utils import getToolByName +from urllib.parse import quote_plus +from zope.component import getMultiAdapter +from zope.component import getUtility +from zope.i18n import translate +from zope.publisher.browser import BrowserView + + +class FullReviewListView(BrowserView): + def revlist(self): + portal_membership = getToolByName(self.context, "portal_membership") + portal_workflow = getToolByName(self.context, "portal_workflow") + if portal_membership.isAnonymousUser(): + return [] + + return portal_workflow.getWorklistsResults() + + def url(self): + return self.context.absolute_url() + "/full_review_list" + + def review_table(self): + table = ReviewListTable(self.context, self.request) + return table.render() + + +class ReviewListTable: + """ + The review list table renders the table and its actions. + """ + + def __init__(self, context, request, **kwargs): + self.context = context + self.request = request + + url = self.context.absolute_url() + view_url = url + "/full_review_list" + self.table = Table(request, url, view_url, self.items, buttons=self.buttons) + + def render(self): + return self.table.render() + + @property + def items(self): + portal_url = getToolByName(self.context, "portal_url") + plone_view = getMultiAdapter((self.context, self.request), name="plone") + portal_workflow = getToolByName(self.context, "portal_workflow") + portal_types = getToolByName(self.context, "portal_types") + portal_membership = getToolByName(self.context, "portal_membership") + registry = getUtility(IRegistry) + use_view_action = registry.get("plone.types_use_view_action_in_listings", ()) + + results = [] + if portal_membership.isAnonymousUser(): + worklist = [] + else: + worklist = portal_workflow.getWorklistsResults() + + for i, obj in enumerate(worklist): + if i % 2 == 0: + table_row_class = "even" + else: + table_row_class = "odd" + + url = obj.absolute_url() + path = "/".join(obj.getPhysicalPath()) + normalizer = getUtility(IIDNormalizer) + type_class = "contenttype-" + normalizer.normalize(obj.portal_type) + + review_state = portal_workflow.getInfoFor(obj, "review_state", "") + + state_class = "state-" + normalizer.normalize(review_state) + relative_url = portal_url.getRelativeContentURL(obj) + + type_title_msgid = portal_types[obj.portal_type].Title() + url_href_title = "{}: {}".format( + translate(type_title_msgid, context=self.request), + safe_text(obj.Description()), + ) + getMember = getToolByName(obj, "portal_membership").getMemberById + creator_id = obj.Creator() + creator = getMember(creator_id) + if creator: + creator_name = creator.getProperty("fullname", "") or creator_id + else: + creator_name = creator_id + modified = "".join( + map( + safe_text, + [ + creator_name, + " - ", + plone_view.toLocalizedTime( + obj.ModificationDate(), long_format=1 + ), + ], + ) + ) + is_structural_folder = obj.restrictedTraverse( + "@@plone_context_state" + ).is_structural_folder() + + if obj.portal_type in use_view_action: + view_url = url + "/view" + elif is_structural_folder: + view_url = url + "/folder_contents" + else: + view_url = url + + # Is this object the default page of its container? + is_browser_default = is_default_page(aq_parent(aq_inner(obj)), obj) + + results.append( + dict( + url=url, + url_href_title=url_href_title, + id=obj.getId(), + quoted_id=quote_plus(obj.getId()), + path=path, + title_or_id=obj.pretty_title_or_id(), + description=obj.Description(), + obj_type=obj.Type, + size=human_readable_size(obj.get_size()), + modified=modified, + type_class=type_class, + wf_state=review_state, + state_title=portal_workflow.getTitleForStateOnType( + review_state, obj.portal_type + ), + state_class=state_class, + is_browser_default=is_browser_default, + folderish=is_structural_folder, + relative_url=relative_url, + view_url=view_url, + table_row_class=table_row_class, + is_expired=is_expired(obj), + ) + ) + return results + + @property + def show_sort_column(self): + return False + + def buttons(self): + buttons = [] + portal_actions = getToolByName(self.context, "portal_actions") + button_actions = portal_actions.listActionInfos( + object=aq_inner(self.context), categories=("folder_buttons",) + ) + + for button in button_actions: + # Make proper classes for our buttons + if button["id"] != "paste" or self.context.cb_dataValid(): + buttons.append(self.setbuttonclass(button)) + return buttons + + def setbuttonclass(self, button): + if button["id"] == "paste": + button["cssclass"] = "btn btn-secondary" + else: + button["cssclass"] = "btn btn-primary" + return button + + +class ReviewListBrowserView(TableBrowserView): + table = ReviewListTable diff --git a/src/plone/app/layout/content/browser/selection.py b/src/plone/app/layout/content/browser/selection.py new file mode 100644 index 00000000..ea794770 --- /dev/null +++ b/src/plone/app/layout/content/browser/selection.py @@ -0,0 +1,134 @@ +from Acquisition import aq_inner +from plone.base import PloneMessageFactory as _ +from plone.registry.interfaces import IRegistry +from Products.CMFCore.utils import getToolByName +from Products.Five.browser import BrowserView +from Products.statusmessages.interfaces import IStatusMessage +from zope.component import getMultiAdapter +from zope.component import getUtility + + +class DefaultViewSelectionView(BrowserView): + def isValidTemplate(self, templateId): + return templateId in [a[0] for a in self.vocab] + + def canSelectDefaultPage(self): + return self.context.isPrincipiaFolderish and self.context.canSetDefaultPage() + + @property + def vocab(self): + return self.context.getAvailableLayouts() + + @property + def selectedLayout(self): + if not self.context_state.is_default_page(): + return self.context.getLayout() + return "" + + def selectViewTemplate(self): + templateId = self.request.get("templateId") + + if self.isValidTemplate(templateId): + self.context.setLayout(templateId) + + self.request.response.redirect(self.context.absolute_url() + "/view") + + @property + def action_url(self): + return f"{self.context_state.object_url():s}/select_default_view" + + def __call__(self): + self.context_state = getMultiAdapter( + (self.context, self.request), name="plone_context_state" + ) + + template_id = self.request.form.get("templateId") + context_view_url = self.context_state.object_url() + "/view" + + if self.request.form.get("form.button.Cancel"): + self.request.response.redirect(context_view_url) + + if self.request.form.get("form.button.Save") and not template_id: + IStatusMessage(self.request).add( + "Please select a template.", type="warning" + ) + + if self.request.form.get("form.button.Save") and template_id: + # Make sure this is a valid template + if not self.isValidTemplate(template_id): + IStatusMessage(self.request).add("Invalid view.", type="error") + return self.index() + # Update the template + self.context.setLayout(template_id) + IStatusMessage(self.request).add("View changed.") + # Redirect to context view + self.request.response.redirect(context_view_url) + + return self.index() + + +class DefaultPageSelectionView(BrowserView): + def __call__(self): + if "form.buttons.Save" in self.request.form: + if "objectId" not in self.request.form: + message = _("Please select an item to use.") + msgtype = "error" + else: + objectId = self.request.form["objectId"] + + if objectId not in self.context.objectIds(): + message = _( + "There is no object with short name ${name} " "in this folder.", + mapping={"name": objectId}, + ) + msgtype = "error" + else: + self.context.setDefaultPage(objectId) + message = _("View changed.") + msgtype = "info" + self.request.response.redirect(self.context.absolute_url()) + IStatusMessage(self.request).add(message, msgtype) + elif "form.buttons.Cancel" in self.request.form: + self.request.response.redirect(self.context.absolute_url()) + + return self.index() + + def get_selectable_items(self): + """Return brains in this container that can be used as default_pages""" + context = aq_inner(self.context) + registry = getUtility(IRegistry) + view_types = registry.get("plone.types_use_view_action_in_listings", []) + default_page_types = registry.get("plone.default_page_types", []) + portal_types = getToolByName(self.context, "portal_types") + portal_catalog = getToolByName(self.context, "portal_catalog") + + results = [] + for brain in portal_catalog( + path={"query": "/".join(context.getPhysicalPath()), "depth": 1}, + sort_on="getObjPositionInParent", + ): + portal_type = brain.portal_type + if portal_type in view_types: + # Skip files and images + continue + + if portal_type in default_page_types: + # Allow types that are explicitly in default_page_types + results.append(brain) + continue + + if brain.is_folderish: + fti = portal_types.get(portal_type) + if not fti: + continue + if ( + fti.filter_content_types + and fti.allowed_content_types + or not fti.filter_content_types + ): + # Disallow folderish types if you can't add any content. + # To override you have to add type to default_page_types + continue + + results.append(brain) + return results diff --git a/src/plone/app/layout/content/browser/table.pt b/src/plone/app/layout/content/browser/table.pt new file mode 100644 index 00000000..939d107e --- /dev/null +++ b/src/plone/app/layout/content/browser/table.pt @@ -0,0 +1,351 @@ +
+ + + + + + + + + +

+ This folder has no visible items. To add content, press the + add button, or paste content from another location. +

+ + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
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 00000000..00e84608 --- /dev/null +++ b/src/plone/app/layout/content/browser/table.txt @@ -0,0 +1,147 @@ +===== +Table +===== + +The table class can render a table like used in the folder contents view. It is +abstracted into a separate class for reuse with other views like the review +list. + +A table can be parameterized at creation time. + + >>> from plone.app.layout.content.browser.tableview import Table + >>> view_url = 'http://plone/view' + >>> base_url = 'http://plone' + >>> table = Table(request={}, base_url=base_url, view_url=view_url, + ... items=[], show_sort_column=False, buttons=[]) + +The most important argument is items. This must provide a list of dictionaries +because the table will add more information to them for rendering. + + +Batching and selecting +---------------------- + +One of the table's main virtues is that it batches. A template can use the +`batch` attribute for accessing the current batch. + + >>> items = [{'id': i} for i in range(100)] + >>> table = Table({}, base_url, view_url, items=items) + >>> from plone.batching.interfaces import IBatch + >>> IBatch.providedBy(table.batch) + True + +The table automatically adds keys for making layout easier. One of these is +indication whether an item is `checked`. + + >>> list(table.batch)[0]['checked'] + +Whether or not an item is checked depends on request variables. If either the +whole batch or the current screen is selected the item we be marked as checked. +Below we will demonstrate this by selecting the items on screen. + + >>> table = Table({'select': 'screen'}, base_url, view_url, items=items) + >>> list(table.batch)[0]['checked'] + 'checked' + +Another is the `table_row_class`. The table will append `selected` to it if it +already exists, otherwise it will create it. + + >>> items = [{'id': i} for i in range(100)] + >>> table = Table({}, base_url, view_url, items=items) + >>> list(table.batch)[0]['table_row_class'] + '' + +If the row is `checked` this will also be reflected in the row class. + + >>> table = Table({'select': 'screen'}, base_url, view_url, items=items) + >>> list(table.batch)[0]['table_row_class'] + ' selected' + +The table will indicate in which selection mode it is in. You can use two +interlinked variables for this. The `selectcurrentbatch` attribute will be true +in case the current screen is selected or the whole batch is selected. + + >>> table = Table({}, base_url, view_url, items=items) + >>> table.selectcurrentbatch + False + + >>> table = Table({'select': 'screen'}, base_url, view_url, items=items) + >>> table.selectcurrentbatch + True + + >>> table = Table({'select': 'all'}, base_url, view_url, items=items) + >>> table.selectcurrentbatch + True + +You can also check specifically if the whole batch has been selected. + + >>> table = Table({}, base_url, view_url, items=items) + >>> table.selectall + False + + >>> table = Table({'select': 'all'}, base_url, view_url, items=items) + >>> table.selectall + True + +When you select all items on the screen and there are no further pages it will +set both the whole `selectcurrentbatch` and `selectall` properties. + + >>> small_numnber_of_items = [{'id': i} for i in range(3)] + >>> table = Table({'select': 'screen'}, base_url, view_url, items=small_numnber_of_items) + >>> table.selectcurrentbatch + True + >>> table.selectall + True + + +Sort column +----------- + +Some tables may be made sortable by indicating so at table creation time. This +will toggle a boolean which will be read by the rendering template. By default +sorting is turned off. + + >>> table = Table({}, base_url, view_url, items=items) + >>> table.show_sort_column + False + + >>> table = Table({}, base_url, view_url, items=items, show_sort_column=True) + >>> table.show_sort_column + True + +Selection urls +-------------- + +The table provides the template with urls for selecting or deselecting items in +the batch. These urls are based on the url passed in at creation time. + + >>> table.selectnone_url + 'http://plone/view?pagenumber=1&pagesize=20' + +You can see how it automatically adds the current page to the url. + + >>> table.selectscreen_url + 'http://plone/view?pagenumber=1&pagesize=20&select=screen' + +The same goes for selecting the screen and the whole batch. + + >>> table.selectall_url + 'http://plone/view?pagenumber=1&pagesize=20&select=all' + +A template may want to display only one of these urls at the same time. +Therefore the table has a boolean property to query whether or not to show the +select all option. The display of this depends on whether the whole batch is +selected (don't show) or the screen has not been selected (don't show then). + + >>> table = Table({}, base_url, view_url, items=items) + >>> table.show_select_all_items + False + + >>> table = Table({'select': 'screen'}, base_url, view_url, items=items) + >>> table.show_select_all_items + True + + >>> table = Table({'select': 'all'}, base_url, view_url, items=items) + >>> table.show_select_all_items + False + diff --git a/src/plone/app/layout/content/browser/tableview.py b/src/plone/app/layout/content/browser/tableview.py new file mode 100644 index 00000000..b3a1ee0c --- /dev/null +++ b/src/plone/app/layout/content/browser/tableview.py @@ -0,0 +1,200 @@ +from plone.base.utils import safe_text +from plone.batching import Batch +from plone.batching.browser import BatchView +from plone.memoize import instance +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from urllib.parse import quote_plus +from zope.i18nmessageid import MessageFactory +from zope.publisher.browser import BrowserView +from ZTUtils import make_query + +_ = MessageFactory("plone") + + +class TableBatchView(BatchView): + def make_link(self, pagenumber): + batchlinkparams = self.request.form.copy() + return "{}?{}".format( + self.request.ACTUAL_URL, + make_query(batchlinkparams, {"pagenumber": pagenumber}), + ) + + +class Table: + """ + The table renders a table with sortable columns etc. + + It is meant to be subclassed to provide methods for getting specific table + info. + """ + + def __init__( + self, + request, + base_url, + view_url, + items, + show_sort_column=False, + buttons=None, + pagesize=20, + show_select_column=True, + show_size_column=True, + show_modified_column=True, + show_status_column=True, + ): + if buttons is None: + buttons = [] + self.request = request + self.context = None # Need for view pagetemplate + + self.base_url = base_url + self.view_url = view_url + self.url = view_url + self.items = items + self.show_sort_column = show_sort_column + self.show_select_column = show_select_column + self.show_size_column = show_size_column + self.show_modified_column = show_modified_column + self.show_status_column = show_status_column + self.buttons = buttons + self.default_page_size = 20 + self.pagesize = pagesize + self.show_all = request.get("show_all", "").lower() == "true" + + selection = request.get("select") + if selection == "screen": + self.selectcurrentbatch = True + elif selection == "all": + self.selectall = True + + self.pagenumber = int(request.get("pagenumber", 1)) + + def msg_select_item(self, item): + title_or_id = ( + item.get("title_or_id") + or item.get("title") + or item.get("Title") + or item.get("id") + or item["getId"] + ) + return _( + "checkbox_select_item", + default="Select ${title}", + mapping={"title": safe_text(title_or_id)}, + ) + + @property + def within_batch_size(self): + return len(self.items) <= self.pagesize + + def set_checked(self, item): + selected = self.selected(item) + item["checked"] = selected and "checked" or None + item["table_row_class"] = item.get("table_row_class", "") + if selected: + item["table_row_class"] += " selected" + + @property + @instance.memoize + def batch(self): + pagesize = self.pagesize + if self.show_all: + pagesize = len(self.items) + b = Batch.fromPagenumber( + self.items, pagesize=pagesize, pagenumber=self.pagenumber + ) + list(map(self.set_checked, b)) + return b + + render = ViewPageTemplateFile("table.pt") + + def batching(self): + return TableBatchView(self.context, self.request)(self.batch) + + # options + _selectcurrentbatch = False + _select_all = False + + def _get_select_currentbatch(self): + return self._selectcurrentbatch + + def _set_select_currentbatch(self, value): + self._selectcurrentbatch = value + if ( + self._selectcurrentbatch + and self.show_all + or (len(self.items) <= self.pagesize) + ): + self.selectall = True + + selectcurrentbatch = property(_get_select_currentbatch, _set_select_currentbatch) + + def _get_select_all(self): + return self._select_all + + def _set_select_all(self, value): + self._select_all = bool(value) + if self._select_all: + self._selectcurrentbatch = True + + selectall = property(_get_select_all, _set_select_all) + + @property + def show_select_all_items(self): + return self.selectcurrentbatch and not self.selectall + + def get_nosort_class(self): + """ """ + return "nosort" + + @property + def selectall_url(self): + return self.selectnone_url + "&select=all" + + @property + def selectscreen_url(self): + return self.selectnone_url + "&select=screen" + + @property + def selectnone_url(self): + base = self.view_url + "?pagenumber={}&pagesize={}".format( + self.pagenumber, self.pagesize + ) + if self.show_all: + base += "&show_all=true" + return base + + @property + def show_all_url(self): + return self.view_url + "?show_all=true" + + def selected(self, item): + if self.selectcurrentbatch: + return True + return False + + @property + def viewname(self): + return self.view_url.split("?")[0].split("/")[-1] + + def quote_plus(self, string): + return quote_plus(string) + + +class TableBrowserView(BrowserView): + """Base class which can be used from a AJAX view + + Subclasses only need to set the table property to a different + class.""" + + table = None + + def update_table( + self, pagenumber="1", sort_on="getObjPositionInParent", show_all=False + ): + self.request.set("sort_on", sort_on) + self.request.set("pagenumber", pagenumber) + table = self.table( + self.context, self.request, contentFilter={"sort_on": sort_on} + ) + return table.render() diff --git a/src/plone/app/layout/content/browser/templates/content_status_history.pt b/src/plone/app/layout/content/browser/templates/content_status_history.pt new file mode 100644 index 00000000..c42567e8 --- /dev/null +++ b/src/plone/app/layout/content/browser/templates/content_status_history.pt @@ -0,0 +1,540 @@ + + + + + + + + + + + + + + + +
+ Note + This form is used in two different ways - from folder_contents, + allowing you to publish several things at once, and from the state + drop-down. In the first case, the 'paths' request parameter is set; + in the second case, giving the relative paths to the content object + to manipulate; in the second case, this parameter is omitted and the + path of the context is used. +
+
+ + + + +

Publishing process

+ +
+ An item's status (also called its review state) determines who + can see it. Another way to control the visibility of an item is + with its + Publishing Date. An item is not publicly + searchable before its publishing date. This will prevent the item + from showing up in portlets and folder listings, although the item + will still be available if accessed directly via its URL. +
+ +
+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + 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 00000000..29241d53 --- /dev/null +++ b/src/plone/app/layout/content/browser/templates/delete_confirmation.pt @@ -0,0 +1,73 @@ + + + + + + + + +

+ This item can not be deleted because it is currently locked by another user. +

+ + +

+ + Do you really want to delete this folder and all its contents? + + + (This will delete a total of + 22 + items.) + +

+ +

+ Do you really want to delete this item? +

+
+ +
+ + + +
    +
  • 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 00000000..e951e4f3 --- /dev/null +++ b/src/plone/app/layout/content/browser/templates/object_rename.pt @@ -0,0 +1,35 @@ + + + + + + + + +
+ +

Rename item

+ +
+
+
+ +
+
+ + + diff --git a/src/plone/app/layout/content/browser/templates/select_default_page.pt b/src/plone/app/layout/content/browser/templates/select_default_page.pt new file mode 100644 index 00000000..d7b33794 --- /dev/null +++ b/src/plone/app/layout/content/browser/templates/select_default_page.pt @@ -0,0 +1,123 @@ + + + + + + + +

Select default page

+ +
+ Please select item which will be displayed as the default page of the + folder. +
+
+
+ + + + + +
+ +
+ + +
+
+ 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 00000000..b7ccdbb9 --- /dev/null +++ b/src/plone/app/layout/content/browser/templates/select_default_view.pt @@ -0,0 +1,134 @@ + + + + + + + + +

Select default view

+ +
+ Please select which template should be used as the default view of the + folder. +
+ +
+
+ + + +
+ + + + + (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 00000000..23ab157a --- /dev/null +++ b/src/plone/app/layout/content/browser/vocabulary.py @@ -0,0 +1,394 @@ +from AccessControl import getSecurityManager +from Acquisition import aq_base +from html import unescape +from logging import getLogger +from plone.app.content.utils import json_dumps +from plone.app.content.utils import json_loads +from plone.app.querystring import queryparser +from plone.app.z3cform.interfaces import IFieldPermissionChecker +from plone.autoform.interfaces import WRITE_PERMISSIONS_KEY +from plone.base import PloneMessageFactory as _ +from plone.base.interfaces.siteroot import INavigationRoot +from plone.base.navigationroot import get_navigation_root +from plone.base.utils import safe_text +from plone.memoize.view import memoize +from plone.supermodel.utils import mergedTaggedValueDict +from Products.CMFCore.utils import getToolByName +from Products.Five import BrowserView +from Products.MimetypesRegistry.MimeTypeItem import guess_icon_path +from Products.MimetypesRegistry.MimeTypeItem import PREFIX +from Products.PortalTransforms.transforms.safe_html import SafeHTML +from types import FunctionType +from z3c.form.browser.object import ObjectWidget +from z3c.form.interfaces import IAddForm +from z3c.form.interfaces import ISubForm +from zope.component import getUtility +from zope.component import queryAdapter +from zope.component import queryUtility +from zope.deprecation import deprecated +from zope.i18n import translate +from zope.schema.interfaces import ICollection +from zope.schema.interfaces import IVocabularyFactory +from zope.security.interfaces import IPermission + +import inspect +import itertools + +logger = getLogger(__name__) + +MAX_BATCH_SIZE = 500 # prevent overloading server + +DEFAULT_PERMISSION = "View" +DEFAULT_PERMISSION_SECURE = "Modify portal content" +PERMISSIONS = { + "plone.app.vocabularies.Catalog": "View", + "plone.app.vocabularies.Keywords": "Modify portal content", + "plone.app.vocabularies.SyndicatableFeedItems": "Modify portal content", + "plone.app.vocabularies.Users": "Modify portal content", + "plone.app.multilingual.RootCatalog": "View", +} +TRANSLATED_IGNORED = [ + "author_name", + "cmf_uid", + "commentators", + "created", + "CreationDate", + "Creator", + "Date", + "Description", + "effective", + "EffectiveDate", + "end", + "exclude_from_nav", + "ExpirationDate", + "expires", + "getIcon", + "getMimeIcon", + "getId", + "getObjSize", + "getRemoteUrl", + "getURL", + "id", + "in_response_to", + "is_folderish", + "last_comment_date", + "listCreators", + "location", + "meta_type", + "ModificationDate", + "modified", + "path", + "portal_type", + "review_state", + "start", + "Subject", + "sync_uid", + "Title", + "total_comments", + "UID", +] + +_permissions = PERMISSIONS +deprecated("_permissions", "Use PERMISSIONS variable instead.") + + +def _parseJSON(s): + # XXX this should be changed to a try loads except return s + if isinstance(s, str): + s = s.strip() + if (s.startswith("{") and s.endswith("}")) or ( + s.startswith("[") and s.endswith("]") + ): # detect if json + return json_loads(s) + return s + + +_unsafe_metadata = [ + "author_name", + "commentors", + "Creator", + "listCreators", +] +_safe_callable_metadata = [ + "getIcon", + "getPath", + "getURL", + "is_folderish", + "review_state", +] + + +class VocabLookupException(Exception): + pass + + +class BaseVocabularyView(BrowserView): + def get_translated_ignored(self): + return TRANSLATED_IGNORED + + def get_base_path(self, context): + return get_navigation_root(context) + + def __call__(self): + """ + Accepts GET parameters of: + name: Name of the vocabulary + field: Name of the field the vocabulary is being retrieved for + query: string or json object of criteria and options. + json value consists of a structure: + { + criteria: object, + sort_on: index, + sort_order: (asc|reversed) + } + attributes: comma separated, or json object list + batch: { + page: 1-based page of results, + size: size of paged results + } + """ + context = self.get_context() + self.request.response.setHeader( + "Content-Type", "application/json; charset=utf-8" + ) + + try: + vocabulary = self.get_vocabulary() + except VocabLookupException as e: + return json_dumps({"error": e.args[0]}) + + results_are_brains = False + if hasattr(vocabulary, "search_catalog"): + query = self.parsed_query() + results = vocabulary.search_catalog(query) + results_are_brains = True + elif hasattr(vocabulary, "search"): + try: + query = self.parsed_query()["SearchableText"]["query"] + except KeyError: + results = iter(vocabulary) + else: + results = vocabulary.search(query) + else: + results = vocabulary + + try: + total = len(results) + except TypeError: + # do not error if object does not support __len__ + # we'll check again later if we can figure some size + # out + total = 0 + + # get batch + batch = _parseJSON(self.request.get("batch", "")) + if batch and ("size" not in batch or "page" not in batch): + batch = None # batching not providing correct options + if batch: + # must be sliceable for batching support + page = int(batch["page"]) + size = int(batch["size"]) + if size > MAX_BATCH_SIZE: + raise Exception("Max batch size is 500") + # page is being passed in is 1-based + start = (max(page - 1, 0)) * size + end = start + size + # Try __getitem__-based slice, then iterator slice. + # The iterator slice has to consume the iterator through + # to the desired slice, but that shouldn't be the end + # of the world because at some point the user will hopefully + # give up scrolling and search instead. + try: + results = results[start:end] + except TypeError: + results = itertools.islice(results, start, end) + + # build result items + items = [] + + attributes = _parseJSON(self.request.get("attributes", "")) + if isinstance(attributes, str) and attributes: + attributes = attributes.split(",") + + translate_ignored = self.get_translated_ignored() + transform = SafeHTML() + if attributes: + base_path = self.get_base_path(context) + sm = getSecurityManager() + can_edit = sm.checkPermission(DEFAULT_PERMISSION_SECURE, context) + mtt = getToolByName(self.context, "mimetypes_registry") + for vocab_item in results: + if not results_are_brains: + vocab_item = vocab_item.value + item = {} + for attr in attributes: + key = attr + if ":" in attr: + key, attr = attr.split(":", 1) + if attr in _unsafe_metadata and not can_edit: + continue + if key == "path": + attr = "getPath" + val = getattr(vocab_item, attr, None) + if callable(val): + if attr in _safe_callable_metadata: + val = val() + else: + continue + if key == "path" and val is not None: + val = val[len(base_path) :] + if key not in translate_ignored and isinstance(val, str): + val = translate(_(safe_text(val)), context=self.request) + item[key] = val + if key == "getMimeIcon": + item[key] = None + # get mime type icon url from mimetype registry' + contenttype = aq_base(getattr(vocab_item, "mime_type", None)) + if contenttype: + ctype = mtt.lookup(contenttype) + if ctype: + item[key] = "/".join( + [base_path, guess_icon_path(ctype[0])] + ) + else: + item[key] = "/".join( + [ + base_path, + PREFIX.rstrip("/"), + "unknown.png", + ] + ) + items.append(item) + else: + items = [ + { + "id": unescape(transform.scrub_html(item.value)), + "text": ( + unescape(transform.scrub_html(item.title)) if item.title else "" + ), + } + for item in results + ] + + if total == 0: + total = len(items) + + return json_dumps({"results": items, "total": total}) + + def parsed_query( + self, + ): + query = _parseJSON(self.request.get("query", "")) + if isinstance(query, str): + query = {"SearchableText": {"query": query}} + elif query: + parsed = queryparser.parseFormquery(self.get_context(), query["criteria"]) + if "sort_on" in query: + parsed["sort_on"] = query["sort_on"] + if "sort_order" in query: + parsed["sort_order"] = str(query["sort_order"]) + query = parsed + else: + query = {} + return query + + +class VocabularyView(BaseVocabularyView): + """Queries a named vocabulary and returns JSON-formatted results.""" + + def get_context(self): + return self.context + + def get_vocabulary(self): + # Look up named vocabulary and check permission. + + context = self.context + factory_name = self.request.get("name", None) + field_name = self.request.get("field", None) + if not factory_name: + raise VocabLookupException("No factory provided.") + authorized = None + sm = getSecurityManager() + if factory_name not in PERMISSIONS or not INavigationRoot.providedBy(context): + # Check field specific permission + if field_name: + permission_checker = queryAdapter(context, IFieldPermissionChecker) + if permission_checker is not None: + authorized = permission_checker.validate(field_name, factory_name) + elif sm.checkPermission( + PERMISSIONS.get(factory_name, DEFAULT_PERMISSION), context + ): + # If no checker, fall back to checking the global registry + authorized = True + + if not authorized: + raise VocabLookupException("Vocabulary lookup not allowed") + + # Short circuit if we are on the site root and permission is + # in global registry + elif not sm.checkPermission( + PERMISSIONS.get(factory_name, DEFAULT_PERMISSION), context + ): + raise VocabLookupException("Vocabulary lookup not allowed") + + factory = queryUtility(IVocabularyFactory, factory_name) + if not factory: + raise VocabLookupException( + 'No factory with name "%s" exists.' % factory_name + ) + + # This part is for backwards-compatibility with the first + # generation of vocabularies created for plone.app.widgets, + # which take the (unparsed) query as a parameter of the vocab + # factory rather than as a separate search method. + if isinstance(factory, FunctionType): + factory_spec = inspect.getfullargspec(factory) + else: + factory_spec = inspect.getfullargspec(factory.__call__) + query = _parseJSON(self.request.get("query", "")) + if query and "query" in factory_spec.args: + vocabulary = factory(context, query=query) + else: + # This is what is reached for non-legacy vocabularies. + vocabulary = factory(context) + + return vocabulary + + +class SourceView(BaseVocabularyView): + """Queries a field's source and returns JSON-formatted results.""" + + def get_context(self): + if ISubForm.providedBy(self.context.form): + context = self.context.form.parentForm.context + elif isinstance(self.context.form, ObjectWidget): + context = self.context.form.form.context + else: + context = self.context.context + return context + + @property + @memoize + def default_permission(self): + if IAddForm.providedBy(self.context.form): + return "cmf.AddPortalContent" + return "cmf.ModifyPortalContent" + + def get_vocabulary(self): + widget = self.context + field = widget.field.bind(widget.context) + + # check field's write permission + info = mergedTaggedValueDict(field.interface, WRITE_PERMISSIONS_KEY) + permission_name = info.get(field.__name__, self.default_permission) + permission = queryUtility(IPermission, name=permission_name) + if permission is None: + permission = getUtility(IPermission, name=self.default_permission) + if not getSecurityManager().checkPermission( + permission.title, self.get_context() + ): + raise VocabLookupException("Vocabulary lookup not allowed.") + + if ICollection.providedBy(field): + return field.value_type.vocabulary + return field.vocabulary diff --git a/src/plone/app/layout/content/configure.zcml b/src/plone/app/layout/content/configure.zcml new file mode 100644 index 00000000..505360d9 --- /dev/null +++ b/src/plone/app/layout/content/configure.zcml @@ -0,0 +1,7 @@ + + + + + diff --git a/src/plone/app/layout/tests/image.png b/src/plone/app/layout/tests/image.png new file mode 100644 index 00000000..4c109bab Binary files /dev/null and b/src/plone/app/layout/tests/image.png differ diff --git a/src/plone/app/layout/tests/test_actions.py b/src/plone/app/layout/tests/test_actions.py new file mode 100644 index 00000000..6962b7c5 --- /dev/null +++ b/src/plone/app/layout/tests/test_actions.py @@ -0,0 +1,474 @@ +from plone.app.content.testing import PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING +from plone.app.testing import login +from plone.app.testing import logout +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from plone.app.testing import TEST_USER_NAME +from plone.app.testing import TEST_USER_PASSWORD +from plone.locking.interfaces import ILockable +from plone.testing.zope import Browser +from z3c.form.interfaces import IFormLayer +from zExceptions import NotFound +from zExceptions import Unauthorized +from zope.component import getMultiAdapter +from zope.interface import alsoProvides + +import transaction +import unittest + + +class ActionsDXTestCase(unittest.TestCase): + layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + + self.portal.acl_users.userFolderAddUser( + "editor", TEST_USER_PASSWORD, ["Editor"], [] + ) + + # For z3c.forms request must provide IFormLayer + alsoProvides(self.request, IFormLayer) + + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + self.portal.invokeFactory(type_name="Folder", id="f1", title="A Tést Folder") + + transaction.commit() + self.browser = Browser(self.layer["app"]) + self.browser.handleErrors = False + self.browser.addHeader( + "Authorization", f"Basic {TEST_USER_NAME}:{TEST_USER_PASSWORD}" + ) + + def tearDown(self): + if "f1" in self.portal.objectIds(): + self.portal.manage_delObjects(ids="f1") + transaction.commit() + + def test_delete_confirmation(self): + folder = self.portal["f1"] + + form = getMultiAdapter((folder, self.request), name="delete_confirmation") + form.update() + + cancel = form.buttons["Cancel"] + form.handlers.getHandler(cancel)(form, form) + + self.assertFalse(form.is_locked) + + def test_delete_confirmation_if_locked(self): + folder = self.portal["f1"] + lockable = ILockable.providedBy(folder) + + form = getMultiAdapter((folder, self.request), name="delete_confirmation") + form.update() + + self.assertFalse(form.is_locked) + + if lockable: + lockable.lock() + + form = getMultiAdapter((folder, self.request), name="delete_confirmation") + form.update() + + self.assertFalse(form.is_locked) + + # After switching the user it should not be possible to delete the + # object. Of course this is only possible if our context provides + # ILockable interface. + if lockable: + logout() + login(self.portal, "editor") + + form = getMultiAdapter((folder, self.request), name="delete_confirmation") + form.update() + self.assertTrue(form.is_locked) + + logout() + login(self.portal, TEST_USER_NAME) + + ILockable(folder).unlock() + + def test_delete_confirmation_cancel(self): + folder = self.portal["f1"] + + self.browser.open(folder.absolute_url() + "/delete_confirmation") + self.browser.getControl(name="form.buttons.Cancel").click() + context_state = getMultiAdapter( + (folder, self.request), name="plone_context_state" + ) + self.assertEqual(self.browser.url, context_state.view_url()) + + def prepare_for_acquisition_tests(self): + """create content and an alternate authenticated browser session + + creates the following content structure: + + |-- f1 + |   |-- test + |-- test + """ + # create a page at the root and one nested with the same id. + p1 = self.portal.invokeFactory( + type_name="Document", id="test", title="Test Page at Root" + ) + folder_1 = self.portal["f1"] + p2 = folder_1.invokeFactory( + type_name="Document", id="test", title="Test Page in Folder" + ) + contained_test_page = folder_1[p2] + + transaction.commit() + + # create an alternate browser also logged in with manager + browser_2 = Browser(self.layer["app"]) + browser_2.handleErrors = False + browser_2.addHeader( + "Authorization", f"Basic {TEST_USER_NAME}:{TEST_USER_PASSWORD}" + ) + + # return the id of the root page, the nested page itself, and the + # alternate browser + return p1, contained_test_page, browser_2 + + def test_delete_wrong_object_by_acquisition_with_action(self): + """exposes delete-by-acquisition bug using the delete action + + see https://github.com/plone/Products.CMFPlone/issues/383 + """ + p1_id, page_2, browser_2 = self.prepare_for_acquisition_tests() + + # open two different browsers to the 'delete confirmation' view + delete_url = page_2.absolute_url() + "/delete_confirmation" + self.browser.open(delete_url) + browser_2.open(delete_url) + self.assertTrue(p1_id in self.portal) + + # Try to delete in both browsers + self.browser.getControl(name="form.buttons.Delete").click() + try: + browser_2.getControl(name="form.buttons.Delete").click() + except NotFound: + pass + + # the nested folder should be gone, but the one at the root should + # remain. + self.assertFalse(page_2.id in self.portal["f1"]) + self.assertTrue(p1_id in self.portal) + + def test_rename_form(self): + logout() + folder = self.portal["f1"] + + # We need zope2.CopyOrMove permission to rename content + self.browser.open(folder.absolute_url() + "/folder_rename") + self.browser.getControl(name="form.widgets.new_id").value = "f2" + self.browser.getControl(name="form.widgets.new_title").value = "F2" + self.browser.getControl(name="form.buttons.Rename").click() + self.assertEqual(folder.getId(), "f2") + self.assertEqual(folder.Title(), "F2") + self.assertEqual(self.browser.url, folder.absolute_url()) + + login(self.portal, TEST_USER_NAME) + self.portal.manage_delObjects(ids="f2") + transaction.commit() + + def test_rename_form_with_view_action(self): + # can't be bothered to register blobs, instead we add documents to + # typesUseViewActionInListings + registry = self.portal.portal_registry + registry["plone.types_use_view_action_in_listings"] = [ + "Image", + "File", + "Document", + ] + + folder = self.portal["f1"] + folder.invokeFactory("Document", "document1") + document1 = folder["document1"] + transaction.commit() + logout() + + # We need zope2.CopyOrMove permission to rename content + self.browser.open(document1.absolute_url() + "/object_rename") + self.browser.getControl(name="form.widgets.new_id").value = "f2" + self.browser.getControl(name="form.widgets.new_title").value = "F2" + self.browser.getControl(name="form.buttons.Rename").click() + self.assertEqual(document1.getId(), "f2") + self.assertEqual(document1.Title(), "F2") + self.assertEqual(self.browser.url, document1.absolute_url() + "/view") + + login(self.portal, TEST_USER_NAME) + self.portal.manage_delObjects(ids="f1") + transaction.commit() + + def test_create_safe_id_on_renaming(self): + logout() + folder = self.portal["f1"] + + # We need zope2.CopyOrMove permission to rename content + self.browser.open(folder.absolute_url() + "/folder_rename") + self.browser.getControl(name="form.widgets.new_id").value = " ? f4 4 " + self.browser.getControl(name="form.widgets.new_title").value = " F2 " + self.browser.getControl(name="form.buttons.Rename").click() + self.assertEqual(folder.getId(), "f4-4") + self.assertEqual(folder.Title(), "F2") + self.assertEqual(self.browser.url, folder.absolute_url()) + + login(self.portal, TEST_USER_NAME) + self.portal.manage_delObjects(ids="f4-4") + transaction.commit() + + def test_default_page_updated_on_rename(self): + login(self.portal, TEST_USER_NAME) + folder = self.portal["f1"] + folder.invokeFactory(type_name="Document", id="d1", title="A Doc") + doc = folder["d1"] + folder.setDefaultPage("d1") + transaction.commit() + self.assertEqual(folder.getDefaultPage(), "d1") + + # We need zope2.CopyOrMove permission to rename content + self.browser.open(doc.absolute_url() + "/object_rename") + self.browser.getControl(name="form.widgets.new_id").value = " ?renamed" + self.browser.getControl(name="form.widgets.new_title").value = "Doc" + self.browser.getControl(name="form.buttons.Rename").click() + self.assertEqual(folder.contentIds()[0], "renamed") + self.assertEqual(folder.getDefaultPage(), "renamed") + + def test_rename_form_cancel(self): + folder = self.portal["f1"] + + _id = folder.getId() + _title = folder.Title() + + self.browser.open(folder.absolute_url() + "/folder_rename") + self.browser.getControl(name="form.buttons.Cancel").click() + transaction.commit() + + self.assertEqual(self.browser.url, folder.absolute_url()) + self.assertEqual(folder.getId(), _id) + self.assertEqual(folder.Title(), _title) + + def test_rename_form_cancel_with_view_action(self): + # can't be bothered to register blobs, instead we add documents to + # typesUseViewActionInListings + registry = self.portal.portal_registry + registry["plone.types_use_view_action_in_listings"] = [ + "Image", + "File", + "Document", + ] + folder = self.portal["f1"] + folder.invokeFactory("Document", "document1") + document1 = folder["document1"] + transaction.commit() + + _id = document1.getId() + _title = document1.Title() + + self.browser.open(document1.absolute_url() + "/object_rename") + self.browser.getControl(name="form.buttons.Cancel").click() + transaction.commit() + + self.assertEqual(self.browser.url, document1.absolute_url() + "/view") + self.assertEqual(document1.getId(), _id) + self.assertEqual(document1.Title(), _title) + + def _get_token(self, context): + authenticator = getMultiAdapter((context, self.request), name="authenticator") + + return authenticator.token() + + def test_object_cut_view(self): + folder = self.portal["f1"] + + # We need pass an authenticator token to prevent Unauthorized + self.assertRaises( + Unauthorized, self.browser.open, f"{folder.absolute_url():s}/object_cut" + ) + + # We need to have Copy or Move permission to cut an object + self.browser.open( + "{:s}/object_cut?_authenticator={:s}".format( + folder.absolute_url(), self._get_token(folder) + ) + ) + + self.assertIn("__cp", self.browser.cookies) + self.assertIn(f"{folder.Title():s} cut.", self.browser.contents) + + def test_object_cut_view_with_view_action(self): + # can't be bothered to register blobs, instead we add documents to + # typesUseViewActionInListings + registry = self.portal.portal_registry + registry["plone.types_use_view_action_in_listings"] = [ + "Image", + "File", + "Document", + ] + folder = self.portal["f1"] + folder.invokeFactory("Document", "document1") + document1 = folder["document1"] + transaction.commit() + + # We need pass an authenticator token to prevent Unauthorized + self.assertRaises( + Unauthorized, self.browser.open, f"{document1.absolute_url():s}/object_cut" + ) + + # We need to have Copy or Move permission to cut an object + self.browser.open( + "{:s}/object_cut?_authenticator={:s}".format( + document1.absolute_url(), self._get_token(document1) + ) + ) + + self.assertIn("__cp", self.browser.cookies) + self.assertIn(f"{document1.Title():s} cut.", self.browser.contents) + self.assertEqual(document1.absolute_url() + "/view", self.browser.url) + + def test_object_copy_view(self): + folder = self.portal["f1"] + + # We need pass an authenticator token to prevent Unauthorized + self.assertRaises( + Unauthorized, self.browser.open, f"{folder.absolute_url():s}/object_copy" + ) + + self.browser.open( + "{:s}/object_copy?_authenticator={:s}".format( + folder.absolute_url(), self._get_token(folder) + ) + ) + + self.assertIn("__cp", self.browser.cookies) + self.assertIn(f"{folder.Title():s} copied.", self.browser.contents) + + def test_object_copy_with_view_action(self): + # can't be bothered to register blobs, instead we add documents to + # typesUseViewActionInListings + registry = self.portal.portal_registry + registry["plone.types_use_view_action_in_listings"] = [ + "Image", + "File", + "Document", + ] + + folder = self.portal["f1"] + folder.invokeFactory("Document", "document1") + document1 = folder["document1"] + transaction.commit() + + # We need pass an authenticator token to prevent Unauthorized + self.assertRaises( + Unauthorized, self.browser.open, f"{document1.absolute_url():s}/object_copy" + ) + + self.browser.open( + "{:s}/object_copy?_authenticator={:s}".format( + document1.absolute_url(), self._get_token(document1) + ) + ) + + self.assertIn("__cp", self.browser.cookies) + self.assertIn(f"{document1.Title():s} copied.", self.browser.contents) + self.assertEqual(document1.absolute_url() + "/view", self.browser.url) + + def test_object_cut_and_paste(self): + folder = self.portal["f1"] + self.portal.invokeFactory(type_name="Document", id="d1", title="A Doc") + doc = self.portal["d1"] + transaction.commit() + + self.browser.open( + "{:s}/object_cut?_authenticator={:s}".format( + doc.absolute_url(), self._get_token(doc) + ) + ) + + self.assertIn("__cp", self.browser.cookies) + self.assertIn("d1", self.portal.objectIds()) + self.assertIn("f1", self.portal.objectIds()) + + # We need pass an authenticator token to prevent Unauthorized + self.assertRaises( + Unauthorized, self.browser.open, f"{folder.absolute_url():s}/object_paste" + ) + + self.browser.open( + "{:s}/object_paste?_authenticator={:s}".format( + folder.absolute_url(), self._get_token(doc) + ) + ) + + self.assertIn("__cp", self.browser.cookies) + transaction.commit() + + self.assertNotIn("d1", self.portal.objectIds()) + self.assertIn("d1", folder.objectIds()) + self.assertIn("Item(s) pasted.", self.browser.contents) + + def test_object_copy_and_paste(self): + folder = self.portal["f1"] + folder.invokeFactory(type_name="Document", id="d1", title="A Doc") + doc = folder["d1"] + transaction.commit() + + self.browser.open( + "{:s}/object_copy?_authenticator={:s}".format( + doc.absolute_url(), self._get_token(doc) + ) + ) + + self.assertIn("__cp", self.browser.cookies) + + # We need pass an authenticator token to prevent Unauthorized + self.assertRaises( + Unauthorized, self.browser.open, f"{folder.absolute_url():s}/object_paste" + ) + + self.browser.open( + "{:s}/object_paste?_authenticator={:s}".format( + folder.absolute_url(), self._get_token(folder) + ) + ) + transaction.commit() + + self.assertIn("f1", self.portal.objectIds()) + self.assertIn("d1", folder.objectIds()) + self.assertIn("copy_of_d1", folder.objectIds()) + self.assertIn("Item(s) pasted.", self.browser.contents) + + def test_object_copy_and_paste_multiple_times(self): + folder = self.portal["f1"] + folder.invokeFactory(type_name="Document", id="d1", title="A Doc") + doc = folder["d1"] + transaction.commit() + + self.browser.open( + "{:s}/object_copy?_authenticator={:s}".format( + doc.absolute_url(), self._get_token(doc) + ) + ) + + self.assertIn("__cp", self.browser.cookies) + self.browser.open( + "{:s}/object_paste?_authenticator={:s}".format( + folder.absolute_url(), self._get_token(folder) + ) + ) + self.browser.open( + "{:s}/object_paste?_authenticator={:s}".format( + folder.absolute_url(), self._get_token(folder) + ) + ) + + # Cookie should persist, because you can paste the item multiple times + self.assertIn("__cp", self.browser.cookies) + self.assertIn("f1", self.portal.objectIds()) + self.assertIn("d1", folder.objectIds()) + self.assertIn("copy_of_d1", folder.objectIds()) + self.assertIn("copy2_of_d1", folder.objectIds()) + self.assertIn("Item(s) pasted.", self.browser.contents) diff --git a/src/plone/app/layout/tests/test_adding.py b/src/plone/app/layout/tests/test_adding.py new file mode 100644 index 00000000..b813c5e8 --- /dev/null +++ b/src/plone/app/layout/tests/test_adding.py @@ -0,0 +1,23 @@ +from Acquisition import aq_get +from plone.app.content.testing import PLONE_APP_CONTENT_INTEGRATION_TESTING + +import unittest + + +class AddingTests(unittest.TestCase): + layer = PLONE_APP_CONTENT_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + + def test_adding_acquisition(self): + adding = self.portal.unrestrictedTraverse("+") + # Check explicit Acquisition + template = aq_get(adding, "portal_skins") + self.assertTrue(template) + # Check implicit Acquisition, unfortunately the CMF skins machinery + # depends on this + template = getattr(adding, "portal_skins") + self.assertTrue(template) + # Check traversal + self.assertTrue(self.portal.unrestrictedTraverse("+/main_template")) diff --git a/src/plone/app/layout/tests/test_constrains.py b/src/plone/app/layout/tests/test_constrains.py new file mode 100644 index 00000000..0f8fbe2c --- /dev/null +++ b/src/plone/app/layout/tests/test_constrains.py @@ -0,0 +1,20 @@ +from plone.app.layout.content.browser.constraintypes import IConstrainForm +from zope.interface.exceptions import Invalid + +import unittest + + +class DocumentIntegrationTest(unittest.TestCase): + def test_formschemainvariants(self): + class Data: + allowed_types = [] + secondary_types = [] + + bad = Data() + bad.allowed_types = [] + bad.secondary_types = ["1"] + good = Data() + good.allowed_types = ["1"] + good.secondary_types = [] + self.assertTrue(IConstrainForm.validateInvariants(good) is None) + self.assertRaises(Invalid, IConstrainForm.validateInvariants, bad) diff --git a/src/plone/app/layout/tests/test_content_status_modify.py b/src/plone/app/layout/tests/test_content_status_modify.py new file mode 100644 index 00000000..7b53a48c --- /dev/null +++ b/src/plone/app/layout/tests/test_content_status_modify.py @@ -0,0 +1,180 @@ +from plone.app.content.testing import PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING +from plone.app.content.testing import PLONE_APP_CONTENT_DX_INTEGRATION_TESTING +from plone.app.testing import login +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from plone.app.testing import TEST_USER_NAME +from plone.app.testing import TEST_USER_PASSWORD +from plone.base.utils import is_expired +from plone.namedfile.file import NamedBlobFile +from plone.namedfile.file import NamedBlobImage +from plone.testing.zope import Browser +from zope.component import getMultiAdapter + +import os +import transaction +import unittest + + +class TestContentStatusModifyIntegration(unittest.TestCase): + """The the content_status_modify view. + + Until and including Plone 5.2, this was a skin script in Products.CMFPlone. + Tests adapted from CMFPlone/tests/testContentPublishing.py. + """ + + layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + self.workflow = self.portal.portal_workflow + # Make sure we can create and publish directly. + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + # Prepare content. + self.portal.invokeFactory("Folder", id="folder") + self.folder = self.portal.folder + self.folder.invokeFactory("Document", id="d1", title="Doc 1") + + def setup_authenticator(self): + from plone.protect.authenticator import createToken + + self.request.form["_authenticator"] = createToken() + + def get_content_status_modify_view(self, obj): + self.setup_authenticator() + view = getMultiAdapter((obj, self.request), name="content_status_modify") + return view + + # Test the recursive behaviour of content_status_modify: + + def testPublishingNonDefaultPageLeavesFolderAlone(self): + view = self.get_content_status_modify_view(self.folder.d1) + view("publish") + self.assertEqual( + self.workflow.getInfoFor(self.folder, "review_state"), "private" + ) + self.assertEqual( + self.workflow.getInfoFor(self.folder.d1, "review_state"), "published" + ) + + def testPublishingDefaultPagePublishesFolder(self): + self.folder.setDefaultPage("d1") + view = self.get_content_status_modify_view(self.folder.d1) + view("publish") + self.assertEqual( + self.workflow.getInfoFor(self.folder, "review_state"), "published" + ) + self.assertEqual( + self.workflow.getInfoFor(self.folder.d1, "review_state"), "published" + ) + + def testPublishingDefaultPageWhenFolderCannotBePublished(self): + self.folder.setDefaultPage("d1") + # make parent be published already when publishing its default document + # results in an attempt to do it again + view = self.get_content_status_modify_view(self.folder) + view("publish") + self.assertEqual( + self.workflow.getInfoFor(self.folder, "review_state"), "published" + ) + view = self.get_content_status_modify_view(self.folder.d1) + view("publish") + self.assertEqual( + self.workflow.getInfoFor(self.folder, "review_state"), "published" + ) + self.assertEqual( + self.workflow.getInfoFor(self.folder.d1, "review_state"), "published" + ) + + # test setting effective/expiration date and isExpired method + + def testIsExpiredWithExplicitExpiredContent(self): + view = self.get_content_status_modify_view(self.folder.d1) + view( + workflow_action="publish", + effective_date="1/1/2001", + expiration_date="1/2/2001", + ) + self.assertTrue(is_expired(self.folder.d1)) + + def testIsExpiredWithExplicitNonExpiredContent(self): + view = self.get_content_status_modify_view(self.folder.d1) + view(workflow_action="publish") + self.assertFalse(is_expired(self.folder.d1)) + + def testEditorCanSubmitButNotPublish(self): + setRoles(self.portal, TEST_USER_ID, ["Contributor"]) + self.folder.invokeFactory("Document", id="d2", title="Doc 2") + view = self.get_content_status_modify_view(self.folder.d2) + view(workflow_action="submit") + self.assertEqual( + self.workflow.getInfoFor(self.folder.d2, "review_state"), "pending" + ) + + # Now try publishing. + # For various reasons, there are no complaints/errors when trying + # a transition that you are not allowed to do. + view = self.get_content_status_modify_view(self.folder.d2) + view(workflow_action="publish") + self.assertEqual( + self.workflow.getInfoFor(self.folder.d2, "review_state"), "pending" + ) + + +class TestContentStatusModifyFunctional(unittest.TestCase): + layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + # Set workflow for files and images. + wf_tool = self.portal.portal_workflow + wf_tool.setChainForPortalTypes(["File"], "simple_publication_workflow") + wf_tool.setChainForPortalTypes(["Image"], "simple_publication_workflow") + + # Create content. + filename = os.path.join(os.path.dirname(__file__), "image.png") + with open(filename, "rb") as f: + FILE_DATA = f.read() + self.portal.invokeFactory("Document", id="d1", title="Doc 1") + self.portal.invokeFactory( + "File", + id="f1", + title="File 1", + file=NamedBlobFile(data=FILE_DATA, filename="image.png"), + ) + self.portal.invokeFactory( + "Image", + id="i1", + title="Image 1", + image=NamedBlobImage(data=FILE_DATA, filename="image.png"), + ) + self.portal_url = self.portal.absolute_url() + transaction.commit() + + # Setup the test browser. + self.browser = Browser(self.layer["app"]) + self.browser.handleErrors = False + self.browser.raiseHttpErrors = False + self.browser.addHeader( + "Authorization", f"Basic {TEST_USER_NAME}:{TEST_USER_PASSWORD}" + ) + + def test_history_doc(self): + self.browser.open(f"{self.portal_url}/d1/content_status_history") + self.browser.getControl("Save").click() + self.assertEqual(self.browser.url, f"{self.portal_url}/d1") + + def test_history_file(self): + self.browser.open(f"{self.portal_url}/f1/content_status_history") + self.browser.getControl("Save").click() + self.assertEqual(self.browser.url, f"{self.portal_url}/f1/view") + + def test_history_image(self): + self.browser.open(f"{self.portal_url}/i1/content_status_history") + self.browser.getControl("Save").click() + self.assertEqual(self.browser.url, f"{self.portal_url}/i1/view") diff --git a/src/plone/app/layout/tests/test_contents.py b/src/plone/app/layout/tests/test_contents.py new file mode 100644 index 00000000..2ec41e1a --- /dev/null +++ b/src/plone/app/layout/tests/test_contents.py @@ -0,0 +1,658 @@ +from datetime import datetime +from datetime import timedelta +from plone.app.content.testing import PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING +from plone.app.content.testing import PLONE_APP_CONTENT_DX_INTEGRATION_TESTING +from plone.app.testing import login +from plone.app.testing import setRoles +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID +from plone.app.testing import TEST_USER_NAME +from plone.dexterity.fti import DexterityFTI +from plone.protect.authenticator import createToken +from plone.registry.interfaces import IRegistry +from plone.testing.zope import Browser +from plone.uuid.interfaces import IUUID +from unittest import mock +from zope.component import getMultiAdapter +from zope.component import getUtility + +import json +import transaction +import unittest + + +class ContentsCopyTests(unittest.TestCase): + layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + + # TYPE 1 + type1_fti = DexterityFTI("type1") + type1_fti.klass = "plone.dexterity.content.Container" + type1_fti.filter_content_types = True + type1_fti.allowed_content_types = ["type1"] + type1_fti.behaviors = ( + "plone.constraintypes", + "plone.basic", + ) + self.portal.portal_types._setObject("type1", type1_fti) + self.type1_fti = type1_fti + + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + @mock.patch( + "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True + ) # noqa + def test_keep_selection_order(self): + """Keep the order of items the same as they were selected.""" + self.portal.invokeFactory("type1", id="f1", title="Folder 1") + f1 = self.portal.f1 + f1.invokeFactory("type1", id="it1", title="Item 1") + f1.invokeFactory("type1", id="it2", title="Item 2") + f1.invokeFactory("type1", id="it3", title="Item 3") + + def _test_order(sel): + self.request.form["selection"] = json.dumps([IUUID(f1[id_]) for id_ in sel]) + view = f1.restrictedTraverse("@@fc-copy") + view() + self.assertEqual([ob.id for ob in view.oblist], sel) + + _test_order(["it1", "it2", "it3"]) + _test_order(["it3", "it1", "it2"]) + + +class ContentsDeleteTests(unittest.TestCase): + layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + + # TYPE 1 + type1_fti = DexterityFTI("type1") + type1_fti.klass = "plone.dexterity.content.Container" + type1_fti.filter_content_types = True + type1_fti.allowed_content_types = ["type1"] + type1_fti.behaviors = ( + "plone.constraintypes", + "plone.basic", + ) + self.portal.portal_types._setObject("type1", type1_fti) + self.type1_fti = type1_fti + + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + @mock.patch( + "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True + ) # noqa + def test_delete_success_with_private_ancestor(self): + """Delete content item from a folder with private ancestor""" + # Create test content /it1/it2/it3 + self.portal.invokeFactory("type1", id="it1", title="Item 1") + self.portal.it1.invokeFactory("type1", id="it2", title="Item 2") + self.portal.it1.it2.invokeFactory("type1", id="it3", title="Item 3") + self.assertEqual(len(self.portal.it1.it2.contentIds()), 1) + + # Block user access to it1m but leave access to its children + self.portal.it1.__ac_local_roles_block__ = True + del self.portal.it1.__ac_local_roles__[TEST_USER_ID] + self.portal.it1.reindexObjectSecurity() + self.portal.it1.it2.reindexObjectSecurity() + + # Remove test user global roles (leaving only local owner roles on it2) + setRoles(self.portal, TEST_USER_ID, []) + + # Execute delete request + selection = [self.portal.it1.it2.it3.UID()] + self.request.form["folder"] = "/it1/it2" + self.request.form["selection"] = json.dumps(selection) + res = self.portal.it1.it2.restrictedTraverse("@@fc-delete")() + + # Check for successful deletion + res = json.loads(res) + self.assertEqual(res["status"], "success") + self.assertEqual(len(self.portal.it1.it2.contentIds()), 0) + + @mock.patch( + "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True + ) # noqa + def test_delete_success_on_inactive_content(self): + """Delete an expired content item from a folder.""" + # Create content + self.portal.invokeFactory("type1", id="it1", title="Item 1") + self.portal.it1.invokeFactory("type1", id="it2", title="Item 2") + + # Expire it2 + exp = datetime.now() - timedelta(days=10) + self.portal.it1.it2.expiration_date = exp + self.portal.it1.it2.reindexObject() + + # Remove test user global roles (leaving only local owner roles on it1 + # and below) + setRoles(self.portal, TEST_USER_ID, []) + + # Execute delete request + selection = [self.portal.it1.it2.UID()] + self.request.form["folder"] = "/it1" + self.request.form["selection"] = json.dumps(selection) + res = self.portal.it1.restrictedTraverse("@@fc-delete")() + + # Check for successful deletion + res = json.loads(res) + self.assertEqual(res["status"], "success") + self.assertEqual(len(self.portal.it1.contentIds()), 0) + + +class ContentsPasteTests(unittest.TestCase): + layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + + # TYPE 1 + type1_fti = DexterityFTI("type1") + type1_fti.klass = "plone.dexterity.content.Container" + type1_fti.filter_content_types = True + type1_fti.allowed_content_types = ["type1"] + type1_fti.behaviors = ( + "plone.constraintypes", + "plone.basic", + ) + self.portal.portal_types._setObject("type1", type1_fti) + self.type1_fti = type1_fti + + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.portal.invokeFactory("type1", id="it1", title="Item 1") + + @mock.patch( + "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True + ) # noqa + def test_paste_success(self): + """Copy content item and paste in portal root.""" + # # setup copying via @@fc-copy + # from plone.uuid.interfaces import IUUID + # self.request['selection'] = [IUUID(self.portal.it1)] + # self.portal.restrictedTraverse('@@fc-copy')() + + self.request["__cp"] = self.portal.manage_copyObjects(["it1"]) + self.request.form["folder"] = "/" + res = self.portal.restrictedTraverse("@@fc-paste")() + + res = json.loads(res) + self.assertEqual(res["status"], "success") + self.assertEqual(len(self.portal.contentIds()), 2) + + @mock.patch( + "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True + ) # noqa + def test_paste_success_paste_in_itself(self): + """Copy content item and paste in itself. Because we can.""" + self.request["__cp"] = self.portal.manage_copyObjects(["it1"]) + self.request.form["folder"] = "/it1" + res = self.portal.it1.restrictedTraverse("@@fc-paste")() + + res = json.loads(res) + self.assertEqual(res["status"], "success") + self.assertEqual(len(self.portal.it1.contentIds()), 1) + + @mock.patch( + "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True + ) # noqa + def test_paste_fail_constraint(self): + """Fail pasting content item in itself when folder constraints don't + allow to. + """ + self.type1_fti.allowed_content_types = [] # set folder constraints + self.request["__cp"] = self.portal.manage_copyObjects(["it1"]) + self.request.form["folder"] = "/it1" + res = self.portal.it1.restrictedTraverse("@@fc-paste")() + + res = json.loads(res) + self.assertEqual(res["status"], "warning") + self.assertEqual(len(self.portal.it1.contentIds()), 0) + + @mock.patch( + "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True + ) # noqa + def test_paste_success_with_private_ancestor(self): + """Copy content item and paste into a folder with private ancestor""" + # Create test content /it2/it3 + self.portal.invokeFactory("type1", id="it2", title="Item 2") + self.portal.it2.invokeFactory("type1", id="it3", title="Item 3") + self.assertEqual(len(self.portal.it2.it3.contentIds()), 0) + + # Block user access to it2, but leave access to its children + self.portal.it2.__ac_local_roles_block__ = True + del self.portal.it2.__ac_local_roles__[TEST_USER_ID] + self.portal.it2.reindexObjectSecurity() + self.portal.it2.it3.reindexObjectSecurity() + + # Remove test user global roles (leaving only local owner roles on it2) + setRoles(self.portal, TEST_USER_ID, []) + + # Execute paste + self.request["__cp"] = self.portal.manage_copyObjects(["it1"]) + self.request.form["folder"] = "/it2/it3" + res = self.portal.it2.it3.restrictedTraverse("@@fc-paste")() + + # Check for successful paste + res = json.loads(res) + self.assertEqual(res["status"], "success") + self.assertEqual(len(self.portal.it2.it3.contentIds()), 1) + + +class ContentsRenameTests(unittest.TestCase): + layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + + # TYPE 1 + type1_fti = DexterityFTI("type1") + type1_fti.klass = "plone.dexterity.content.Container" + type1_fti.filter_content_types = True + type1_fti.allowed_content_types = ["type1"] + type1_fti.behaviors = ( + "plone.constraintypes", + "plone.basic", + ) + self.portal.portal_types._setObject("type1", type1_fti) + self.type1_fti = type1_fti + + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + @mock.patch( + "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True + ) # noqa + def test_rename_success_with_private_ancestor(self): + """Rename content item from a folder with private ancestor""" + # Create test content /it1/it2/it3 + self.portal.invokeFactory("type1", id="it1", title="Item 1") + self.portal.it1.invokeFactory("type1", id="it2", title="Item 2") + self.portal.it1.it2.invokeFactory("type1", id="it3", title="Item 3") + self.assertEqual(len(self.portal.it1.it2.contentIds()), 1) + + # Block user access to it1m but leave access to its children + self.portal.it1.__ac_local_roles_block__ = True + del self.portal.it1.__ac_local_roles__[TEST_USER_ID] + self.portal.it1.reindexObjectSecurity() + self.portal.it1.it2.reindexObjectSecurity() + + # Remove test user global roles (leaving only local owner roles on it2) + setRoles(self.portal, TEST_USER_ID, []) + + # Execute rename request + self.request.form["UID_1"] = self.portal.it1.it2.it3.UID() + self.request.form["newid_1"] = "it3bak" + self.request.form["newtitle_1"] = "Item 3 BAK" + res = self.portal.it1.it2.restrictedTraverse("@@fc-rename")() + + # Check for successful deletion + res = json.loads(res) + self.assertEqual(res["status"], "success") + self.assertEqual(self.portal.it1.it2.it3bak.id, "it3bak") + self.assertEqual(self.portal.it1.it2.it3bak.title, "Item 3 BAK") + + @mock.patch( + "plone.app.layout.content.browser.contents.ContentsBaseAction.protect", lambda x: True + ) # noqa + def test_rename_success_on_inactive_content(self): + """Rename an expired content item from a folder.""" + # Create content + self.portal.invokeFactory("type1", id="it1", title="Item 1") + self.portal.it1.invokeFactory("type1", id="it2", title="Item 2") + + # Expire it2 + exp = datetime.now() - timedelta(days=10) + self.portal.it1.it2.expiration_date = exp + self.portal.it1.it2.reindexObject() + + # Remove test user global roles (leaving only local owner roles on it1 + # and below) + setRoles(self.portal, TEST_USER_ID, []) + + # Execute rename request + self.request.form["UID_1"] = self.portal.it1.it2.UID() + self.request.form["newid_1"] = "it2bak" + self.request.form["newtitle_1"] = "Item 2 BAK" + res = self.portal.it1.restrictedTraverse("@@fc-rename")() + + # Check for successful deletion + res = json.loads(res) + self.assertEqual(res["status"], "success") + self.assertEqual(self.portal.it1.it2bak.id, "it2bak") + self.assertEqual(self.portal.it1.it2bak.title, "Item 2 BAK") + + +class AllowUploadViewTests(unittest.TestCase): + layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + + # TYPE 1 + type1_fti = DexterityFTI("type1") + type1_fti.klass = "plone.dexterity.content.Container" + type1_fti.filter_content_types = True + type1_fti.allowed_content_types = [] + type1_fti.behaviors = ("plone.basic",) + self.portal.portal_types._setObject("type1", type1_fti) + self.type1_fti = type1_fti + + # TYPE 2 + type2_fti = DexterityFTI("type1") + type2_fti.klass = "plone.dexterity.content.Item" + type2_fti.filter_content_types = True + type2_fti.allowed_content_types = [] + type2_fti.behaviors = ("plone.basic",) + self.portal.portal_types._setObject("type2", type2_fti) + self.type2_fti = type2_fti + + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.portal.invokeFactory("type1", id="it1", title="Item 1") + self.portal.invokeFactory("type2", id="it2", title="Item 2") + + def test_allow_upload(self): + """Test, if file or images are allowed in a container in different FTI + configurations. + """ + + # Test non-container, none allowed + allow_upload = self.portal.it2.restrictedTraverse("@@allow_upload") + allow_upload = json.loads(allow_upload()) + + self.assertEqual(allow_upload["allowUpload"], False) + self.assertEqual(allow_upload["allowImages"], False) + self.assertEqual(allow_upload["allowFiles"], False) + + # Test none allowed + self.type1_fti.allowed_content_types = [] + allow_upload = self.portal.it1.restrictedTraverse("@@allow_upload") + allow_upload = json.loads(allow_upload()) + + self.assertEqual(allow_upload["allowUpload"], False) + self.assertEqual(allow_upload["allowImages"], False) + self.assertEqual(allow_upload["allowFiles"], False) + + # Test images allowed + self.type1_fti.allowed_content_types = ["Image"] + allow_upload = self.portal.it1.restrictedTraverse("@@allow_upload") + allow_upload = json.loads(allow_upload()) + + self.assertEqual(allow_upload["allowUpload"], True) + self.assertEqual(allow_upload["allowImages"], True) + self.assertEqual(allow_upload["allowFiles"], False) + + # Test files allowed + self.type1_fti.allowed_content_types = ["File"] + allow_upload = self.portal.it1.restrictedTraverse("@@allow_upload") + allow_upload = json.loads(allow_upload()) + + self.assertEqual(allow_upload["allowUpload"], True) + self.assertEqual(allow_upload["allowImages"], False) + self.assertEqual(allow_upload["allowFiles"], True) + + # Test images and files allowed + self.type1_fti.allowed_content_types = ["Image", "File"] + allow_upload = self.portal.it1.restrictedTraverse("@@allow_upload") + allow_upload = json.loads(allow_upload()) + + self.assertEqual(allow_upload["allowUpload"], True) + self.assertEqual(allow_upload["allowImages"], True) + self.assertEqual(allow_upload["allowFiles"], True) + + # Test files allowed, path via request variable + self.type1_fti.allowed_content_types = ["File"] + # First, test on Portal root to see the difference + allow_upload = self.portal.restrictedTraverse("@@allow_upload") + allow_upload = json.loads(allow_upload()) + self.assertEqual(allow_upload["allowUpload"], True) + self.assertEqual(allow_upload["allowImages"], True) + self.assertEqual(allow_upload["allowFiles"], True) + # Then, with path set to sub item + allow_upload = self.portal.restrictedTraverse("@@allow_upload") + allow_upload.request.form["path"] = "/plone/it1" + allow_upload = json.loads(allow_upload()) + self.assertEqual(allow_upload["allowUpload"], True) + self.assertEqual(allow_upload["allowImages"], False) + self.assertEqual(allow_upload["allowFiles"], True) + + +class FCPropertiesTests(unittest.TestCase): + layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + # Disable plone.protect for these tests + self.request.environ["REQUEST_METHOD"] = "POST" + self.request.form["_authenticator"] = createToken() + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + # set available languages + registry = getUtility(IRegistry) + registry["plone.available_languages"] = ["en", "de"] + + self.portal.invokeFactory("Folder", "main1") + self.portal.main1.invokeFactory("Folder", "sub1") + self.portal.main1.sub1.invokeFactory("Folder", "subsub1") + self.portal.main1.invokeFactory("Document", "sub2") + self.portal.invokeFactory("Document", "main2") + + self.setup_initial() + + def setup_initial(self): + # Initial Settings + self.portal.main1.exclude_from_nav = True + self.portal.main1.sub1.exclude_from_nav = True + self.portal.main1.sub1.subsub1.exclude_from_nav = True + self.portal.main1.sub2.exclude_from_nav = True + self.portal.main2.exclude_from_nav = True + + self.portal.main1.language = "en" + self.portal.main1.sub1.language = "en" + self.portal.main1.sub1.subsub1.language = "en" + self.portal.main1.sub2.language = "en" + self.portal.main2.language = "en" + + def test_fc_properties__changes__no_recurse(self): + """Test changing properties without recursion.""" + req = self.request + req.form["language"] = "de" + req.form["exclude-from-nav"] = "no" + req.form["selection"] = '["{}", "{}"]'.format( + IUUID(self.portal.main1), IUUID(self.portal.main2) + ) + + view = getMultiAdapter((self.portal, req), name="fc-properties") + + # Call the view and execute the actions + view() + + self.assertEqual(self.portal.main1.language, "de") + self.assertEqual(self.portal.main2.language, "de") + self.assertEqual(self.portal.main1.sub1.language, "en") + self.assertEqual(self.portal.main1.sub1.subsub1.language, "en") + self.assertEqual(self.portal.main1.sub2.language, "en") + + self.assertEqual(self.portal.main1.exclude_from_nav, False) + self.assertEqual(self.portal.main2.exclude_from_nav, False) + self.assertEqual(self.portal.main1.sub1.exclude_from_nav, True) + self.assertEqual(self.portal.main1.sub1.subsub1.exclude_from_nav, True) + self.assertEqual(self.portal.main1.sub2.exclude_from_nav, True) + + def test_fc_properties__changes__with_recurse(self): + """Test changing properties without recursion.""" + req = self.request + req.form["language"] = "de" + req.form["exclude-from-nav"] = "no" + req.form["recurse"] = "yes" + req.form["selection"] = '["{}", "{}"]'.format( + IUUID(self.portal.main1), IUUID(self.portal.main2) + ) + + view = getMultiAdapter((self.portal, req), name="fc-properties") + + # Call the view and execute the actions + view() + + self.assertEqual(self.portal.main1.language, "de") + self.assertEqual(self.portal.main2.language, "de") + self.assertEqual(self.portal.main1.sub1.language, "de") + self.assertEqual(self.portal.main1.sub1.subsub1.language, "de") + self.assertEqual(self.portal.main1.sub2.language, "de") + + self.assertEqual(self.portal.main1.exclude_from_nav, False) + self.assertEqual(self.portal.main2.exclude_from_nav, False) + self.assertEqual(self.portal.main1.sub1.exclude_from_nav, False) + self.assertEqual(self.portal.main1.sub1.subsub1.exclude_from_nav, False) # noqa + self.assertEqual(self.portal.main1.sub2.exclude_from_nav, False) + + +# We want to avoid hackers getting script tags inserted. +# But for example an ampersand is okay as long as it is escaped, +# although it should not be doubly escaped, because that looks wrong. +NORMAL_TEXT = "Smith & Jones" +ESCAPED_TEXT = "Smith & Jones" +DOUBLY_ESCAPED_TEXT = "Smith &amp; Jones" +# For script tags, safest is to filter them using the safe html filter. +HACKED = 'The was here.' + + +class TestSafeHtmlInFolderContents(unittest.TestCase): + """Test that the title in the folder contents is safe. + + From PloneHotfix20200121, see + https://plone.org/security/hotfix/20200121/xss-in-the-title-field-on-plone-5-0-and-higher + + Same for other fields, from PloneHotfix20210518, see + https://plone.org/security/hotfix/20210518/stored-xss-in-folder-contents + """ + + layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + def get_browser(self): + browser = Browser(self.layer["app"]) + browser.handleErrors = False + browser.addHeader( + "Authorization", + f"Basic {SITE_OWNER_NAME}:{SITE_OWNER_PASSWORD}", + ) + return browser + + def test_ampersand(self): + self.portal.invokeFactory( + "Folder", + id="folder1", + title=NORMAL_TEXT, + description=NORMAL_TEXT, + creators=(NORMAL_TEXT,), + contributors=(NORMAL_TEXT,), + ) + folder1 = self.portal.folder1 + self.assertEqual(folder1.Title(), NORMAL_TEXT) + self.assertEqual(folder1.Description(), NORMAL_TEXT) + folder1.invokeFactory( + "Document", + id="page1", + title=NORMAL_TEXT, + description=NORMAL_TEXT, + creators=(NORMAL_TEXT,), + contributors=(NORMAL_TEXT,), + ) + page1 = folder1.page1 + self.assertEqual(page1.Title(), NORMAL_TEXT) + self.assertEqual(page1.Description(), NORMAL_TEXT) + transaction.commit() + + # Check the output. + browser = self.get_browser() + browser.open(folder1.absolute_url()) + self.assert_only_escaped_text(browser) + browser.open(page1.absolute_url()) + self.assert_only_escaped_text(browser) + browser.open(folder1.absolute_url() + "/folder_contents") + self.assert_only_escaped_text(browser) + + browser.open(folder1.absolute_url() + "/@@fc-contextInfo") + self.assert_only_escaped_text(browser) + + def test_xss(self): + self.portal.invokeFactory( + "Folder", + id="folder1", + title=HACKED, + description=HACKED, + creators=(HACKED,), + contributors=(HACKED,), + ) + folder1 = self.portal.folder1 + self.assertEqual(folder1.Title(), HACKED) + # With good old Archetypes the description gets cleaned up to + # 'The alert("hacker") was here.' + # self.assertEqual(folder1.Description(), HACKED) + folder1.invokeFactory( + "Document", + id="page1", + title=HACKED, + description=HACKED, + creators=(HACKED,), + contributors=(HACKED,), + ) + page1 = folder1.page1 + self.assertEqual(page1.Title(), HACKED) + # self.assertEqual(page1.Description(), HACKED) + transaction.commit() + + # Check the output. + browser = self.get_browser() + browser.open(folder1.absolute_url()) + self.assert_not_in(HACKED, browser.contents) + browser.open(page1.absolute_url()) + self.assert_not_in(HACKED, browser.contents) + browser.open(folder1.absolute_url() + "/folder_contents") + self.assert_not_in(HACKED, browser.contents) + + browser.open(folder1.absolute_url() + "/@@fc-contextInfo") + self.assert_not_in(HACKED, browser.contents) + + def assert_only_escaped_text(self, browser): + body = browser.contents + # The escaped version of the text text should be in the response text. + self.assertIn(ESCAPED_TEXT, body) + # The normal version should not. + self.assert_not_in(NORMAL_TEXT, body) + # We should avoid escaping twice. + self.assert_not_in(DOUBLY_ESCAPED_TEXT, body) + + def assert_not_in(self, target, body): + # This gives a too verbose error message, showing the entire body: + # self.assertNotIn("x", body) + # So we roll our own less verbose version. + if target not in body: + return + index = body.index(target) + start = max(0, index - 50) + end = min(index + len(target) + 50, len(body)) + assert False, "Text '{}' unexpectedly found in body: ... {} ...".format( + target, body[start:end] + ) diff --git a/src/plone/app/layout/tests/test_folder.py b/src/plone/app/layout/tests/test_folder.py new file mode 100644 index 00000000..435eba8f --- /dev/null +++ b/src/plone/app/layout/tests/test_folder.py @@ -0,0 +1,588 @@ +from DateTime import DateTime +from plone.app.content.testing import PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING +from plone.app.content.testing import PLONE_APP_CONTENT_DX_INTEGRATION_TESTING +from plone.app.testing import login +from plone.app.testing import logout +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from plone.app.testing import TEST_USER_NAME +from plone.app.testing import TEST_USER_PASSWORD +from plone.dexterity.fti import DexterityFTI +from plone.locking.interfaces import IRefreshableLockable +from plone.protect.authenticator import createToken +from plone.uuid.interfaces import IUUID +from Products.CMFCore.utils import getToolByName +from Testing.makerequest import makerequest +from transaction import commit +from urllib.parse import urlparse +from zope.annotation.interfaces import IAttributeAnnotatable +from zope.interface import alsoProvides +from zope.publisher.browser import TestRequest + +import json +import unittest + + +class BaseTest(unittest.TestCase): + def setUp(self): + self.portal = self.layer["portal"] + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.portal.invokeFactory("Document", id="page", title="page") + self.portal.page.reindexObject() + + self.request = TestRequest( + environ={"HTTP_ACCEPT_LANGUAGE": "en", "REQUEST_METHOD": "POST"}, + form={ + "selection": '["' + IUUID(self.portal.page) + '"]', + "_authenticator": createToken(), + "folder": "/", + }, + ) + self.request.REQUEST_METHOD = "POST" + # Mock physicalPathFromURL + # NOTE: won't return the right path in virtual hosting environments + self.request.physicalPathFromURL = lambda url: urlparse(url).path.split( + "/" + ) # noqa + alsoProvides(self.request, IAttributeAnnotatable) + self.userList = "one,two" + + +class DXBaseTest(BaseTest): + layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + portal_types = getToolByName(self.portal, "portal_types") + if "Document" not in portal_types.objectIds(): + fti = DexterityFTI("Document") + portal_types._setObject("Document", fti) + super().setUp() + + +class PropertiesDXTest(DXBaseTest): + def testEffective(self): + from plone.app.layout.content.browser.contents.properties import ( # noqa + PropertiesActionView, + ) + + self.request.form["effectiveDate"] = "1999/01/01 09:00" + view = PropertiesActionView(self.portal.page, self.request) + view() + self.assertEqual(self.portal.page.effective_date, DateTime("1999/01/01 09:00")) + + def testExpires(self): + from plone.app.layout.content.browser.contents.properties import ( # noqa + PropertiesActionView, + ) + + self.request.form["expirationDate"] = "1999/01/01 09:00" + view = PropertiesActionView(self.portal.page, self.request) + view() + self.assertEqual(self.portal.page.expiration_date, DateTime("1999/01/01 09:00")) + + def testSetDexterityExcludeFromNav(self): + from plone.app.layout.content.browser.contents.properties import ( # noqa + PropertiesActionView, + ) + + self.request.form["exclude-from-nav"] = "yes" + view = PropertiesActionView(self.portal.page, self.request) + view() + self.assertEqual(self.portal.page.exclude_from_nav, True) + + def testRights(self): + from plone.app.layout.content.browser.contents.properties import ( # noqa + PropertiesActionView, + ) + + self.request.form["copyright"] = "foobar" + view = PropertiesActionView(self.portal.page, self.request) + view() + self.assertEqual(self.portal.page.rights, "foobar") + + def testContributors(self): + from plone.app.layout.content.browser.contents.properties import ( # noqa + PropertiesActionView, + ) + + self.request.form["contributors"] = self.userList + view = PropertiesActionView(self.portal.page, self.request) + view() + self.assertEqual(self.portal.page.contributors, ("one", "two")) + + def testCreators(self): + from plone.app.layout.content.browser.contents.properties import ( # noqa + PropertiesActionView, + ) + + self.request.form["creators"] = self.userList + view = PropertiesActionView(self.portal.page, self.request) + view() + self.assertEqual(self.portal.page.creators, ("one", "two")) + + +class WorkflowTest(BaseTest): + layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING + + def convertDateTimeToIndexRepr(self, date): + t_tup = date.toZone("UTC").parts() + yr = t_tup[0] + mo = t_tup[1] + dy = t_tup[2] + hr = t_tup[3] + mn = t_tup[4] + + return (((yr * 12 + mo) * 31 + dy) * 24 + hr) * 60 + mn + + def testStateChange(self): + from plone.app.layout.content.browser.contents.workflow import ( # noqa + WorkflowActionView, + ) + + self.request.form["transition"] = "publish" + default_effective = DateTime(f"1969/12/31 00:00:00 {DateTime().timezone()}") + default_effective_index = self.convertDateTimeToIndexRepr(default_effective) + pc = getToolByName(self.portal, "portal_catalog") + # i need to call it, to populate catalog indexes + pc() + self.assertEqual(pc.uniqueValuesFor("effective"), (default_effective_index,)) + view = WorkflowActionView(self.portal.page, self.request) + view() + workflowTool = getToolByName(self.portal, "portal_workflow") + self.assertEqual( + workflowTool.getInfoFor(self.portal.page, "review_state"), "published" + ) + # commit to update indexes in catalog + commit() + effective_index = self.convertDateTimeToIndexRepr( + self.portal.page.effective_date + ) + self.assertIn(effective_index, pc.uniqueValuesFor("effective")) + + +class RenameTest(BaseTest): + layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING + + def test_folder_rename_objects(self): + from plone.app.layout.content.browser.contents.rename import RenameActionView + + uid = IUUID(self.portal.page) + self.portal.invokeFactory("Document", id="page2", title="2nd page") + uid2 = IUUID(self.portal.page2) + self.request.form.update( + { + "UID_0": uid, + "newid_0": "I am UnSafe! ", + "newtitle_0": "New!", + "UID_1": uid2, + "newid_1": ". ,;new id : _! ", + "newtitle_1": "Newer!", + } + ) + view = RenameActionView(self.portal, self.request) + view() + self.assertEqual(self.portal["i-am-unsafe"].title, "New!") + self.assertEqual(self.portal["new-id-_"].title, "Newer!") + + def test_default_page_updated_on_rename_objects(self): + from plone.app.layout.content.browser.contents.rename import RenameActionView + + self.portal.setDefaultPage("page") + uid = IUUID(self.portal.page) + self.request.form.update( + {"UID_0": uid, "newid_0": "page-renamed", "newtitle_0": "Page"} + ) + view = RenameActionView(self.portal, self.request) + view() + self.assertEqual(self.portal.getDefaultPage(), "page-renamed") + + +class ContextInfoTest(BaseTest): + layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING + + def testStateChange(self): + from plone.app.layout.content.browser.contents import ContextInfo + + view = ContextInfo(self.portal.page, self.request) + result = json.loads(view()) + self.assertEqual(result["object"]["Title"], "page") + self.assertTrue(len(result["breadcrumbs"]) > 0) + + +class CutCopyLockedTest(BaseTest): + """in folder contents""" + + layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.portal.acl_users.userFolderAddUser( + "editor", TEST_USER_PASSWORD, ["Editor"], [] + ) + + self.portal.invokeFactory("Document", id="page", title="page") + self.portal.page.reindexObject() + + self.env = {"HTTP_ACCEPT_LANGUAGE": "en", "REQUEST_METHOD": "POST"} + self.request = makerequest(self.layer["app"]).REQUEST + self.request.environ.update(self.env) + self.request.form = { + "selection": '["' + IUUID(self.portal.page) + '"]', + "_authenticator": createToken(), + "folder": "/", + } + self.request.REQUEST_METHOD = "POST" + + def test_cut_object_when_locked_by_current_user(self): + from plone.app.layout.content.browser.contents.cut import CutActionView + + plone_lock_info = self.portal.page.restrictedTraverse("@@plone_lock_info") + lockable = IRefreshableLockable(self.portal.page) + lockable.lock() + self.assertTrue(plone_lock_info.is_locked()) + view = CutActionView(self.portal, self.request) + view() + self.assertEqual(len(view.errors), 0) + self.assertFalse(plone_lock_info.is_locked()) + + def test_cut_object_when_locked_by_other_user(self): + from plone.app.layout.content.browser.contents.cut import CutActionView + + plone_lock_info = self.portal.page.restrictedTraverse("@@plone_lock_info") + lockable = IRefreshableLockable(self.portal.page) + lockable.lock() + logout() + + login(self.portal, "editor") + self.assertTrue(plone_lock_info.is_locked()) + self.request.form["_authenticator"] = createToken() + view = CutActionView(self.portal, self.request) + view() + self.assertEqual(len(view.errors), 1) + self.assertTrue(plone_lock_info.is_locked()) + + +class DeleteDXTest(BaseTest): + """Verify delete behavior from the folder contents view""" + + layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.portal.acl_users.userFolderAddUser( + "editor", TEST_USER_PASSWORD, ["Editor"], [] + ) + + self.portal.invokeFactory("Document", id="page", title="page") + self.portal.page.reindexObject() + + self.env = {"HTTP_ACCEPT_LANGUAGE": "en", "REQUEST_METHOD": "POST"} + self.request = makerequest(self.layer["app"]).REQUEST + self.request.environ.update(self.env) + self.request.form = { + "selection": '["' + IUUID(self.portal.page) + '"]', + "_authenticator": createToken(), + "folder": "/", + } + self.request.REQUEST_METHOD = "POST" + + def make_request(self): + request = makerequest(self.layer["app"], environ=self.env).REQUEST + self.request.environ.update(self.env) + request.REQUEST_METHOD = "POST" + return request + + def test_delete_object(self): + from plone.app.layout.content.browser.contents.delete import DeleteActionView + + page_id = self.portal.page.id + self.assertTrue(page_id in self.portal) + view = DeleteActionView(self.portal, self.request) + view() + self.assertTrue(page_id not in self.portal) + + def test_delete_object_when_locked_by_current_user(self): + from plone.app.layout.content.browser.contents.delete import DeleteActionView + + page_id = self.portal.page.id + lockable = IRefreshableLockable(self.portal.page) + lockable.lock() + view = DeleteActionView(self.portal, self.request) + view() + self.assertEqual(len(view.errors), 0) + self.assertTrue(page_id not in self.portal) + + def test_delete_object_when_locked_by_other_user(self): + from plone.app.layout.content.browser.contents.delete import DeleteActionView + + plone_lock_info = self.portal.page.restrictedTraverse("@@plone_lock_info") + lockable = IRefreshableLockable(self.portal.page) + lockable.lock() + logout() + + login(self.portal, "editor") + self.assertTrue(plone_lock_info.is_locked()) + self.request.form["_authenticator"] = createToken() + view = DeleteActionView(self.portal, self.request) + view() + self.assertEqual(len(view.errors), 1) + self.assertTrue(plone_lock_info.is_locked()) + + def test_delete_wrong_object_by_acquisition(self): + page_id = self.portal.page.id + f1 = self.portal.invokeFactory("Folder", id="f1", title="folder one") + # created a nested page with the same id as the one at the site root + p1 = self.portal[f1].invokeFactory("Document", id=page_id, title="page") + self.assertEqual(p1, page_id) + request2 = self.make_request() + + # both pages exist before we delete on + for location in [self.portal, self.portal[f1]]: + self.assertTrue(p1 in location) + + # instantiate two different views and delete the same object with each + from plone.app.layout.content.browser.contents.delete import DeleteActionView + + object_uuid = IUUID(self.portal[f1][p1]) + for req in [self.request, request2]: + req.form = { + "selection": f'["{object_uuid}"]', + "_authenticator": createToken(), + "folder": f"/{f1}/", + } + view = DeleteActionView(self.portal, req) + view() + + # the root page exists, the nested one is gone + self.assertTrue(p1 in self.portal) + self.assertFalse(p1 in self.portal[f1]) + + +class RearrangeDXTest(BaseTest): + """Verify rearrange feature from the folder contents view""" + + layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.portal.invokeFactory("Folder", id="basefolder", title="Folder Base") + self.bf = self.portal.basefolder + self.bf.reindexObject() + for idx in range(0, 5): + newid = f"f{idx}" + self.bf.invokeFactory( + "Folder", + id=newid, + # title in reverse order + title=f"Folder {4 - idx}", + ) + self.bf[newid].reindexObject() + + # create 3 documents in plone root + for idx in range(0, 3): + _id = f"page_{idx}" + self.portal.invokeFactory("Document", id=_id, title=f"Page {idx}") + self.portal[_id].reindexObject() + + self.env = {"HTTP_ACCEPT_LANGUAGE": "en", "REQUEST_METHOD": "POST"} + self.request = makerequest(self.layer["app"]).REQUEST + self.request.environ.update(self.env) + self.request.form = { + "selection": '["' + IUUID(self.bf) + '"]', + "_authenticator": createToken(), + "folder": "/basefolder", + } + self.request.REQUEST_METHOD = "POST" + + def test_initial_order(self): + # just to be sure preconditions are fine + # + # initial ids are forward + # and titles are set reversed! + self.assertEqual( + [(c[0], c[1].Title()) for c in self.bf.contentItems()], + [ + ("f0", "Folder 4"), + ("f1", "Folder 3"), + ("f2", "Folder 2"), + ("f3", "Folder 1"), + ("f4", "Folder 0"), + ], + ) + + def test_rearrange_by_title(self): + from plone.app.layout.content.browser.contents.rearrange import ( # noqa + RearrangeActionView, + ) + + self.request.form.update( + { + "rearrange_on": "sortable_title", + } + ) + view = RearrangeActionView(self.bf, self.request) + view() + self.assertEqual( + [(c[0], c[1].Title()) for c in self.bf.contentItems()], + [ + ("f4", "Folder 0"), + ("f3", "Folder 1"), + ("f2", "Folder 2"), + ("f1", "Folder 3"), + ("f0", "Folder 4"), + ], + ) + + def test_item_order_move_to_top(self): + from plone.app.layout.content.browser.contents.rearrange import ( # noqa + ItemOrderActionView, + ) + + self.request.form.update( + { + "id": "f2", + "delta": "top", + } + ) + view = ItemOrderActionView(self.bf, self.request) + view() + self.assertEqual( + [(c[0], c[1].Title()) for c in self.bf.contentItems()], + [ + ("f2", "Folder 2"), + ("f0", "Folder 4"), + ("f1", "Folder 3"), + ("f3", "Folder 1"), + ("f4", "Folder 0"), + ], + ) + + def test_item_order_move_to_bottom(self): + from plone.app.layout.content.browser.contents.rearrange import ( # noqa + ItemOrderActionView, + ) + + self.request.form.update( + { + "id": "f2", + "delta": "bottom", + } + ) + view = ItemOrderActionView(self.bf, self.request) + view() + self.assertEqual( + [(c[0], c[1].Title()) for c in self.bf.contentItems()], + [ + ("f0", "Folder 4"), + ("f1", "Folder 3"), + ("f3", "Folder 1"), + ("f4", "Folder 0"), + ("f2", "Folder 2"), + ], + ) + + def test_item_order_move_by_delta(self): + from plone.app.layout.content.browser.contents.rearrange import ( # noqa + ItemOrderActionView, + ) + + self.request.form.update( + { + "id": "f2", + "delta": "-1", + } + ) + view = ItemOrderActionView(self.bf, self.request) + view() + self.assertEqual( + [(c[0], c[1].Title()) for c in self.bf.contentItems()], + [ + ("f0", "Folder 4"), + ("f2", "Folder 2"), + ("f1", "Folder 3"), + ("f3", "Folder 1"), + ("f4", "Folder 0"), + ], + ) + + def test_item_order_move_by_delta_in_plone_root(self): + from plone.app.layout.content.browser.contents.rearrange import ( # noqa + ItemOrderActionView, + ) + + # first move the 'basefolder' to the top + self.request.form.update( + { + "id": "basefolder", + "delta": "top", + } + ) + view = ItemOrderActionView(self.portal, self.request) + view() + + # move 'basefolder' two positions down + self.request.form.update( + { + "id": "basefolder", + "delta": "2", + "subsetIds": '["basefolder", "page_0", "page_1", "page_2"]', + } + ) + view = ItemOrderActionView(self.portal, self.request) + view() + + self.assertEqual( + [(c[0], c[1].Title()) for c in self.portal.contentItems()], + [ + ("page_0", "Page 0"), + ("page_1", "Page 1"), + ("basefolder", "Folder Base"), + ("page_2", "Page 2"), + ], + ) + + +class FolderFactoriesTest(unittest.TestCase): + layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + def test_folder_factories_regression(self): + from plone.app.layout.content.browser.folderfactories import FolderFactoriesView as FFV + + view = FFV(self.portal, self.request) + self.request.form.update( + {"form.button.Add": "yes", "url": self.portal.absolute_url()} + ) + view() + self.assertEqual( + self.request.response.headers.get("location"), self.portal.absolute_url() + ) + + def test_folder_factories(self): + from plone.app.layout.content.browser.folderfactories import FolderFactoriesView as FFV + + view = FFV(self.portal, self.request) + self.request.form.update( + {"form.button.Add": "yes", "url": "http://www.foobar.com"} + ) + view() + self.assertNotEqual( + self.request.response.headers.get("location"), "http://www.foobar.com" + ) diff --git a/src/plone/app/layout/tests/test_folder_publish.py b/src/plone/app/layout/tests/test_folder_publish.py new file mode 100644 index 00000000..d3e6373d --- /dev/null +++ b/src/plone/app/layout/tests/test_folder_publish.py @@ -0,0 +1,128 @@ +from plone.app.content.testing import PLONE_APP_CONTENT_DX_INTEGRATION_TESTING +from plone.app.testing import login +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from plone.app.testing import TEST_USER_NAME +from plone.base.utils import is_expired +from zExceptions import Forbidden +from zope.component import getMultiAdapter + +import unittest + + +class TestContentPublishing(unittest.TestCase): + """Test the recursive behaviour of folder_publish. + + Adapted from CMFPlone/tests/testContentPublishing.py. + """ + + layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + self.workflow = self.portal.portal_workflow + # Make sure we can create and publish directly. + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + # Prepare content. + self.portal.invokeFactory("Folder", id="folder") + self.folder = self.portal.folder + self.folder.invokeFactory("Document", id="d1", title="Doc 1") + self.folder.invokeFactory("Folder", id="f1", title="Folder 1") + self.folder.f1.invokeFactory("Document", id="d2", title="Doc 2") + self.folder.f1.invokeFactory("Folder", id="f2", title="Folder 2") + + def setup_authenticator(self): + from plone.protect.authenticator import createToken + + self.request.form["_authenticator"] = createToken() + + def test_folder_publish_get(self, **kwargs): + self.setup_authenticator() + view = getMultiAdapter((self.folder, self.request), name="folder_publish") + with self.assertRaises(Forbidden): + view(**kwargs) + + def test_folder_publish_post_without_authenticator(self, **kwargs): + self.request.environ["REQUEST_METHOD"] = "POST" + # request.set("REQUEST_METHOD", "POST") + view = getMultiAdapter((self.folder, self.request), name="folder_publish") + with self.assertRaises(Forbidden): + view(**kwargs) + + def folder_publish(self, **kwargs): + self.request.set("REQUEST_METHOD", "POST") + self.setup_authenticator() + view = getMultiAdapter((self.folder, self.request), name="folder_publish") + return view(**kwargs) + + def test_initial_state(self): + # Depending on the Plone version, + # the review state may be visible or private. Check which one it is. + for o in (self.folder.d1, self.folder.f1, self.folder.f1.d2, self.folder.f1.f2): + self.assertEqual(self.workflow.getInfoFor(o, "review_state"), "private") + + def test_publishing_subobjects(self): + paths = [] + for o in (self.folder.d1, self.folder.f1): + paths.append("/".join(o.getPhysicalPath())) + + self.folder_publish( + workflow_action="publish", paths=paths, include_children=True + ) + for o in (self.folder.d1, self.folder.f1, self.folder.f1.d2, self.folder.f1.f2): + self.assertEqual(self.workflow.getInfoFor(o, "review_state"), "published") + self.assertEqual(self.request.response.getStatus(), 302) + self.assertEqual( + self.request.response.getHeader("Location"), self.folder.absolute_url() + ) + + def test_publishing_subobjects_and_expire_them(self): + paths = [] + for o in (self.folder.d1, self.folder.f1): + paths.append("/".join(o.getPhysicalPath())) + + self.folder_publish( + workflow_action="publish", + paths=paths, + effective_date="1/1/2001", + expiration_date="1/2/2001", + include_children=True, + ) + for o in (self.folder.d1, self.folder.f1, self.folder.f1.d2, self.folder.f1.f2): + self.assertEqual(self.workflow.getInfoFor(o, "review_state"), "published") + self.assertTrue(is_expired(o)) + + def test_publishing_without_subobjects(self): + paths = [] + for o in (self.folder.d1, self.folder.f1): + paths.append("/".join(o.getPhysicalPath())) + + self.folder_publish( + workflow_action="publish", paths=paths, include_children=False + ) + for o in (self.folder.d1, self.folder.f1): + self.assertEqual(self.workflow.getInfoFor(o, "review_state"), "published") + for o in (self.folder.f1.d2, self.folder.f1.f2): + self.assertEqual(self.workflow.getInfoFor(o, "review_state"), "private") + + def test_publishing_orig_template_safe(self): + paths = [] + for o in (self.folder.d1, self.folder.f1): + paths.append("/".join(o.getPhysicalPath())) + + self.request.form["orig_template"] = "some_view" + self.folder_publish(workflow_action="publish", paths=paths) + self.assertEqual(self.request.response.getHeader("Location"), "some_view") + + def test_publishing_orig_template_attacker(self): + paths = [] + for o in (self.folder.d1, self.folder.f1): + paths.append("/".join(o.getPhysicalPath())) + + self.request.form["orig_template"] = "https://attacker.com" + self.folder_publish(workflow_action="publish", paths=paths) + self.assertEqual( + self.request.response.getHeader("Location"), self.folder.absolute_url() + ) diff --git a/src/plone/app/layout/tests/test_non_ascii_characters_in_workflow_state.py b/src/plone/app/layout/tests/test_non_ascii_characters_in_workflow_state.py new file mode 100644 index 00000000..0d5768c5 --- /dev/null +++ b/src/plone/app/layout/tests/test_non_ascii_characters_in_workflow_state.py @@ -0,0 +1,53 @@ +from plone.app.content.testing import PLONE_APP_CONTENT_NON_ASCII_INTEGRATION_TESTING +from plone.app.testing import login +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from plone.app.testing import TEST_USER_NAME +from plone.uuid.interfaces import IUUID +from Products.CMFCore.utils import getToolByName + +import json +import unittest + + +class TestNonAsciiCharactersWorkflow(unittest.TestCase): + layer = PLONE_APP_CONTENT_NON_ASCII_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + # set non-ascii-workflow for Documents + wf_tool = getToolByName(self, "portal_workflow") + wf_tool.setChainForPortalTypes(["Document"], "non-ascii-workflow") + + # create an object having the non-ascii-workflow assigned + self.portal.invokeFactory("Document", "doc") + + def test_non_ascii_characters_in_workflow_title(self): + wf_tool = getToolByName(self, "portal_workflow") + workflow_matching_id = list( + filter( + lambda workflow: ("non-ascii-workflow" in workflow.id), + wf_tool.getWorkflowsFor(self.portal.doc), + ) + ) + + # Make sure that the non-ascii-workflow was assigned to the Document + self.assertTrue(workflow_matching_id) + + # Build POST request to get state title for the Document + documents_uid = IUUID(self.portal.doc) + self.request.form["selection"] = json.dumps(documents_uid) + self.request.form["transitions"] = True + self.request.form["render"] = "yes" + + try: + # try to do the json request which among other things returns + # the workflow state title containing non-ascii characters. + self.portal.unrestrictedTraverse("@@fc-workflow")() + except UnicodeDecodeError: + self.fail("Calling @@fc-workflow raised UnicodeDecodeError \ + unexpectedly.") diff --git a/src/plone/app/layout/tests/test_permissions.py b/src/plone/app/layout/tests/test_permissions.py new file mode 100644 index 00000000..d9bf2a7f --- /dev/null +++ b/src/plone/app/layout/tests/test_permissions.py @@ -0,0 +1,263 @@ +from plone.app.layout.content.browser.vocabulary import VocabularyView +from plone.app.testing import login +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from plone.app.testing import TEST_USER_NAME +from plone.app.z3cform.interfaces import IPloneFormLayer +from plone.app.z3cform.tests.layer import PAZ3CForm_INTEGRATION_TESTING +from plone.autoform.interfaces import WIDGETS_KEY +from plone.autoform.interfaces import WRITE_PERMISSIONS_KEY +from plone.dexterity.browser.add import DefaultAddForm +from plone.dexterity.browser.add import DefaultAddView +from plone.dexterity.fti import DexterityFTI +from z3c.form.interfaces import IFieldWidget +from z3c.form.util import getSpecification +from z3c.form.widget import FieldWidget +from zope import schema +from zope.component import provideAdapter +from zope.component.globalregistry import base +from zope.globalrequest import setRequest +from zope.interface import Interface +from zope.publisher.browser import TestRequest + +import json +import unittest + + +def add_mock_fti(portal): + # Fake DX Type + fti = DexterityFTI("dx_mock") + portal.portal_types._setObject("dx_mock", fti) + fti.klass = "plone.dexterity.content.Item" + fti.schema = "plone.app.layout.tests.test_permissions.IMockSchema" + fti.filter_content_types = False + fti.behaviors = ("plone.app.dexterity.behaviors.metadata.IBasic",) + + +def _custom_field_widget(field, request): + from plone.app.z3cform.widgets.select import AjaxSelectWidget + + widget = FieldWidget(field, AjaxSelectWidget(request)) + widget.vocabulary = "plone.app.vocabularies.PortalTypes" + return widget + + +class IMockSchema(Interface): + allowed_field = schema.Choice(vocabulary="plone.app.vocabularies.PortalTypes") + disallowed_field = schema.Choice(vocabulary="plone.app.vocabularies.PortalTypes") + default_field = schema.Choice(vocabulary="plone.app.vocabularies.PortalTypes") + custom_widget_field = schema.TextLine() + adapted_widget_field = schema.TextLine() + + +IMockSchema.setTaggedValue( + WRITE_PERMISSIONS_KEY, + { + "allowed_field": "zope2.View", + "disallowed_field": "zope2.ViewManagementScreens", + "custom_widget_field": "zope2.View", + "adapted_widget_field": "zope2.View", + }, +) +IMockSchema.setTaggedValue( + WIDGETS_KEY, + { + "custom_widget_field": _custom_field_widget, + }, +) + + +def _enable_custom_widget(field): + provideAdapter( + _custom_field_widget, + adapts=(getSpecification(field), IPloneFormLayer), + provides=IFieldWidget, + ) + + +def _disable_custom_widget(field): + base.unregisterAdapter( + required=( + getSpecification(field), + IPloneFormLayer, + ), + provided=IFieldWidget, + ) + + +class DexterityVocabularyPermissionTests(unittest.TestCase): + layer = PAZ3CForm_INTEGRATION_TESTING + + def setUp(self): + self.request = TestRequest(environ={"HTTP_ACCEPT_LANGUAGE": "en"}) + setRequest(self.request) + self.portal = self.layer["portal"] + + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + add_mock_fti(self.portal) + self.portal.invokeFactory("dx_mock", "test_dx") + + self.portal.test_dx.manage_permission("View", ("Anonymous",), acquire=False) + self.portal.test_dx.manage_permission( + "View management screens", (), acquire=False + ) + self.portal.test_dx.manage_permission( + "Modify portal content", + ("Editor", "Manager", "Site Adiminstrator"), + acquire=False, + ) + + def test_vocabulary_field_allowed(self): + view = VocabularyView(self.portal.test_dx, self.request) + self.request.form.update( + { + "name": "plone.app.vocabularies.PortalTypes", + "field": "allowed_field", + } + ) + data = json.loads(view()) + self.assertEqual( + len(data["results"]), + len(self.portal.portal_types.objectIds()), + ) + + def test_vocabulary_field_wrong_vocabulary_disallowed(self): + view = VocabularyView(self.portal.test_dx, self.request) + self.request.form.update( + { + "name": "plone.app.vocabularies.Fake", + "field": "allowed_field", + } + ) + data = json.loads(view()) + self.assertEqual(data["error"], "Vocabulary lookup not allowed") + + def test_vocabulary_field_disallowed(self): + view = VocabularyView(self.portal.test_dx, self.request) + self.request.form.update( + { + "name": "plone.app.vocabularies.PortalTypes", + "field": "disallowed_field", + } + ) + data = json.loads(view()) + self.assertEqual(data["error"], "Vocabulary lookup not allowed") + + def test_vocabulary_field_default_permission(self): + view = VocabularyView(self.portal.test_dx, self.request) + self.request.form.update( + { + "name": "plone.app.vocabularies.PortalTypes", + "field": "default_field", + } + ) + # If the field is does not have a security declaration, the + # default edit permission is tested (Modify portal content) + setRoles(self.portal, TEST_USER_ID, ["Member"]) + data = json.loads(view()) + self.assertEqual(data["error"], "Vocabulary lookup not allowed") + + setRoles(self.portal, TEST_USER_ID, ["Editor"]) + # Now access should be allowed, but the vocabulary does not exist + data = json.loads(view()) + self.assertEqual( + len(data["results"]), + len(self.portal.portal_types.objectIds()), + ) + + def test_vocabulary_field_default_permission_wrong_vocab(self): + view = VocabularyView(self.portal.test_dx, self.request) + self.request.form.update( + { + "name": "plone.app.vocabularies.Fake", + "field": "default_field", + } + ) + setRoles(self.portal, TEST_USER_ID, ["Editor"]) + # Now access should be allowed, but the vocabulary does not exist + data = json.loads(view()) + self.assertEqual(data["error"], "Vocabulary lookup not allowed") + + def test_vocabulary_missing_field(self): + view = VocabularyView(self.portal.test_dx, self.request) + self.request.form.update( + { + "name": "plone.app.vocabularies.PortalTypes", + "field": "missing_field", + } + ) + setRoles(self.portal, TEST_USER_ID, ["Member"]) + with self.assertRaises(AttributeError): + view() + + def test_vocabulary_on_widget(self): + view = VocabularyView(self.portal.test_dx, self.request) + self.request.form.update( + { + "name": "plone.app.vocabularies.PortalTypes", + "field": "custom_widget_field", + } + ) + data = json.loads(view()) + self.assertEqual( + len(data["results"]), + len(self.portal.portal_types.objectIds()), + ) + self.request.form["name"] = "plone.app.vocabularies.Fake" + data = json.loads(view()) + self.assertEqual(data["error"], "Vocabulary lookup not allowed") + + def test_vocabulary_on_adapted_widget(self): + _enable_custom_widget(IMockSchema["adapted_widget_field"]) + view = VocabularyView(self.portal.test_dx, self.request) + self.request.form.update( + { + "name": "plone.app.vocabularies.PortalTypes", + "field": "adapted_widget_field", + } + ) + data = json.loads(view()) + self.assertEqual( + len(data["results"]), + len(self.portal.portal_types.objectIds()), + ) + + self.request.form["name"] = "plone.app.vocabularies.Fake" + data = json.loads(view()) + self.assertEqual(data["error"], "Vocabulary lookup not allowed") + _disable_custom_widget(IMockSchema["adapted_widget_field"]) + + def test_vocabulary_field_allowed_from_add_view(self): + add_view = DefaultAddView( + self.portal, self.request, self.portal.portal_types["dx_mock"] + ) + view = VocabularyView(add_view, self.request) + self.request.form.update( + { + "name": "plone.app.vocabularies.PortalTypes", + "field": "allowed_field", + } + ) + data = json.loads(view()) + self.assertEqual( + len(data["results"]), + len(self.portal.portal_types.objectIds()), + ) + + def test_vocabulary_field_allowed_from_add_form(self): + add_form = DefaultAddForm(self.portal, self.request) + add_form.portal_type = "dx_mock" + view = VocabularyView(add_form, self.request) + self.request.form.update( + { + "name": "plone.app.vocabularies.PortalTypes", + "field": "allowed_field", + } + ) + data = json.loads(view()) + self.assertEqual( + len(data["results"]), + len(self.portal.portal_types.objectIds()), + ) diff --git a/src/plone/app/layout/tests/test_reviewlist.py b/src/plone/app/layout/tests/test_reviewlist.py new file mode 100644 index 00000000..e63b4a3b --- /dev/null +++ b/src/plone/app/layout/tests/test_reviewlist.py @@ -0,0 +1,82 @@ +from plone.app.content.testing import PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from plone.app.testing import TEST_USER_PASSWORD +from plone.testing.zope import Browser +from Products.CMFCore.utils import getToolByName + +import transaction +import unittest + + +class ReviewListTestCase(unittest.TestCase): + """dsfsdaf""" + + layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.uf = self.portal.acl_users + self.uf.userFolderAddUser("reviewer", TEST_USER_PASSWORD, ["Reviewer"], []) + transaction.commit() + self.browser = Browser(self.layer["app"]) + self.browser.handleErrors = True + self.wftool = getToolByName(self.portal, "portal_workflow") + + def createDocument(self, id, title, description): + setRoles( + self.portal, + TEST_USER_ID, + [ + "Manager", + ], + ) + self.portal.invokeFactory(id=id, type_name="Document") + doc = getattr(self.portal, id) + doc.title = title + doc.description = description + # we don't want it in the navigation + doc.exclude_from_nav = True + doc.reindexObject() + transaction.commit() + return doc + + def submitToReview(self, obj): + """call the workflow action 'submit' for an object""" + self.wftool.doActionFor(obj, "submit") + + def test_unauthenticated(self): + """ + unauthenticated users do not have the necessary permissions to view + the review list + """ + self.browser.open("http://nohost/plone/full_review_list") + self.assertTrue("Login Name" in self.browser.contents) + + def test_authenticated(self): + """ + authenticated users do have the necessary permissions to view + the review list + """ + self.browser.addHeader( + "Authorization", "Basic {}:{}".format("reviewer", TEST_USER_PASSWORD) + ) + self.browser.open("http://nohost/plone/full_review_list") + self.assertTrue("Full review list:" in self.browser.contents) + + def test_with_content(self): + """ + authenticated users do have the necessary permissions to view + the review list + """ + doc = self.createDocument("testdoc", "Test Document", "Test Description") + self.wftool.doActionFor(doc, "submit") + transaction.commit() + + self.browser.addHeader( + "Authorization", "Basic {}:{}".format("reviewer", TEST_USER_PASSWORD) + ) + self.browser.open("http://nohost/plone/full_review_list") + self.assertTrue("Full review list:" in self.browser.contents) + # test if the table with review items contains an entry for testdoc + self.assertTrue('value="/plone/testdoc"' in self.browser.contents) diff --git a/src/plone/app/layout/tests/test_selectdefaultpage.py b/src/plone/app/layout/tests/test_selectdefaultpage.py new file mode 100644 index 00000000..8c16a2bc --- /dev/null +++ b/src/plone/app/layout/tests/test_selectdefaultpage.py @@ -0,0 +1,167 @@ +from plone.app.content.testing import PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from plone.app.testing import TEST_USER_PASSWORD +from plone.testing.zope import Browser + +import transaction +import unittest + +FOLDER = { + "id": "testfolder", + "title": "Test Folder", + "description": "Test Folder Description", +} + +DOCUMENT = { + "id": "testdoc", + "title": "Test Document", + "description": "Test Document Description", +} + +NEWSITEM = { + "id": "testnews", + "title": "Test News Item", + "description": "Test News Item Description", +} + + +class SelectDefaultPageDXTestCase(unittest.TestCase): + layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal.acl_users.userFolderAddUser( + "editor", TEST_USER_PASSWORD, ["Editor"], [] + ) + + self._create_structure() + transaction.commit() + + self.browser = Browser(self.layer["app"]) + self.browser.addHeader( + "Authorization", "Basic {}:{}".format("editor", TEST_USER_PASSWORD) + ) + + def tearDown(self): + self.portal.manage_delObjects(ids=FOLDER["id"]) + transaction.commit() + + def _createFolder(self): + self.portal.invokeFactory(id=FOLDER["id"], type_name="Folder") + folder = getattr(self.portal, FOLDER["id"]) + folder.setTitle(FOLDER["title"]) + folder.setDescription(FOLDER["description"]) + folder.reindexObject() + # we don't want it in the navigation + # folder.setExcludeFromNav(True) + return folder + + def _createDocument(self, context): + context.invokeFactory(id=DOCUMENT["id"], type_name="Document") + doc = getattr(context, DOCUMENT["id"]) + doc.setTitle(DOCUMENT["title"]) + doc.setDescription(DOCUMENT["description"]) + doc.reindexObject() + # we don't want it in the navigation + # doc.setExcludeFromNav(True) + return doc + + def _createNewsItem(self, context): + context.invokeFactory(id=NEWSITEM["id"], type_name="News Item") + doc = getattr(context, NEWSITEM["id"]) + doc.setTitle(NEWSITEM["title"]) + doc.setDescription(NEWSITEM["description"]) + doc.reindexObject() + # we don't want it in the navigation + # doc.setExcludeFromNav(True) + return doc + + def _create_structure(self): + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + folder = self._createFolder() + self._createDocument(folder) + return folder + + def test_select_default_page_view(self): + """Check that the form can be rendered.""" + folder = self.portal.testfolder + + self.browser.open("%s/@@select_default_page" % folder.absolute_url()) + + self.assertTrue("Select default page" in self.browser.contents) + self.assertTrue('id="testdoc"' in self.browser.contents) + + def test_select_default_page_vhm_hosted(self): + # Install a Virtual Host Monster + if "virtual_hosting" not in self.app.objectIds(): + # If ZopeLite was imported, we have no default virtual + # host monster + from Products.SiteAccess.VirtualHostMonster import ( + manage_addVirtualHostMonster, + ) + + manage_addVirtualHostMonster(self.app, "virtual_hosting") + transaction.commit() + + folder = self.portal.testfolder + folder_vhm_url = ( + "{}/VirtualHostBase/http/plone.org/{}/VirtualHostRoot/{}".format( + self.app.absolute_url(), + self.portal.id, + folder.id, + ) + ) + + self.browser.open(f"{folder_vhm_url}/@@select_default_page") + + self.assertTrue("Select default page" in self.browser.contents) + self.assertTrue('id="testdoc"' in self.browser.contents) + + def test_select_default_page_view_with_folderish_type(self): + """Check if folderish types are available.""" + folder = self.portal.testfolder + folder.invokeFactory(id=FOLDER["id"], type_name="Folder") + folder2 = getattr(folder, FOLDER["id"]) + folder.setTitle(FOLDER["title"]) + folder2.reindexObject() + folder_fti = self.portal.portal_types["Folder"] + folder_fti.manage_changeProperties( + filter_content_types=True, allowed_content_types=[] + ) + view = folder.restrictedTraverse("@@select_default_page")() + + self.assertTrue('id="testdoc"' in view) + self.assertTrue('id="testfolder"' in view) + + def test_default_page_action_cancel(self): + """Check the Cancel action.""" + folder = self.portal.testfolder + + self.browser.open("%s/@@select_default_page" % folder.absolute_url()) + cancel_button = self.browser.getControl(name="form.buttons.Cancel") + cancel_button.click() + + self.assertEqual(self.browser.url, folder.absolute_url()) + self.assertIs(folder.getDefaultPage(), None) + + def test_default_page_action_save(self): + """Check the Save action.""" + folder = self.portal.testfolder + self.browser.open("%s/@@select_default_page" % folder.absolute_url()) + + submit_button = self.browser.getControl(name="form.buttons.Save") + submit_button.click() + + self.assertEqual(self.browser.url, folder.absolute_url()) + self.assertEqual(folder.getDefaultPage(), "testdoc") + + def test_selectable_types_filter(self): + self.portal.portal_registry["plone.default_page_types"] = ["News Item"] + folder = self.portal.testfolder + self._createNewsItem(folder) + + view = folder.restrictedTraverse("@@select_default_page")() + self.assertTrue('id="testdoc"' not in view) + self.assertTrue('id="testnews"' in view) diff --git a/src/plone/app/layout/tests/test_table.py b/src/plone/app/layout/tests/test_table.py new file mode 100644 index 00000000..9da4cc49 --- /dev/null +++ b/src/plone/app/layout/tests/test_table.py @@ -0,0 +1,17 @@ +from zope.component.testing import setUp +from zope.component.testing import tearDown + +import doctest +import unittest + + +def test_suite(): + return unittest.TestSuite( + doctest.DocFileSuite( + "table.txt", + package="plone.app.layout.content.browser", + optionflags=doctest.ELLIPSIS, + setUp=setUp, + tearDown=tearDown, + ) + ) diff --git a/src/plone/app/layout/tests/test_widgets.py b/src/plone/app/layout/tests/test_widgets.py new file mode 100644 index 00000000..8aef998a --- /dev/null +++ b/src/plone/app/layout/tests/test_widgets.py @@ -0,0 +1,748 @@ +from plone.app.layout.content.browser import vocabulary +from plone.app.layout.content.browser.file import FileUploadView +from plone.app.layout.content.browser.query import QueryStringIndexOptions +from plone.app.layout.content.browser.vocabulary import VocabularyView +from plone.app.content.testing import ExampleFunctionVocabulary +from plone.app.content.testing import ExampleVocabulary +from plone.app.content.testing import PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING +from plone.app.content.testing import PLONE_APP_CONTENT_DX_INTEGRATION_TESTING +from plone.app.testing import login +from plone.app.testing import logout +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from plone.app.testing import TEST_USER_NAME +from plone.app.testing import TEST_USER_PASSWORD +from plone.app.z3cform.interfaces import IFieldPermissionChecker +from Products.CMFCore.indexing import processQueue +from unittest import mock +from zope.component import getMultiAdapter +from zope.component import provideAdapter +from zope.component import provideUtility +from zope.component.globalregistry import base +from zope.globalrequest import setRequest +from zope.interface import alsoProvides +from zope.interface import Interface +from zope.interface import noLongerProvides +from zope.publisher.browser import TestRequest + +import json +import operator +import os +import transaction +import unittest + +_dir = os.path.dirname(__file__) + + +class PermissionChecker: + def __init__(self, context): + pass + + def validate(self, field_name, vocabulary_name=None): + if field_name == "allowed_field": + return True + elif field_name == "disallowed_field": + return False + else: + raise AttributeError("Missing Field") + + +class ICustomPermissionProvider(Interface): + pass + + +def _enable_permission_checker(context): + provideAdapter( + PermissionChecker, + adapts=(ICustomPermissionProvider,), + provides=IFieldPermissionChecker, + ) + alsoProvides(context, ICustomPermissionProvider) + + +def _disable_permission_checker(context): + noLongerProvides(context, ICustomPermissionProvider) + base.unregisterAdapter( + required=(ICustomPermissionProvider,), provided=IFieldPermissionChecker + ) + + +class BrowserTest(unittest.TestCase): + layer = PLONE_APP_CONTENT_DX_INTEGRATION_TESTING + + def setUp(self): + self.request = TestRequest(environ={"HTTP_ACCEPT_LANGUAGE": "en"}) + setRequest(self.request) + self.portal = self.layer["portal"] + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + provideUtility(ExampleVocabulary(), name="vocab_class") + provideUtility(ExampleFunctionVocabulary, name="vocab_function") + vocabulary.PERMISSIONS.update( + { + "vocab_class": "Modify portal content", + "vocab_function": "Modify portal content", + } + ) + + def testVocabularyQueryString(self): + """Test querying a class based vocabulary with a search string.""" + view = VocabularyView(self.portal, self.request) + self.request.form.update({"name": "vocab_class", "query": "three"}) + data = json.loads(view()) + self.assertEqual(len(data["results"]), 1) + + def testVocabularyFunctionQueryString(self): + """Test querying a function based vocabulary with a search string.""" + view = VocabularyView(self.portal, self.request) + self.request.form.update({"name": "vocab_function", "query": "third"}) + data = json.loads(view()) + self.assertEqual(len(data["results"]), 1) + + def testVocabularyNoResults(self): + """Tests that the widgets displays correctly""" + view = VocabularyView(self.portal, self.request) + query = { + "criteria": [ + { + "i": "path", + "o": "plone.app.querystring.operation.string.path", + "v": "/foo", + } + ] + } + self.request.form.update( + {"name": "plone.app.vocabularies.Catalog", "query": json.dumps(query)} + ) + data = json.loads(view()) + self.assertEqual(len(data["results"]), 0) + + def testVocabularyCatalogResults(self): + self.portal.invokeFactory("Document", id="page", title="page") + self.portal.page.reindexObject() + view = VocabularyView(self.portal, self.request) + query = { + "criteria": [ + { + "i": "path", + "o": "plone.app.querystring.operation.string.path", + "v": "/plone", + }, + { + "i": "portal_type", + "o": "plone.app.querystring.operation.selection.any", + "v": "Document", + }, + ] + } + self.request.form.update( + { + "name": "plone.app.vocabularies.Catalog", + "query": json.dumps(query), + "attributes": ["UID", "id", "title", "path"], + } + ) + data = json.loads(view()) + self.assertEqual(len(data["results"]), 1) + + def testVocabularyCatalogUnsafeMetadataAllowed(self): + """Users with permission "Modify portal content" are allowed to see + ``_unsafe_metadata``. + """ + self.portal.invokeFactory("Document", id="page", title="page") + self.portal.page.reindexObject() + view = VocabularyView(self.portal, self.request) + query = { + "criteria": [ + { + "i": "path", + "o": "plone.app.querystring.operation.string.path", + "v": "/plone/page", + } + ] + } + self.request.form.update( + { + "name": "plone.app.vocabularies.Catalog", + "query": json.dumps(query), + "attributes": [ + "id", + "commentors", + "Creator", + "listCreators", + ], + } + ) + data = json.loads(view()) + self.assertEqual(len(list(data["results"][0].keys())), 4) + + def testVocabularyCatalogUnsafeMetadataDisallowed(self): + """Users without permission "Modify portal content" are not allowed to + see ``_unsafe_metadata``. + """ + self.portal.invokeFactory("Document", id="page", title="page") + self.portal.page.reindexObject() + # Downgrade permissions + setRoles(self.portal, TEST_USER_ID, []) + view = VocabularyView(self.portal, self.request) + query = { + "criteria": [ + { + "i": "path", + "o": "plone.app.querystring.operation.string.path", + "v": "/plone/page", + } + ] + } + self.request.form.update( + { + "name": "plone.app.vocabularies.Catalog", + "query": json.dumps(query), + "attributes": [ + "id", + "commentors", + "Creator", + "listCreators", + ], + } + ) + data = json.loads(view()) + # Only one result key should be returned, as ``commentors``, + # ``Creator`` and ``listCreators`` is considered unsafe and thus + # skipped. + self.assertEqual(len(list(data["results"][0].keys())), 1) + + def testVocabularyBatching(self): + amount = 30 + for i in range(amount): + self.portal.invokeFactory( + "Document", id="page" + str(i), title="Page" + str(i) + ) + self.portal["page" + str(i)].reindexObject() + view = VocabularyView(self.portal, self.request) + query = { + "criteria": [ + { + "i": "path", + "o": "plone.app.querystring.operation.string.path", + "v": "/plone", + }, + { + "i": "portal_type", + "o": "plone.app.querystring.operation.selection.any", + "v": "Document", + }, + ] + } + # batch pages are 1-based + self.request.form.update( + { + "name": "plone.app.vocabularies.Catalog", + "query": json.dumps(query), + "attributes": ["UID", "id", "title", "path"], + "batch": {"page": "1", "size": "10"}, + } + ) + data = json.loads(view()) + self.assertEqual(len(data["results"]), 10) + self.assertEqual(data["total"], amount) + + def testVocabularyEncoding(self): + """The vocabulary should not return the binary encoded token + ("N=C3=A5=C3=B8=C3=AF"), but instead the value as the id in the result + set. Fixes an encoding problem. See: + https://github.com/plone/Products.CMFPlone/issues/650 + """ + test_val = "Nåøï" + + self.portal.invokeFactory("Document", id="page", title="page") + self.portal.page.subject = (test_val,) + self.portal.page.reindexObject(idxs=["Subject"]) + processQueue() + + self.request.form["name"] = "plone.app.vocabularies.Keywords" + results = getMultiAdapter((self.portal, self.request), name="getVocabulary")() + results = json.loads(results) + result = results["results"][0] + + self.assertEqual(result["text"], test_val) + self.assertEqual(result["id"], test_val) + + def testVocabularyHtmlEntity(self): + """The vocabulary token should not convert to htmlentities. + See https://github.com/plone/Products.CMFPlone/issues/3874 + """ + test_val = "Question & Answer" + + self.portal.invokeFactory("Document", id="page", title="page") + self.portal.page.subject = (test_val,) + self.portal.page.reindexObject(idxs=["Subject"]) + processQueue() + + self.request.form["name"] = "plone.app.vocabularies.Keywords" + results = getMultiAdapter((self.portal, self.request), name="getVocabulary")() + results = json.loads(results) + result = results["results"][0] + + self.assertEqual(result["text"], test_val) + self.assertEqual(result["id"], test_val) + + def testVocabularyUnauthorized(self): + setRoles(self.portal, TEST_USER_ID, []) + view = VocabularyView(self.portal, self.request) + self.request.form.update( + {"name": "plone.app.vocabularies.Users", "query": TEST_USER_NAME} + ) + data = json.loads(view()) + self.assertEqual(data["error"], "Vocabulary lookup not allowed") + + def testVocabularyMissing(self): + view = VocabularyView(self.portal, self.request) + self.request.form.update( + { + "name": "vocabulary.that.does.not.exist", + } + ) + data = json.loads(view()) + self.assertEqual(data["error"], "Vocabulary lookup not allowed") + + def testPermissionCheckerAllowed(self): + # Setup a custom permission checker on the portal + _enable_permission_checker(self.portal) + view = VocabularyView(self.portal, self.request) + + # Allowed field is allowed + self.request.form.update( + { + "name": "plone.app.vocabularies.PortalTypes", + "field": "allowed_field", + } + ) + data = json.loads(view()) + self.assertEqual( + len(data["results"]), len(self.portal.portal_types.objectIds()) + ) + _disable_permission_checker(self.portal) + + def testPermissionCheckerUnknownVocab(self): + _enable_permission_checker(self.portal) + view = VocabularyView(self.portal, self.request) + # Unknown vocabulary gives error + self.request.form.update( + { + "name": "vocab.does.not.exist", + "field": "allowed_field", + } + ) + data = json.loads(view()) + self.assertEqual( + data["error"], + 'No factory with name "{}" exists.'.format("vocab.does.not.exist"), + ) + _disable_permission_checker(self.portal) + + def testPermissionCheckerDisallowed(self): + _enable_permission_checker(self.portal) + view = VocabularyView(self.portal, self.request) + # Disallowed field is not allowed + # Allowed field is allowed + self.request.form.update( + { + "name": "plone.app.vocabularies.PortalTypes", + "field": "disallowed_field", + } + ) + data = json.loads(view()) + self.assertEqual(data["error"], "Vocabulary lookup not allowed") + _disable_permission_checker(self.portal) + + def testPermissionCheckerShortCircuit(self): + _enable_permission_checker(self.portal) + view = VocabularyView(self.portal, self.request) + # Known vocabulary name short-circuits field permission check + # global permission + self.request.form["name"] = "plone.app.vocabularies.Users" + self.request.form.update( + { + "name": "plone.app.vocabularies.Users", + "field": "disallowed_field", + } + ) + data = json.loads(view()) + self.assertEqual(data["results"], []) + _disable_permission_checker(self.portal) + + def testPermissionCheckerUnknownField(self): + _enable_permission_checker(self.portal) + view = VocabularyView(self.portal, self.request) + # Unknown field is raises error + self.request.form.update( + { + "name": "plone.app.vocabularies.PortalTypes", + "field": "missing_field", + } + ) + with self.assertRaises(AttributeError): + view() + _disable_permission_checker(self.portal) + + def testVocabularyUsers(self): + acl_users = self.portal.acl_users + membership = self.portal.portal_membership + amount = 10 + # Let's test that safe html is used on the fullname, + # as alternative to the workaround in PloneHotfix20210518. + for i in range(amount): + id = "user" + str(i) + acl_users.userFolderAddUser(id, TEST_USER_PASSWORD, ["Member"], []) + member = membership.getMemberById(id) + # Make user0 the hacker. + if i == 0: + fullname = "user hacker" + else: + fullname = id + member.setMemberProperties(mapping={"fullname": fullname}) + view = VocabularyView(self.portal, self.request) + self.request.form.update( + {"name": "plone.app.vocabularies.Users", "query": "user"} + ) + data = json.loads(view()) + + self.assertEqual(len(data["results"]), amount) + # Let's sort, just to be sure. + results = sorted(data["results"], key=operator.itemgetter("id")) + # The first one is the hacker. The hack should have failed. + self.assertDictEqual(results[0], {"id": "user0", "text": "user hacker"}) + + def testSource(self): + from z3c.form.browser.text import TextWidget + from zope.interface import implementer + from zope.interface import Interface + from zope.schema import Choice + from zope.schema.interfaces import ISource + + @implementer(ISource) + class DummyCatalogSource: + def search_catalog(self, query): + querytext = query["SearchableText"]["query"] + return [mock.Mock(id=querytext)] + + widget = TextWidget(self.request) + widget.context = self.portal + widget.field = Choice(source=DummyCatalogSource()) + widget.field.interface = Interface + + from plone.app.layout.content.browser.vocabulary import SourceView + + view = SourceView(widget, self.request) + query = { + "criteria": [ + { + "i": "SearchableText", + "o": "plone.app.querystring.operation.string.is", + "v": "foo", + } + ] + } + self.request.form.update( + { + "query": json.dumps(query), + "attributes": "id", + } + ) + data = json.loads(view()) + self.assertEqual(len(data["results"]), 1) + self.assertEqual(data["results"][0]["id"], "foo") + + def testSourceCollectionField(self): + # This test uses a collection field + # and a source providing the 'search' method + # to help achieve coverage. + from z3c.form.browser.text import TextWidget + from zope.interface import implementer + from zope.interface import Interface + from zope.schema import Choice + from zope.schema import List + from zope.schema.interfaces import ISource + from zope.schema.vocabulary import SimpleTerm + + @implementer(ISource) + class DummySource: + def search(self, query): + terms = [SimpleTerm(query, query)] + return iter(terms) + + widget = TextWidget(self.request) + widget.context = self.portal + widget.field = List(value_type=Choice(source=DummySource())) + widget.field.interface = Interface + + from plone.app.layout.content.browser.vocabulary import SourceView + + view = SourceView(widget, self.request) + query = { + "criteria": [ + { + "i": "SearchableText", + "o": "plone.app.querystring.operation.string.is", + "v": "foo", + } + ], + "sort_on": "id", + "sort_order": "ascending", + } + self.request.form.update( + { + "query": json.dumps(query), + "batch": json.dumps({"size": 10, "page": 1}), + } + ) + data = json.loads(view()) + self.assertEqual(len(data["results"]), 1) + self.assertEqual(data["results"][0]["id"], "foo") + + def testSourcePermissionDenied(self): + from z3c.form.browser.text import TextWidget + from zope.interface import implementer + from zope.interface import Interface + from zope.schema import Choice + from zope.schema.interfaces import ISource + + @implementer(ISource) + class DummyCatalogSource: + def search_catalog(self, query): + querytext = query["SearchableText"]["query"] + return [mock.Mock(id=querytext)] + + widget = TextWidget(self.request) + widget.context = self.portal + widget.field = Choice(source=DummyCatalogSource()) + widget.field.interface = Interface + + from plone.app.layout.content.browser.vocabulary import SourceView + + view = SourceView(widget, self.request) + query = { + "criteria": [ + { + "i": "SearchableText", + "o": "plone.app.querystring.operation.string.is", + "v": "foo", + } + ] + } + self.request.form.update( + { + "query": json.dumps(query), + } + ) + logout() + data = json.loads(view()) + self.assertEqual(data["error"], "Vocabulary lookup not allowed.") + + def testSourceDefaultPermission(self): + from plone.app.layout.content.browser.vocabulary import SourceView + from z3c.form.browser.text import TextWidget + + widget = TextWidget(self.request) + view = SourceView(widget, self.request) + self.assertEqual(view.default_permission, "cmf.ModifyPortalContent") + + def testSourceDefaultPermissionOnAddForm(self): + from plone.app.layout.content.browser.vocabulary import SourceView + from z3c.form import form + from z3c.form.browser.text import TextWidget + + widget = TextWidget(self.request) + widget.form = form.AddForm(self.portal, self.request) + + view = SourceView(widget, self.request) + self.assertEqual(view.default_permission, "cmf.AddPortalContent") + + def testSourceTextQuery(self): + from z3c.form.browser.text import TextWidget + from zope.interface import implementer + from zope.interface import Interface + from zope.schema import Choice + from zope.schema.interfaces import ISource + + @implementer(ISource) + class DummyCatalogSource: + def search(self, query): + return [mock.Mock(value=mock.Mock(id=query))] + + widget = TextWidget(self.request) + widget.context = self.portal + widget.field = Choice(source=DummyCatalogSource()) + widget.field.interface = Interface + + from plone.app.layout.content.browser.vocabulary import SourceView + + view = SourceView(widget, self.request) + self.request.form.update( + { + "query": "foo", + "attributes": "id", + } + ) + data = json.loads(view()) + self.assertEqual(len(data["results"]), 1) + self.assertEqual(data["results"][0]["id"], "foo") + + def testQueryStringConfiguration(self): + view = QueryStringIndexOptions(self.portal, self.request) + data = json.loads(view()) + # just test one so we know it's working... + self.assertEqual(data["indexes"]["sortable_title"]["sortable"], True) + + @mock.patch("zope.i18n.negotiate", new=lambda ctx: "de") + def testUntranslatableMetadata(self): + """Test translation of ``@@getVocabulary`` view results. + From the standard metadata columns, only ``Type`` is translated. + """ + # Language is set via language negotiaton patch. + + self.portal.invokeFactory("Document", id="page", title="page") + self.portal.page.reindexObject() + view = VocabularyView(self.portal, self.request) + query = { + "criteria": [ + { + "i": "path", + "o": "plone.app.querystring.operation.string.path", + "v": "/plone/page", + } + ] + } + self.request.form.update( + { + "name": "plone.app.vocabularies.Catalog", + "query": json.dumps(query), + "attributes": [ + "id", + "portal_type", + "Type", + ], + } + ) + + # data['results'] should return one item, which represents the document + # created before. + data = json.loads(view()) + + # Type is translated + self.assertEqual(data["results"][0]["Type"], "Seite") + + # portal_type is never translated + self.assertEqual(data["results"][0]["portal_type"], "Document") + + def testGetMimeIcon(self): + """Check if the returned icon is correct""" + query = { + "criteria": [ + { + "i": "portal_type", + "o": "plone.app.querystring.operation.selection.any", + "v": "File", + }, + ] + } + self.request.form.update( + { + "name": "plone.app.vocabularies.Catalog", + "attributes": ["getMimeIcon"], + "query": json.dumps(query), + } + ) + view = VocabularyView(self.portal, self.request) + + # Check an empty file + self.portal.invokeFactory("File", id="my-file", title="My file") + obj = self.portal["my-file"] + obj.reindexObject() + + self.assertListEqual(json.loads(view())["results"], [{"getMimeIcon": None}]) + + # mock a pdf + obj.file = mock.Mock(contentType="application/pdf") + obj.reindexObject() + self.assertListEqual( + json.loads(view())["results"], + [{"getMimeIcon": "/plone/++resource++mimetype.icons/pdf.png"}], + ) + + # mock something unknown + obj.file = mock.Mock(contentType="x-foo/x-bar") + obj.reindexObject() + self.assertListEqual( + json.loads(view())["results"], + [{"getMimeIcon": "/plone/++resource++mimetype.icons/unknown.png"}], + ) + + def testGeneratesValidJson(self): + from zope.schema.vocabulary import SimpleTerm + from zope.schema.vocabulary import SimpleVocabulary + + view = VocabularyView(self.portal, self.request) + vocab = SimpleVocabulary( + [ + SimpleTerm( + token=f"term {idx} ", + value=f"term {idx} ", + title=f"term {idx} ", + ) + for idx in range(3) + ] + ) + with mock.patch.object(view, "get_vocabulary", return_value=vocab): + result = view() + # The above values could result in invalid json if there is an error in + # the code: the following call would give a json.decoder.JSONDecodeError. + # See https://github.com/plone/plone.app.content/pull/288 + parsed = json.loads(result) + self.assertEqual(parsed["results"][0]["text"], "term 0 ") + + +class FunctionalBrowserTest(unittest.TestCase): + layer = PLONE_APP_CONTENT_DX_FUNCTIONAL_TESTING + + def setUp(self): + self.request = TestRequest() + setRequest(self.request) + self.portal = self.layer["portal"] + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + def testFileUpload(self): + view = FileUploadView(self.portal, self.request) + from plone.namedfile.file import FileChunk + + chunk = FileChunk(b"foobar") + chunk.filename = "test.xml" + self.request.form["file"] = chunk + self.request.REQUEST_METHOD = "POST" + # the next calls plone.app.dexterity.factories and does a + # transaction.commit. Needs cleanup and FunctionalTesting layer. + data = json.loads(view()) + self.assertEqual(data["url"], "http://nohost/plone/test.xml") + self.assertTrue(data["UID"] is not None) + # clean it up... + self.portal.manage_delObjects(["test.xml"]) + transaction.commit() + + def testFileUploadTxt(self): + view = FileUploadView(self.portal, self.request) + from plone.namedfile.file import FileChunk + + chunk = FileChunk(b"foobar") + chunk.filename = "test.txt" + self.request.form["file"] = chunk + self.request.REQUEST_METHOD = "POST" + # the next calls plone.app.dexterity.factories and does a + # transaction.commit. Needs cleanup and FunctionalTesting layer. + data = json.loads(view()) + self.assertEqual(data["url"], "http://nohost/plone/test.txt") + self.assertTrue(data["UID"] is not None) + # clean it up... + self.portal.manage_delObjects(["test.txt"]) + transaction.commit()