diff --git a/news/3953.breaking b/news/3953.breaking
new file mode 100644
index 00000000..d5550759
--- /dev/null
+++ b/news/3953.breaking
@@ -0,0 +1,2 @@
+Move plone.app.contenttypes views to plone.app.layout
+[frapell]
diff --git a/setup.py b/setup.py
index a7424451..b449c490 100644
--- a/setup.py
+++ b/setup.py
@@ -41,6 +41,7 @@
python_requires=">=3.10",
install_requires=[
"plone.app.content",
+ "plone.app.contenttypes",
"plone.app.dexterity",
"plone.app.linkintegrity",
"plone.app.relationfield",
diff --git a/src/plone/app/layout/configure.zcml b/src/plone/app/layout/configure.zcml
index f1cefa5d..5e25d403 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/contenttypes/__init__.py b/src/plone/app/layout/contenttypes/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/plone/app/layout/contenttypes/collection.py b/src/plone/app/layout/contenttypes/collection.py
new file mode 100644
index 00000000..71ff23a6
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/collection.py
@@ -0,0 +1,85 @@
+from Acquisition import aq_inner
+from plone.app.contenttypes import _
+from plone.app.contenttypes.behaviors.collection import ICollection
+from plone.app.contenttypes.behaviors.leadimage import ILeadImage
+from plone.app.contenttypes.interfaces import IFolder
+from plone.app.contenttypes.interfaces import IImage
+from plone.app.layout.contenttypes.folder import FolderView
+from plone.memoize.view import memoize
+
+
+class CollectionView(FolderView):
+ @property
+ def collection_behavior(self):
+ return ICollection(aq_inner(self.context))
+
+ @property
+ def b_size(self):
+ return getattr(self, "_b_size", self.collection_behavior.item_count)
+
+ def results(self, **kwargs):
+ """Return a content listing based result set with results from the
+ collection query.
+
+ :param **kwargs: Any keyword argument, which can be used for catalog
+ queries.
+ :type **kwargs: keyword argument
+
+ :returns: plone.app.contentlisting based result set.
+ :rtype: ``plone.app.contentlisting.interfaces.IContentListing`` based
+ sequence.
+ """
+ # Extra filter
+ contentFilter = dict(self.request.get("contentFilter", {}))
+ contentFilter.update(kwargs.get("contentFilter", {}))
+ kwargs.setdefault("custom_query", contentFilter)
+ kwargs.setdefault("batch", True)
+ kwargs.setdefault("b_size", self.b_size)
+ kwargs.setdefault("b_start", self.b_start)
+
+ results = self.collection_behavior.results(**kwargs)
+ return results
+
+ def batch(self):
+ # collection is already batched.
+ return self.results()
+
+ @property
+ @memoize
+ def _album_results(self):
+ """Get results to display an album with subalbums."""
+ results = self.results()
+ images = []
+ folders = []
+ for it in results:
+ # TODO: potentially expensive!
+ ob = it.getObject()
+ if IFolder.providedBy(ob):
+ folders.append(it)
+ elif IImage.providedBy(ob) or ILeadImage.providedBy(ob):
+ images.append(it)
+ return {"images": images, "folders": folders}
+
+ @property
+ def album_images(self):
+ """Get all images within this collection."""
+ return self._album_results["images"]
+
+ @property
+ def album_folders(self):
+ """Get all folders within this collection."""
+ return self._album_results["folders"]
+
+ def tabular_fields(self):
+ """Return a list of all metadata fields from the catalog that were
+ selected.
+ """
+ context = aq_inner(self.context)
+ wrapped = ICollection(context)
+ fields = wrapped.selectedViewFields()
+ fields = [field[0] for field in fields]
+ return fields
+
+ @property
+ def no_items_message(self):
+ return _("description_no_results_found", default="No results were found.")
diff --git a/src/plone/app/layout/contenttypes/configure.zcml b/src/plone/app/layout/contenttypes/configure.zcml
new file mode 100644
index 00000000..4854c611
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/configure.zcml
@@ -0,0 +1,226 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/contenttypes/file.py b/src/plone/app/layout/contenttypes/file.py
new file mode 100644
index 00000000..a6783305
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/file.py
@@ -0,0 +1,33 @@
+from plone.app.layout.contenttypes.utils import Utils
+from plone.base.utils import human_readable_size
+
+import re
+
+
+class FileView(Utils):
+ def is_videotype(self):
+ ct = self.context.file.contentType
+ return "video/" in ct
+
+ def is_audiotype(self):
+ ct = self.context.file.contentType
+ return "audio/" in ct
+
+ def human_readable_size(self):
+ return human_readable_size(self.context.file.getSize())
+
+ def icon(self):
+ if "text" in self.context.file.contentType:
+ return "contenttype-text"
+ if "html" in self.context.file.contentType:
+ return "contenttype-text"
+ if re.search(r"(archive|zip|compressed)", self.context.file.contentType):
+ return "contenttype-archive"
+ if "audio" in self.context.file.contentType:
+ return "contenttype-audio"
+ if "video" in self.context.file.contentType:
+ return "contenttype-video"
+ if "pdf" in self.context.file.contentType:
+ return "contenttype-pdf"
+ else:
+ return "contenttype-file"
diff --git a/src/plone/app/layout/contenttypes/folder.py b/src/plone/app/layout/contenttypes/folder.py
new file mode 100644
index 00000000..9bdac03b
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/folder.py
@@ -0,0 +1,278 @@
+from Acquisition import aq_base
+from Acquisition import aq_inner
+from plone.app.contenttypes import _
+from plone.app.contenttypes.behaviors.leadimage import ILeadImage
+from plone.app.contenttypes.interfaces import IFolder
+from plone.app.contenttypes.interfaces import IImage
+from plone.app.vocabularies.metadatafields import get_field_label
+from plone.base.batch import Batch
+from plone.base.interfaces import ISecuritySchema
+from plone.base.interfaces import ISiteSchema
+from plone.base.utils import safe_callable
+from plone.event.interfaces import IEvent
+from plone.memoize.view import memoize
+from plone.registry.interfaces import IRegistry
+from Products.Five import BrowserView
+from zope.component import getMultiAdapter
+from zope.component import getUtility
+from zope.contentprovider.interfaces import IContentProvider
+
+import random
+
+
+class FolderView(BrowserView):
+ text_class = None
+ _plone_view = None
+ _portal_state = None
+ _pas_member = None
+
+ @property
+ def plone_view(self):
+ if not self._plone_view:
+ self._plone_view = getMultiAdapter(
+ (self.context, self.request), name="plone"
+ )
+ return self._plone_view
+
+ @property
+ def portal_state(self):
+ if not self._portal_state:
+ self._portal_state = getMultiAdapter(
+ (self.context, self.request), name="plone_portal_state"
+ )
+ return self._portal_state
+
+ @property
+ def pas_member(self):
+ if not self._pas_member:
+ self._pas_member = getMultiAdapter(
+ (self.context, self.request), name="pas_member"
+ )
+ return self._pas_member
+
+ @property
+ def b_size(self):
+ b_size = (
+ getattr(self.request, "b_size", None)
+ or getattr(self.request, "limit_display", None)
+ or 20
+ )
+ return int(b_size)
+
+ @property
+ def b_start(self):
+ b_start = getattr(self.request, "b_start", None) or 0
+ return int(b_start)
+
+ def results(self, **kwargs):
+ """Return a content listing based result set with contents of the
+ folder.
+
+ :param **kwargs: Any keyword argument, which can be used for catalog
+ queries.
+ :type **kwargs: keyword argument
+
+ :returns: plone.app.contentlisting based result set.
+ :rtype: ``plone.app.contentlisting.interfaces.IContentListing`` based
+ sequence.
+ """
+ # Extra filter
+ kwargs.update(self.request.get("contentFilter", {}))
+ if "object_provides" not in kwargs: # object_provides is more specific
+ kwargs.setdefault("portal_type", self.friendly_types)
+ kwargs.setdefault("batch", True)
+ kwargs.setdefault("b_size", self.b_size)
+ kwargs.setdefault("b_start", self.b_start)
+ kwargs.setdefault("orphan", 1)
+
+ listing = aq_inner(self.context).restrictedTraverse("@@folderListing", None)
+ if listing is None:
+ return []
+ results = listing(**kwargs)
+ return results
+
+ def batch(self):
+ batch = Batch(self.results(), size=self.b_size, start=self.b_start, orphan=1)
+ return batch
+
+ def normalizeString(self, text):
+ return self.plone_view.normalizeString(text)
+
+ def toLocalizedTime(self, time, long_format=None, time_only=None):
+ return self.plone_view.toLocalizedTime(time, long_format, time_only)
+
+ @property
+ def friendly_types(self):
+ return self.portal_state.friendly_types()
+
+ @property
+ def isAnon(self):
+ return self.portal_state.anonymous()
+
+ @property
+ def navigation_root_url(self):
+ return self.portal_state.navigation_root_url()
+
+ @property
+ def use_view_action(self):
+ registry = getUtility(IRegistry)
+ return registry.get("plone.types_use_view_action_in_listings", [])
+
+ @property
+ def show_about(self):
+ registry = getUtility(IRegistry)
+ settings = registry.forInterface(ISecuritySchema, prefix="plone")
+ show_about = getattr(settings, "allow_anon_views_about", False)
+ return show_about or not self.isAnon
+
+ @property
+ def text(self):
+ textfield = getattr(aq_base(self.context), "text", None)
+ text = (
+ textfield.output_relative_to(self.context)
+ if getattr(textfield, "output_relative_to", None)
+ else None
+ )
+ if text:
+ self.text_class = (
+ "stx"
+ if textfield.mimeType
+ in ("text/structured", "text/x-rst", "text/restructured")
+ else "plain"
+ )
+ return text
+
+ @property
+ def tabular_fields(self):
+ ret = []
+ ret.append("Title")
+ if self.show_about:
+ ret.append("Creator")
+ ret.append("Type")
+ if self.show_about:
+ ret.append("ModificationDate")
+ return ret
+
+ def tabular_field_label(self, field):
+ """Return the internationalized label (Message object) corresponding
+ to the field.
+ """
+ return get_field_label(field)
+
+ def tabular_fielddata(self, item, fieldname):
+ value = getattr(item, fieldname, "")
+ if safe_callable(value):
+ value = value()
+ if fieldname in [
+ "CreationDate",
+ "ModificationDate",
+ "Date",
+ "EffectiveDate",
+ "ExpirationDate",
+ "effective",
+ "expires",
+ "start",
+ "end",
+ "created",
+ "modified",
+ "last_comment_date",
+ ]:
+ value = self.toLocalizedTime(value, long_format=1)
+
+ return {
+ # 'title': _(fieldname, default=fieldname),
+ "value": value
+ }
+
+ def is_event(self, obj):
+ if getattr(obj, "getObject", False):
+ obj = obj.getObject()
+ return IEvent.providedBy(obj)
+
+ def formatted_date(self, item):
+ provider = getMultiAdapter(
+ (self.context, self.request, self), IContentProvider, name="formatted_date"
+ )
+ return provider(item)
+
+ @property
+ @memoize
+ def album_images(self):
+ """Get all images within this folder."""
+ provides = [
+ IImage.__identifier__,
+ ILeadImage.__identifier__,
+ ]
+ images = self.results(batch=False, object_provides=provides)
+ return images
+
+ @property
+ @memoize
+ def album_folders(self):
+ """Get all folders within this folder."""
+ images = self.results(batch=False, object_provides=IFolder.__identifier__)
+ return images
+
+ @property
+ def album_random_image(self):
+ """Get random image from this folder."""
+ img = None
+ images = self.album_images
+ if images:
+ img = random.choice(images)
+ return img
+
+ @property
+ def album_number_images(self):
+ """Get number of images from this folder."""
+ images = self.album_images
+ return len(images)
+
+ @property
+ def no_items_message(self):
+ return _(
+ "description_no_items_in_folder",
+ default="There are currently no items in this folder.",
+ )
+
+ @memoize
+ def get_thumb_scale_table(self):
+ if getattr(self.context, "suppress_thumbs", False):
+ return None
+ thsize = getattr(self.context, "thumb_scale_table", None)
+ if thsize:
+ return thsize
+ registry = getUtility(IRegistry)
+ settings = registry.forInterface(ISiteSchema, prefix="plone", check=False)
+ if settings.no_thumbs_tables:
+ return None
+ return settings.thumb_scale_table
+
+ @memoize
+ def get_thumb_scale_list(self):
+ if getattr(self.context, "suppress_thumbs", False):
+ return None
+ thsize = getattr(self.context, "thumb_scale_list", None)
+ if thsize:
+ return thsize
+ registry = getUtility(IRegistry)
+ settings = registry.forInterface(ISiteSchema, prefix="plone", check=False)
+ if settings.no_thumbs_lists:
+ return None
+ return settings.thumb_scale_listing
+
+ @memoize
+ def get_thumb_scale_summary(self):
+ if getattr(self.context, "suppress_thumbs", False):
+ return None
+ thsize = getattr(self.context, "thumb_scale_summary", None)
+ if thsize:
+ return thsize
+ registry = getUtility(IRegistry)
+ settings = registry.forInterface(ISiteSchema, prefix="plone", check=False)
+ if settings.no_thumbs_summary:
+ return None
+ return settings.thumb_scale_summary
+
+ def show_icons(self):
+ return not getattr(self.context, "suppress_icons", False)
diff --git a/src/plone/app/layout/contenttypes/full_view.py b/src/plone/app/layout/contenttypes/full_view.py
new file mode 100644
index 00000000..f2fc191f
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/full_view.py
@@ -0,0 +1,30 @@
+from plone.registry.interfaces import IRegistry
+from Products.Five.browser import BrowserView
+from zope.component import getUtility
+from zope.publisher.interfaces.browser import IBrowserView
+
+
+class FullViewItem(BrowserView):
+ @property
+ def default_view(self):
+ item_layout = self.context.getLayout()
+ default_view = self.context.restrictedTraverse(item_layout)
+ return default_view
+
+ @property
+ def item_macros(self):
+ default_view = self.default_view
+ if IBrowserView.providedBy(default_view):
+ # IBrowserView
+ return default_view.index.macros
+ # FSPageTemplate
+ return default_view.macros
+
+ @property
+ def item_url(self):
+ registry = getUtility(IRegistry)
+ use_view_action = registry.get("plone.types_use_view_action_in_listings", [])
+ url = self.context.absolute_url()
+ if self.context.portal_type in use_view_action:
+ url = f"{url}/view"
+ return url
diff --git a/src/plone/app/layout/contenttypes/images/icon_export_ical.png b/src/plone/app/layout/contenttypes/images/icon_export_ical.png
new file mode 100644
index 00000000..df034b20
Binary files /dev/null and b/src/plone/app/layout/contenttypes/images/icon_export_ical.png differ
diff --git a/src/plone/app/layout/contenttypes/images/icon_export_vcal.png b/src/plone/app/layout/contenttypes/images/icon_export_vcal.png
new file mode 100644
index 00000000..d91f94b9
Binary files /dev/null and b/src/plone/app/layout/contenttypes/images/icon_export_vcal.png differ
diff --git a/src/plone/app/layout/contenttypes/link_redirect_view.py b/src/plone/app/layout/contenttypes/link_redirect_view.py
new file mode 100644
index 00000000..1ebc0ea8
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/link_redirect_view.py
@@ -0,0 +1,163 @@
+from plone.app.contenttypes.utils import replace_link_variables_by_paths
+from plone.app.uuid.utils import uuidToObject
+from plone.base.interfaces import ITypesSchema
+from plone.registry.interfaces import IRegistry
+from Products.CMFCore.utils import getToolByName
+from Products.Five.browser import BrowserView
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from urllib.parse import urlparse
+from zope.component import getUtility
+
+# links starting with these URL scheme should not be redirected to
+NON_REDIRECTABLE_URL_SCHEMES = [
+ "mailto:",
+ "tel:",
+ "callto:", # nonstandard according to RFC 3966. used for skype.
+ "webdav:",
+ "caldav:",
+]
+
+# links starting with these URL scheme should not be resolved to paths
+NON_RESOLVABLE_URL_SCHEMES = NON_REDIRECTABLE_URL_SCHEMES + [
+ "file:",
+ "ftp:",
+]
+
+
+def normalize_uid_from_path(url=None):
+ """
+ Args:
+ url (string): a path or orl
+
+ Returns:
+ tuple: tuple of (uid, fragment) a fragment is an anchor id e.g. #head1
+ """
+ uid = None
+ fragment = None
+
+ if not url:
+ return uid, fragment
+
+ # resolve uid
+ paths = url.split("/")
+ paths_lower = [_item.lower() for _item in paths]
+
+ if "resolveuid" in paths_lower:
+ ri = paths_lower.index("resolveuid")
+ if ri + 1 != len(paths):
+ uid = paths[ri + 1]
+ if uid == "":
+ uid = None
+
+ if not uid:
+ return uid, fragment
+
+ # resolve fragment
+ parts = urlparse(uid)
+
+ uid = parts.path
+
+ fragment = f"#{parts.fragment}" if parts.fragment else None
+
+ return uid, fragment
+
+
+class LinkRedirectView(BrowserView):
+ index = ViewPageTemplateFile("templates/link.pt")
+
+ def _url_uses_scheme(self, schemes, url=None):
+ url = url or self.context.remoteUrl.strip()
+ for scheme in schemes:
+ if url.startswith(scheme):
+ return True
+ return False
+
+ @property
+ def can_edit(self):
+ context = self.context
+ mtool = getToolByName(context, "portal_membership")
+ return mtool.checkPermission("Modify portal content", context)
+
+ def __call__(self):
+ """Redirect to the Link target URL, if and only if:
+ - redirect_links property is enabled in
+ configuration registry
+ - the link is of a redirectable type (no mailto:, etc)
+ - AND current user doesn't have permission to edit the Link"""
+
+ registry = getUtility(IRegistry)
+ settings = registry.forInterface(ITypesSchema, prefix="plone")
+ redirect_links = settings.redirect_links
+
+ redirect_links = redirect_links and not self._url_uses_scheme(
+ NON_REDIRECTABLE_URL_SCHEMES
+ )
+
+ if redirect_links and not self.can_edit:
+ return self.request.RESPONSE.redirect(self.absolute_target_url())
+ return self.index()
+
+ def url(self):
+ """Returns the url with link variables replaced."""
+ url = replace_link_variables_by_paths(
+ self.context, self.context.remoteUrl.strip()
+ )
+ return url
+
+ def display_link(self):
+ """Format the url for display"""
+
+ url = self.url()
+ if "resolveuid" in url:
+ uid = url.split("/")[-1]
+ obj = uuidToObject(uid)
+ if obj:
+ title = obj.Title()
+ meta = obj.absolute_url_path()
+ return {
+ "title": title,
+ "meta": meta,
+ }
+
+ parsed = urlparse(url)
+ if parsed.scheme == "mailto":
+ return {
+ "title": parsed.path,
+ "meta": "",
+ }
+
+ return {
+ "title": url,
+ "meta": "",
+ }
+
+ def absolute_target_url(self):
+ """Compute the absolute target URL."""
+ url = self.url()
+
+ if self._url_uses_scheme(NON_RESOLVABLE_URL_SCHEMES):
+ # For non http/https url schemes, there is no path to resolve.
+ return url
+
+ if url.startswith("."):
+ # we just need to adapt ../relative/links, /absolute/ones work
+ # anyway -> this requires relative links to start with ./ or
+ # ../
+ context_state = self.context.restrictedTraverse("@@plone_context_state")
+ url = "/".join([context_state.canonical_object_url(), url])
+ else:
+ if "resolveuid" in url:
+ uid, fragment = normalize_uid_from_path(url)
+ obj = uuidToObject(uid)
+ if obj is None:
+ # uid can't resolve, return the url
+ return url
+
+ url = obj.absolute_url()
+ if fragment is not None:
+ url = f"{url}{fragment}"
+
+ if not url.startswith(("http://", "https://")):
+ url = self.request["SERVER_URL"] + url
+
+ return url
diff --git a/src/plone/app/layout/contenttypes/migration.py b/src/plone/app/layout/contenttypes/migration.py
new file mode 100644
index 00000000..ff829623
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/migration.py
@@ -0,0 +1,112 @@
+from plone.app.contenttypes.utils import changed_base_classes
+from plone.app.contenttypes.utils import get_old_class_name_string
+from plone.app.contenttypes.utils import migrate_base_class_to_new_class
+from plone.base import PloneMessageFactory as _
+from plone.z3cform.layout import wrap_form
+from Products.CMFCore.utils import getToolByName
+from Products.statusmessages.interfaces import IStatusMessage
+from z3c.form import button
+from z3c.form import field
+from z3c.form import form
+from z3c.form.browser.checkbox import CheckBoxFieldWidget
+from zExceptions import NotFound
+from zope import schema
+from zope.interface import implementer
+from zope.interface import Interface
+from zope.schema.interfaces import IVocabularyFactory
+from zope.schema.vocabulary import SimpleVocabulary
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+@implementer(IVocabularyFactory)
+class ChangedBaseClasses:
+ def __call__(self, context):
+ """Return a vocabulary with all changed base classes."""
+ terms = []
+ for class_name, data in changed_base_classes(context).items():
+ title = "{} (➡ {}) - ({} items)".format(
+ data["old"], data["new"], data["amount"]
+ )
+ term = SimpleVocabulary.createTerm(class_name, class_name, title)
+ terms.append(term)
+ return SimpleVocabulary(terms)
+
+
+class IBaseClassMigratorForm(Interface):
+ changed_base_classes = schema.List(
+ title="Changed base classes (old class, new class and number of items)",
+ description="Select changed base classes you want to migrate. "
+ "If the new types are folderish that change is also applied.",
+ value_type=schema.Choice(
+ vocabulary="plone.app.contenttypes.migration.changed_base_classes",
+ ),
+ default=[],
+ required=True,
+ )
+
+
+class BaseClassMigratorForm(form.Form):
+ label = _(
+ "heading_class_migrator",
+ default="Update base-classes for content with changed classes",
+ )
+
+ fields = field.Fields(IBaseClassMigratorForm)
+ fields["changed_base_classes"].widgetFactory = CheckBoxFieldWidget
+ ignoreContext = True
+ enableCSRFProtection = True
+
+ def updateWidgets(self):
+ super().updateWidgets()
+ changed_base_classes = self.widgets["changed_base_classes"]
+ if not changed_base_classes.terms.terms.by_value:
+ IStatusMessage(self.request).addStatusMessage(
+ "No types with changed classes to migrate!", type="warning"
+ )
+ return
+
+ @button.buttonAndHandler("Update", name="update")
+ def handle_migrate(self, action):
+ data, errors = self.extractData()
+
+ if errors:
+ return
+
+ changed_base_classes = data.get("changed_base_classes", [])
+ messages = IStatusMessage(self.request)
+ if not changed_base_classes:
+ messages.addStatusMessage("No types were selected", type="warning")
+ return
+
+ catalog = getToolByName(self.context, "portal_catalog")
+ migrated = []
+ not_migrated = []
+ for brain in catalog():
+ try:
+ obj = brain.getObject()
+ except (KeyError, NotFound):
+ continue
+ old_class_name = get_old_class_name_string(obj)
+ if old_class_name in changed_base_classes:
+ if migrate_base_class_to_new_class(obj, migrate_to_folderish=True):
+ migrated.append(obj)
+ else:
+ not_migrated.append(obj)
+
+ info_message_template = "{0} objects were migrated."
+ warn_message_template = "{0} objects were not migrated."
+ if migrated:
+ msg = info_message_template.format(len(migrated))
+ messages.addStatusMessage(msg, type="info")
+ if not_migrated:
+ msg = warn_message_template.format(len(not_migrated))
+ messages.addStatusMessage(msg, type="warning")
+ self.request.response.redirect(self.request["ACTUAL_URL"])
+
+
+BaseClassMigrator = wrap_form(
+ BaseClassMigratorForm,
+)
diff --git a/src/plone/app/layout/contenttypes/templates/document.pt b/src/plone/app/layout/contenttypes/templates/document.pt
new file mode 100644
index 00000000..5e4da56a
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/templates/document.pt
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/contenttypes/templates/file.pt b/src/plone/app/layout/contenttypes/templates/file.pt
new file mode 100644
index 00000000..6da3fc03
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/templates/file.pt
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ image/jpeg
+
+
+
+ 1000 kB
+
+
+
+
+
+
+
+
+
+
+
+ There is no file uploaded.
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/contenttypes/templates/full_view.pt b/src/plone/app/layout/contenttypes/templates/full_view.pt
new file mode 100644
index 00000000..dee5d811
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/templates/full_view.pt
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/contenttypes/templates/full_view_item.pt b/src/plone/app/layout/contenttypes/templates/full_view_item.pt
new file mode 100644
index 00000000..e17e5790
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/templates/full_view_item.pt
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+
+ Read More…
+
+
+
+
+
+
diff --git a/src/plone/app/layout/contenttypes/templates/image.pt b/src/plone/app/layout/contenttypes/templates/image.pt
new file mode 100644
index 00000000..cb77820f
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/templates/image.pt
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Type
+ image/jpeg
+
+
+
+ Dimension
+ 400x300
+
+
+
+ File size
+
+ ${python:round(size/1024/1024, 1)}
+ MB
+
+ ${python:round(size/1024, 1)}
+ KB
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/contenttypes/templates/image_view_fullscreen.pt b/src/plone/app/layout/contenttypes/templates/image_view_fullscreen.pt
new file mode 100644
index 00000000..828d0ad9
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/templates/image_view_fullscreen.pt
@@ -0,0 +1,85 @@
+
+
+
+
+
+ Title
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/contenttypes/templates/link.pt b/src/plone/app/layout/contenttypes/templates/link.pt
new file mode 100644
index 00000000..c7689e21
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/templates/link.pt
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You see this page because you have permission to edit this link.
+ Others will be immediately redirected to the link's target URL.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/contenttypes/templates/listing.pt b/src/plone/app/layout/contenttypes/templates/listing.pt
new file mode 100644
index 00000000..9ad3df22
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/templates/listing.pt
@@ -0,0 +1,194 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ —
+ Oslo
+
+ —
+
+
+
+
+
+ by
+
+ Bob Dobalina
+
+
+
+
+
+ —
+ last modified
+
+ August 16, 2001 at 23:35:59
+
+
+
+
+
+ Place custom listing info for custom types here
+
+
+
+
+
+
+
+
+ description
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ There are currently no items in this folder.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/contenttypes/templates/listing_album.pt b/src/plone/app/layout/contenttypes/templates/listing_album.pt
new file mode 100644
index 00000000..70561c0e
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/templates/listing_album.pt
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
Image title
+
Description
+
View
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
Image title
+
Description
+
View
+
+
+
+
+
+
+
+
+
+
+
+ There are currently no items in this folder.
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/contenttypes/templates/listing_summary.pt b/src/plone/app/layout/contenttypes/templates/listing_summary.pt
new file mode 100644
index 00000000..fec35e61
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/templates/listing_summary.pt
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ description
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/contenttypes/templates/listing_tabular.pt b/src/plone/app/layout/contenttypes/templates/listing_tabular.pt
new file mode 100644
index 00000000..29b8cdc3
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/templates/listing_tabular.pt
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ There are currently no items in this folder.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/contenttypes/templates/newsitem.pt b/src/plone/app/layout/contenttypes/templates/newsitem.pt
new file mode 100644
index 00000000..77d7eac2
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/templates/newsitem.pt
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/app/layout/contenttypes/utils.py b/src/plone/app/layout/contenttypes/utils.py
new file mode 100644
index 00000000..307f5ff0
--- /dev/null
+++ b/src/plone/app/layout/contenttypes/utils.py
@@ -0,0 +1,55 @@
+from Acquisition import aq_inner
+from plone.memoize.view import memoize
+from Products.CMFCore.utils import getToolByName
+from Products.Five.browser import BrowserView
+from Products.MimetypesRegistry.MimeTypeItem import guess_icon_path
+from zope.component import getMultiAdapter
+from zope.interface import implementer
+from zope.interface import Interface
+
+PREFIX = "++resource++mimetype.icons/"
+
+
+class IUtils(Interface):
+ """ """
+
+ def getMimeTypeIcon(content_file):
+ """ """
+
+
+@implementer(IUtils)
+class Utils(BrowserView):
+ def _get_mimes(self, content_file):
+ # We use 'yield' so iteration can be cut short
+ # if the calling code is happy.
+ context = aq_inner(self.context)
+ mtr = getToolByName(context, "mimetypes_registry")
+ if content_file.contentType:
+ # this gives a tuple
+ yield from mtr.lookup(content_file.contentType)
+ if content_file.filename:
+ # this gives a single mime type
+ yield mtr.lookupExtension(content_file.filename)
+ yield from mtr.lookup("application/octet-stream")
+
+ @memoize
+ def getMimeTypeIcon(self, content_file):
+ # Get possible mime types, and try to find an icon path.
+ # Keep the first one, in case there is no good match.
+ first = None
+ for mime in self._get_mimes(content_file):
+ if first is None:
+ first = mime
+ if hasattr(mime, "icon_path"):
+ icon_path = mime.icon_path
+ if not icon_path.startswith("++"):
+ icon_path = PREFIX + icon_path
+ return icon_path
+
+ if first is None:
+ # Probably does not happen in practice.
+ return ""
+ context = aq_inner(self.context)
+ pstate = getMultiAdapter((context, self.request), name="plone_portal_state")
+ portal_url = pstate.portal_url()
+ return portal_url + "/" + guess_icon_path(first)
diff --git a/src/plone/app/layout/tests/test_contenttypes_folder.py b/src/plone/app/layout/tests/test_contenttypes_folder.py
new file mode 100644
index 00000000..fe1fde18
--- /dev/null
+++ b/src/plone/app/layout/tests/test_contenttypes_folder.py
@@ -0,0 +1,184 @@
+from datetime import datetime
+from datetime import timedelta
+from plone.app.contenttypes.testing import ( # noqa
+ PLONE_APP_CONTENTTYPES_FUNCTIONAL_TESTING,
+)
+from plone.app.contenttypes.testing import ( # noqa
+ PLONE_APP_CONTENTTYPES_INTEGRATION_TESTING,
+)
+from plone.app.contenttypes.tests.test_image import dummy_image
+from plone.app.layout.contenttypes.folder import FolderView
+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.testing.zope import Browser
+
+import unittest
+
+
+class FolderViewIntegrationTest(unittest.TestCase):
+ layer = PLONE_APP_CONTENTTYPES_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ self.request = self.layer["request"]
+ self.request["ACTUAL_URL"] = self.portal.absolute_url()
+ setRoles(self.portal, TEST_USER_ID, ["Contributor"])
+
+ def test_result_filtering(self):
+ """Test, if portal_state's friendly_types and the result method's
+ keyword arguments are included in the query.
+ """
+
+ self.portal.invokeFactory("News Item", "newsitem")
+ self.portal.invokeFactory("Document", "document")
+ view = FolderView(self.portal, self.request)
+
+ # Test, if all results are found.
+ view.portal_state.friendly_types = lambda: ["Document", "News Item"]
+ res = view.results()
+ self.assertEqual(len(res), 2)
+
+ # Test, if friendly_types does filter for types.
+ view.portal_state.friendly_types = lambda: ["Document"]
+ res = view.results()
+ self.assertEqual(len(res), 1)
+
+ # Test, if friendly_types does filter for types.
+ view.portal_state.friendly_types = lambda: ["NotExistingType"]
+ res = view.results()
+ self.assertEqual(len(res), 0)
+
+ # Test, if kwargs filtering is applied.
+ view.portal_state.friendly_types = lambda: ["NotExistingType"]
+ res = view.results(
+ object_provides="plone.app.contenttypes.interfaces.IDocument"
+ )
+ self.assertEqual(len(res), 1)
+
+ def test_result_batching(self):
+ for idx in range(5):
+ self.portal.invokeFactory("Document", f"document{idx}")
+ request = self.request.clone()
+ request.form["b_size"] = 5
+ view = FolderView(self.portal, request)
+
+ batch = view.batch()
+ self.assertEqual(batch.length, 5)
+ self.assertEqual(len([item for item in batch]), 5)
+ self.assertFalse(batch.has_next)
+
+ self.portal.invokeFactory("Document", "document5")
+
+ batch = view.batch()
+ self.assertEqual(batch.length, 6)
+ self.assertEqual(len([item for item in batch]), 6)
+ self.assertFalse(batch.has_next)
+
+ self.portal.invokeFactory("Document", "document6")
+
+ batch = view.batch()
+ self.assertEqual(batch.length, 5)
+ self.assertEqual(len([item for item in batch]), 5)
+ self.assertTrue(batch.has_next)
+ self.assertEqual(batch.next_item_count, 2)
+
+
+class FolderViewFunctionalTest(unittest.TestCase):
+ layer = PLONE_APP_CONTENTTYPES_FUNCTIONAL_TESTING
+
+ def setUp(self):
+ app = self.layer["app"]
+ self.portal = self.layer["portal"]
+ self.request = self.layer["request"]
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+ self.portal_url = self.portal.absolute_url()
+ self.portal.invokeFactory("Folder", id="folder", title="My Folder")
+ self.folder = self.portal.folder
+ self.folder_url = self.folder.absolute_url()
+ self.folder.invokeFactory("Document", id="doc1", title="Document 1")
+ import transaction
+
+ transaction.commit()
+ self.browser = Browser(app)
+ self.browser.handleErrors = False
+ self.browser.addHeader(
+ "Authorization",
+ "Basic {}:{}".format(
+ SITE_OWNER_NAME,
+ SITE_OWNER_PASSWORD,
+ ),
+ )
+
+ def test_folder_view(self):
+ self.browser.open(self.folder_url + "/view")
+ self.assertIn("My Folder", self.browser.contents)
+ self.assertIn("Document 1", self.browser.contents)
+
+ def test_folder_summary_view(self):
+ self.browser.open(self.folder_url + "/summary_view")
+ self.assertIn("My Folder", self.browser.contents)
+ self.assertIn("Document 1", self.browser.contents)
+
+ def test_folder_full_view(self):
+ self.browser.open(self.folder_url + "/full_view")
+ self.assertIn("My Folder", self.browser.contents)
+ self.assertIn("Document 1", self.browser.contents)
+
+ def test_folder_tabular_view(self):
+ self.browser.open(self.folder_url + "/tabular_view")
+ self.assertIn("My Folder", self.browser.contents)
+ self.assertIn("Document 1", self.browser.contents)
+
+ def test_folder_album_view(self):
+ self.folder.invokeFactory("Image", id="image1", title="Image 1")
+ img1 = self.folder["image1"]
+ img1.image = dummy_image()
+ import transaction
+
+ transaction.commit()
+ self.browser.open(self.folder_url + "/album_view")
+ self.assertIn("My Folder", self.browser.contents)
+ self.assertIn(
+ '
+
+ def test_list_item_wout_title(self):
+ )