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 @@ + + + + + + +
+
+ Text +
+
+ +
+
+ + + 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 @@ + + + + + + + + +
+ +
+ +
+ + + + + +
+ Filename +
+ + + +
+ +
+ Download +
+
+
+ + +

+ 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 @@ + + + +
+ +
+ +

+ + Title + +

+ +
+ +

+ Description +

+ +
+ +
+ +
+ +
+ content +
+ +
+ +
+ +
+ + + + + +

+ + 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 @@ + + + + + + +
+
+ + + +
+ +
+ Filename +
+ + +
+ +
+ Download + View full-size image +
+ + +
+
+ + + 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 @@ + + + + + + +
+
The body
+
+ + + + + +
+ + + + + +
+ +
+ + + + +
+ + + + + — + 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 @@ + + + + + + +
+
The body
+
+ + + +
+ + +
+
+
+ +
+
+
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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + +
+
The body
+
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Field nameImage
+ + + + + + + Item Title + + + + ${name} + + + + + +
+ +
+ +
+ +
+ + +

+ 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 @@ + + + + + + +
+
+ Text +
+
+ +
+
+ + + 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( + '