From 98b71c6a2f5632891f69246fb6a77461d1b6367f Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sun, 27 Apr 2025 13:44:34 +0530 Subject: [PATCH 001/122] Add Recycle Bin control panel and settings configuration --- .../controlpanel/browser/configure.zcml | 8 +++++ .../controlpanel/browser/recyclerbin.py | 31 +++++++++++++++++++ .../profiles/default/controlpanel.xml | 13 ++++++++ .../profiles/dependencies/registry.xml | 5 +++ 4 files changed, 57 insertions(+) create mode 100644 src/Products/CMFPlone/controlpanel/browser/recyclerbin.py diff --git a/src/Products/CMFPlone/controlpanel/browser/configure.zcml b/src/Products/CMFPlone/controlpanel/browser/configure.zcml index 7cca9f0b3a..42986b7fba 100644 --- a/src/Products/CMFPlone/controlpanel/browser/configure.zcml +++ b/src/Products/CMFPlone/controlpanel/browser/configure.zcml @@ -348,4 +348,12 @@ permission="cmf.ManagePortal" /> + + + + diff --git a/src/Products/CMFPlone/controlpanel/browser/recyclerbin.py b/src/Products/CMFPlone/controlpanel/browser/recyclerbin.py new file mode 100644 index 0000000000..11b847e1f3 --- /dev/null +++ b/src/Products/CMFPlone/controlpanel/browser/recyclerbin.py @@ -0,0 +1,31 @@ +# testcp.py +from zope import schema +from zope.interface import Interface +from plone.app.registry.browser.controlpanel import RegistryEditForm, ControlPanelFormWrapper +from plone.z3cform import layout + +class IRecyleBinControlPanelSettings(Interface): + recycling_enabled = schema.Bool( + title=u"Enable the Recycle Bin", + description=u"Enable or disable the Recycle Bin feature.", + default=True, + ) + + retention_period = schema.Int( + title=u"Retention Period", + description=u"Number of days to keep deleted items in the Recycle Bin.", + default=30, + ) + + maximum_size = schema.Int( + title=u"Maximum Size", + description=u"Maximum size of the Recycle Bin in MB.", + default=100, + ) + +class RecyclebinControlPanelForm(RegistryEditForm): + schema = IRecyleBinControlPanelSettings + schema_prefix = "plone-recyclebin" + label = u"Recycler Settings" + +RecyclebinControlPanelView = layout.wrap_form(RecyclebinControlPanelForm, ControlPanelFormWrapper) \ No newline at end of file diff --git a/src/Products/CMFPlone/profiles/default/controlpanel.xml b/src/Products/CMFPlone/profiles/default/controlpanel.xml index 08bf394391..d2fc9cb119 100644 --- a/src/Products/CMFPlone/profiles/default/controlpanel.xml +++ b/src/Products/CMFPlone/profiles/default/controlpanel.xml @@ -352,4 +352,17 @@ > Inspect Relations + + + + Manage portal + diff --git a/src/Products/CMFPlone/profiles/dependencies/registry.xml b/src/Products/CMFPlone/profiles/dependencies/registry.xml index 5f596cebda..29788a6cf4 100644 --- a/src/Products/CMFPlone/profiles/dependencies/registry.xml +++ b/src/Products/CMFPlone/profiles/dependencies/registry.xml @@ -126,5 +126,10 @@ {"actionOptions": {"displayInModal": false}} + + True + 30 + 100 + From cfc8268453ac9d8d9cc361ee6e20bd7a75edb44e Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sun, 27 Apr 2025 14:28:31 +0530 Subject: [PATCH 002/122] Implement Recycle Bin functionality with management views and settings --- src/Products/CMFPlone/browser/recyclebin.py | 199 +++++++++++++++++ .../CMFPlone/browser/templates/recyclebin.pt | 71 +++++++ .../browser/templates/recyclebin_item.pt | 85 ++++++++ src/Products/CMFPlone/configure.zcml | 26 +++ .../controlpanel/browser/recyclerbin.py | 15 +- src/Products/CMFPlone/events.py | 39 ++++ src/Products/CMFPlone/events/recyclebin.py | 38 ++++ .../CMFPlone/interfaces/recyclebin.py | 63 ++++++ .../profiles/dependencies/registry.xml | 4 +- src/Products/CMFPlone/recyclebin.py | 200 ++++++++++++++++++ 10 files changed, 736 insertions(+), 4 deletions(-) create mode 100644 src/Products/CMFPlone/browser/recyclebin.py create mode 100644 src/Products/CMFPlone/browser/templates/recyclebin.pt create mode 100644 src/Products/CMFPlone/browser/templates/recyclebin_item.pt create mode 100644 src/Products/CMFPlone/events/recyclebin.py create mode 100644 src/Products/CMFPlone/interfaces/recyclebin.py create mode 100644 src/Products/CMFPlone/recyclebin.py diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py new file mode 100644 index 0000000000..fc0c9172dd --- /dev/null +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -0,0 +1,199 @@ +from datetime import datetime +from Products.Five.browser import BrowserView +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from Products.statusmessages.interfaces import IStatusMessage +from zope.component import getUtility, getMultiAdapter +from zope.interface import implementer +from zope.publisher.interfaces import IPublishTraverse + +from Products.CMFPlone.interfaces.recyclebin import IRecycleBin + + +class RecycleBinView(BrowserView): + """Browser view for recycle bin management""" + + template = ViewPageTemplateFile('templates/recyclebin.pt') + + def __call__(self): + form = self.request.form + + if form.get('form.submitted', False): + if form.get('form.button.Restore', None) is not None: + self.restore_items() + elif form.get('form.button.Delete', None) is not None: + self.delete_items() + elif form.get('form.button.Empty', None) is not None: + self.empty_bin() + + return self.template() + + def get_recycle_bin(self): + """Get the recycle bin utility""" + return getUtility(IRecycleBin) + + def get_items(self): + """Get all items in the recycle bin""" + recycle_bin = self.get_recycle_bin() + return recycle_bin.get_items() + + def format_date(self, date): + """Format date for display""" + if isinstance(date, datetime): + return date.strftime('%Y-%m-%d %H:%M') + return str(date) + + def format_size(self, size_bytes): + """Format size in bytes to human-readable format""" + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + else: + return f"{size_bytes / (1024 * 1024):.1f} MB" + + def restore_items(self): + """Restore selected items from the recycle bin""" + form = self.request.form + recycle_bin = self.get_recycle_bin() + + # Get the selected items + selected_items = form.get('selected_items', []) + if not isinstance(selected_items, list): + selected_items = [selected_items] + + if not selected_items: + message = u"No items selected for restoration." + IStatusMessage(self.request).addStatusMessage(message, type="info") + return + + restored_count = 0 + for item_id in selected_items: + if recycle_bin.restore_item(item_id): + restored_count += 1 + + message = f"{restored_count} item(s) restored successfully." + IStatusMessage(self.request).addStatusMessage(message, type="info") + + def delete_items(self): + """Permanently delete selected items""" + form = self.request.form + recycle_bin = self.get_recycle_bin() + + # Get the selected items + selected_items = form.get('selected_items', []) + if not isinstance(selected_items, list): + selected_items = [selected_items] + + if not selected_items: + message = u"No items selected for deletion." + IStatusMessage(self.request).addStatusMessage(message, type="info") + return + + deleted_count = 0 + for item_id in selected_items: + if recycle_bin.purge_item(item_id): + deleted_count += 1 + + message = f"{deleted_count} item(s) permanently deleted." + IStatusMessage(self.request).addStatusMessage(message, type="info") + + def empty_bin(self): + """Empty the entire recycle bin""" + recycle_bin = self.get_recycle_bin() + items = recycle_bin.get_items() + deleted_count = 0 + + for item in items: + item_id = item['recycle_id'] + if recycle_bin.purge_item(item_id): + deleted_count += 1 + + message = f"Recycle bin emptied. {deleted_count} item(s) permanently deleted." + IStatusMessage(self.request).addStatusMessage(message, type="info") + + +@implementer(IPublishTraverse) +class RecycleBinItemView(BrowserView): + """View for managing individual recycled items""" + + template = ViewPageTemplateFile('templates/recyclebin_item.pt') + item_id = None + + def publishTraverse(self, request, name): + """Handle URLs like /recyclebin/item/[item_id]""" + if self.item_id is None: # First traversal + self.item_id = name + return self + raise NotFound(self, name, request) + + def __call__(self): + """Handle item operations""" + if self.item_id is None: + self.request.response.redirect(f"{self.context.absolute_url()}/recyclebin") + return "" + + form = self.request.form + if form.get('form.submitted', False): + if form.get('form.button.Restore', None) is not None: + self.restore_item() + return "" + elif form.get('form.button.Delete', None) is not None: + self.delete_item() + return "" + + return self.template() + + def get_item(self): + """Get the specific recycled item""" + recycle_bin = getUtility(IRecycleBin) + return recycle_bin.get_item(self.item_id) + + def restore_item(self): + """Restore this item""" + recycle_bin = getUtility(IRecycleBin) + + # Get target container if specified + target_path = self.request.form.get('target_container', '') + target_container = None + + if target_path: + try: + target_container = self.context.unrestrictedTraverse(target_path) + except (KeyError, AttributeError): + message = f"Target location not found: {target_path}" + IStatusMessage(self.request).addStatusMessage(message, type="error") + self.request.response.redirect(f"{self.context.absolute_url()}/recyclebin/item/{self.item_id}") + return + + # Restore the item + restored_obj = recycle_bin.restore_item(self.item_id, target_container) + + if restored_obj: + message = f"Item '{restored_obj.Title()}' successfully restored." + IStatusMessage(self.request).addStatusMessage(message, type="info") + self.request.response.redirect(restored_obj.absolute_url()) + else: + message = "Failed to restore item. It may have been already restored or deleted." + IStatusMessage(self.request).addStatusMessage(message, type="error") + self.request.response.redirect(f"{self.context.absolute_url()}/recyclebin") + + def delete_item(self): + """Permanently delete this item""" + recycle_bin = getUtility(IRecycleBin) + + # Get item info before deletion + item = self.get_item() + if item: + item_title = item.get('title', 'Unknown') + + if recycle_bin.purge_item(self.item_id): + message = f"Item '{item_title}' permanently deleted." + IStatusMessage(self.request).addStatusMessage(message, type="info") + else: + message = f"Failed to delete item '{item_title}'." + IStatusMessage(self.request).addStatusMessage(message, type="error") + else: + message = "Item not found. It may have been already deleted." + IStatusMessage(self.request).addStatusMessage(message, type="error") + + self.request.response.redirect(f"{self.context.absolute_url()}/recyclebin") diff --git a/src/Products/CMFPlone/browser/templates/recyclebin.pt b/src/Products/CMFPlone/browser/templates/recyclebin.pt new file mode 100644 index 0000000000..ce5ae5e0d5 --- /dev/null +++ b/src/Products/CMFPlone/browser/templates/recyclebin.pt @@ -0,0 +1,71 @@ + + + + +

Recycle Bin

+ +
+ Items deleted from this site are stored here and can be restored or permanently deleted. +
+ +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
TitleTypeOriginal PathDeletion DateSize
No items in recycle bin
+ + + Title + Content typeOriginal pathDeletion dateSize
+ +
+ + + +
+
+
+
+
+ + + diff --git a/src/Products/CMFPlone/browser/templates/recyclebin_item.pt b/src/Products/CMFPlone/browser/templates/recyclebin_item.pt new file mode 100644 index 0000000000..b0a83e6c83 --- /dev/null +++ b/src/Products/CMFPlone/browser/templates/recyclebin_item.pt @@ -0,0 +1,85 @@ + + + + + +
+

Item Not Found

+

+ The requested item was not found in the recycle bin. It may have been already restored or deleted. +

+

+ Return to recycle bin +

+
+ +
+

+ Title + (Content Type) +

+ +
+ Deleted item information +
+ + + + + + + + + + + + + + + + + + + + +
Original IDID
Original PathPath
Parent PathParent
Deletion DateDate
+ +
+ + +
+ +
+ Leave blank to restore to the original location. If the original location no longer exists, + the item will be restored to the site root. +
+ +
+ +
+ + +
+
+ +
+ +

+ Return to recycle bin +

+
+
+
+ + + diff --git a/src/Products/CMFPlone/configure.zcml b/src/Products/CMFPlone/configure.zcml index ce2dfc190b..2d42c8e151 100644 --- a/src/Products/CMFPlone/configure.zcml +++ b/src/Products/CMFPlone/configure.zcml @@ -159,4 +159,30 @@ for="zope.pagetemplate.engine.ZopeBaseEngine" /> + + + + + + + + + + + diff --git a/src/Products/CMFPlone/controlpanel/browser/recyclerbin.py b/src/Products/CMFPlone/controlpanel/browser/recyclerbin.py index 11b847e1f3..357033c727 100644 --- a/src/Products/CMFPlone/controlpanel/browser/recyclerbin.py +++ b/src/Products/CMFPlone/controlpanel/browser/recyclerbin.py @@ -4,7 +4,7 @@ from plone.app.registry.browser.controlpanel import RegistryEditForm, ControlPanelFormWrapper from plone.z3cform import layout -class IRecyleBinControlPanelSettings(Interface): +class IRecycleBinControlPanelSettings(Interface): recycling_enabled = schema.Bool( title=u"Enable the Recycle Bin", description=u"Enable or disable the Recycle Bin feature.", @@ -15,17 +15,26 @@ class IRecyleBinControlPanelSettings(Interface): title=u"Retention Period", description=u"Number of days to keep deleted items in the Recycle Bin.", default=30, + min=1, ) maximum_size = schema.Int( title=u"Maximum Size", description=u"Maximum size of the Recycle Bin in MB.", default=100, + min=10, + ) + + auto_purge = schema.Bool( + title=u"Auto Purge", + description=u"Automatically purge items older than the retention period.", + default=True, ) class RecyclebinControlPanelForm(RegistryEditForm): - schema = IRecyleBinControlPanelSettings + schema = IRecycleBinControlPanelSettings schema_prefix = "plone-recyclebin" - label = u"Recycler Settings" + label = u"Recycle Bin Settings" + description = u"Settings for the Plone Recycle Bin functionality" RecyclebinControlPanelView = layout.wrap_form(RecyclebinControlPanelForm, ControlPanelFormWrapper) \ No newline at end of file diff --git a/src/Products/CMFPlone/events.py b/src/Products/CMFPlone/events.py index 77baf900f6..3573f7e97e 100644 --- a/src/Products/CMFPlone/events.py +++ b/src/Products/CMFPlone/events.py @@ -3,6 +3,10 @@ from plone.base.utils import get_installer from zope.interface import implementer from zope.interface.interfaces import ObjectEvent +from zope.component import adapter, queryUtility +from zope.lifecycleevent.interfaces import IObjectRemovedEvent +from Products.CMFCore.interfaces import IContentish +from Products.CMFPlone.interfaces.recyclebin import IRecycleBin @implementer(ISiteManagerCreatedEvent) @@ -37,3 +41,38 @@ def removeBase(event): https://dev.plone.org/ticket/13705 """ event.request.response.base = None + + +@adapter(IContentish, IObjectRemovedEvent) +def handle_content_removal(obj, event): + """Event handler for content removal + + This intercepts standard content removal and puts the item in the recycle bin + instead of letting it be deleted if the recycle bin is enabled. + """ + # Ignore if the object is being moved + if getattr(obj, '_v_is_being_moved', False): + return + + # Get the recycle bin + recycle_bin = queryUtility(IRecycleBin) + if recycle_bin is None or not recycle_bin.is_enabled(): + return + + # Only process if this is a direct deletion (not part of container deletion) + if event.newParent is not None: + return + + # Get original information + original_container = event.oldParent + original_path = '/'.join(obj.getPhysicalPath()) + + # Add to recycle bin + try: + recycle_bin.add_item(obj, original_container, original_path) + except Exception as e: + # Log but don't prevent deletion if recycle bin fails + import logging + logger = logging.getLogger("Products.CMFPlone.RecycleBin") + logger.exception(f"Error adding item to recycle bin: {e}") + diff --git a/src/Products/CMFPlone/events/recyclebin.py b/src/Products/CMFPlone/events/recyclebin.py new file mode 100644 index 0000000000..5c819b9f35 --- /dev/null +++ b/src/Products/CMFPlone/events/recyclebin.py @@ -0,0 +1,38 @@ +from zope.component import adapter, queryUtility +from zope.lifecycleevent.interfaces import IObjectRemovedEvent +from Products.CMFCore.interfaces import IContentish +from Products.CMFPlone.interfaces.recyclebin import IRecycleBin + + +@adapter(IContentish, IObjectRemovedEvent) +def handle_content_removal(obj, event): + """Event handler for content removal + + This intercepts standard content removal and puts the item in the recycle bin + instead of letting it be deleted if the recycle bin is enabled. + """ + # Ignore if the object is being moved + if getattr(obj, '_v_is_being_moved', False): + return + + # Get the recycle bin + recycle_bin = queryUtility(IRecycleBin) + if recycle_bin is None or not recycle_bin.is_enabled(): + return + + # Only process if this is a direct deletion (not part of container deletion) + if event.newParent is not None: + return + + # Get original information + original_container = event.oldParent + original_path = '/'.join(obj.getPhysicalPath()) + + # Add to recycle bin + try: + recycle_bin.add_item(obj, original_container, original_path) + except Exception as e: + # Log but don't prevent deletion if recycle bin fails + import logging + logger = logging.getLogger("Products.CMFPlone.RecycleBin") + logger.exception(f"Error adding item to recycle bin: {e}") diff --git a/src/Products/CMFPlone/interfaces/recyclebin.py b/src/Products/CMFPlone/interfaces/recyclebin.py new file mode 100644 index 0000000000..a3924000ff --- /dev/null +++ b/src/Products/CMFPlone/interfaces/recyclebin.py @@ -0,0 +1,63 @@ +from zope.interface import Interface + + +class IRecycleBin(Interface): + """Interface for the recycle bin functionality""" + + def add_item(obj, original_container, original_path): + """Add deleted item to recycle bin + + Args: + obj: The object being deleted + original_container: The parent container before deletion + original_path: The full path to the object before deletion + + Returns: + The ID of the item in the recycle bin + """ + + def get_items(): + """Return all items in recycle bin + + Returns: + A list of dictionaries with information about deleted items + """ + + def get_item(item_id): + """Get a specific deleted item by ID + + Args: + item_id: The ID of the deleted item in the recycle bin + + Returns: + Dictionary with item information or None if not found + """ + + def restore_item(item_id, target_container=None): + """Restore item to original location or specified container + + Args: + item_id: The ID of the item in the recycle bin + target_container: Optional target container to restore to + (defaults to original container) + + Returns: + The restored object or None if restore failed + """ + + def purge_item(item_id): + """Permanently delete an item + + Args: + item_id: The ID of the item in the recycle bin + + Returns: + Boolean indicating success + """ + + def purge_expired_items(): + """Purge items that exceed the retention period + + Returns: + Number of items purged + """ diff --git a/src/Products/CMFPlone/profiles/dependencies/registry.xml b/src/Products/CMFPlone/profiles/dependencies/registry.xml index 29788a6cf4..0981d089bc 100644 --- a/src/Products/CMFPlone/profiles/dependencies/registry.xml +++ b/src/Products/CMFPlone/profiles/dependencies/registry.xml @@ -126,10 +126,12 @@ {"actionOptions": {"displayInModal": false}} - + True 30 100 + True + diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py new file mode 100644 index 0000000000..4512ec6491 --- /dev/null +++ b/src/Products/CMFPlone/recyclebin.py @@ -0,0 +1,200 @@ +from datetime import datetime, timedelta +import logging +import uuid +from persistent.mapping import PersistentMapping +from zope.component import getUtility, getSiteManager +from zope.interface import implementer +from zope.annotation.interfaces import IAnnotations +from plone.registry.interfaces import IRegistry +from zope.component.hooks import getSite + +from Products.CMFPlone.interfaces.recyclebin import IRecycleBin +from Products.CMFPlone.controlpanel.browser.recyclerbin import IRecycleBinControlPanelSettings + +logger = logging.getLogger("Products.CMFPlone.RecycleBin") + +ANNOTATION_KEY = 'Products.CMFPlone.RecycleBin' + + +@implementer(IRecycleBin) +class RecycleBin(object): + """Stores deleted content items""" + + def __init__(self, context=None): + """Initialize the recycle bin for a site + + Args: + context: The Plone site object (optional when used as a utility) + """ + self.context = context + # When used as a utility without context, we'll get the context on demand + + def _get_context(self): + """Get the context (Plone site) if not already available""" + if self.context is None: + self.context = getSite() + return self.context + + def _get_storage(self): + """Get the storage for recycled items""" + context = self._get_context() + annotations = IAnnotations(context) + + if ANNOTATION_KEY not in annotations: + annotations[ANNOTATION_KEY] = PersistentMapping() + + return annotations[ANNOTATION_KEY] + + # Update property for storage to use _get_storage + @property + def storage(self): + return self._get_storage() + + def _get_settings(self): + """Get recycle bin settings from registry""" + registry = getUtility(IRegistry) + return registry.forInterface(IRecycleBinControlPanelSettings, prefix="plone-recyclebin") + + def is_enabled(self): + """Check if recycle bin is enabled""" + try: + settings = self._get_settings() + return settings.recycling_enabled + except (KeyError, AttributeError): + return False + + def add_item(self, obj, original_container, original_path): + """Add deleted item to recycle bin""" + if not self.is_enabled(): + return None + + # Generate a unique ID for the recycled item + item_id = str(uuid.uuid4()) + + # Store metadata about the deletion + self.storage[item_id] = { + 'id': obj.getId(), + 'title': obj.Title(), + 'type': obj.portal_type, + 'path': original_path, + 'parent_path': '/'.join(original_container.getPhysicalPath()), + 'deletion_date': datetime.now(), + 'size': getattr(obj, 'get_size', lambda: 0)(), + 'object': obj, # Store the actual object + } + + # Check if we need to clean up old items + self._check_size_limits() + + return item_id + + def get_items(self): + """Return all items in recycle bin""" + items = [] + for item_id, data in self.storage.items(): + item_data = data.copy() + item_data['recycle_id'] = item_id + # Don't include the actual object in the listing + if 'object' in item_data: + del item_data['object'] + items.append(item_data) + + # Sort by deletion date (newest first) + return sorted(items, key=lambda x: x['deletion_date'], reverse=True) + + def get_item(self, item_id): + """Get a specific deleted item by ID""" + return self.storage.get(item_id) + + def restore_item(self, item_id, target_container=None): + """Restore item to original location or specified container""" + if item_id not in self.storage: + return None + + item_data = self.storage[item_id] + obj = item_data['object'] + obj_id = item_data['id'] + + # Find the container to restore to + site = self._get_context() + if target_container is None: + # Try to get the original parent + parent_path = item_data['parent_path'] + try: + target_container = site.unrestrictedTraverse(parent_path) + except (KeyError, AttributeError): + # If original parent doesn't exist, restore to site root + target_container = site + + # Make sure we don't overwrite existing content + if obj_id in target_container: + # Generate a unique ID by appending a timestamp + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + obj_id = f"{obj_id}-restored-{timestamp}" + + # Set the new ID if it was changed + if obj_id != item_data['id']: + obj.id = obj_id + + # Add object to the target container + target_container._setObject(obj_id, obj) + + # Remove from recycle bin + del self.storage[item_id] + + restored_obj = target_container[obj_id] + return restored_obj + + def purge_item(self, item_id): + """Permanently delete an item""" + if item_id not in self.storage: + return False + + # Simply remove from storage - the object will be garbage collected + del self.storage[item_id] + return True + + def purge_expired_items(self): + """Purge items that exceed the retention period""" + settings = self._get_settings() + if not settings.auto_purge: + return 0 + + retention_days = settings.retention_period + cutoff_date = datetime.now() - timedelta(days=retention_days) + + items_to_purge = [] + for item_id, data in self.storage.items(): + if data['deletion_date'] < cutoff_date: + items_to_purge.append(item_id) + + purge_count = 0 + for item_id in items_to_purge: + if self.purge_item(item_id): + purge_count += 1 + + return purge_count + + def _check_size_limits(self): + """Check if the recycle bin exceeds size limits and purge oldest items if needed""" + settings = self._get_settings() + max_size_bytes = settings.maximum_size * 1024 * 1024 # Convert MB to bytes + + total_size = 0 + items_by_date = [] + + # Calculate total size and prepare sorted list + for item_id, data in self.storage.items(): + size = data.get('size', 0) + total_size += size + items_by_date.append((item_id, data['deletion_date'], size)) + + # Sort by date (oldest first) + items_by_date.sort(key=lambda x: x[1]) + + # Remove oldest items if size limit is exceeded + while total_size > max_size_bytes and items_by_date: + item_id, _, size = items_by_date.pop(0) + if self.purge_item(item_id): + total_size -= size + logger.info(f"Purged item {item_id} due to size constraints") From 6975cb1901dbdbe58e4b84636ea72106e551b7de Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sun, 27 Apr 2025 14:35:35 +0530 Subject: [PATCH 003/122] Add Recycle Bin action to actions.xml --- .../CMFPlone/profiles/default/actions.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Products/CMFPlone/profiles/default/actions.xml b/src/Products/CMFPlone/profiles/default/actions.xml index 46e0a043cd..328aaf39c1 100644 --- a/src/Products/CMFPlone/profiles/default/actions.xml +++ b/src/Products/CMFPlone/profiles/default/actions.xml @@ -494,6 +494,24 @@ True + + Recycle Bin + + string:${portal_url}/@@recyclebin + string:plone-delete + + + + + True + Date: Sun, 27 Apr 2025 14:41:02 +0530 Subject: [PATCH 004/122] lint --- src/Products/CMFPlone/browser/recyclebin.py | 120 ++++++++------- .../controlpanel/browser/recyclerbin.py | 34 +++-- src/Products/CMFPlone/events.py | 23 +-- src/Products/CMFPlone/events/recyclebin.py | 20 +-- .../CMFPlone/interfaces/recyclebin.py | 32 ++-- src/Products/CMFPlone/recyclebin.py | 141 +++++++++--------- 6 files changed, 195 insertions(+), 175 deletions(-) diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index fc0c9172dd..0bf28674c3 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -1,47 +1,47 @@ from datetime import datetime +from Products.CMFPlone.interfaces.recyclebin import IRecycleBin from Products.Five.browser import BrowserView from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile from Products.statusmessages.interfaces import IStatusMessage -from zope.component import getUtility, getMultiAdapter +from zExceptions import NotFound +from zope.component import getUtility from zope.interface import implementer from zope.publisher.interfaces import IPublishTraverse -from Products.CMFPlone.interfaces.recyclebin import IRecycleBin - class RecycleBinView(BrowserView): """Browser view for recycle bin management""" - - template = ViewPageTemplateFile('templates/recyclebin.pt') - + + template = ViewPageTemplateFile("templates/recyclebin.pt") + def __call__(self): form = self.request.form - - if form.get('form.submitted', False): - if form.get('form.button.Restore', None) is not None: + + if form.get("form.submitted", False): + if form.get("form.button.Restore", None) is not None: self.restore_items() - elif form.get('form.button.Delete', None) is not None: + elif form.get("form.button.Delete", None) is not None: self.delete_items() - elif form.get('form.button.Empty', None) is not None: + elif form.get("form.button.Empty", None) is not None: self.empty_bin() - + return self.template() - + def get_recycle_bin(self): """Get the recycle bin utility""" return getUtility(IRecycleBin) - + def get_items(self): """Get all items in the recycle bin""" recycle_bin = self.get_recycle_bin() return recycle_bin.get_items() - + def format_date(self, date): """Format date for display""" if isinstance(date, datetime): - return date.strftime('%Y-%m-%d %H:%M') + return date.strftime("%Y-%m-%d %H:%M") return str(date) - + def format_size(self, size_bytes): """Format size in bytes to human-readable format""" if size_bytes < 1024: @@ -50,64 +50,64 @@ def format_size(self, size_bytes): return f"{size_bytes / 1024:.1f} KB" else: return f"{size_bytes / (1024 * 1024):.1f} MB" - + def restore_items(self): """Restore selected items from the recycle bin""" form = self.request.form recycle_bin = self.get_recycle_bin() - + # Get the selected items - selected_items = form.get('selected_items', []) + selected_items = form.get("selected_items", []) if not isinstance(selected_items, list): selected_items = [selected_items] - + if not selected_items: - message = u"No items selected for restoration." + message = "No items selected for restoration." IStatusMessage(self.request).addStatusMessage(message, type="info") return - + restored_count = 0 for item_id in selected_items: if recycle_bin.restore_item(item_id): restored_count += 1 - + message = f"{restored_count} item(s) restored successfully." IStatusMessage(self.request).addStatusMessage(message, type="info") - + def delete_items(self): """Permanently delete selected items""" form = self.request.form recycle_bin = self.get_recycle_bin() - + # Get the selected items - selected_items = form.get('selected_items', []) + selected_items = form.get("selected_items", []) if not isinstance(selected_items, list): selected_items = [selected_items] - + if not selected_items: - message = u"No items selected for deletion." + message = "No items selected for deletion." IStatusMessage(self.request).addStatusMessage(message, type="info") return - + deleted_count = 0 for item_id in selected_items: if recycle_bin.purge_item(item_id): deleted_count += 1 - + message = f"{deleted_count} item(s) permanently deleted." IStatusMessage(self.request).addStatusMessage(message, type="info") - + def empty_bin(self): """Empty the entire recycle bin""" recycle_bin = self.get_recycle_bin() items = recycle_bin.get_items() deleted_count = 0 - + for item in items: - item_id = item['recycle_id'] + item_id = item["recycle_id"] if recycle_bin.purge_item(item_id): deleted_count += 1 - + message = f"Recycle bin emptied. {deleted_count} item(s) permanently deleted." IStatusMessage(self.request).addStatusMessage(message, type="info") @@ -115,77 +115,81 @@ def empty_bin(self): @implementer(IPublishTraverse) class RecycleBinItemView(BrowserView): """View for managing individual recycled items""" - - template = ViewPageTemplateFile('templates/recyclebin_item.pt') + + template = ViewPageTemplateFile("templates/recyclebin_item.pt") item_id = None - + def publishTraverse(self, request, name): """Handle URLs like /recyclebin/item/[item_id]""" if self.item_id is None: # First traversal self.item_id = name return self raise NotFound(self, name, request) - + def __call__(self): """Handle item operations""" if self.item_id is None: self.request.response.redirect(f"{self.context.absolute_url()}/recyclebin") return "" - + form = self.request.form - if form.get('form.submitted', False): - if form.get('form.button.Restore', None) is not None: + if form.get("form.submitted", False): + if form.get("form.button.Restore", None) is not None: self.restore_item() return "" - elif form.get('form.button.Delete', None) is not None: + elif form.get("form.button.Delete", None) is not None: self.delete_item() return "" - + return self.template() - + def get_item(self): """Get the specific recycled item""" recycle_bin = getUtility(IRecycleBin) return recycle_bin.get_item(self.item_id) - + def restore_item(self): """Restore this item""" recycle_bin = getUtility(IRecycleBin) - + # Get target container if specified - target_path = self.request.form.get('target_container', '') + target_path = self.request.form.get("target_container", "") target_container = None - + if target_path: try: target_container = self.context.unrestrictedTraverse(target_path) except (KeyError, AttributeError): message = f"Target location not found: {target_path}" IStatusMessage(self.request).addStatusMessage(message, type="error") - self.request.response.redirect(f"{self.context.absolute_url()}/recyclebin/item/{self.item_id}") + self.request.response.redirect( + f"{self.context.absolute_url()}/recyclebin/item/{self.item_id}" + ) return - + # Restore the item restored_obj = recycle_bin.restore_item(self.item_id, target_container) - + if restored_obj: message = f"Item '{restored_obj.Title()}' successfully restored." IStatusMessage(self.request).addStatusMessage(message, type="info") self.request.response.redirect(restored_obj.absolute_url()) else: - message = "Failed to restore item. It may have been already restored or deleted." + message = ( + "Failed to restore item. It may have been already restored or deleted." + ) IStatusMessage(self.request).addStatusMessage(message, type="error") self.request.response.redirect(f"{self.context.absolute_url()}/recyclebin") - + def delete_item(self): """Permanently delete this item""" recycle_bin = getUtility(IRecycleBin) - + # Get item info before deletion item = self.get_item() if item: - item_title = item.get('title', 'Unknown') - + item_title = item.get("title", "Unknown") + if recycle_bin.purge_item(self.item_id): message = f"Item '{item_title}' permanently deleted." IStatusMessage(self.request).addStatusMessage(message, type="info") @@ -195,5 +199,5 @@ def delete_item(self): else: message = "Item not found. It may have been already deleted." IStatusMessage(self.request).addStatusMessage(message, type="error") - + self.request.response.redirect(f"{self.context.absolute_url()}/recyclebin") diff --git a/src/Products/CMFPlone/controlpanel/browser/recyclerbin.py b/src/Products/CMFPlone/controlpanel/browser/recyclerbin.py index 357033c727..4b12daa448 100644 --- a/src/Products/CMFPlone/controlpanel/browser/recyclerbin.py +++ b/src/Products/CMFPlone/controlpanel/browser/recyclerbin.py @@ -1,40 +1,46 @@ # testcp.py +from plone.app.registry.browser.controlpanel import ControlPanelFormWrapper +from plone.app.registry.browser.controlpanel import RegistryEditForm +from plone.z3cform import layout from zope import schema from zope.interface import Interface -from plone.app.registry.browser.controlpanel import RegistryEditForm, ControlPanelFormWrapper -from plone.z3cform import layout + class IRecycleBinControlPanelSettings(Interface): recycling_enabled = schema.Bool( - title=u"Enable the Recycle Bin", - description=u"Enable or disable the Recycle Bin feature.", + title="Enable the Recycle Bin", + description="Enable or disable the Recycle Bin feature.", default=True, ) retention_period = schema.Int( - title=u"Retention Period", - description=u"Number of days to keep deleted items in the Recycle Bin.", + title="Retention Period", + description="Number of days to keep deleted items in the Recycle Bin.", default=30, min=1, ) maximum_size = schema.Int( - title=u"Maximum Size", - description=u"Maximum size of the Recycle Bin in MB.", + title="Maximum Size", + description="Maximum size of the Recycle Bin in MB.", default=100, min=10, ) - + auto_purge = schema.Bool( - title=u"Auto Purge", - description=u"Automatically purge items older than the retention period.", + title="Auto Purge", + description="Automatically purge items older than the retention period.", default=True, ) + class RecyclebinControlPanelForm(RegistryEditForm): schema = IRecycleBinControlPanelSettings schema_prefix = "plone-recyclebin" - label = u"Recycle Bin Settings" - description = u"Settings for the Plone Recycle Bin functionality" + label = "Recycle Bin Settings" + description = "Settings for the Plone Recycle Bin functionality" + -RecyclebinControlPanelView = layout.wrap_form(RecyclebinControlPanelForm, ControlPanelFormWrapper) \ No newline at end of file +RecyclebinControlPanelView = layout.wrap_form( + RecyclebinControlPanelForm, ControlPanelFormWrapper +) diff --git a/src/Products/CMFPlone/events.py b/src/Products/CMFPlone/events.py index 3573f7e97e..a5736df2bf 100644 --- a/src/Products/CMFPlone/events.py +++ b/src/Products/CMFPlone/events.py @@ -1,12 +1,13 @@ from plone.base.interfaces import IReorderedEvent from plone.base.interfaces import ISiteManagerCreatedEvent from plone.base.utils import get_installer +from Products.CMFCore.interfaces import IContentish +from Products.CMFPlone.interfaces.recyclebin import IRecycleBin +from zope.component import adapter +from zope.component import queryUtility from zope.interface import implementer from zope.interface.interfaces import ObjectEvent -from zope.component import adapter, queryUtility from zope.lifecycleevent.interfaces import IObjectRemovedEvent -from Products.CMFCore.interfaces import IContentish -from Products.CMFPlone.interfaces.recyclebin import IRecycleBin @implementer(ISiteManagerCreatedEvent) @@ -46,33 +47,33 @@ def removeBase(event): @adapter(IContentish, IObjectRemovedEvent) def handle_content_removal(obj, event): """Event handler for content removal - + This intercepts standard content removal and puts the item in the recycle bin instead of letting it be deleted if the recycle bin is enabled. """ # Ignore if the object is being moved - if getattr(obj, '_v_is_being_moved', False): + if getattr(obj, "_v_is_being_moved", False): return - + # Get the recycle bin recycle_bin = queryUtility(IRecycleBin) if recycle_bin is None or not recycle_bin.is_enabled(): return - + # Only process if this is a direct deletion (not part of container deletion) if event.newParent is not None: return - + # Get original information original_container = event.oldParent - original_path = '/'.join(obj.getPhysicalPath()) - + original_path = "/".join(obj.getPhysicalPath()) + # Add to recycle bin try: recycle_bin.add_item(obj, original_container, original_path) except Exception as e: # Log but don't prevent deletion if recycle bin fails import logging + logger = logging.getLogger("Products.CMFPlone.RecycleBin") logger.exception(f"Error adding item to recycle bin: {e}") - diff --git a/src/Products/CMFPlone/events/recyclebin.py b/src/Products/CMFPlone/events/recyclebin.py index 5c819b9f35..495dfe03a6 100644 --- a/src/Products/CMFPlone/events/recyclebin.py +++ b/src/Products/CMFPlone/events/recyclebin.py @@ -1,38 +1,40 @@ -from zope.component import adapter, queryUtility -from zope.lifecycleevent.interfaces import IObjectRemovedEvent from Products.CMFCore.interfaces import IContentish from Products.CMFPlone.interfaces.recyclebin import IRecycleBin +from zope.component import adapter +from zope.component import queryUtility +from zope.lifecycleevent.interfaces import IObjectRemovedEvent @adapter(IContentish, IObjectRemovedEvent) def handle_content_removal(obj, event): """Event handler for content removal - + This intercepts standard content removal and puts the item in the recycle bin instead of letting it be deleted if the recycle bin is enabled. """ # Ignore if the object is being moved - if getattr(obj, '_v_is_being_moved', False): + if getattr(obj, "_v_is_being_moved", False): return - + # Get the recycle bin recycle_bin = queryUtility(IRecycleBin) if recycle_bin is None or not recycle_bin.is_enabled(): return - + # Only process if this is a direct deletion (not part of container deletion) if event.newParent is not None: return - + # Get original information original_container = event.oldParent - original_path = '/'.join(obj.getPhysicalPath()) - + original_path = "/".join(obj.getPhysicalPath()) + # Add to recycle bin try: recycle_bin.add_item(obj, original_container, original_path) except Exception as e: # Log but don't prevent deletion if recycle bin fails import logging + logger = logging.getLogger("Products.CMFPlone.RecycleBin") logger.exception(f"Error adding item to recycle bin: {e}") diff --git a/src/Products/CMFPlone/interfaces/recyclebin.py b/src/Products/CMFPlone/interfaces/recyclebin.py index a3924000ff..48e1913bd3 100644 --- a/src/Products/CMFPlone/interfaces/recyclebin.py +++ b/src/Products/CMFPlone/interfaces/recyclebin.py @@ -3,61 +3,61 @@ class IRecycleBin(Interface): """Interface for the recycle bin functionality""" - + def add_item(obj, original_container, original_path): """Add deleted item to recycle bin - + Args: obj: The object being deleted original_container: The parent container before deletion original_path: The full path to the object before deletion - + Returns: The ID of the item in the recycle bin """ - + def get_items(): """Return all items in recycle bin - + Returns: A list of dictionaries with information about deleted items """ - + def get_item(item_id): """Get a specific deleted item by ID - + Args: item_id: The ID of the deleted item in the recycle bin - + Returns: Dictionary with item information or None if not found """ - + def restore_item(item_id, target_container=None): """Restore item to original location or specified container - + Args: item_id: The ID of the item in the recycle bin target_container: Optional target container to restore to (defaults to original container) - + Returns: The restored object or None if restore failed """ - + def purge_item(item_id): """Permanently delete an item - + Args: item_id: The ID of the item in the recycle bin - + Returns: Boolean indicating success """ - + def purge_expired_items(): """Purge items that exceed the retention period - + Returns: Number of items purged """ diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index 4512ec6491..1de8ba57e3 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -1,60 +1,67 @@ -from datetime import datetime, timedelta -import logging -import uuid +from datetime import datetime +from datetime import timedelta from persistent.mapping import PersistentMapping -from zope.component import getUtility, getSiteManager -from zope.interface import implementer -from zope.annotation.interfaces import IAnnotations from plone.registry.interfaces import IRegistry +from Products.CMFPlone.controlpanel.browser.recyclerbin import ( + IRecycleBinControlPanelSettings, +) +from Products.CMFPlone.interfaces.recyclebin import IRecycleBin +from zope.annotation.interfaces import IAnnotations +from zope.component import getSiteManager +from zope.component import getUtility from zope.component.hooks import getSite +from zope.interface import implementer + +import logging +import uuid -from Products.CMFPlone.interfaces.recyclebin import IRecycleBin -from Products.CMFPlone.controlpanel.browser.recyclerbin import IRecycleBinControlPanelSettings logger = logging.getLogger("Products.CMFPlone.RecycleBin") -ANNOTATION_KEY = 'Products.CMFPlone.RecycleBin' +ANNOTATION_KEY = "Products.CMFPlone.RecycleBin" @implementer(IRecycleBin) -class RecycleBin(object): +class RecycleBin: """Stores deleted content items""" - + def __init__(self, context=None): """Initialize the recycle bin for a site - + Args: context: The Plone site object (optional when used as a utility) """ self.context = context # When used as a utility without context, we'll get the context on demand - + def _get_context(self): """Get the context (Plone site) if not already available""" if self.context is None: self.context = getSite() return self.context - + def _get_storage(self): """Get the storage for recycled items""" context = self._get_context() annotations = IAnnotations(context) - + if ANNOTATION_KEY not in annotations: annotations[ANNOTATION_KEY] = PersistentMapping() - + return annotations[ANNOTATION_KEY] - + # Update property for storage to use _get_storage @property def storage(self): return self._get_storage() - + def _get_settings(self): """Get recycle bin settings from registry""" registry = getUtility(IRegistry) - return registry.forInterface(IRecycleBinControlPanelSettings, prefix="plone-recyclebin") - + return registry.forInterface( + IRecycleBinControlPanelSettings, prefix="plone-recyclebin" + ) + def is_enabled(self): """Check if recycle bin is enabled""" try: @@ -62,136 +69,136 @@ def is_enabled(self): return settings.recycling_enabled except (KeyError, AttributeError): return False - + def add_item(self, obj, original_container, original_path): """Add deleted item to recycle bin""" if not self.is_enabled(): return None - + # Generate a unique ID for the recycled item item_id = str(uuid.uuid4()) - + # Store metadata about the deletion self.storage[item_id] = { - 'id': obj.getId(), - 'title': obj.Title(), - 'type': obj.portal_type, - 'path': original_path, - 'parent_path': '/'.join(original_container.getPhysicalPath()), - 'deletion_date': datetime.now(), - 'size': getattr(obj, 'get_size', lambda: 0)(), - 'object': obj, # Store the actual object + "id": obj.getId(), + "title": obj.Title(), + "type": obj.portal_type, + "path": original_path, + "parent_path": "/".join(original_container.getPhysicalPath()), + "deletion_date": datetime.now(), + "size": getattr(obj, "get_size", lambda: 0)(), + "object": obj, # Store the actual object } - + # Check if we need to clean up old items self._check_size_limits() - + return item_id - + def get_items(self): """Return all items in recycle bin""" items = [] for item_id, data in self.storage.items(): item_data = data.copy() - item_data['recycle_id'] = item_id + item_data["recycle_id"] = item_id # Don't include the actual object in the listing - if 'object' in item_data: - del item_data['object'] + if "object" in item_data: + del item_data["object"] items.append(item_data) - + # Sort by deletion date (newest first) - return sorted(items, key=lambda x: x['deletion_date'], reverse=True) - + return sorted(items, key=lambda x: x["deletion_date"], reverse=True) + def get_item(self, item_id): """Get a specific deleted item by ID""" return self.storage.get(item_id) - + def restore_item(self, item_id, target_container=None): """Restore item to original location or specified container""" if item_id not in self.storage: return None - + item_data = self.storage[item_id] - obj = item_data['object'] - obj_id = item_data['id'] - + obj = item_data["object"] + obj_id = item_data["id"] + # Find the container to restore to site = self._get_context() if target_container is None: # Try to get the original parent - parent_path = item_data['parent_path'] + parent_path = item_data["parent_path"] try: target_container = site.unrestrictedTraverse(parent_path) except (KeyError, AttributeError): # If original parent doesn't exist, restore to site root target_container = site - + # Make sure we don't overwrite existing content if obj_id in target_container: # Generate a unique ID by appending a timestamp timestamp = datetime.now().strftime("%Y%m%d%H%M%S") obj_id = f"{obj_id}-restored-{timestamp}" - + # Set the new ID if it was changed - if obj_id != item_data['id']: + if obj_id != item_data["id"]: obj.id = obj_id - + # Add object to the target container target_container._setObject(obj_id, obj) - + # Remove from recycle bin del self.storage[item_id] - + restored_obj = target_container[obj_id] return restored_obj - + def purge_item(self, item_id): """Permanently delete an item""" if item_id not in self.storage: return False - + # Simply remove from storage - the object will be garbage collected del self.storage[item_id] return True - + def purge_expired_items(self): """Purge items that exceed the retention period""" settings = self._get_settings() if not settings.auto_purge: return 0 - + retention_days = settings.retention_period cutoff_date = datetime.now() - timedelta(days=retention_days) - + items_to_purge = [] for item_id, data in self.storage.items(): - if data['deletion_date'] < cutoff_date: + if data["deletion_date"] < cutoff_date: items_to_purge.append(item_id) - + purge_count = 0 for item_id in items_to_purge: if self.purge_item(item_id): purge_count += 1 - + return purge_count - + def _check_size_limits(self): """Check if the recycle bin exceeds size limits and purge oldest items if needed""" settings = self._get_settings() max_size_bytes = settings.maximum_size * 1024 * 1024 # Convert MB to bytes - + total_size = 0 items_by_date = [] - + # Calculate total size and prepare sorted list for item_id, data in self.storage.items(): - size = data.get('size', 0) + size = data.get("size", 0) total_size += size - items_by_date.append((item_id, data['deletion_date'], size)) - + items_by_date.append((item_id, data["deletion_date"], size)) + # Sort by date (oldest first) items_by_date.sort(key=lambda x: x[1]) - + # Remove oldest items if size limit is exceeded while total_size > max_size_bytes and items_by_date: item_id, _, size = items_by_date.pop(0) From 888bce1215a98649214432b53af476e0e288e161 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sun, 27 Apr 2025 14:47:20 +0530 Subject: [PATCH 005/122] Remove obsolete recycle bin event handler code --- src/Products/CMFPlone/events/recyclebin.py | 40 ---------------------- 1 file changed, 40 deletions(-) delete mode 100644 src/Products/CMFPlone/events/recyclebin.py diff --git a/src/Products/CMFPlone/events/recyclebin.py b/src/Products/CMFPlone/events/recyclebin.py deleted file mode 100644 index 495dfe03a6..0000000000 --- a/src/Products/CMFPlone/events/recyclebin.py +++ /dev/null @@ -1,40 +0,0 @@ -from Products.CMFCore.interfaces import IContentish -from Products.CMFPlone.interfaces.recyclebin import IRecycleBin -from zope.component import adapter -from zope.component import queryUtility -from zope.lifecycleevent.interfaces import IObjectRemovedEvent - - -@adapter(IContentish, IObjectRemovedEvent) -def handle_content_removal(obj, event): - """Event handler for content removal - - This intercepts standard content removal and puts the item in the recycle bin - instead of letting it be deleted if the recycle bin is enabled. - """ - # Ignore if the object is being moved - if getattr(obj, "_v_is_being_moved", False): - return - - # Get the recycle bin - recycle_bin = queryUtility(IRecycleBin) - if recycle_bin is None or not recycle_bin.is_enabled(): - return - - # Only process if this is a direct deletion (not part of container deletion) - if event.newParent is not None: - return - - # Get original information - original_container = event.oldParent - original_path = "/".join(obj.getPhysicalPath()) - - # Add to recycle bin - try: - recycle_bin.add_item(obj, original_container, original_path) - except Exception as e: - # Log but don't prevent deletion if recycle bin fails - import logging - - logger = logging.getLogger("Products.CMFPlone.RecycleBin") - logger.exception(f"Error adding item to recycle bin: {e}") From 3f00ff457789fe4996e053d224da67bb776a1576 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sun, 27 Apr 2025 15:05:30 +0530 Subject: [PATCH 006/122] changelog --- news/2966.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/2966.feature diff --git a/news/2966.feature b/news/2966.feature new file mode 100644 index 0000000000..bf87cb0cf1 --- /dev/null +++ b/news/2966.feature @@ -0,0 +1 @@ +implements RecycleBin feature @rohnsha0 \ No newline at end of file From 41e0ef143cd3f2f97dfacd811b4049ce158db690 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sun, 27 Apr 2025 18:00:36 +0530 Subject: [PATCH 007/122] add tests --- .../CMFPlone/tests/test_recyclebin.py | 384 ++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 src/Products/CMFPlone/tests/test_recyclebin.py diff --git a/src/Products/CMFPlone/tests/test_recyclebin.py b/src/Products/CMFPlone/tests/test_recyclebin.py new file mode 100644 index 0000000000..5f45ef1360 --- /dev/null +++ b/src/Products/CMFPlone/tests/test_recyclebin.py @@ -0,0 +1,384 @@ +import unittest +from datetime import datetime, timedelta +from unittest.mock import Mock, patch, PropertyMock + +from persistent.mapping import PersistentMapping +from plone.registry.interfaces import IRegistry +from Products.CMFPlone.controlpanel.browser.recyclerbin import IRecycleBinControlPanelSettings +from Products.CMFPlone.interfaces.recyclebin import IRecycleBin +from Products.CMFPlone.recyclebin import RecycleBin, ANNOTATION_KEY +from zope.annotation.interfaces import IAnnotations +from zope.component import getUtility +from zope.component.hooks import getSite + + +class TestRecycleBin(unittest.TestCase): + """Test the RecycleBin functionality""" + + def setUp(self): + """Set up test fixtures""" + # Mock site and annotations + self.site = Mock() + self.annotations = {} + self.annotations_mock = Mock() + self.annotations_mock.__getitem__ = lambda _, key: self.annotations.get(key, None) + self.annotations_mock.__setitem__ = lambda _, key, value: self.annotations.__setitem__(key, value) + self.annotations_mock.__contains__ = lambda _, key: key in self.annotations + + # Mock registry and settings + self.settings_mock = Mock() + self.settings_mock.recycling_enabled = True + self.settings_mock.auto_purge = True + self.settings_mock.retention_period = 30 + self.settings_mock.maximum_size = 100 # 100 MB + + self.registry_mock = Mock() + self.registry_mock.forInterface.return_value = self.settings_mock + + # Create recycle bin instance + self.recycle_bin = RecycleBin(self.site) + + def _setup_storage(self): + """Initialize storage with test data""" + self.storage = PersistentMapping() + self.annotations[ANNOTATION_KEY] = self.storage + + @patch('Products.CMFPlone.recyclebin.IAnnotations') + @patch('Products.CMFPlone.recyclebin.getUtility') + def test_is_enabled(self, getUtility_mock, IAnnotations_mock): + """Test checking if recycle bin is enabled""" + # Configure mocks + getUtility_mock.return_value = self.registry_mock + + # Test enabled + result = self.recycle_bin.is_enabled() + self.assertTrue(result) + + # Test disabled + self.settings_mock.recycling_enabled = False + result = self.recycle_bin.is_enabled() + self.assertFalse(result) + + # Test exception handling + getUtility_mock.side_effect = KeyError("Not found") + result = self.recycle_bin.is_enabled() + self.assertFalse(result) + + @patch('Products.CMFPlone.recyclebin.IAnnotations') + @patch('Products.CMFPlone.recyclebin.getUtility') + @patch('Products.CMFPlone.recyclebin.uuid') + def test_add_item(self, uuid_mock, getUtility_mock, IAnnotations_mock): + """Test adding an item to the recycle bin""" + # Configure mocks + test_uuid = "test-uuid-12345" + uuid_mock.uuid4.return_value = test_uuid + getUtility_mock.return_value = self.registry_mock + IAnnotations_mock.return_value = self.annotations_mock + + # Setup storage + self._setup_storage() + + # Create a mock deleted object + deleted_obj = Mock() + deleted_obj.getId.return_value = "test-document" + deleted_obj.Title.return_value = "Test Document" + deleted_obj.portal_type = "Document" + deleted_obj.get_size = lambda: 1024 + + # Create mock container + container = Mock() + container.getPhysicalPath.return_value = ["", "plone", "folder"] + + # Add item to recycle bin + item_id = self.recycle_bin.add_item( + deleted_obj, + container, + "/plone/folder/test-document" + ) + + # Verify item was added correctly + self.assertEqual(item_id, test_uuid) + self.assertIn(test_uuid, self.storage) + item_data = self.storage[test_uuid] + self.assertEqual(item_data["id"], "test-document") + self.assertEqual(item_data["title"], "Test Document") + self.assertEqual(item_data["type"], "Document") + self.assertEqual(item_data["path"], "/plone/folder/test-document") + self.assertEqual(item_data["parent_path"], "/plone/folder") + self.assertEqual(item_data["size"], 1024) + self.assertEqual(item_data["object"], deleted_obj) + + # Test when recycle bin is disabled + self.settings_mock.recycling_enabled = False + item_id = self.recycle_bin.add_item(deleted_obj, container, "/path") + self.assertIsNone(item_id) + + @patch('Products.CMFPlone.recyclebin.IAnnotations') + def test_get_items(self, IAnnotations_mock): + """Test retrieving items from the recycle bin""" + # Configure mocks + IAnnotations_mock.return_value = self.annotations_mock + + # Setup storage with test data + self._setup_storage() + now = datetime.now() + + # Add test items with different dates + self.storage["item1"] = { + "id": "doc1", + "title": "Document 1", + "type": "Document", + "path": "/site/doc1", + "parent_path": "/site", + "deletion_date": now - timedelta(days=1), + "size": 1024, + "object": Mock() + } + + self.storage["item2"] = { + "id": "doc2", + "title": "Document 2", + "type": "Document", + "path": "/site/doc2", + "parent_path": "/site", + "deletion_date": now, # more recent + "size": 2048, + "object": Mock() + } + + # Get items + items = self.recycle_bin.get_items() + + # Verify correct sorting (newest first) and data + self.assertEqual(len(items), 2) + self.assertEqual(items[0]["recycle_id"], "item2") + self.assertEqual(items[1]["recycle_id"], "item1") + + # Verify object is not included in listing + self.assertNotIn("object", items[0]) + self.assertNotIn("object", items[1]) + + @patch('Products.CMFPlone.recyclebin.IAnnotations') + def test_get_item(self, IAnnotations_mock): + """Test retrieving a specific item from the recycle bin""" + # Configure mocks + IAnnotations_mock.return_value = self.annotations_mock + + # Setup storage with test data + self._setup_storage() + test_obj = Mock() + + self.storage["test-id"] = { + "id": "document", + "object": test_obj, + "title": "Test Document" + } + + # Get existing item + item = self.recycle_bin.get_item("test-id") + self.assertEqual(item["id"], "document") + self.assertEqual(item["object"], test_obj) + + # Get non-existent item + item = self.recycle_bin.get_item("non-existent") + self.assertIsNone(item) + + @patch('Products.CMFPlone.recyclebin.IAnnotations') + def test_restore_item(self, IAnnotations_mock): + """Test restoring an item from the recycle bin""" + # Configure mocks + IAnnotations_mock.return_value = self.annotations_mock + + # Setup storage with test data + self._setup_storage() + test_obj = Mock() + + # Setup target container mock + target_container = Mock() + target_container._setObject = Mock() + target_container.__contains__ = lambda _, key: key in ['existing-doc'] + target_container.__getitem__ = lambda _, key: test_obj if key == 'doc1' else None + + # Test normal restoration + self.storage["test-id"] = { + "id": "doc1", + "object": test_obj, + "title": "Test Document", + "parent_path": "/plone/folder" + } + + # Mock site traversal + self.site.unrestrictedTraverse.return_value = target_container + + # Restore item + result = self.recycle_bin.restore_item("test-id") + + # Verify item was restored correctly + target_container._setObject.assert_called_once_with("doc1", test_obj) + self.assertEqual(result, test_obj) + self.assertNotIn("test-id", self.storage) + + # Test ID conflict resolution + self.storage["test-id2"] = { + "id": "existing-doc", # This ID already exists in the container + "object": test_obj, + "title": "Test Document", + "parent_path": "/plone/folder" + } + + # Need to reset the mock count for the second test + target_container._setObject.reset_mock() + + # Restore with conflicting ID + with patch('Products.CMFPlone.recyclebin.datetime') as dt_mock: + dt_mock.now.return_value = datetime(2023, 1, 1, 12, 0, 0) + dt_mock.strftime = datetime.strftime + + self.recycle_bin.restore_item("test-id2") + + # Should have generated a new ID + target_container._setObject.assert_called_once() + call_args = target_container._setObject.call_args[0] + self.assertTrue(call_args[0].startswith("existing-doc-restored-")) + + # Test non-existent item + result = self.recycle_bin.restore_item("non-existent") + self.assertIsNone(result) + + @patch('Products.CMFPlone.recyclebin.IAnnotations') + def test_purge_item(self, IAnnotations_mock): + """Test purging an item from the recycle bin""" + # Configure mocks + IAnnotations_mock.return_value = self.annotations_mock + + # Setup storage with test data + self._setup_storage() + + # Add test item + self.storage["test-id"] = {"id": "doc1"} + + # Purge existing item + result = self.recycle_bin.purge_item("test-id") + self.assertTrue(result) + self.assertNotIn("test-id", self.storage) + + # Purge non-existent item + result = self.recycle_bin.purge_item("non-existent") + self.assertFalse(result) + + @patch('Products.CMFPlone.recyclebin.IAnnotations') + @patch('Products.CMFPlone.recyclebin.getUtility') + @patch('Products.CMFPlone.recyclebin.datetime') + def test_purge_expired_items(self, datetime_mock, getUtility_mock, IAnnotations_mock): + """Test purging expired items from the recycle bin""" + # Configure mocks + now = datetime(2023, 1, 31) + datetime_mock.now.return_value = now + getUtility_mock.return_value = self.registry_mock + IAnnotations_mock.return_value = self.annotations_mock + + # Setup storage with test data + self._setup_storage() + + # Add items with different ages + self.storage["recent"] = { + "id": "recent-doc", + "deletion_date": now - timedelta(days=10) # Within retention period + } + + self.storage["old1"] = { + "id": "old-doc1", + "deletion_date": now - timedelta(days=35) # Expired + } + + self.storage["old2"] = { + "id": "old-doc2", + "deletion_date": now - timedelta(days=40) # Expired + } + + # Test purging with auto_purge enabled + count = self.recycle_bin.purge_expired_items() + + # Should have purged 2 items + self.assertEqual(count, 2) + self.assertIn("recent", self.storage) # Should still be there + self.assertNotIn("old1", self.storage) # Should be purged + self.assertNotIn("old2", self.storage) # Should be purged + + # Test with auto_purge disabled + self.settings_mock.auto_purge = False + + # Reset storage + self._setup_storage() + self.storage["old"] = { + "id": "old-doc", + "deletion_date": now - timedelta(days=100) # Very old + } + + count = self.recycle_bin.purge_expired_items() + self.assertEqual(count, 0) # Should not have purged anything + self.assertIn("old", self.storage) # Should still be there + + @patch('Products.CMFPlone.recyclebin.IAnnotations') + @patch('Products.CMFPlone.recyclebin.getUtility') + @patch('Products.CMFPlone.recyclebin.logger') + def test_check_size_limits(self, logger_mock, getUtility_mock, IAnnotations_mock): + """Test checking size limits and purging oldest items if needed""" + # Configure mocks + getUtility_mock.return_value = self.registry_mock + IAnnotations_mock.return_value = self.annotations_mock + + # Set maximum size to 10 MB + self.settings_mock.maximum_size = 10 + + # Setup storage with test data + self._setup_storage() + + # Add items of different sizes and dates + # Total: 12 MB (exceeds the 10 MB limit) + now = datetime.now() + + self.storage["item1"] = { + "id": "doc1", + "deletion_date": now - timedelta(days=10), + "size": 5 * 1024 * 1024 # 5 MB + } + + self.storage["item2"] = { + "id": "doc2", + "deletion_date": now - timedelta(days=5), + "size": 4 * 1024 * 1024 # 4 MB + } + + self.storage["item3"] = { + "id": "doc3", + "deletion_date": now, + "size": 3 * 1024 * 1024 # 3 MB + } + + # Check size limits + self.recycle_bin._check_size_limits() + + # The oldest item (item1) should be purged + self.assertNotIn("item1", self.storage) + self.assertIn("item2", self.storage) + self.assertIn("item3", self.storage) + self.assertEqual(len(logger_mock.info.mock_calls), 1) # Should log the purge + + @patch('Products.CMFPlone.recyclebin.IAnnotations') + @patch('Products.CMFPlone.recyclebin.getSite') + def test_get_context(self, getSite_mock, IAnnotations_mock): + """Test getting context when used as a utility""" + # Create a recycle bin without context + rb = RecycleBin() + + # Mock the site + site_mock = Mock() + getSite_mock.return_value = site_mock + + # Get context + context = rb._get_context() + + # Should have called getSite + getSite_mock.assert_called_once() + self.assertEqual(context, site_mock) From 1f583e0f42c254f18fe5d7358cf5ac8bf3d7a959 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sun, 27 Apr 2025 19:00:20 +0530 Subject: [PATCH 008/122] Fix action index in test cases for adding and changing category in Actions Control Panel --- .../CMFPlone/tests/robot/test_controlpanel_actions.robot | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Products/CMFPlone/tests/robot/test_controlpanel_actions.robot b/src/Products/CMFPlone/tests/robot/test_controlpanel_actions.robot index 68ad1f1007..904a1630f8 100644 --- a/src/Products/CMFPlone/tests/robot/test_controlpanel_actions.robot +++ b/src/Products/CMFPlone/tests/robot/test_controlpanel_actions.robot @@ -91,7 +91,7 @@ I add a new action Type Text //input[@name="form.widgets.id"] favorites Click //div[contains(@class,'pattern-modal-buttons')]/button Wait For Condition Text //body contains favorites - Click //*[@id="content-core"]/section[6]/section/ol/li[8]/form/a + Click //*[@id="content-core"]/section[6]/section/ol/li[9]/form/a Wait For Condition Text //body contains Action Settings Type Text //input[@name="form.widgets.title"] My favorites Type Text //input[@name="form.widgets.url_expr"] string:\${globals_view/navigationRootUrl}/favorites @@ -111,7 +111,7 @@ I delete an action Click //*[@id="content-core"]/section[2]/section/ol/li[1]/form/button[@name="delete"] I change category of an action - Click //*[@id="content-core"]/section[6]/section/ol/li[7]/form/a + Click //*[@id="content-core"]/section[6]/section/ol/li[8]/form/a Wait For Condition Text //body contains Action Settings Select Options By //select[@name="form.widgets.category:list"] value portal_tabs Click //div[contains(@class,'pattern-modal-buttons')]/button From 08916260412b86dd5f84c28743998f64c5661330 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Mon, 28 Apr 2025 07:09:49 +0530 Subject: [PATCH 009/122] Refactor button classes in recycle bin template for consistency --- src/Products/CMFPlone/browser/templates/recyclebin.pt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Products/CMFPlone/browser/templates/recyclebin.pt b/src/Products/CMFPlone/browser/templates/recyclebin.pt index ce5ae5e0d5..5bfb95e7a3 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin.pt @@ -56,9 +56,9 @@
- - - + +
From 4bd455b729a7d44a1df2eb3f2944192802727f44 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Mon, 28 Apr 2025 07:27:45 +0530 Subject: [PATCH 010/122] remove dedundant format_date logic --- src/Products/CMFPlone/browser/recyclebin.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index 0bf28674c3..b9673d9bb7 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -7,6 +7,7 @@ from zope.component import getUtility from zope.interface import implementer from zope.publisher.interfaces import IPublishTraverse +from Products.CMFCore.utils import getToolByName class RecycleBinView(BrowserView): @@ -38,9 +39,11 @@ def get_items(self): def format_date(self, date): """Format date for display""" - if isinstance(date, datetime): - return date.strftime("%Y-%m-%d %H:%M") - return str(date) + if date is None: + return "" + portal = getToolByName(self.context, 'portal_url').getPortalObject() + # Use long_format=True to include hours, minutes and seconds + return portal.restrictedTraverse('@@plone').toLocalizedTime(date, long_format=True) def format_size(self, size_bytes): """Format size in bytes to human-readable format""" From 453c37e0b5d342b1d773cf302d6bf1a67e0ca6ac Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Tue, 29 Apr 2025 00:11:20 +0530 Subject: [PATCH 011/122] ui cleanup --- src/Products/CMFPlone/browser/templates/recyclebin.pt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Products/CMFPlone/browser/templates/recyclebin.pt b/src/Products/CMFPlone/browser/templates/recyclebin.pt index 5bfb95e7a3..da2a3ef2bf 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin.pt @@ -18,12 +18,9 @@
-
- +
From a7e585c27d581b3f08b647b72d86c6f9459760e7 Mon Sep 17 00:00:00 2001 From: Rohan Shaw <86848116+rohnsha0@users.noreply.github.com> Date: Tue, 29 Apr 2025 12:11:31 +0530 Subject: [PATCH 012/122] Apply suggestions from code review Co-authored-by: Steve Piercy --- news/2966.feature | 2 +- src/Products/CMFPlone/browser/recyclebin.py | 6 +++--- src/Products/CMFPlone/profiles/default/controlpanel.xml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/news/2966.feature b/news/2966.feature index bf87cb0cf1..30d4db4c5b 100644 --- a/news/2966.feature +++ b/news/2966.feature @@ -1 +1 @@ -implements RecycleBin feature @rohnsha0 \ No newline at end of file +Added recycle bin feature. @rohnsha0 \ No newline at end of file diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index b9673d9bb7..0fb40712e7 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -74,7 +74,7 @@ def restore_items(self): if recycle_bin.restore_item(item_id): restored_count += 1 - message = f"{restored_count} item(s) restored successfully." + message = f"{restored_count} item{'s' if deleted_count != 1} restored successfully." IStatusMessage(self.request).addStatusMessage(message, type="info") def delete_items(self): @@ -97,7 +97,7 @@ def delete_items(self): if recycle_bin.purge_item(item_id): deleted_count += 1 - message = f"{deleted_count} item(s) permanently deleted." + message = f"{deleted_count} item{'s' if deleted_count != 1} permanently deleted." IStatusMessage(self.request).addStatusMessage(message, type="info") def empty_bin(self): @@ -111,7 +111,7 @@ def empty_bin(self): if recycle_bin.purge_item(item_id): deleted_count += 1 - message = f"Recycle bin emptied. {deleted_count} item(s) permanently deleted." + message = f"Recycle bin emptied. {deleted_count} item{'s' if deleted_count != 1} permanently deleted." IStatusMessage(self.request).addStatusMessage(message, type="info") diff --git a/src/Products/CMFPlone/profiles/default/controlpanel.xml b/src/Products/CMFPlone/profiles/default/controlpanel.xml index d2fc9cb119..b4f2189811 100644 --- a/src/Products/CMFPlone/profiles/default/controlpanel.xml +++ b/src/Products/CMFPlone/profiles/default/controlpanel.xml @@ -355,7 +355,7 @@ Date: Tue, 29 Apr 2025 21:34:29 +0530 Subject: [PATCH 013/122] implement z3c.form --- src/Products/CMFPlone/browser/recyclebin.py | 305 +++++++++++------- .../CMFPlone/browser/templates/recyclebin.pt | 22 +- .../browser/templates/recyclebin_item.pt | 16 +- 3 files changed, 207 insertions(+), 136 deletions(-) diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index 0fb40712e7..d1cacec1bf 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -6,8 +6,102 @@ from zExceptions import NotFound from zope.component import getUtility from zope.interface import implementer +from zope.interface import Interface from zope.publisher.interfaces import IPublishTraverse from Products.CMFCore.utils import getToolByName +from zope import schema +from z3c.form import button +from z3c.form import field +from z3c.form import form +from plone.z3cform import layout + + +class IRecycleBinForm(Interface): + """Schema for the Recycle Bin form""" + + selected_items = schema.List( + title="Selected Items", + description="Selected items for operations", + value_type=schema.TextLine(), + required=False, + ) + + +class RecycleBinForm(form.Form): + """Form for the recycle bin operations""" + + ignoreContext = True + schema = IRecycleBinForm + + # Don't need fields as we'll just use the template's checkboxes + # and handle the extraction manually + + @button.buttonAndHandler("Restore Selected", name="restore") + def handle_restore(self, action): + """Restore selected items handler""" + data, errors = self.extractData() + + # Get the selected items from the request directly + selected_items = self.request.form.get("selected_items", []) + if not isinstance(selected_items, list): + selected_items = [selected_items] + + if not selected_items: + IStatusMessage(self.request).addStatusMessage( + "No items selected for restoration.", type="info" + ) + return + + recycle_bin = getUtility(IRecycleBin) + restored_count = 0 + for item_id in selected_items: + if recycle_bin.restore_item(item_id): + restored_count += 1 + + message = f"{restored_count} item{'s' if restored_count != 1 else ''} restored successfully." + IStatusMessage(self.request).addStatusMessage(message, type="info") + + @button.buttonAndHandler("Delete Selected", name="delete") + def handle_delete(self, action): + """Delete selected items handler""" + data, errors = self.extractData() + + # Get the selected items from the request directly + selected_items = self.request.form.get("selected_items", []) + if not isinstance(selected_items, list): + selected_items = [selected_items] + + if not selected_items: + IStatusMessage(self.request).addStatusMessage( + "No items selected for deletion.", type="info" + ) + return + + recycle_bin = getUtility(IRecycleBin) + deleted_count = 0 + for item_id in selected_items: + if recycle_bin.purge_item(item_id): + deleted_count += 1 + + message = f"{deleted_count} item{'s' if deleted_count != 1 else ''} permanently deleted." + IStatusMessage(self.request).addStatusMessage(message, type="info") + + @button.buttonAndHandler("Empty Recycle Bin", name="empty") + def handle_empty(self, action): + """Empty recycle bin handler""" + data, errors = self.extractData() + + recycle_bin = getUtility(IRecycleBin) + items = recycle_bin.get_items() + deleted_count = 0 + + for item in items: + item_id = item["recycle_id"] + if recycle_bin.purge_item(item_id): + deleted_count += 1 + + message = f"Recycle bin emptied. {deleted_count} item{'s' if deleted_count != 1 else ''} permanently deleted." + IStatusMessage(self.request).addStatusMessage(message, type="info") class RecycleBinView(BrowserView): @@ -16,16 +110,11 @@ class RecycleBinView(BrowserView): template = ViewPageTemplateFile("templates/recyclebin.pt") def __call__(self): - form = self.request.form - - if form.get("form.submitted", False): - if form.get("form.button.Restore", None) is not None: - self.restore_items() - elif form.get("form.button.Delete", None) is not None: - self.delete_items() - elif form.get("form.button.Empty", None) is not None: - self.empty_bin() - + # Initialize form + form = RecycleBinForm(self.context, self.request) + form.update() + + # Check if form was submitted and return template return self.template() def get_recycle_bin(self): @@ -54,109 +143,40 @@ def format_size(self, size_bytes): else: return f"{size_bytes / (1024 * 1024):.1f} MB" - def restore_items(self): - """Restore selected items from the recycle bin""" - form = self.request.form - recycle_bin = self.get_recycle_bin() - - # Get the selected items - selected_items = form.get("selected_items", []) - if not isinstance(selected_items, list): - selected_items = [selected_items] - if not selected_items: - message = "No items selected for restoration." - IStatusMessage(self.request).addStatusMessage(message, type="info") - return - - restored_count = 0 - for item_id in selected_items: - if recycle_bin.restore_item(item_id): - restored_count += 1 - - message = f"{restored_count} item{'s' if deleted_count != 1} restored successfully." - IStatusMessage(self.request).addStatusMessage(message, type="info") - - def delete_items(self): - """Permanently delete selected items""" - form = self.request.form - recycle_bin = self.get_recycle_bin() - - # Get the selected items - selected_items = form.get("selected_items", []) - if not isinstance(selected_items, list): - selected_items = [selected_items] - - if not selected_items: - message = "No items selected for deletion." - IStatusMessage(self.request).addStatusMessage(message, type="info") - return - - deleted_count = 0 - for item_id in selected_items: - if recycle_bin.purge_item(item_id): - deleted_count += 1 - - message = f"{deleted_count} item{'s' if deleted_count != 1} permanently deleted." - IStatusMessage(self.request).addStatusMessage(message, type="info") - - def empty_bin(self): - """Empty the entire recycle bin""" - recycle_bin = self.get_recycle_bin() - items = recycle_bin.get_items() - deleted_count = 0 - - for item in items: - item_id = item["recycle_id"] - if recycle_bin.purge_item(item_id): - deleted_count += 1 - - message = f"Recycle bin emptied. {deleted_count} item{'s' if deleted_count != 1} permanently deleted." - IStatusMessage(self.request).addStatusMessage(message, type="info") - - -@implementer(IPublishTraverse) -class RecycleBinItemView(BrowserView): - """View for managing individual recycled items""" - - template = ViewPageTemplateFile("templates/recyclebin_item.pt") - item_id = None - - def publishTraverse(self, request, name): - """Handle URLs like /recyclebin/item/[item_id]""" - if self.item_id is None: # First traversal - self.item_id = name - return self - raise NotFound(self, name, request) - - def __call__(self): - """Handle item operations""" - if self.item_id is None: - self.request.response.redirect(f"{self.context.absolute_url()}/recyclebin") - return "" - - form = self.request.form - if form.get("form.submitted", False): - if form.get("form.button.Restore", None) is not None: - self.restore_item() - return "" - elif form.get("form.button.Delete", None) is not None: - self.delete_item() - return "" - - return self.template() - - def get_item(self): - """Get the specific recycled item""" - recycle_bin = getUtility(IRecycleBin) - return recycle_bin.get_item(self.item_id) - - def restore_item(self): +class IRecycleBinItemForm(Interface): + """Schema for the recycle bin item form""" + + target_container = schema.TextLine( + title="Target Container", + description="Path to container where the item should be restored (optional)", + required=False + ) + + +class RecycleBinItemForm(form.Form): + """Form for managing individual recycled items""" + + ignoreContext = True + fields = field.Fields(IRecycleBinItemForm) + + def __init__(self, context, request, item_id=None): + super().__init__(context, request) + self.item_id = item_id + self.recycle_bin = getUtility(IRecycleBin) + self.item = None + if self.item_id: + self.item = self.recycle_bin.get_item(self.item_id) + + @button.buttonAndHandler("Restore Item", name="restore") + def handle_restore(self, action): """Restore this item""" - recycle_bin = getUtility(IRecycleBin) - + data, errors = self.extractData() + if errors: + return + # Get target container if specified - target_path = self.request.form.get("target_container", "") + target_path = data.get("target_container", "") target_container = None if target_path: @@ -165,13 +185,10 @@ def restore_item(self): except (KeyError, AttributeError): message = f"Target location not found: {target_path}" IStatusMessage(self.request).addStatusMessage(message, type="error") - self.request.response.redirect( - f"{self.context.absolute_url()}/recyclebin/item/{self.item_id}" - ) return # Restore the item - restored_obj = recycle_bin.restore_item(self.item_id, target_container) + restored_obj = self.recycle_bin.restore_item(self.item_id, target_container) if restored_obj: message = f"Item '{restored_obj.Title()}' successfully restored." @@ -182,18 +199,18 @@ def restore_item(self): "Failed to restore item. It may have been already restored or deleted." ) IStatusMessage(self.request).addStatusMessage(message, type="error") - self.request.response.redirect(f"{self.context.absolute_url()}/recyclebin") - - def delete_item(self): + self.request.response.redirect(f"{self.context.absolute_url()}/@@recyclebin") + + @button.buttonAndHandler("Permanently Delete", name="delete") + def handle_delete(self, action): """Permanently delete this item""" - recycle_bin = getUtility(IRecycleBin) - + data, errors = self.extractData() + # Get item info before deletion - item = self.get_item() - if item: - item_title = item.get("title", "Unknown") + if self.item: + item_title = self.item.get("title", "Unknown") - if recycle_bin.purge_item(self.item_id): + if self.recycle_bin.purge_item(self.item_id): message = f"Item '{item_title}' permanently deleted." IStatusMessage(self.request).addStatusMessage(message, type="info") else: @@ -203,4 +220,44 @@ def delete_item(self): message = "Item not found. It may have been already deleted." IStatusMessage(self.request).addStatusMessage(message, type="error") - self.request.response.redirect(f"{self.context.absolute_url()}/recyclebin") + self.request.response.redirect(f"{self.context.absolute_url()}/@@recyclebin") + + +@implementer(IPublishTraverse) +class RecycleBinItemView(BrowserView): + """View for managing individual recycled items""" + + template = ViewPageTemplateFile("templates/recyclebin_item.pt") + item_id = None + + def publishTraverse(self, request, name): + """Handle URLs like /recyclebin/item/[item_id]""" + if self.item_id is None: # First traversal + self.item_id = name + return self + raise NotFound(self, name, request) + + def __call__(self): + """Handle item operations""" + if self.item_id is None: + self.request.response.redirect(f"{self.context.absolute_url()}/@@recyclebin") + return "" + + # Initialize and update the form + form = RecycleBinItemForm(self.context, self.request, self.item_id) + form.update() + + return self.template() + + def get_item(self): + """Get the specific recycled item""" + recycle_bin = getUtility(IRecycleBin) + return recycle_bin.get_item(self.item_id) + + def format_date(self, date): + """Format date for display""" + if date is None: + return "" + portal = getToolByName(self.context, 'portal_url').getPortalObject() + # Use long_format=True to include hours, minutes and seconds + return portal.restrictedTraverse('@@plone').toLocalizedTime(date, long_format=True) diff --git a/src/Products/CMFPlone/browser/templates/recyclebin.pt b/src/Products/CMFPlone/browser/templates/recyclebin.pt index da2a3ef2bf..a883bb73af 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin.pt @@ -53,9 +53,9 @@
- - - + +
@@ -64,5 +64,21 @@ + + diff --git a/src/Products/CMFPlone/browser/templates/recyclebin_item.pt b/src/Products/CMFPlone/browser/templates/recyclebin_item.pt index b0a83e6c83..ac2b99b3c1 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin_item.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin_item.pt @@ -7,7 +7,8 @@ - +

Item Not Found

@@ -50,22 +51,19 @@ -

- - +
- +
Leave blank to restore to the original location. If the original location no longer exists, the item will be restored to the site root.
- +
- - +
From f667d2ea11bfeb71814f97c75ead85a39356eae7 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Wed, 30 Apr 2025 09:25:45 +0530 Subject: [PATCH 014/122] fix(recyclebin): added support for discussions --- src/Products/CMFPlone/browser/recyclebin.py | 25 +- .../CMFPlone/browser/templates/recyclebin.pt | 12 +- .../browser/templates/recyclebin_item.pt | 31 +- src/Products/CMFPlone/recyclebin.py | 300 +++++++++++++++++- 4 files changed, 361 insertions(+), 7 deletions(-) diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index d1cacec1bf..7a5cbdd359 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -124,7 +124,30 @@ def get_recycle_bin(self): def get_items(self): """Get all items in the recycle bin""" recycle_bin = self.get_recycle_bin() - return recycle_bin.get_items() + items = recycle_bin.get_items() + + # For comments, add extra information about the content they belong to + for item in items: + if item.get('type') == 'Discussion Item': + # Extract content path from comment path + path = item.get('path', '') + # The conversation part is usually ++conversation++default + parts = path.split('++conversation++') + if len(parts) > 1: + content_path = parts[0] + # Remove trailing slash if present + if content_path.endswith('/'): + content_path = content_path[:-1] + item['content_path'] = content_path + + # Try to get the content title + try: + content = self.context.unrestrictedTraverse(content_path) + item['content_title'] = content.Title() + except (KeyError, AttributeError): + item['content_title'] = 'Content no longer exists' + + return items def format_date(self, date): """Format date for display""" diff --git a/src/Products/CMFPlone/browser/templates/recyclebin.pt b/src/Products/CMFPlone/browser/templates/recyclebin.pt index a883bb73af..75e0d9ab66 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin.pt @@ -43,9 +43,19 @@ Title +
+ Comment on: Content Title +
Content type - Original path + + Original path +
+ Content: Content Path +
+ Deletion date Size diff --git a/src/Products/CMFPlone/browser/templates/recyclebin_item.pt b/src/Products/CMFPlone/browser/templates/recyclebin_item.pt index ac2b99b3c1..875bbd19e5 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin_item.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin_item.pt @@ -26,7 +26,26 @@ (Content Type) -
+ +
+

+ Comment deleted from + + + Content title + + + + content that no longer exists + +

+
+ +
Deleted item information
@@ -48,6 +67,16 @@ Deletion Date Date + + + Comment Text + Comment text + + + + Comment Author + Author + diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index 1de8ba57e3..08bc82e57c 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -70,7 +70,7 @@ def is_enabled(self): except (KeyError, AttributeError): return False - def add_item(self, obj, original_container, original_path): + def add_item(self, obj, original_container, original_path, item_type=None): """Add deleted item to recycle bin""" if not self.is_enabled(): return None @@ -80,9 +80,9 @@ def add_item(self, obj, original_container, original_path): # Store metadata about the deletion self.storage[item_id] = { - "id": obj.getId(), - "title": obj.Title(), - "type": obj.portal_type, + "id": obj.getId() if hasattr(obj, "getId") else getattr(obj, "id", "unknown"), + "title": obj.Title() if hasattr(obj, "Title") else getattr(obj, "title", "Unknown"), + "type": item_type or getattr(obj, "portal_type", "Unknown"), "path": original_path, "parent_path": "/".join(original_container.getPhysicalPath()), "deletion_date": datetime.now(), @@ -121,7 +121,17 @@ def restore_item(self, item_id, target_container=None): item_data = self.storage[item_id] obj = item_data["object"] obj_id = item_data["id"] + item_type = item_data.get("type", None) + # Special handling for CommentTree (comments with replies) + if item_type == "CommentTree": + return self._restore_comment_tree(item_id, item_data, target_container) + + # Special handling for Discussion Item (Comments) + if item_data.get("type") == "Discussion Item": + return self._restore_comment(item_id, item_data, target_container) + + # Regular content object restoration # Find the container to restore to site = self._get_context() if target_container is None: @@ -152,6 +162,288 @@ def restore_item(self, item_id, target_container=None): restored_obj = target_container[obj_id] return restored_obj + def _restore_comment(self, item_id, item_data, target_container=None): + """Enhanced restoration method for comments that preserves reply relationships""" + obj = item_data["object"] + site = self._get_context() + + # Try to find the original conversation + parent_path = item_data["parent_path"] + try: + conversation = site.unrestrictedTraverse(parent_path) + except (KeyError, AttributeError): + # If original conversation doesn't exist, we can't restore the comment + logger.warning(f"Cannot restore comment {item_id}: conversation no longer exists at {parent_path}") + return None + + # Restore comment back to conversation + from plone.app.discussion.interfaces import IConversation + if IConversation.providedBy(conversation): + # Store the original comment ID before restoration + original_id = getattr(obj, 'comment_id', None) + original_in_reply_to = getattr(obj, 'in_reply_to', None) + + # Track comment relationships using a simple dictionary + # We won't use annotations directly on the conversation since that causes adaptation issues + # Instead, we'll use a module-level cache + from zope.globalrequest import getRequest + request = getRequest() + if request and not hasattr(request, '_comment_restore_mapping'): + request._comment_restore_mapping = {} + + # Initialize mapping if needed + mapping = getattr(request, '_comment_restore_mapping', {}) + conversation_path = '/'.join(conversation.getPhysicalPath()) + if conversation_path not in mapping: + mapping[conversation_path] = {} + + id_mapping = mapping[conversation_path] + + # Check if the parent comment exists in the conversation (direct or restored) + if original_in_reply_to is not None and original_in_reply_to != 0: + parent_found = False + + # First check if it exists directly (not previously deleted) + if original_in_reply_to in conversation: + parent_found = True + # Then check if it was restored with a different ID + elif str(original_in_reply_to) in id_mapping: + # Use the ID mapping to find the new ID + obj.in_reply_to = id_mapping[str(original_in_reply_to)] + parent_found = True + else: + # Look through all comments to see if any have the original_id attribute matching our in_reply_to + for comment_id in conversation.keys(): + comment = conversation[comment_id] + comment_original_id = getattr(comment, 'original_id', None) + + if comment_original_id is not None and str(comment_original_id) == str(original_in_reply_to): + # We found the parent with a new ID, update the reference + obj.in_reply_to = comment_id + parent_found = True + break + + # If no parent was found, make this a top-level comment + if not parent_found: + obj.in_reply_to = None + + # Store the original ID for future reference + if not hasattr(obj, 'original_id'): + obj.original_id = original_id + + # When restored, add the comment to the conversation + new_id = conversation.addComment(obj) + + # Store the mapping of original ID to new ID + if original_id is not None: + id_mapping[str(original_id)] = new_id + + # Remove from recycle bin + del self.storage[item_id] + + # Return the restored comment + return conversation[new_id] + else: + # If the parent is not a conversation, we can't restore + logger.warning(f"Cannot restore comment {item_id}: parent is not a conversation") + return None + + def _restore_comment_tree(self, item_id, item_data, target_container=None): + """Restore a comment tree with all its replies while preserving relationships""" + comment_tree = item_data["object"] + root_comment_id = comment_tree.get('root_comment_id') + comments_to_restore = comment_tree.get('comments', []) + + logger.info(f"Attempting to restore comment tree {item_id} with root_comment_id: {root_comment_id}") + logger.info(f"Found {len(comments_to_restore)} comments to restore") + + if not comments_to_restore: + logger.warning(f"Cannot restore comment tree {item_id}: no comments found in tree") + return None + + site = self._get_context() + + # Try to find the original conversation + parent_path = item_data["parent_path"] + try: + conversation = site.unrestrictedTraverse(parent_path) + except (KeyError, AttributeError): + # If original conversation doesn't exist, we can't restore the comment + logger.warning(f"Cannot restore comment tree {item_id}: conversation no longer exists at {parent_path}") + return None + + # Restore comments back to conversation + from plone.app.discussion.interfaces import IConversation + if IConversation.providedBy(conversation): + # First extract all comments and create a mapping of original IDs + # to comment objects for quick lookup + comment_dict = {} + id_mapping = {} # Will map original IDs to new IDs + + # Process comments to build reference dictionary + for comment_obj, _ in comments_to_restore: + # Store original values we'll need for restoration + original_id = getattr(comment_obj, 'comment_id', None) + original_in_reply_to = getattr(comment_obj, 'in_reply_to', None) + + # Add some debug logging + logger.info(f"Processing comment with ID: {original_id}, in_reply_to: {original_in_reply_to}") + + # Mark this comment with its original ID for future reference + if not hasattr(comment_obj, 'original_id'): + comment_obj.original_id = original_id + + # Store in our dictionary for quick access + comment_dict[original_id] = { + 'comment': comment_obj, + 'in_reply_to': original_in_reply_to + } + + # First, try to find the root comment + root_comment = None + if root_comment_id in comment_dict: + root_comment = comment_dict[root_comment_id]['comment'] + logger.info(f"Found root comment with ID: {root_comment_id}") + else: + # Root comment not found by explicit ID, try alternative approaches + logger.warning(f"Root comment with ID {root_comment_id} not found in comment dictionary") + + # Try to find a top-level comment or one with the lowest ID to use as root + for comment_id, comment_data in comment_dict.items(): + in_reply_to = comment_data['in_reply_to'] + if in_reply_to == 0 or in_reply_to is None: + # Found a top-level comment, use it as root + root_comment = comment_data['comment'] + root_comment_id = comment_id + logger.info(f"Using top-level comment with ID {comment_id} as root") + break + + # If still no root, use the first comment in the dictionary + if not root_comment and comment_dict: + first_key = list(comment_dict.keys())[0] + root_comment = comment_dict[first_key]['comment'] + root_comment_id = first_key + logger.info(f"Using first available comment with ID {first_key} as root") + + if not root_comment: + logger.error(f"Cannot restore comment tree {item_id}: no valid root comment could be determined") + return None + + # If this is a reply to another comment, check if that comment exists + original_in_reply_to = getattr(root_comment, 'in_reply_to', None) + if original_in_reply_to is not None and original_in_reply_to != 0: + # Check if parent exists in conversation or needs to be handled specially + if original_in_reply_to not in conversation: + # Look through all comments to see if any were previously this comment's parent + parent_found = False + for comment_id in conversation.keys(): + comment = conversation[comment_id] + # Check if this comment was previously the parent (by original ID) + original_id = getattr(comment, 'original_id', None) + if original_id == original_in_reply_to: + # We found the parent with a new ID, update the reference + root_comment.in_reply_to = comment_id + parent_found = True + logger.info(f"Found existing parent for root comment: {comment_id}") + break + + # If no parent was found, make this a top-level comment + if not parent_found: + logger.info("No parent found for root comment, making it a top-level comment") + root_comment.in_reply_to = None + + # Add the root comment to the conversation + new_root_id = conversation.addComment(root_comment) + id_mapping[root_comment_id] = new_root_id + logger.info(f"Added root comment to conversation with new ID: {new_root_id}") + + # Now restore all child comments in order, updating their in_reply_to references + # Skip the root comment which we've already restored + remaining_comments = {k: v for k, v in comment_dict.items() if k != root_comment_id} + + # Keep track of successfully restored comments + restored_count = 1 # Start with 1 for the root comment + + # Keep trying to restore comments until we can't restore any more + # We need multiple passes because comments might depend on other comments + # that haven't been restored yet + max_passes = 10 # Limit the number of passes to avoid infinite loops + current_pass = 0 + + while remaining_comments and current_pass < max_passes: + current_pass += 1 + logger.info(f"Pass {current_pass}: {len(remaining_comments)} comments remaining to restore") + restored_in_pass = 0 + + # Copy keys to avoid modifying dict during iteration + for comment_id in list(remaining_comments.keys()): + comment_data = remaining_comments[comment_id] + comment = comment_data['comment'] + in_reply_to = comment_data['in_reply_to'] + + # Check if the parent comment has been restored + if in_reply_to in id_mapping: + # Update reference to the new parent ID + comment.in_reply_to = id_mapping[in_reply_to] + + # Add to conversation + new_id = conversation.addComment(comment) + id_mapping[comment_id] = new_id + + # Remove from remaining comments + del remaining_comments[comment_id] + restored_in_pass += 1 + logger.info(f"Restored comment {comment_id} with new ID {new_id}, parent {in_reply_to} -> {id_mapping[in_reply_to]}") + + # If we couldn't restore any comments in this pass, we have an issue + if restored_in_pass == 0 and remaining_comments: + logger.warning( + f"Pass {current_pass}: No comments could be restored. " + f"{len(remaining_comments)} comments remaining." + ) + # Try one more approach - see if any remaining comments have parents + # that don't exist in our mapping but do exist in the conversation + for comment_id, comment_data in list(remaining_comments.items()): + comment = comment_data['comment'] + in_reply_to = comment_data['in_reply_to'] + + # Check if the parent exists directly in the conversation + if in_reply_to and in_reply_to in conversation: + comment.in_reply_to = in_reply_to # Keep the original reference + new_id = conversation.addComment(comment) + id_mapping[comment_id] = new_id + del remaining_comments[comment_id] + restored_in_pass += 1 + logger.info(f"Found parent directly in conversation for {comment_id} -> {new_id}") + + # If still no progress, make them top-level + if restored_in_pass == 0: + # Just restore remaining comments as top-level comments + logger.warning( + f"Some comments in tree {item_id} couldn't be restored with proper relationships. " + "Restoring them as top-level comments." + ) + for comment_id, comment_data in remaining_comments.items(): + comment = comment_data['comment'] + comment.in_reply_to = None + new_id = conversation.addComment(comment) + id_mapping[comment_id] = new_id + logger.info(f"Restored comment {comment_id} as top-level comment with new ID {new_id}") + break + + restored_count += restored_in_pass + + # Remove from recycle bin + del self.storage[item_id] + + # Return the root comment + logger.info(f"Restored comment tree with {restored_count} comments.") + return conversation[new_root_id] + else: + # If the parent is not a conversation, we can't restore + logger.warning(f"Cannot restore comment tree {item_id}: parent is not a conversation") + return None + def purge_item(self, item_id): """Permanently delete an item""" if item_id not in self.storage: From 53aa84be25091e98bb1dc501ed0eea74e77fda34 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Wed, 30 Apr 2025 09:34:02 +0530 Subject: [PATCH 015/122] feat(recyclebin): enhance comment tree handling with meaningful titles --- .../browser/templates/recyclebin_item.pt | 21 +++++++++-- src/Products/CMFPlone/recyclebin.py | 35 ++++++++++++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/Products/CMFPlone/browser/templates/recyclebin_item.pt b/src/Products/CMFPlone/browser/templates/recyclebin_item.pt index 875bbd19e5..1399120c40 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin_item.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin_item.pt @@ -26,7 +26,24 @@ (Content Type) - + +
+

+ Comment thread deleted from + + + Content title + + + + content that no longer exists + +

+
+ +

@@ -44,7 +61,7 @@

Deleted item information
diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index 08bc82e57c..df06263590 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -78,10 +78,43 @@ def add_item(self, obj, original_container, original_path, item_type=None): # Generate a unique ID for the recycled item item_id = str(uuid.uuid4()) + # Generate a meaningful title + item_title = "Unknown" + if item_type == "CommentTree": + # For comment trees, generate a title including the number of comments + comment_count = len(obj.get('comments', [])) + root_comment = None + + # Try to find the root comment to get its text + for comment, _ in obj.get('comments', []): + if getattr(comment, 'comment_id', None) == obj.get('root_comment_id'): + root_comment = comment + break + + # If we found the root comment, get a preview of its text + comment_preview = "" + if root_comment and hasattr(root_comment, 'text'): + # Take the first 30 characters of the text as a preview + text = getattr(root_comment, 'text', '') + if text: + if len(text) > 30: + comment_preview = text[:30] + "..." + else: + comment_preview = text + + # Create a meaningful title + if comment_preview: + item_title = f"Comment thread: \"{comment_preview}\" ({comment_count} comments)" + else: + item_title = f"Comment thread ({comment_count} comments)" + else: + # For regular items, use Title() if available + item_title = obj.Title() if hasattr(obj, "Title") else getattr(obj, "title", "Unknown") + # Store metadata about the deletion self.storage[item_id] = { "id": obj.getId() if hasattr(obj, "getId") else getattr(obj, "id", "unknown"), - "title": obj.Title() if hasattr(obj, "Title") else getattr(obj, "title", "Unknown"), + "title": item_title, "type": item_type or getattr(obj, "portal_type", "Unknown"), "path": original_path, "parent_path": "/".join(original_container.getPhysicalPath()), From 8eba3219da28643f2132073d5ddd54f10f0a6655 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Wed, 30 Apr 2025 09:38:28 +0530 Subject: [PATCH 016/122] lint --- src/Products/CMFPlone/browser/recyclebin.py | 110 ++++---- src/Products/CMFPlone/configure.zcml | 12 +- .../controlpanel/browser/configure.zcml | 9 +- .../profiles/default/controlpanel.xml | 20 +- .../profiles/dependencies/registry.xml | 6 +- src/Products/CMFPlone/recyclebin.py | 258 +++++++++++------- .../CMFPlone/tests/test_recyclebin.py | 233 ++++++++-------- 7 files changed, 357 insertions(+), 291 deletions(-) diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index 7a5cbdd359..6fa05ad8ec 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -1,24 +1,22 @@ -from datetime import datetime +from Products.CMFCore.utils import getToolByName from Products.CMFPlone.interfaces.recyclebin import IRecycleBin 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 zExceptions import NotFound +from zope import schema from zope.component import getUtility from zope.interface import implementer from zope.interface import Interface from zope.publisher.interfaces import IPublishTraverse -from Products.CMFCore.utils import getToolByName -from zope import schema -from z3c.form import button -from z3c.form import field -from z3c.form import form -from plone.z3cform import layout class IRecycleBinForm(Interface): """Schema for the Recycle Bin form""" - + selected_items = schema.List( title="Selected Items", description="Selected items for operations", @@ -29,77 +27,77 @@ class IRecycleBinForm(Interface): class RecycleBinForm(form.Form): """Form for the recycle bin operations""" - + ignoreContext = True schema = IRecycleBinForm - + # Don't need fields as we'll just use the template's checkboxes # and handle the extraction manually - + @button.buttonAndHandler("Restore Selected", name="restore") def handle_restore(self, action): """Restore selected items handler""" data, errors = self.extractData() - + # Get the selected items from the request directly selected_items = self.request.form.get("selected_items", []) if not isinstance(selected_items, list): selected_items = [selected_items] - + if not selected_items: IStatusMessage(self.request).addStatusMessage( "No items selected for restoration.", type="info" ) return - + recycle_bin = getUtility(IRecycleBin) restored_count = 0 for item_id in selected_items: if recycle_bin.restore_item(item_id): restored_count += 1 - + message = f"{restored_count} item{'s' if restored_count != 1 else ''} restored successfully." IStatusMessage(self.request).addStatusMessage(message, type="info") - + @button.buttonAndHandler("Delete Selected", name="delete") def handle_delete(self, action): """Delete selected items handler""" data, errors = self.extractData() - + # Get the selected items from the request directly selected_items = self.request.form.get("selected_items", []) if not isinstance(selected_items, list): selected_items = [selected_items] - + if not selected_items: IStatusMessage(self.request).addStatusMessage( "No items selected for deletion.", type="info" ) return - + recycle_bin = getUtility(IRecycleBin) deleted_count = 0 for item_id in selected_items: if recycle_bin.purge_item(item_id): deleted_count += 1 - + message = f"{deleted_count} item{'s' if deleted_count != 1 else ''} permanently deleted." IStatusMessage(self.request).addStatusMessage(message, type="info") - + @button.buttonAndHandler("Empty Recycle Bin", name="empty") def handle_empty(self, action): """Empty recycle bin handler""" data, errors = self.extractData() - + recycle_bin = getUtility(IRecycleBin) items = recycle_bin.get_items() deleted_count = 0 - + for item in items: item_id = item["recycle_id"] if recycle_bin.purge_item(item_id): deleted_count += 1 - + message = f"Recycle bin emptied. {deleted_count} item{'s' if deleted_count != 1 else ''} permanently deleted." IStatusMessage(self.request).addStatusMessage(message, type="info") @@ -113,7 +111,7 @@ def __call__(self): # Initialize form form = RecycleBinForm(self.context, self.request) form.update() - + # Check if form was submitted and return template return self.template() @@ -125,37 +123,39 @@ def get_items(self): """Get all items in the recycle bin""" recycle_bin = self.get_recycle_bin() items = recycle_bin.get_items() - + # For comments, add extra information about the content they belong to for item in items: - if item.get('type') == 'Discussion Item': + if item.get("type") == "Discussion Item": # Extract content path from comment path - path = item.get('path', '') + path = item.get("path", "") # The conversation part is usually ++conversation++default - parts = path.split('++conversation++') + parts = path.split("++conversation++") if len(parts) > 1: content_path = parts[0] # Remove trailing slash if present - if content_path.endswith('/'): + if content_path.endswith("/"): content_path = content_path[:-1] - item['content_path'] = content_path - + item["content_path"] = content_path + # Try to get the content title try: content = self.context.unrestrictedTraverse(content_path) - item['content_title'] = content.Title() + item["content_title"] = content.Title() except (KeyError, AttributeError): - item['content_title'] = 'Content no longer exists' - + item["content_title"] = "Content no longer exists" + return items def format_date(self, date): """Format date for display""" if date is None: return "" - portal = getToolByName(self.context, 'portal_url').getPortalObject() + portal = getToolByName(self.context, "portal_url").getPortalObject() # Use long_format=True to include hours, minutes and seconds - return portal.restrictedTraverse('@@plone').toLocalizedTime(date, long_format=True) + return portal.restrictedTraverse("@@plone").toLocalizedTime( + date, long_format=True + ) def format_size(self, size_bytes): """Format size in bytes to human-readable format""" @@ -169,20 +169,20 @@ def format_size(self, size_bytes): class IRecycleBinItemForm(Interface): """Schema for the recycle bin item form""" - + target_container = schema.TextLine( title="Target Container", description="Path to container where the item should be restored (optional)", - required=False + required=False, ) class RecycleBinItemForm(form.Form): """Form for managing individual recycled items""" - + ignoreContext = True fields = field.Fields(IRecycleBinItemForm) - + def __init__(self, context, request, item_id=None): super().__init__(context, request) self.item_id = item_id @@ -190,14 +190,14 @@ def __init__(self, context, request, item_id=None): self.item = None if self.item_id: self.item = self.recycle_bin.get_item(self.item_id) - + @button.buttonAndHandler("Restore Item", name="restore") def handle_restore(self, action): """Restore this item""" data, errors = self.extractData() if errors: return - + # Get target container if specified target_path = data.get("target_container", "") target_container = None @@ -222,13 +222,15 @@ def handle_restore(self, action): "Failed to restore item. It may have been already restored or deleted." ) IStatusMessage(self.request).addStatusMessage(message, type="error") - self.request.response.redirect(f"{self.context.absolute_url()}/@@recyclebin") - + self.request.response.redirect( + f"{self.context.absolute_url()}/@@recyclebin" + ) + @button.buttonAndHandler("Permanently Delete", name="delete") def handle_delete(self, action): """Permanently delete this item""" data, errors = self.extractData() - + # Get item info before deletion if self.item: item_title = self.item.get("title", "Unknown") @@ -263,24 +265,28 @@ def publishTraverse(self, request, name): def __call__(self): """Handle item operations""" if self.item_id is None: - self.request.response.redirect(f"{self.context.absolute_url()}/@@recyclebin") + self.request.response.redirect( + f"{self.context.absolute_url()}/@@recyclebin" + ) return "" - + # Initialize and update the form form = RecycleBinItemForm(self.context, self.request, self.item_id) form.update() - + return self.template() def get_item(self): """Get the specific recycled item""" recycle_bin = getUtility(IRecycleBin) return recycle_bin.get_item(self.item_id) - + def format_date(self, date): """Format date for display""" if date is None: return "" - portal = getToolByName(self.context, 'portal_url').getPortalObject() + portal = getToolByName(self.context, "portal_url").getPortalObject() # Use long_format=True to include hours, minutes and seconds - return portal.restrictedTraverse('@@plone').toLocalizedTime(date, long_format=True) + return portal.restrictedTraverse("@@plone").toLocalizedTime( + date, long_format=True + ) diff --git a/src/Products/CMFPlone/configure.zcml b/src/Products/CMFPlone/configure.zcml index 2d42c8e151..73e6a0b31f 100644 --- a/src/Products/CMFPlone/configure.zcml +++ b/src/Products/CMFPlone/configure.zcml @@ -162,14 +162,16 @@ - + provides=".interfaces.recyclebin.IRecycleBin" + /> + - + handler=".events.handle_content_removal" + /> + - + + name="plone-recyclebin" + for="Products.CMFPlone.interfaces.IPloneSiteRoot" + class="Products.CMFPlone.controlpanel.browser.recyclerbin.RecyclebinControlPanelView" + permission="cmf.ManagePortal" + /> diff --git a/src/Products/CMFPlone/profiles/default/controlpanel.xml b/src/Products/CMFPlone/profiles/default/controlpanel.xml index b4f2189811..02df1616b7 100644 --- a/src/Products/CMFPlone/profiles/default/controlpanel.xml +++ b/src/Products/CMFPlone/profiles/default/controlpanel.xml @@ -354,15 +354,15 @@ - + Manage portal - +
diff --git a/src/Products/CMFPlone/profiles/dependencies/registry.xml b/src/Products/CMFPlone/profiles/dependencies/registry.xml index 0981d089bc..1051461acd 100644 --- a/src/Products/CMFPlone/profiles/dependencies/registry.xml +++ b/src/Products/CMFPlone/profiles/dependencies/registry.xml @@ -126,12 +126,14 @@ {"actionOptions": {"displayInModal": false}} - + True 30 100 True - + diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index df06263590..d5d553a88e 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -7,7 +7,6 @@ ) from Products.CMFPlone.interfaces.recyclebin import IRecycleBin from zope.annotation.interfaces import IAnnotations -from zope.component import getSiteManager from zope.component import getUtility from zope.component.hooks import getSite from zope.interface import implementer @@ -82,38 +81,46 @@ def add_item(self, obj, original_container, original_path, item_type=None): item_title = "Unknown" if item_type == "CommentTree": # For comment trees, generate a title including the number of comments - comment_count = len(obj.get('comments', [])) + comment_count = len(obj.get("comments", [])) root_comment = None - + # Try to find the root comment to get its text - for comment, _ in obj.get('comments', []): - if getattr(comment, 'comment_id', None) == obj.get('root_comment_id'): + for comment, _ in obj.get("comments", []): + if getattr(comment, "comment_id", None) == obj.get("root_comment_id"): root_comment = comment break - + # If we found the root comment, get a preview of its text comment_preview = "" - if root_comment and hasattr(root_comment, 'text'): + if root_comment and hasattr(root_comment, "text"): # Take the first 30 characters of the text as a preview - text = getattr(root_comment, 'text', '') + text = getattr(root_comment, "text", "") if text: if len(text) > 30: comment_preview = text[:30] + "..." else: comment_preview = text - + # Create a meaningful title if comment_preview: - item_title = f"Comment thread: \"{comment_preview}\" ({comment_count} comments)" + item_title = ( + f'Comment thread: "{comment_preview}" ({comment_count} comments)' + ) else: item_title = f"Comment thread ({comment_count} comments)" else: # For regular items, use Title() if available - item_title = obj.Title() if hasattr(obj, "Title") else getattr(obj, "title", "Unknown") + item_title = ( + obj.Title() + if hasattr(obj, "Title") + else getattr(obj, "title", "Unknown") + ) # Store metadata about the deletion self.storage[item_id] = { - "id": obj.getId() if hasattr(obj, "getId") else getattr(obj, "id", "unknown"), + "id": ( + obj.getId() if hasattr(obj, "getId") else getattr(obj, "id", "unknown") + ), "title": item_title, "type": item_type or getattr(obj, "portal_type", "Unknown"), "path": original_path, @@ -159,7 +166,7 @@ def restore_item(self, item_id, target_container=None): # Special handling for CommentTree (comments with replies) if item_type == "CommentTree": return self._restore_comment_tree(item_id, item_data, target_container) - + # Special handling for Discussion Item (Comments) if item_data.get("type") == "Discussion Item": return self._restore_comment(item_id, item_data, target_container) @@ -199,43 +206,47 @@ def _restore_comment(self, item_id, item_data, target_container=None): """Enhanced restoration method for comments that preserves reply relationships""" obj = item_data["object"] site = self._get_context() - + # Try to find the original conversation parent_path = item_data["parent_path"] try: conversation = site.unrestrictedTraverse(parent_path) except (KeyError, AttributeError): # If original conversation doesn't exist, we can't restore the comment - logger.warning(f"Cannot restore comment {item_id}: conversation no longer exists at {parent_path}") + logger.warning( + f"Cannot restore comment {item_id}: conversation no longer exists at {parent_path}" + ) return None # Restore comment back to conversation from plone.app.discussion.interfaces import IConversation + if IConversation.providedBy(conversation): # Store the original comment ID before restoration - original_id = getattr(obj, 'comment_id', None) - original_in_reply_to = getattr(obj, 'in_reply_to', None) - + original_id = getattr(obj, "comment_id", None) + original_in_reply_to = getattr(obj, "in_reply_to", None) + # Track comment relationships using a simple dictionary # We won't use annotations directly on the conversation since that causes adaptation issues # Instead, we'll use a module-level cache from zope.globalrequest import getRequest + request = getRequest() - if request and not hasattr(request, '_comment_restore_mapping'): + if request and not hasattr(request, "_comment_restore_mapping"): request._comment_restore_mapping = {} - + # Initialize mapping if needed - mapping = getattr(request, '_comment_restore_mapping', {}) - conversation_path = '/'.join(conversation.getPhysicalPath()) + mapping = getattr(request, "_comment_restore_mapping", {}) + conversation_path = "/".join(conversation.getPhysicalPath()) if conversation_path not in mapping: mapping[conversation_path] = {} - + id_mapping = mapping[conversation_path] - + # Check if the parent comment exists in the conversation (direct or restored) if original_in_reply_to is not None and original_in_reply_to != 0: parent_found = False - + # First check if it exists directly (not previously deleted) if original_in_reply_to in conversation: parent_found = True @@ -248,122 +259,143 @@ def _restore_comment(self, item_id, item_data, target_container=None): # Look through all comments to see if any have the original_id attribute matching our in_reply_to for comment_id in conversation.keys(): comment = conversation[comment_id] - comment_original_id = getattr(comment, 'original_id', None) - - if comment_original_id is not None and str(comment_original_id) == str(original_in_reply_to): + comment_original_id = getattr(comment, "original_id", None) + + if comment_original_id is not None and str( + comment_original_id + ) == str(original_in_reply_to): # We found the parent with a new ID, update the reference obj.in_reply_to = comment_id parent_found = True break - + # If no parent was found, make this a top-level comment if not parent_found: obj.in_reply_to = None - + # Store the original ID for future reference - if not hasattr(obj, 'original_id'): + if not hasattr(obj, "original_id"): obj.original_id = original_id - + # When restored, add the comment to the conversation new_id = conversation.addComment(obj) - + # Store the mapping of original ID to new ID if original_id is not None: id_mapping[str(original_id)] = new_id - + # Remove from recycle bin del self.storage[item_id] - + # Return the restored comment return conversation[new_id] else: # If the parent is not a conversation, we can't restore - logger.warning(f"Cannot restore comment {item_id}: parent is not a conversation") + logger.warning( + f"Cannot restore comment {item_id}: parent is not a conversation" + ) return None def _restore_comment_tree(self, item_id, item_data, target_container=None): """Restore a comment tree with all its replies while preserving relationships""" comment_tree = item_data["object"] - root_comment_id = comment_tree.get('root_comment_id') - comments_to_restore = comment_tree.get('comments', []) - - logger.info(f"Attempting to restore comment tree {item_id} with root_comment_id: {root_comment_id}") + root_comment_id = comment_tree.get("root_comment_id") + comments_to_restore = comment_tree.get("comments", []) + + logger.info( + f"Attempting to restore comment tree {item_id} with root_comment_id: {root_comment_id}" + ) logger.info(f"Found {len(comments_to_restore)} comments to restore") - + if not comments_to_restore: - logger.warning(f"Cannot restore comment tree {item_id}: no comments found in tree") + logger.warning( + f"Cannot restore comment tree {item_id}: no comments found in tree" + ) return None - + site = self._get_context() - + # Try to find the original conversation parent_path = item_data["parent_path"] try: conversation = site.unrestrictedTraverse(parent_path) except (KeyError, AttributeError): # If original conversation doesn't exist, we can't restore the comment - logger.warning(f"Cannot restore comment tree {item_id}: conversation no longer exists at {parent_path}") + logger.warning( + f"Cannot restore comment tree {item_id}: conversation no longer exists at {parent_path}" + ) return None # Restore comments back to conversation from plone.app.discussion.interfaces import IConversation + if IConversation.providedBy(conversation): - # First extract all comments and create a mapping of original IDs + # First extract all comments and create a mapping of original IDs # to comment objects for quick lookup comment_dict = {} id_mapping = {} # Will map original IDs to new IDs - + # Process comments to build reference dictionary for comment_obj, _ in comments_to_restore: # Store original values we'll need for restoration - original_id = getattr(comment_obj, 'comment_id', None) - original_in_reply_to = getattr(comment_obj, 'in_reply_to', None) - + original_id = getattr(comment_obj, "comment_id", None) + original_in_reply_to = getattr(comment_obj, "in_reply_to", None) + # Add some debug logging - logger.info(f"Processing comment with ID: {original_id}, in_reply_to: {original_in_reply_to}") - + logger.info( + f"Processing comment with ID: {original_id}, in_reply_to: {original_in_reply_to}" + ) + # Mark this comment with its original ID for future reference - if not hasattr(comment_obj, 'original_id'): + if not hasattr(comment_obj, "original_id"): comment_obj.original_id = original_id - + # Store in our dictionary for quick access comment_dict[original_id] = { - 'comment': comment_obj, - 'in_reply_to': original_in_reply_to + "comment": comment_obj, + "in_reply_to": original_in_reply_to, } - + # First, try to find the root comment root_comment = None if root_comment_id in comment_dict: - root_comment = comment_dict[root_comment_id]['comment'] + root_comment = comment_dict[root_comment_id]["comment"] logger.info(f"Found root comment with ID: {root_comment_id}") else: # Root comment not found by explicit ID, try alternative approaches - logger.warning(f"Root comment with ID {root_comment_id} not found in comment dictionary") - + logger.warning( + f"Root comment with ID {root_comment_id} not found in comment dictionary" + ) + # Try to find a top-level comment or one with the lowest ID to use as root for comment_id, comment_data in comment_dict.items(): - in_reply_to = comment_data['in_reply_to'] + in_reply_to = comment_data["in_reply_to"] if in_reply_to == 0 or in_reply_to is None: # Found a top-level comment, use it as root - root_comment = comment_data['comment'] + root_comment = comment_data["comment"] root_comment_id = comment_id - logger.info(f"Using top-level comment with ID {comment_id} as root") + logger.info( + f"Using top-level comment with ID {comment_id} as root" + ) break - + # If still no root, use the first comment in the dictionary if not root_comment and comment_dict: first_key = list(comment_dict.keys())[0] - root_comment = comment_dict[first_key]['comment'] + root_comment = comment_dict[first_key]["comment"] root_comment_id = first_key - logger.info(f"Using first available comment with ID {first_key} as root") - + logger.info( + f"Using first available comment with ID {first_key} as root" + ) + if not root_comment: - logger.error(f"Cannot restore comment tree {item_id}: no valid root comment could be determined") + logger.error( + f"Cannot restore comment tree {item_id}: no valid root comment could be determined" + ) return None - + # If this is a reply to another comment, check if that comment exists - original_in_reply_to = getattr(root_comment, 'in_reply_to', None) + original_in_reply_to = getattr(root_comment, "in_reply_to", None) if original_in_reply_to is not None and original_in_reply_to != 0: # Check if parent exists in conversation or needs to be handled specially if original_in_reply_to not in conversation: @@ -372,83 +404,99 @@ def _restore_comment_tree(self, item_id, item_data, target_container=None): for comment_id in conversation.keys(): comment = conversation[comment_id] # Check if this comment was previously the parent (by original ID) - original_id = getattr(comment, 'original_id', None) + original_id = getattr(comment, "original_id", None) if original_id == original_in_reply_to: # We found the parent with a new ID, update the reference root_comment.in_reply_to = comment_id parent_found = True - logger.info(f"Found existing parent for root comment: {comment_id}") + logger.info( + f"Found existing parent for root comment: {comment_id}" + ) break - + # If no parent was found, make this a top-level comment if not parent_found: - logger.info("No parent found for root comment, making it a top-level comment") + logger.info( + "No parent found for root comment, making it a top-level comment" + ) root_comment.in_reply_to = None - + # Add the root comment to the conversation new_root_id = conversation.addComment(root_comment) id_mapping[root_comment_id] = new_root_id - logger.info(f"Added root comment to conversation with new ID: {new_root_id}") - + logger.info( + f"Added root comment to conversation with new ID: {new_root_id}" + ) + # Now restore all child comments in order, updating their in_reply_to references # Skip the root comment which we've already restored - remaining_comments = {k: v for k, v in comment_dict.items() if k != root_comment_id} - + remaining_comments = { + k: v for k, v in comment_dict.items() if k != root_comment_id + } + # Keep track of successfully restored comments restored_count = 1 # Start with 1 for the root comment - + # Keep trying to restore comments until we can't restore any more # We need multiple passes because comments might depend on other comments # that haven't been restored yet max_passes = 10 # Limit the number of passes to avoid infinite loops current_pass = 0 - + while remaining_comments and current_pass < max_passes: current_pass += 1 - logger.info(f"Pass {current_pass}: {len(remaining_comments)} comments remaining to restore") + logger.info( + f"Pass {current_pass}: {len(remaining_comments)} comments remaining to restore" + ) restored_in_pass = 0 - + # Copy keys to avoid modifying dict during iteration for comment_id in list(remaining_comments.keys()): comment_data = remaining_comments[comment_id] - comment = comment_data['comment'] - in_reply_to = comment_data['in_reply_to'] - + comment = comment_data["comment"] + in_reply_to = comment_data["in_reply_to"] + # Check if the parent comment has been restored if in_reply_to in id_mapping: # Update reference to the new parent ID comment.in_reply_to = id_mapping[in_reply_to] - + # Add to conversation new_id = conversation.addComment(comment) id_mapping[comment_id] = new_id - + # Remove from remaining comments del remaining_comments[comment_id] restored_in_pass += 1 - logger.info(f"Restored comment {comment_id} with new ID {new_id}, parent {in_reply_to} -> {id_mapping[in_reply_to]}") - + logger.info( + f"Restored comment {comment_id} with new ID {new_id}, parent {in_reply_to} -> {id_mapping[in_reply_to]}" + ) + # If we couldn't restore any comments in this pass, we have an issue if restored_in_pass == 0 and remaining_comments: logger.warning( f"Pass {current_pass}: No comments could be restored. " f"{len(remaining_comments)} comments remaining." ) - # Try one more approach - see if any remaining comments have parents + # Try one more approach - see if any remaining comments have parents # that don't exist in our mapping but do exist in the conversation for comment_id, comment_data in list(remaining_comments.items()): - comment = comment_data['comment'] - in_reply_to = comment_data['in_reply_to'] - + comment = comment_data["comment"] + in_reply_to = comment_data["in_reply_to"] + # Check if the parent exists directly in the conversation if in_reply_to and in_reply_to in conversation: - comment.in_reply_to = in_reply_to # Keep the original reference + comment.in_reply_to = ( + in_reply_to # Keep the original reference + ) new_id = conversation.addComment(comment) id_mapping[comment_id] = new_id del remaining_comments[comment_id] restored_in_pass += 1 - logger.info(f"Found parent directly in conversation for {comment_id} -> {new_id}") - + logger.info( + f"Found parent directly in conversation for {comment_id} -> {new_id}" + ) + # If still no progress, make them top-level if restored_in_pass == 0: # Just restore remaining comments as top-level comments @@ -457,24 +505,28 @@ def _restore_comment_tree(self, item_id, item_data, target_container=None): "Restoring them as top-level comments." ) for comment_id, comment_data in remaining_comments.items(): - comment = comment_data['comment'] + comment = comment_data["comment"] comment.in_reply_to = None new_id = conversation.addComment(comment) id_mapping[comment_id] = new_id - logger.info(f"Restored comment {comment_id} as top-level comment with new ID {new_id}") + logger.info( + f"Restored comment {comment_id} as top-level comment with new ID {new_id}" + ) break - + restored_count += restored_in_pass - + # Remove from recycle bin del self.storage[item_id] - + # Return the root comment logger.info(f"Restored comment tree with {restored_count} comments.") return conversation[new_root_id] else: # If the parent is not a conversation, we can't restore - logger.warning(f"Cannot restore comment tree {item_id}: parent is not a conversation") + logger.warning( + f"Cannot restore comment tree {item_id}: parent is not a conversation" + ) return None def purge_item(self, item_id): diff --git a/src/Products/CMFPlone/tests/test_recyclebin.py b/src/Products/CMFPlone/tests/test_recyclebin.py index 5f45ef1360..74607147c5 100644 --- a/src/Products/CMFPlone/tests/test_recyclebin.py +++ b/src/Products/CMFPlone/tests/test_recyclebin.py @@ -1,15 +1,12 @@ -import unittest -from datetime import datetime, timedelta -from unittest.mock import Mock, patch, PropertyMock - +from datetime import datetime +from datetime import timedelta from persistent.mapping import PersistentMapping -from plone.registry.interfaces import IRegistry -from Products.CMFPlone.controlpanel.browser.recyclerbin import IRecycleBinControlPanelSettings -from Products.CMFPlone.interfaces.recyclebin import IRecycleBin -from Products.CMFPlone.recyclebin import RecycleBin, ANNOTATION_KEY -from zope.annotation.interfaces import IAnnotations -from zope.component import getUtility -from zope.component.hooks import getSite +from Products.CMFPlone.recyclebin import ANNOTATION_KEY +from Products.CMFPlone.recyclebin import RecycleBin +from unittest.mock import Mock +from unittest.mock import patch + +import unittest class TestRecycleBin(unittest.TestCase): @@ -21,20 +18,24 @@ def setUp(self): self.site = Mock() self.annotations = {} self.annotations_mock = Mock() - self.annotations_mock.__getitem__ = lambda _, key: self.annotations.get(key, None) - self.annotations_mock.__setitem__ = lambda _, key, value: self.annotations.__setitem__(key, value) + self.annotations_mock.__getitem__ = lambda _, key: self.annotations.get( + key, None + ) + self.annotations_mock.__setitem__ = ( + lambda _, key, value: self.annotations.__setitem__(key, value) + ) self.annotations_mock.__contains__ = lambda _, key: key in self.annotations - + # Mock registry and settings self.settings_mock = Mock() self.settings_mock.recycling_enabled = True self.settings_mock.auto_purge = True self.settings_mock.retention_period = 30 self.settings_mock.maximum_size = 100 # 100 MB - + self.registry_mock = Mock() self.registry_mock.forInterface.return_value = self.settings_mock - + # Create recycle bin instance self.recycle_bin = RecycleBin(self.site) @@ -42,31 +43,31 @@ def _setup_storage(self): """Initialize storage with test data""" self.storage = PersistentMapping() self.annotations[ANNOTATION_KEY] = self.storage - - @patch('Products.CMFPlone.recyclebin.IAnnotations') - @patch('Products.CMFPlone.recyclebin.getUtility') + + @patch("Products.CMFPlone.recyclebin.IAnnotations") + @patch("Products.CMFPlone.recyclebin.getUtility") def test_is_enabled(self, getUtility_mock, IAnnotations_mock): """Test checking if recycle bin is enabled""" # Configure mocks getUtility_mock.return_value = self.registry_mock - + # Test enabled result = self.recycle_bin.is_enabled() self.assertTrue(result) - + # Test disabled self.settings_mock.recycling_enabled = False result = self.recycle_bin.is_enabled() self.assertFalse(result) - + # Test exception handling getUtility_mock.side_effect = KeyError("Not found") result = self.recycle_bin.is_enabled() self.assertFalse(result) - - @patch('Products.CMFPlone.recyclebin.IAnnotations') - @patch('Products.CMFPlone.recyclebin.getUtility') - @patch('Products.CMFPlone.recyclebin.uuid') + + @patch("Products.CMFPlone.recyclebin.IAnnotations") + @patch("Products.CMFPlone.recyclebin.getUtility") + @patch("Products.CMFPlone.recyclebin.uuid") def test_add_item(self, uuid_mock, getUtility_mock, IAnnotations_mock): """Test adding an item to the recycle bin""" # Configure mocks @@ -74,28 +75,26 @@ def test_add_item(self, uuid_mock, getUtility_mock, IAnnotations_mock): uuid_mock.uuid4.return_value = test_uuid getUtility_mock.return_value = self.registry_mock IAnnotations_mock.return_value = self.annotations_mock - + # Setup storage self._setup_storage() - + # Create a mock deleted object deleted_obj = Mock() deleted_obj.getId.return_value = "test-document" deleted_obj.Title.return_value = "Test Document" deleted_obj.portal_type = "Document" deleted_obj.get_size = lambda: 1024 - + # Create mock container container = Mock() container.getPhysicalPath.return_value = ["", "plone", "folder"] - + # Add item to recycle bin item_id = self.recycle_bin.add_item( - deleted_obj, - container, - "/plone/folder/test-document" + deleted_obj, container, "/plone/folder/test-document" ) - + # Verify item was added correctly self.assertEqual(item_id, test_uuid) self.assertIn(test_uuid, self.storage) @@ -107,22 +106,22 @@ def test_add_item(self, uuid_mock, getUtility_mock, IAnnotations_mock): self.assertEqual(item_data["parent_path"], "/plone/folder") self.assertEqual(item_data["size"], 1024) self.assertEqual(item_data["object"], deleted_obj) - + # Test when recycle bin is disabled self.settings_mock.recycling_enabled = False item_id = self.recycle_bin.add_item(deleted_obj, container, "/path") self.assertIsNone(item_id) - - @patch('Products.CMFPlone.recyclebin.IAnnotations') + + @patch("Products.CMFPlone.recyclebin.IAnnotations") def test_get_items(self, IAnnotations_mock): """Test retrieving items from the recycle bin""" # Configure mocks IAnnotations_mock.return_value = self.annotations_mock - + # Setup storage with test data self._setup_storage() now = datetime.now() - + # Add test items with different dates self.storage["item1"] = { "id": "doc1", @@ -132,9 +131,9 @@ def test_get_items(self, IAnnotations_mock): "parent_path": "/site", "deletion_date": now - timedelta(days=1), "size": 1024, - "object": Mock() + "object": Mock(), } - + self.storage["item2"] = { "id": "doc2", "title": "Document 2", @@ -143,242 +142,246 @@ def test_get_items(self, IAnnotations_mock): "parent_path": "/site", "deletion_date": now, # more recent "size": 2048, - "object": Mock() + "object": Mock(), } - + # Get items items = self.recycle_bin.get_items() - + # Verify correct sorting (newest first) and data self.assertEqual(len(items), 2) self.assertEqual(items[0]["recycle_id"], "item2") self.assertEqual(items[1]["recycle_id"], "item1") - + # Verify object is not included in listing self.assertNotIn("object", items[0]) self.assertNotIn("object", items[1]) - - @patch('Products.CMFPlone.recyclebin.IAnnotations') + + @patch("Products.CMFPlone.recyclebin.IAnnotations") def test_get_item(self, IAnnotations_mock): """Test retrieving a specific item from the recycle bin""" # Configure mocks IAnnotations_mock.return_value = self.annotations_mock - + # Setup storage with test data self._setup_storage() test_obj = Mock() - + self.storage["test-id"] = { "id": "document", "object": test_obj, - "title": "Test Document" + "title": "Test Document", } - + # Get existing item item = self.recycle_bin.get_item("test-id") self.assertEqual(item["id"], "document") self.assertEqual(item["object"], test_obj) - + # Get non-existent item item = self.recycle_bin.get_item("non-existent") self.assertIsNone(item) - - @patch('Products.CMFPlone.recyclebin.IAnnotations') + + @patch("Products.CMFPlone.recyclebin.IAnnotations") def test_restore_item(self, IAnnotations_mock): """Test restoring an item from the recycle bin""" # Configure mocks IAnnotations_mock.return_value = self.annotations_mock - + # Setup storage with test data self._setup_storage() test_obj = Mock() - + # Setup target container mock target_container = Mock() target_container._setObject = Mock() - target_container.__contains__ = lambda _, key: key in ['existing-doc'] - target_container.__getitem__ = lambda _, key: test_obj if key == 'doc1' else None - + target_container.__contains__ = lambda _, key: key in ["existing-doc"] + target_container.__getitem__ = lambda _, key: ( + test_obj if key == "doc1" else None + ) + # Test normal restoration self.storage["test-id"] = { "id": "doc1", "object": test_obj, "title": "Test Document", - "parent_path": "/plone/folder" + "parent_path": "/plone/folder", } - + # Mock site traversal self.site.unrestrictedTraverse.return_value = target_container - + # Restore item result = self.recycle_bin.restore_item("test-id") - + # Verify item was restored correctly target_container._setObject.assert_called_once_with("doc1", test_obj) self.assertEqual(result, test_obj) self.assertNotIn("test-id", self.storage) - + # Test ID conflict resolution self.storage["test-id2"] = { "id": "existing-doc", # This ID already exists in the container "object": test_obj, "title": "Test Document", - "parent_path": "/plone/folder" + "parent_path": "/plone/folder", } - + # Need to reset the mock count for the second test target_container._setObject.reset_mock() - + # Restore with conflicting ID - with patch('Products.CMFPlone.recyclebin.datetime') as dt_mock: + with patch("Products.CMFPlone.recyclebin.datetime") as dt_mock: dt_mock.now.return_value = datetime(2023, 1, 1, 12, 0, 0) dt_mock.strftime = datetime.strftime - + self.recycle_bin.restore_item("test-id2") - + # Should have generated a new ID target_container._setObject.assert_called_once() call_args = target_container._setObject.call_args[0] self.assertTrue(call_args[0].startswith("existing-doc-restored-")) - + # Test non-existent item result = self.recycle_bin.restore_item("non-existent") self.assertIsNone(result) - - @patch('Products.CMFPlone.recyclebin.IAnnotations') + + @patch("Products.CMFPlone.recyclebin.IAnnotations") def test_purge_item(self, IAnnotations_mock): """Test purging an item from the recycle bin""" # Configure mocks IAnnotations_mock.return_value = self.annotations_mock - + # Setup storage with test data self._setup_storage() - + # Add test item self.storage["test-id"] = {"id": "doc1"} - + # Purge existing item result = self.recycle_bin.purge_item("test-id") self.assertTrue(result) self.assertNotIn("test-id", self.storage) - + # Purge non-existent item result = self.recycle_bin.purge_item("non-existent") self.assertFalse(result) - - @patch('Products.CMFPlone.recyclebin.IAnnotations') - @patch('Products.CMFPlone.recyclebin.getUtility') - @patch('Products.CMFPlone.recyclebin.datetime') - def test_purge_expired_items(self, datetime_mock, getUtility_mock, IAnnotations_mock): + + @patch("Products.CMFPlone.recyclebin.IAnnotations") + @patch("Products.CMFPlone.recyclebin.getUtility") + @patch("Products.CMFPlone.recyclebin.datetime") + def test_purge_expired_items( + self, datetime_mock, getUtility_mock, IAnnotations_mock + ): """Test purging expired items from the recycle bin""" # Configure mocks now = datetime(2023, 1, 31) datetime_mock.now.return_value = now getUtility_mock.return_value = self.registry_mock IAnnotations_mock.return_value = self.annotations_mock - + # Setup storage with test data self._setup_storage() - + # Add items with different ages self.storage["recent"] = { "id": "recent-doc", - "deletion_date": now - timedelta(days=10) # Within retention period + "deletion_date": now - timedelta(days=10), # Within retention period } - + self.storage["old1"] = { "id": "old-doc1", - "deletion_date": now - timedelta(days=35) # Expired + "deletion_date": now - timedelta(days=35), # Expired } - + self.storage["old2"] = { "id": "old-doc2", - "deletion_date": now - timedelta(days=40) # Expired + "deletion_date": now - timedelta(days=40), # Expired } - + # Test purging with auto_purge enabled count = self.recycle_bin.purge_expired_items() - + # Should have purged 2 items self.assertEqual(count, 2) self.assertIn("recent", self.storage) # Should still be there self.assertNotIn("old1", self.storage) # Should be purged self.assertNotIn("old2", self.storage) # Should be purged - + # Test with auto_purge disabled self.settings_mock.auto_purge = False - + # Reset storage self._setup_storage() self.storage["old"] = { "id": "old-doc", - "deletion_date": now - timedelta(days=100) # Very old + "deletion_date": now - timedelta(days=100), # Very old } - + count = self.recycle_bin.purge_expired_items() self.assertEqual(count, 0) # Should not have purged anything self.assertIn("old", self.storage) # Should still be there - - @patch('Products.CMFPlone.recyclebin.IAnnotations') - @patch('Products.CMFPlone.recyclebin.getUtility') - @patch('Products.CMFPlone.recyclebin.logger') + + @patch("Products.CMFPlone.recyclebin.IAnnotations") + @patch("Products.CMFPlone.recyclebin.getUtility") + @patch("Products.CMFPlone.recyclebin.logger") def test_check_size_limits(self, logger_mock, getUtility_mock, IAnnotations_mock): """Test checking size limits and purging oldest items if needed""" # Configure mocks getUtility_mock.return_value = self.registry_mock IAnnotations_mock.return_value = self.annotations_mock - + # Set maximum size to 10 MB self.settings_mock.maximum_size = 10 - + # Setup storage with test data self._setup_storage() - + # Add items of different sizes and dates # Total: 12 MB (exceeds the 10 MB limit) now = datetime.now() - + self.storage["item1"] = { "id": "doc1", "deletion_date": now - timedelta(days=10), - "size": 5 * 1024 * 1024 # 5 MB + "size": 5 * 1024 * 1024, # 5 MB } - + self.storage["item2"] = { "id": "doc2", "deletion_date": now - timedelta(days=5), - "size": 4 * 1024 * 1024 # 4 MB + "size": 4 * 1024 * 1024, # 4 MB } - + self.storage["item3"] = { "id": "doc3", "deletion_date": now, - "size": 3 * 1024 * 1024 # 3 MB + "size": 3 * 1024 * 1024, # 3 MB } - + # Check size limits self.recycle_bin._check_size_limits() - + # The oldest item (item1) should be purged self.assertNotIn("item1", self.storage) self.assertIn("item2", self.storage) self.assertIn("item3", self.storage) self.assertEqual(len(logger_mock.info.mock_calls), 1) # Should log the purge - @patch('Products.CMFPlone.recyclebin.IAnnotations') - @patch('Products.CMFPlone.recyclebin.getSite') + @patch("Products.CMFPlone.recyclebin.IAnnotations") + @patch("Products.CMFPlone.recyclebin.getSite") def test_get_context(self, getSite_mock, IAnnotations_mock): """Test getting context when used as a utility""" # Create a recycle bin without context rb = RecycleBin() - + # Mock the site site_mock = Mock() getSite_mock.return_value = site_mock - + # Get context context = rb._get_context() - + # Should have called getSite getSite_mock.assert_called_once() self.assertEqual(context, site_mock) From dbe61af0a44774b4b6068ebf50ba33ce4f397cc3 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Wed, 30 Apr 2025 09:41:57 +0530 Subject: [PATCH 017/122] Refactor(recyclebin): rename module and update interface references --- .../controlpanel/browser/configure.zcml | 2 +- .../controlpanel/browser/recyclebin.py | 46 +++++++++++++++++++ .../profiles/dependencies/registry.xml | 2 +- src/Products/CMFPlone/recyclebin.py | 2 +- 4 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 src/Products/CMFPlone/controlpanel/browser/recyclebin.py diff --git a/src/Products/CMFPlone/controlpanel/browser/configure.zcml b/src/Products/CMFPlone/controlpanel/browser/configure.zcml index 27dc37247d..1a7dc5a9c8 100644 --- a/src/Products/CMFPlone/controlpanel/browser/configure.zcml +++ b/src/Products/CMFPlone/controlpanel/browser/configure.zcml @@ -353,7 +353,7 @@ diff --git a/src/Products/CMFPlone/controlpanel/browser/recyclebin.py b/src/Products/CMFPlone/controlpanel/browser/recyclebin.py new file mode 100644 index 0000000000..4b12daa448 --- /dev/null +++ b/src/Products/CMFPlone/controlpanel/browser/recyclebin.py @@ -0,0 +1,46 @@ +# testcp.py +from plone.app.registry.browser.controlpanel import ControlPanelFormWrapper +from plone.app.registry.browser.controlpanel import RegistryEditForm +from plone.z3cform import layout +from zope import schema +from zope.interface import Interface + + +class IRecycleBinControlPanelSettings(Interface): + recycling_enabled = schema.Bool( + title="Enable the Recycle Bin", + description="Enable or disable the Recycle Bin feature.", + default=True, + ) + + retention_period = schema.Int( + title="Retention Period", + description="Number of days to keep deleted items in the Recycle Bin.", + default=30, + min=1, + ) + + maximum_size = schema.Int( + title="Maximum Size", + description="Maximum size of the Recycle Bin in MB.", + default=100, + min=10, + ) + + auto_purge = schema.Bool( + title="Auto Purge", + description="Automatically purge items older than the retention period.", + default=True, + ) + + +class RecyclebinControlPanelForm(RegistryEditForm): + schema = IRecycleBinControlPanelSettings + schema_prefix = "plone-recyclebin" + label = "Recycle Bin Settings" + description = "Settings for the Plone Recycle Bin functionality" + + +RecyclebinControlPanelView = layout.wrap_form( + RecyclebinControlPanelForm, ControlPanelFormWrapper +) diff --git a/src/Products/CMFPlone/profiles/dependencies/registry.xml b/src/Products/CMFPlone/profiles/dependencies/registry.xml index 1051461acd..2a112828de 100644 --- a/src/Products/CMFPlone/profiles/dependencies/registry.xml +++ b/src/Products/CMFPlone/profiles/dependencies/registry.xml @@ -126,7 +126,7 @@ {"actionOptions": {"displayInModal": false}} - True diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index d5d553a88e..b443eae182 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -2,7 +2,7 @@ from datetime import timedelta from persistent.mapping import PersistentMapping from plone.registry.interfaces import IRegistry -from Products.CMFPlone.controlpanel.browser.recyclerbin import ( +from Products.CMFPlone.controlpanel.browser.recyclebin import ( IRecycleBinControlPanelSettings, ) from Products.CMFPlone.interfaces.recyclebin import IRecycleBin From 53a7c95ea9591c3a2207d7effb75624514c406c3 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Thu, 1 May 2025 08:47:59 +0530 Subject: [PATCH 018/122] refactor(recyclebin): simplify initialization and context retrieval in RecycleBin class --- src/Products/CMFPlone/recyclebin.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index b443eae182..0c3ef58a4b 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -24,20 +24,16 @@ class RecycleBin: """Stores deleted content items""" - def __init__(self, context=None): - """Initialize the recycle bin for a site - - Args: - context: The Plone site object (optional when used as a utility) + def __init__(self): + """Initialize the recycle bin utility + + It will get the context (Plone site) on demand using getSite() """ - self.context = context - # When used as a utility without context, we'll get the context on demand + pass def _get_context(self): - """Get the context (Plone site) if not already available""" - if self.context is None: - self.context = getSite() - return self.context + """Get the context (Plone site)""" + return getSite() def _get_storage(self): """Get the storage for recycled items""" From e97578371aea93f3f964da2106596aab39544262 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Thu, 1 May 2025 08:49:51 +0530 Subject: [PATCH 019/122] fix(recyclebin): simplify error handling in content removal by allowing exceptions to propagate --- src/Products/CMFPlone/events.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/Products/CMFPlone/events.py b/src/Products/CMFPlone/events.py index a5736df2bf..580899c929 100644 --- a/src/Products/CMFPlone/events.py +++ b/src/Products/CMFPlone/events.py @@ -68,12 +68,5 @@ def handle_content_removal(obj, event): original_container = event.oldParent original_path = "/".join(obj.getPhysicalPath()) - # Add to recycle bin - try: - recycle_bin.add_item(obj, original_container, original_path) - except Exception as e: - # Log but don't prevent deletion if recycle bin fails - import logging - - logger = logging.getLogger("Products.CMFPlone.RecycleBin") - logger.exception(f"Error adding item to recycle bin: {e}") + # Add to recycle bin - let any exceptions propagate to make problems visible + recycle_bin.add_item(obj, original_container, original_path) From ce3d597085d8db10741b17db06af6c64431b68a2 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Thu, 1 May 2025 09:52:52 +0530 Subject: [PATCH 020/122] fix(recyclebin): add conditions to actions.xml and update icon --- src/Products/CMFPlone/browser/configure.zcml | 7 +++++++ src/Products/CMFPlone/browser/recyclebin.py | 8 ++++++++ src/Products/CMFPlone/profiles/default/actions.xml | 2 +- src/Products/CMFPlone/profiles/default/controlpanel.xml | 3 +-- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/Products/CMFPlone/browser/configure.zcml b/src/Products/CMFPlone/browser/configure.zcml index 23ecdcfdd3..a4ebd2037b 100644 --- a/src/Products/CMFPlone/browser/configure.zcml +++ b/src/Products/CMFPlone/browser/configure.zcml @@ -305,4 +305,11 @@ permission="zope2.View" /> + + diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index 6fa05ad8ec..0dc2b31797 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -290,3 +290,11 @@ def format_date(self, date): return portal.restrictedTraverse("@@plone").toLocalizedTime( date, long_format=True ) + +class RecycleBinEnabled(BrowserView): + """Check if the recycle bin is enabled""" + + def __call__(self): + """Return True if recycle bin is enabled, else False""" + recycle_bin = getUtility(IRecycleBin) + return recycle_bin.is_enabled() \ No newline at end of file diff --git a/src/Products/CMFPlone/profiles/default/actions.xml b/src/Products/CMFPlone/profiles/default/actions.xml index 328aaf39c1..3db039d02e 100644 --- a/src/Products/CMFPlone/profiles/default/actions.xml +++ b/src/Products/CMFPlone/profiles/default/actions.xml @@ -506,7 +506,7 @@ /> string:${portal_url}/@@recyclebin string:plone-delete - + portal/@@recyclebin-enabled|nothing diff --git a/src/Products/CMFPlone/profiles/default/controlpanel.xml b/src/Products/CMFPlone/profiles/default/controlpanel.xml index 02df1616b7..b137266cdf 100644 --- a/src/Products/CMFPlone/profiles/default/controlpanel.xml +++ b/src/Products/CMFPlone/profiles/default/controlpanel.xml @@ -353,12 +353,11 @@ Inspect Relations - Date: Thu, 1 May 2025 10:02:40 +0530 Subject: [PATCH 021/122] refactor(recyclebin): replace PersistentMapping with RecycleBinStorage for improved performance --- src/Products/CMFPlone/recyclebin.py | 46 +++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index 0c3ef58a4b..f302d35f9d 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -1,6 +1,7 @@ from datetime import datetime from datetime import timedelta -from persistent.mapping import PersistentMapping +from BTrees.OOBTree import OOBTree +from persistent import Persistent from plone.registry.interfaces import IRegistry from Products.CMFPlone.controlpanel.browser.recyclebin import ( IRecycleBinControlPanelSettings, @@ -20,6 +21,41 @@ ANNOTATION_KEY = "Products.CMFPlone.RecycleBin" +class RecycleBinStorage(Persistent): + """Storage class for RecycleBin using BTrees for better performance""" + + def __init__(self): + self.items = OOBTree() + + def __getitem__(self, key): + return self.items[key] + + def __setitem__(self, key, value): + self.items[key] = value + + def __delitem__(self, key): + del self.items[key] + + def __contains__(self, key): + return key in self.items + + def __len__(self): + return len(self.items) + + def get(self, key, default=None): + return self.items.get(key, default) + + def keys(self): + return self.items.keys() + + def values(self): + return self.items.values() + + def get_items(self): + """Return all items as key-value pairs""" + return self.items.items() + + @implementer(IRecycleBin) class RecycleBin: """Stores deleted content items""" @@ -41,7 +77,7 @@ def _get_storage(self): annotations = IAnnotations(context) if ANNOTATION_KEY not in annotations: - annotations[ANNOTATION_KEY] = PersistentMapping() + annotations[ANNOTATION_KEY] = RecycleBinStorage() return annotations[ANNOTATION_KEY] @@ -134,7 +170,7 @@ def add_item(self, obj, original_container, original_path, item_type=None): def get_items(self): """Return all items in recycle bin""" items = [] - for item_id, data in self.storage.items(): + for item_id, data in self.storage.get_items(): item_data = data.copy() item_data["recycle_id"] = item_id # Don't include the actual object in the listing @@ -544,7 +580,7 @@ def purge_expired_items(self): cutoff_date = datetime.now() - timedelta(days=retention_days) items_to_purge = [] - for item_id, data in self.storage.items(): + for item_id, data in self.storage.get_items(): if data["deletion_date"] < cutoff_date: items_to_purge.append(item_id) @@ -564,7 +600,7 @@ def _check_size_limits(self): items_by_date = [] # Calculate total size and prepare sorted list - for item_id, data in self.storage.items(): + for item_id, data in self.storage.get_items(): size = data.get("size", 0) total_size += size items_by_date.append((item_id, data["deletion_date"], size)) From b49437ebce2f2da01404bf60fb92bf9b4c63bb7d Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Thu, 1 May 2025 11:08:27 +0530 Subject: [PATCH 022/122] fix(recyclebin): avoid copying and sorting objects --- src/Products/CMFPlone/recyclebin.py | 69 +++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index f302d35f9d..203e78e95e 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -1,6 +1,7 @@ from datetime import datetime from datetime import timedelta from BTrees.OOBTree import OOBTree +from BTrees.OOBTree import OOTreeSet from persistent import Persistent from plone.registry.interfaces import IRegistry from Products.CMFPlone.controlpanel.browser.recyclebin import ( @@ -26,14 +27,54 @@ class RecycleBinStorage(Persistent): def __init__(self): self.items = OOBTree() + # Add a sorted index that stores (deletion_date, item_id) tuples + # This will automatically maintain items sorted by date + self._sorted_index = OOTreeSet() def __getitem__(self, key): return self.items[key] def __setitem__(self, key, value): + # When adding or updating an item, update the sorted index + if key in self.items: + # If updating an existing item, remove old index entry first + old_value = self.items[key] + if "deletion_date" in old_value: + try: + # Create a sortable key (date, id) + old_key = (old_value["deletion_date"], key) + if old_key in self._sorted_index: + self._sorted_index.remove(old_key) + except (KeyError, TypeError): + # Ignore errors if the entry doesn't exist or date is not comparable + pass + + # Add the item to main storage self.items[key] = value + + # Add to sorted index if it has a deletion_date + if "deletion_date" in value: + try: + # Store as (date, id) for automatic sorting + self._sorted_index.add((value["deletion_date"], key)) + except TypeError: + # Skip if the date is not comparable + logger.warning(f"Could not index item {key} by date: {value.get('deletion_date')}") def __delitem__(self, key): + # When deleting an item, also remove it from the sorted index + if key in self.items: + item = self.items[key] + if "deletion_date" in item: + try: + sort_key = (item["deletion_date"], key) + if sort_key in self._sorted_index: + self._sorted_index.remove(sort_key) + except (KeyError, TypeError): + # Ignore errors if the entry doesn't exist or date is not comparable + pass + + # Remove from main storage del self.items[key] def __contains__(self, key): @@ -54,6 +95,28 @@ def values(self): def get_items(self): """Return all items as key-value pairs""" return self.items.items() + + def get_items_sorted_by_date(self, reverse=True): + """Return items sorted by deletion date + + Args: + reverse: If True, return newest items first (default), + if False, return oldest items first + + Returns: + Generator yielding (item_id, item_data) tuples + """ + # OOTreeSet is not reversible, so we need to handle ordering differently + sorted_keys = list(self._sorted_index) + + # If we want newest first (reverse=True), reverse the list + if reverse: + sorted_keys.reverse() + + # Yield items in the requested order + for date, item_id in sorted_keys: + if item_id in self.items: # Double check item still exists + yield (item_id, self.items[item_id]) @implementer(IRecycleBin) @@ -170,7 +233,8 @@ def add_item(self, obj, original_container, original_path, item_type=None): def get_items(self): """Return all items in recycle bin""" items = [] - for item_id, data in self.storage.get_items(): + # Use the pre-sorted index to get items by date (newest first) + for item_id, data in self.storage.get_items_sorted_by_date(reverse=True): item_data = data.copy() item_data["recycle_id"] = item_id # Don't include the actual object in the listing @@ -178,8 +242,7 @@ def get_items(self): del item_data["object"] items.append(item_data) - # Sort by deletion date (newest first) - return sorted(items, key=lambda x: x["deletion_date"], reverse=True) + return items def get_item(self, item_id): """Get a specific deleted item by ID""" From 63b2e408d8c82be775cbeee43e37604f9e9173b8 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Thu, 1 May 2025 19:40:53 +0530 Subject: [PATCH 023/122] fix(recyclebin): fix broken link to itemView --- src/Products/CMFPlone/browser/recyclebin.py | 29 ++++++++++++++++++- .../CMFPlone/browser/templates/recyclebin.pt | 2 +- .../browser/templates/recyclebin_item.pt | 9 +++--- src/Products/CMFPlone/configure.zcml | 2 +- src/Products/CMFPlone/recyclebin.py | 11 +++++-- 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index 0dc2b31797..ec637268fd 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -12,7 +12,9 @@ from zope.interface import implementer from zope.interface import Interface from zope.publisher.interfaces import IPublishTraverse +import logging +logger = logging.getLogger(__name__) class IRecycleBinForm(Interface): """Schema for the Recycle Bin form""" @@ -257,14 +259,19 @@ class RecycleBinItemView(BrowserView): def publishTraverse(self, request, name): """Handle URLs like /recyclebin/item/[item_id]""" + logger.info(f"RecycleBinItemView.publishTraverse called with name: {name}") if self.item_id is None: # First traversal self.item_id = name + logger.info(f"Set item_id to: {self.item_id}") return self + logger.warning(f"Additional traversal attempted with name: {name}") raise NotFound(self, name, request) def __call__(self): """Handle item operations""" + logger.info(f"RecycleBinItemView.__call__ started with item_id: {self.item_id}") if self.item_id is None: + logger.warning("No item_id set, redirecting to main recyclebin view") self.request.response.redirect( f"{self.context.absolute_url()}/@@recyclebin" ) @@ -274,12 +281,32 @@ def __call__(self): form = RecycleBinItemForm(self.context, self.request, self.item_id) form.update() + # Get the item before rendering template + item = self.get_item() + if item is None: + logger.warning(f"No item found with ID: {self.item_id}, redirecting to main recyclebin view") + self.request.response.redirect( + f"{self.context.absolute_url()}/@@recyclebin" + ) + return "" + + logger.info(f"Found item with title: {item.get('title', 'Unknown')}") return self.template() def get_item(self): """Get the specific recycled item""" + logger.info(f"RecycleBinItemView.get_item called for ID: {self.item_id}") + if not self.item_id: + logger.warning("get_item called with no item_id") + return None + recycle_bin = getUtility(IRecycleBin) - return recycle_bin.get_item(self.item_id) + item = recycle_bin.get_item(self.item_id) + if item is None: + logger.warning(f"No item found in recycle bin with ID: {self.item_id}") + else: + logger.info(f"Found item: {item.get('title', 'Unknown')} of type {item.get('type', 'Unknown')}") + return item def format_date(self, date): """Format date for display""" diff --git a/src/Products/CMFPlone/browser/templates/recyclebin.pt b/src/Products/CMFPlone/browser/templates/recyclebin.pt index 75e0d9ab66..52f95f8436 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin.pt @@ -41,7 +41,7 @@ tal:attributes="value item/recycle_id" /> - Title
diff --git a/src/Products/CMFPlone/browser/templates/recyclebin_item.pt b/src/Products/CMFPlone/browser/templates/recyclebin_item.pt index 1399120c40..3761d0e413 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin_item.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin_item.pt @@ -97,12 +97,13 @@ - +
- +
- Leave blank to restore to the original location. If the original location no longer exists, - the item will be restored to the site root. + Specify where to restore this item. You must provide a location if the original parent + () no longer exists. Leave blank to restore to + the original location if it still exists.
diff --git a/src/Products/CMFPlone/configure.zcml b/src/Products/CMFPlone/configure.zcml index 73e6a0b31f..22e9200753 100644 --- a/src/Products/CMFPlone/configure.zcml +++ b/src/Products/CMFPlone/configure.zcml @@ -181,7 +181,7 @@ /> Date: Thu, 1 May 2025 22:08:45 +0530 Subject: [PATCH 024/122] feat(recyclebin): enhance child item handling in recycle bin with restoration --- src/Products/CMFPlone/browser/recyclebin.py | 80 +++++++++++++++++ .../browser/templates/recyclebin_item.pt | 66 ++++++++++++++ src/Products/CMFPlone/recyclebin.py | 85 ++++++++++++++++++- 3 files changed, 228 insertions(+), 3 deletions(-) diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index ec637268fd..5052305c62 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -126,6 +126,21 @@ def get_items(self): recycle_bin = self.get_recycle_bin() items = recycle_bin.get_items() + # Create a list of all items that are children of a parent in the recycle bin + child_items_to_exclude = [] + for item in items: + # If this item is a parent with children, add its children to exclusion list + if 'children' in item: + for child_id in item.get('children', {}): + child_items_to_exclude.append(child_id) + + logger.debug(f"Child items to exclude: {child_items_to_exclude}") + print(f"Child items to exclude: {child_items_to_exclude}") + + # Only include items that are not children of other recycled items + items = [item for item in items if item.get('id') not in child_items_to_exclude] + print(f"Filtered items: {items}") + # For comments, add extra information about the content they belong to for item in items: if item.get("type") == "Discussion Item": @@ -277,6 +292,48 @@ def __call__(self): ) return "" + # Handle restoration of children + if "restore.child" in self.request.form: + child_id = self.request.form.get("child_id") + target_path = self.request.form.get("target_path") + + if child_id and target_path: + try: + # Get item data + recycle_bin = getUtility(IRecycleBin) + item_data = recycle_bin.get_item(self.item_id) + + if item_data and "children" in item_data: + child_data = item_data["children"].get(child_id) + if child_data: + # Try to get target container + try: + target_container = self.context.unrestrictedTraverse(target_path) + + # Create a temporary storage entry for the child + temp_id = str(uuid.uuid4()) + recycle_bin.storage[temp_id] = child_data + + # Restore the child + restored_obj = recycle_bin.restore_item(temp_id, target_container) + + if restored_obj: + # Remove child from parent's children dict + del item_data["children"][child_id] + item_data["children_count"] = len(item_data["children"]) + + message = f"Child item '{child_data['title']}' successfully restored." + IStatusMessage(self.request).addStatusMessage(message, type="info") + self.request.response.redirect(restored_obj.absolute_url()) + return + except (KeyError, AttributeError): + message = f"Target location not found: {target_path}" + IStatusMessage(self.request).addStatusMessage(message, type="error") + except Exception as e: + logger.error(f"Error restoring child item: {e}") + message = "Failed to restore child item." + IStatusMessage(self.request).addStatusMessage(message, type="error") + # Initialize and update the form form = RecycleBinItemForm(self.context, self.request, self.item_id) form.update() @@ -308,6 +365,20 @@ def get_item(self): logger.info(f"Found item: {item.get('title', 'Unknown')} of type {item.get('type', 'Unknown')}") return item + def get_children(self): + """Get the children of this item if it's a folder/collection""" + item = self.get_item() + if item and "children" in item: + children = [] + for child_id, child_data in item["children"].items(): + # Don't include the actual object in the listing + child_info = child_data.copy() + if "object" in child_info: + del child_info["object"] + children.append(child_info) + return children + return [] + def format_date(self, date): """Format date for display""" if date is None: @@ -318,6 +389,15 @@ def format_date(self, date): date, long_format=True ) + def format_size(self, size_bytes): + """Format size in bytes to human-readable format""" + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + else: + return f"{size_bytes / (1024 * 1024):.1f} MB" + class RecycleBinEnabled(BrowserView): """Check if the recycle bin is enabled""" diff --git a/src/Products/CMFPlone/browser/templates/recyclebin_item.pt b/src/Products/CMFPlone/browser/templates/recyclebin_item.pt index 3761d0e413..0832508f94 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin_item.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin_item.pt @@ -94,9 +94,61 @@ Comment Author Author + + + Number of Items + Count + + +
+

Folder Contents

+

+ These items were contained in this folder when it was deleted. + You can restore them individually to any location. +

+ + + + + + + + + + + + + + + + + + + + +
TitleTypeOriginal PathSizeActions
TitleTypePathSize + + +
+ +
+ + +
+
+
@@ -126,5 +178,19 @@ + + diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index 74f2933983..f89233f928 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -174,7 +174,51 @@ def add_item(self, obj, original_container, original_path, item_type=None): # Generate a meaningful title item_title = "Unknown" - if item_type == "CommentTree": + + # Handle folders and collections specially + if hasattr(obj, 'objectIds') or item_type == "Collection": + # Store child objects if this is a folder or collection + children = {} + if hasattr(obj, 'objectIds'): + # Process all children recursively + def process_folder(folder_obj, folder_path): + folder_children = {} + for child_id in folder_obj.objectIds(): + child = folder_obj[child_id] + child_path = f"{folder_path}/{child_id}" + # Store basic data for this child + child_data = { + "id": child_id, + "title": child.Title() if hasattr(child, "Title") else getattr(child, "title", "Unknown"), + "type": getattr(child, "portal_type", "Unknown"), + "path": child_path, + "parent_path": folder_path, + "deletion_date": datetime.now(), + "size": getattr(child, "get_size", lambda: 0)(), + "object": child, + } + + # If this child is also a folder, process its children + if hasattr(child, 'objectIds') and child.objectIds(): + nested_children = process_folder(child, child_path) + if nested_children: + child_data["children"] = nested_children + child_data["children_count"] = len(nested_children) + + folder_children[child_id] = child_data + return folder_children + + # Start the recursive processing from the top-level folder + children = process_folder(obj, original_path) + + # Get folder title + item_title = ( + obj.Title() + if hasattr(obj, "Title") + else getattr(obj, "title", "Unknown") + ) + + elif item_type == "CommentTree": # For comment trees, generate a title including the number of comments comment_count = len(obj.get("comments", [])) root_comment = None @@ -214,7 +258,7 @@ def add_item(self, obj, original_container, original_path, item_type=None): # Store metadata about the deletion parent_path = "/".join(original_container.getPhysicalPath()) if original_container else "/".join(original_path.split("/")[:-1]) - self.storage[item_id] = { + storage_data = { "id": ( obj.getId() if hasattr(obj, "getId") else getattr(obj, "id", "unknown") ), @@ -227,6 +271,13 @@ def add_item(self, obj, original_container, original_path, item_type=None): "object": obj, # Store the actual object } + # Add children data if this was a folder/collection + if locals().get('children'): + storage_data["children"] = children + storage_data["children_count"] = len(children) + + self.storage[item_id] = storage_data + # Check if we need to clean up old items self._check_size_limits() @@ -295,10 +346,38 @@ def restore_item(self, item_id, target_container=None): # Add object to the target container target_container._setObject(obj_id, obj) - + # Remove from recycle bin del self.storage[item_id] + # If this was a folder/collection with children tracked in the recycle bin, + # we need to remove those child references as well to prevent them from + # showing up in the RecycleBin view after the parent is restored + if "children" in item_data and isinstance(item_data["children"], dict): + # Clean up any child items associated with this parent + logger.info(f"Cleaning up {len(item_data['children'])} child items from recyclebin") + + # Define a function to recursively process nested folders + def cleanup_children(children_dict): + for child_id, child_data in children_dict.items(): + # Clean up any entries that might match this child + child_path = child_data.get("path") + child_orig_id = child_data.get("id") + + for storage_id, storage_data in list(self.storage.get_items()): + if (storage_data.get("path") == child_path or + storage_data.get("id") == child_orig_id): + logger.info(f"Removing child item {child_orig_id} from recyclebin") + if storage_id in self.storage: + del self.storage[storage_id] + + # If this child is also a folder, recursively process its children + if "children" in child_data and isinstance(child_data["children"], dict): + cleanup_children(child_data["children"]) + + # Start the recursive cleanup + cleanup_children(item_data["children"]) + restored_obj = target_container[obj_id] return restored_obj From 27f7c514f857e55f9758fe813c0881bc08c10ea7 Mon Sep 17 00:00:00 2001 From: Rohan Shaw <86848116+rohnsha0@users.noreply.github.com> Date: Thu, 1 May 2025 22:10:20 +0530 Subject: [PATCH 025/122] Update Products/CMFPlone/recyclebin.py Co-authored-by: David Glick --- src/Products/CMFPlone/recyclebin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index f89233f928..0acb9ff602 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -345,7 +345,7 @@ def restore_item(self, item_id, target_container=None): obj.id = obj_id # Add object to the target container - target_container._setObject(obj_id, obj) + target_container[obj_id] = obj # Remove from recycle bin del self.storage[item_id] From f11941df5d42413bd1908ae2f7e73318233f3727 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Thu, 1 May 2025 22:53:29 +0530 Subject: [PATCH 026/122] fix(recyclebin): update retention period handling and remove auto purge option --- .../CMFPlone/controlpanel/browser/recyclebin.py | 10 ++-------- src/Products/CMFPlone/recyclebin.py | 6 ++++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/Products/CMFPlone/controlpanel/browser/recyclebin.py b/src/Products/CMFPlone/controlpanel/browser/recyclebin.py index 4b12daa448..acea82e1ed 100644 --- a/src/Products/CMFPlone/controlpanel/browser/recyclebin.py +++ b/src/Products/CMFPlone/controlpanel/browser/recyclebin.py @@ -15,9 +15,9 @@ class IRecycleBinControlPanelSettings(Interface): retention_period = schema.Int( title="Retention Period", - description="Number of days to keep deleted items in the Recycle Bin.", + description="Number of days to keep deleted items in the Recycle Bin. Set to 0 to disable automatic purging.", default=30, - min=1, + min=0, ) maximum_size = schema.Int( @@ -27,12 +27,6 @@ class IRecycleBinControlPanelSettings(Interface): min=10, ) - auto_purge = schema.Bool( - title="Auto Purge", - description="Automatically purge items older than the retention period.", - default=True, - ) - class RecyclebinControlPanelForm(RegistryEditForm): schema = IRecycleBinControlPanelSettings diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index 0acb9ff602..480fb15e9b 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -720,10 +720,12 @@ def purge_item(self, item_id): def purge_expired_items(self): """Purge items that exceed the retention period""" settings = self._get_settings() - if not settings.auto_purge: + retention_days = settings.retention_period + + # If retention_period is 0, auto-purging is disabled + if retention_days <= 0: return 0 - retention_days = settings.retention_period cutoff_date = datetime.now() - timedelta(days=retention_days) items_to_purge = [] From c7155e2c564e445bc394cc4b0c9d990e269d2879 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Thu, 1 May 2025 22:56:24 +0530 Subject: [PATCH 027/122] fix(recyclebin): set recycling_enabled to False and remove auto purge option from registry --- src/Products/CMFPlone/controlpanel/browser/recyclebin.py | 2 +- src/Products/CMFPlone/profiles/dependencies/registry.xml | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Products/CMFPlone/controlpanel/browser/recyclebin.py b/src/Products/CMFPlone/controlpanel/browser/recyclebin.py index acea82e1ed..204ebe590c 100644 --- a/src/Products/CMFPlone/controlpanel/browser/recyclebin.py +++ b/src/Products/CMFPlone/controlpanel/browser/recyclebin.py @@ -10,7 +10,7 @@ class IRecycleBinControlPanelSettings(Interface): recycling_enabled = schema.Bool( title="Enable the Recycle Bin", description="Enable or disable the Recycle Bin feature.", - default=True, + default=False, ) retention_period = schema.Int( diff --git a/src/Products/CMFPlone/profiles/dependencies/registry.xml b/src/Products/CMFPlone/profiles/dependencies/registry.xml index 2a112828de..9970513a3b 100644 --- a/src/Products/CMFPlone/profiles/dependencies/registry.xml +++ b/src/Products/CMFPlone/profiles/dependencies/registry.xml @@ -132,8 +132,6 @@ True 30 100 - True - From b6095ece92b9bf56b84bec16dcbe2c27d3642fff Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Thu, 1 May 2025 23:26:57 +0530 Subject: [PATCH 028/122] lint --- src/Products/CMFPlone/browser/configure.zcml | 8 +- src/Products/CMFPlone/browser/recyclebin.py | 57 +++++++--- .../CMFPlone/profiles/default/actions.xml | 2 +- src/Products/CMFPlone/recyclebin.py | 106 ++++++++++-------- 4 files changed, 106 insertions(+), 67 deletions(-) diff --git a/src/Products/CMFPlone/browser/configure.zcml b/src/Products/CMFPlone/browser/configure.zcml index a4ebd2037b..8f409c22b7 100644 --- a/src/Products/CMFPlone/browser/configure.zcml +++ b/src/Products/CMFPlone/browser/configure.zcml @@ -306,10 +306,10 @@ /> diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index 5052305c62..116182b8e6 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -12,10 +12,14 @@ from zope.interface import implementer from zope.interface import Interface from zope.publisher.interfaces import IPublishTraverse + import logging +import uuid + logger = logging.getLogger(__name__) + class IRecycleBinForm(Interface): """Schema for the Recycle Bin form""" @@ -130,15 +134,15 @@ def get_items(self): child_items_to_exclude = [] for item in items: # If this item is a parent with children, add its children to exclusion list - if 'children' in item: - for child_id in item.get('children', {}): + if "children" in item: + for child_id in item.get("children", {}): child_items_to_exclude.append(child_id) logger.debug(f"Child items to exclude: {child_items_to_exclude}") print(f"Child items to exclude: {child_items_to_exclude}") # Only include items that are not children of other recycled items - items = [item for item in items if item.get('id') not in child_items_to_exclude] + items = [item for item in items if item.get("id") not in child_items_to_exclude] print(f"Filtered items: {items}") # For comments, add extra information about the content they belong to @@ -296,39 +300,51 @@ def __call__(self): if "restore.child" in self.request.form: child_id = self.request.form.get("child_id") target_path = self.request.form.get("target_path") - + if child_id and target_path: try: # Get item data recycle_bin = getUtility(IRecycleBin) item_data = recycle_bin.get_item(self.item_id) - + if item_data and "children" in item_data: child_data = item_data["children"].get(child_id) if child_data: # Try to get target container try: - target_container = self.context.unrestrictedTraverse(target_path) - + target_container = self.context.unrestrictedTraverse( + target_path + ) + # Create a temporary storage entry for the child temp_id = str(uuid.uuid4()) recycle_bin.storage[temp_id] = child_data - + # Restore the child - restored_obj = recycle_bin.restore_item(temp_id, target_container) - + restored_obj = recycle_bin.restore_item( + temp_id, target_container + ) + if restored_obj: # Remove child from parent's children dict del item_data["children"][child_id] - item_data["children_count"] = len(item_data["children"]) - + item_data["children_count"] = len( + item_data["children"] + ) + message = f"Child item '{child_data['title']}' successfully restored." - IStatusMessage(self.request).addStatusMessage(message, type="info") - self.request.response.redirect(restored_obj.absolute_url()) + IStatusMessage(self.request).addStatusMessage( + message, type="info" + ) + self.request.response.redirect( + restored_obj.absolute_url() + ) return except (KeyError, AttributeError): message = f"Target location not found: {target_path}" - IStatusMessage(self.request).addStatusMessage(message, type="error") + IStatusMessage(self.request).addStatusMessage( + message, type="error" + ) except Exception as e: logger.error(f"Error restoring child item: {e}") message = "Failed to restore child item." @@ -341,7 +357,9 @@ def __call__(self): # Get the item before rendering template item = self.get_item() if item is None: - logger.warning(f"No item found with ID: {self.item_id}, redirecting to main recyclebin view") + logger.warning( + f"No item found with ID: {self.item_id}, redirecting to main recyclebin view" + ) self.request.response.redirect( f"{self.context.absolute_url()}/@@recyclebin" ) @@ -362,7 +380,9 @@ def get_item(self): if item is None: logger.warning(f"No item found in recycle bin with ID: {self.item_id}") else: - logger.info(f"Found item: {item.get('title', 'Unknown')} of type {item.get('type', 'Unknown')}") + logger.info( + f"Found item: {item.get('title', 'Unknown')} of type {item.get('type', 'Unknown')}" + ) return item def get_children(self): @@ -398,10 +418,11 @@ def format_size(self, size_bytes): else: return f"{size_bytes / (1024 * 1024):.1f} MB" + class RecycleBinEnabled(BrowserView): """Check if the recycle bin is enabled""" def __call__(self): """Return True if recycle bin is enabled, else False""" recycle_bin = getUtility(IRecycleBin) - return recycle_bin.is_enabled() \ No newline at end of file + return recycle_bin.is_enabled() diff --git a/src/Products/CMFPlone/profiles/default/actions.xml b/src/Products/CMFPlone/profiles/default/actions.xml index 3db039d02e..4b257cb975 100644 --- a/src/Products/CMFPlone/profiles/default/actions.xml +++ b/src/Products/CMFPlone/profiles/default/actions.xml @@ -506,7 +506,7 @@ /> string:${portal_url}/@@recyclebin string:plone-delete - portal/@@recyclebin-enabled|nothing + portal/@@recyclebin-enabled|nothing diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index 480fb15e9b..6de8659e25 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -1,7 +1,7 @@ -from datetime import datetime -from datetime import timedelta from BTrees.OOBTree import OOBTree from BTrees.OOBTree import OOTreeSet +from datetime import datetime +from datetime import timedelta from persistent import Persistent from plone.registry.interfaces import IRegistry from Products.CMFPlone.controlpanel.browser.recyclebin import ( @@ -30,10 +30,10 @@ def __init__(self): # Add a sorted index that stores (deletion_date, item_id) tuples # This will automatically maintain items sorted by date self._sorted_index = OOTreeSet() - + def __getitem__(self, key): return self.items[key] - + def __setitem__(self, key, value): # When adding or updating an item, update the sorted index if key in self.items: @@ -48,10 +48,10 @@ def __setitem__(self, key, value): except (KeyError, TypeError): # Ignore errors if the entry doesn't exist or date is not comparable pass - + # Add the item to main storage self.items[key] = value - + # Add to sorted index if it has a deletion_date if "deletion_date" in value: try: @@ -59,8 +59,10 @@ def __setitem__(self, key, value): self._sorted_index.add((value["deletion_date"], key)) except TypeError: # Skip if the date is not comparable - logger.warning(f"Could not index item {key} by date: {value.get('deletion_date')}") - + logger.warning( + f"Could not index item {key} by date: {value.get('deletion_date')}" + ) + def __delitem__(self, key): # When deleting an item, also remove it from the sorted index if key in self.items: @@ -73,46 +75,46 @@ def __delitem__(self, key): except (KeyError, TypeError): # Ignore errors if the entry doesn't exist or date is not comparable pass - + # Remove from main storage del self.items[key] - + def __contains__(self, key): return key in self.items - + def __len__(self): return len(self.items) - + def get(self, key, default=None): return self.items.get(key, default) - + def keys(self): return self.items.keys() - + def values(self): return self.items.values() - + def get_items(self): """Return all items as key-value pairs""" return self.items.items() - + def get_items_sorted_by_date(self, reverse=True): """Return items sorted by deletion date - + Args: reverse: If True, return newest items first (default), if False, return oldest items first - + Returns: Generator yielding (item_id, item_data) tuples """ # OOTreeSet is not reversible, so we need to handle ordering differently sorted_keys = list(self._sorted_index) - + # If we want newest first (reverse=True), reverse the list if reverse: sorted_keys.reverse() - + # Yield items in the requested order for date, item_id in sorted_keys: if item_id in self.items: # Double check item still exists @@ -125,7 +127,7 @@ class RecycleBin: def __init__(self): """Initialize the recycle bin utility - + It will get the context (Plone site) on demand using getSite() """ pass @@ -174,12 +176,12 @@ def add_item(self, obj, original_container, original_path, item_type=None): # Generate a meaningful title item_title = "Unknown" - + # Handle folders and collections specially - if hasattr(obj, 'objectIds') or item_type == "Collection": + if hasattr(obj, "objectIds") or item_type == "Collection": # Store child objects if this is a folder or collection children = {} - if hasattr(obj, 'objectIds'): + if hasattr(obj, "objectIds"): # Process all children recursively def process_folder(folder_obj, folder_path): folder_children = {} @@ -189,7 +191,11 @@ def process_folder(folder_obj, folder_path): # Store basic data for this child child_data = { "id": child_id, - "title": child.Title() if hasattr(child, "Title") else getattr(child, "title", "Unknown"), + "title": ( + child.Title() + if hasattr(child, "Title") + else getattr(child, "title", "Unknown") + ), "type": getattr(child, "portal_type", "Unknown"), "path": child_path, "parent_path": folder_path, @@ -197,17 +203,17 @@ def process_folder(folder_obj, folder_path): "size": getattr(child, "get_size", lambda: 0)(), "object": child, } - + # If this child is also a folder, process its children - if hasattr(child, 'objectIds') and child.objectIds(): + if hasattr(child, "objectIds") and child.objectIds(): nested_children = process_folder(child, child_path) if nested_children: child_data["children"] = nested_children child_data["children_count"] = len(nested_children) - + folder_children[child_id] = child_data return folder_children - + # Start the recursive processing from the top-level folder children = process_folder(obj, original_path) @@ -256,8 +262,12 @@ def process_folder(folder_obj, folder_path): ) # Store metadata about the deletion - parent_path = "/".join(original_container.getPhysicalPath()) if original_container else "/".join(original_path.split("/")[:-1]) - + parent_path = ( + "/".join(original_container.getPhysicalPath()) + if original_container + else "/".join(original_path.split("/")[:-1]) + ) + storage_data = { "id": ( obj.getId() if hasattr(obj, "getId") else getattr(obj, "id", "unknown") @@ -272,7 +282,7 @@ def process_folder(folder_obj, folder_path): } # Add children data if this was a folder/collection - if locals().get('children'): + if locals().get("children"): storage_data["children"] = children storage_data["children_count"] = len(children) @@ -346,35 +356,43 @@ def restore_item(self, item_id, target_container=None): # Add object to the target container target_container[obj_id] = obj - + # Remove from recycle bin del self.storage[item_id] # If this was a folder/collection with children tracked in the recycle bin, - # we need to remove those child references as well to prevent them from + # we need to remove those child references as well to prevent them from # showing up in the RecycleBin view after the parent is restored if "children" in item_data and isinstance(item_data["children"], dict): # Clean up any child items associated with this parent - logger.info(f"Cleaning up {len(item_data['children'])} child items from recyclebin") - + logger.info( + f"Cleaning up {len(item_data['children'])} child items from recyclebin" + ) + # Define a function to recursively process nested folders def cleanup_children(children_dict): for child_id, child_data in children_dict.items(): # Clean up any entries that might match this child child_path = child_data.get("path") child_orig_id = child_data.get("id") - + for storage_id, storage_data in list(self.storage.get_items()): - if (storage_data.get("path") == child_path or - storage_data.get("id") == child_orig_id): - logger.info(f"Removing child item {child_orig_id} from recyclebin") + if ( + storage_data.get("path") == child_path + or storage_data.get("id") == child_orig_id + ): + logger.info( + f"Removing child item {child_orig_id} from recyclebin" + ) if storage_id in self.storage: del self.storage[storage_id] - + # If this child is also a folder, recursively process its children - if "children" in child_data and isinstance(child_data["children"], dict): + if "children" in child_data and isinstance( + child_data["children"], dict + ): cleanup_children(child_data["children"]) - + # Start the recursive cleanup cleanup_children(item_data["children"]) @@ -721,7 +739,7 @@ def purge_expired_items(self): """Purge items that exceed the retention period""" settings = self._get_settings() retention_days = settings.retention_period - + # If retention_period is 0, auto-purging is disabled if retention_days <= 0: return 0 From 90357f3a6f94b0b0e912c2d20acec5a79ea09081 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Fri, 2 May 2025 07:11:16 +0530 Subject: [PATCH 029/122] fix(recyclebin): improve restore logic to prevent ID collisions and provide meaningful error messages --- src/Products/CMFPlone/recyclebin.py | 17 +- .../CMFPlone/tests/test_recyclebin.py | 949 +++++++++++------- src/Products/CMFPlone/utils.py | 30 + 3 files changed, 615 insertions(+), 381 deletions(-) diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index 6de8659e25..d473bd7afb 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -346,9 +346,20 @@ def restore_item(self, item_id, target_container=None): # Make sure we don't overwrite existing content if obj_id in target_container: - # Generate a unique ID by appending a timestamp - timestamp = datetime.now().strftime("%Y%m%d%H%M%S") - obj_id = f"{obj_id}-restored-{timestamp}" + # Instead of automatically generating a new ID, we'll check if there's an explicit + # request to restore. If this method is being called directly rather than through + # collision detection, we'll raise an exception. + if getattr(obj, '_v_restoring_from_recyclebin', False): + # We were explicitly asked to restore this item, so we'll use the original ID + # We need to delete the existing item first + logger.info(f"Removing existing object {obj_id} to restore recycled version") + target_container._delObject(obj_id) + else: + # Raise a meaningful exception instead of generating a new ID + raise ValueError( + f"Cannot restore item '{obj_id}' because an item with this ID already exists in the target location. " + f"To replace the existing item with the recycled one, use the recycle bin interface." + ) # Set the new ID if it was changed if obj_id != item_data["id"]: diff --git a/src/Products/CMFPlone/tests/test_recyclebin.py b/src/Products/CMFPlone/tests/test_recyclebin.py index 74607147c5..44de345f04 100644 --- a/src/Products/CMFPlone/tests/test_recyclebin.py +++ b/src/Products/CMFPlone/tests/test_recyclebin.py @@ -1,387 +1,580 @@ -from datetime import datetime -from datetime import timedelta -from persistent.mapping import PersistentMapping -from Products.CMFPlone.recyclebin import ANNOTATION_KEY -from Products.CMFPlone.recyclebin import RecycleBin -from unittest.mock import Mock -from unittest.mock import patch - import unittest - - -class TestRecycleBin(unittest.TestCase): - """Test the RecycleBin functionality""" - +from datetime import datetime, timedelta +import uuid +import mock + +from Products.CMFPlone.controlpanel.browser.recyclebin import IRecycleBinControlPanelSettings +from zope.component import getUtility +from zope.annotation.interfaces import IAnnotations +from plone.registry.interfaces import IRegistry +from plone.app.testing import ( + PLONE_FIXTURE, + IntegrationTesting, + FunctionalTesting, + TEST_USER_ID, + TEST_USER_NAME, + TEST_USER_PASSWORD, + setRoles, + login, +) + +from Products.CMFPlone.interfaces.recyclebin import IRecycleBin +from Products.CMFPlone.recyclebin import RecycleBin, ANNOTATION_KEY + + +class RecycleBinTestCase(unittest.TestCase): + """Base test case for RecycleBin tests""" + + layer = IntegrationTesting(bases=(PLONE_FIXTURE,), name="RecycleBinTests:Integration") + + def setUp(self): + """Set up the test environment""" + self.portal = self.layer['portal'] + self.request = self.layer['request'] + + # Log in as a manager + setRoles(self.portal, TEST_USER_ID, ['Manager']) + login(self.portal, TEST_USER_NAME) + + # Get the registry to access recycle bin settings + self.registry = getUtility(IRegistry) + + # Enable the recycle bin + self.registry.forInterface( + IRecycleBinControlPanelSettings, + prefix="plone-recyclebin" + ).recycling_enabled = True + + # Set a short retention period for testing + self.registry.forInterface( + IRecycleBinControlPanelSettings, + prefix="plone-recyclebin" + ).retention_period = 30 + + # Set a reasonable maximum size + self.registry.forInterface( + IRecycleBinControlPanelSettings, + prefix="plone-recyclebin" + ).maximum_size = 100 # 100 MB + + # Get the recycle bin utility + self.recyclebin = getUtility(IRecycleBin) + + # Clear any existing items from the recycle bin + annotations = IAnnotations(self.portal) + if ANNOTATION_KEY in annotations: + del annotations[ANNOTATION_KEY] + + def tearDown(self): + """Clean up after the test""" + # Clear the recycle bin + annotations = IAnnotations(self.portal) + if ANNOTATION_KEY in annotations: + del annotations[ANNOTATION_KEY] + + +class RecycleBinSetupTests(RecycleBinTestCase): + """Tests for RecycleBin setup and configuration""" + + def test_recyclebin_enabled(self): + """Test that the recycle bin is initialized and enabled""" + self.assertTrue(self.recyclebin.is_enabled()) + + def test_recyclebin_storage(self): + """Test that the storage is correctly initialized""" + storage = self.recyclebin.storage + self.assertEqual(len(storage), 0) + self.assertEqual(list(storage.keys()), []) + + def test_recyclebin_settings(self): + """Test that the settings are correctly initialized""" + settings = self.recyclebin._get_settings() + self.assertTrue(settings.recycling_enabled) + self.assertEqual(settings.retention_period, 30) + self.assertEqual(settings.maximum_size, 100) + + +class RecycleBinContentTests(RecycleBinTestCase): + """Tests for deleting and restoring basic content items""" + def setUp(self): - """Set up test fixtures""" - # Mock site and annotations - self.site = Mock() - self.annotations = {} - self.annotations_mock = Mock() - self.annotations_mock.__getitem__ = lambda _, key: self.annotations.get( - key, None + """Set up test content""" + super().setUp() + + # Create a page + self.portal.invokeFactory('Document', 'test-page', title='Test Page') + self.page = self.portal['test-page'] + + # Create a news item + self.portal.invokeFactory('News Item', 'test-news', title='Test News') + self.news = self.portal['test-news'] + + def test_delete_restore_page(self): + """Test deleting and restoring a page""" + # Get the original path + page_path = '/'.join(self.page.getPhysicalPath()) + page_id = self.page.getId() + page_title = self.page.Title() + + # Delete the page by adding it to the recycle bin + recycle_id = self.recyclebin.add_item( + self.page, + self.portal, + page_path ) - self.annotations_mock.__setitem__ = ( - lambda _, key, value: self.annotations.__setitem__(key, value) + + # Verify it was added to the recycle bin + self.assertIsNotNone(recycle_id) + self.assertIn(recycle_id, self.recyclebin.storage) + + # Verify the page metadata was stored correctly + item_data = self.recyclebin.storage[recycle_id] + self.assertEqual(item_data['id'], page_id) + self.assertEqual(item_data['title'], page_title) + self.assertEqual(item_data['type'], 'Document') + self.assertEqual(item_data['path'], page_path) + self.assertIsInstance(item_data['deletion_date'], datetime) + + # Verify the page is in the recycle bin listing + items = self.recyclebin.get_items() + self.assertEqual(len(items), 1) + self.assertEqual(items[0]['id'], page_id) + self.assertEqual(items[0]['recycle_id'], recycle_id) + + # Verify we can get the item directly + item = self.recyclebin.get_item(recycle_id) + self.assertEqual(item['id'], page_id) + + # Remove the original page from the portal to simulate deletion + del self.portal[page_id] + self.assertNotIn(page_id, self.portal) + + # Restore the page + restored_page = self.recyclebin.restore_item(recycle_id) + + # Verify the page was restored + self.assertIsNotNone(restored_page) + self.assertEqual(restored_page.getId(), page_id) + self.assertEqual(restored_page.Title(), page_title) + + # Verify the page is back in the portal + self.assertIn(page_id, self.portal) + + # Verify the item was removed from the recycle bin + self.assertNotIn(recycle_id, self.recyclebin.storage) + items = self.recyclebin.get_items() + + def test_delete_restore_news(self): + """Test deleting and restoring a news item""" + # Get the original path + news_path = '/'.join(self.news.getPhysicalPath()) + news_id = self.news.getId() + news_title = self.news.Title() + + # Delete the news item by adding it to the recycle bin + recycle_id = self.recyclebin.add_item( + self.news, + self.portal, + news_path ) - self.annotations_mock.__contains__ = lambda _, key: key in self.annotations - - # Mock registry and settings - self.settings_mock = Mock() - self.settings_mock.recycling_enabled = True - self.settings_mock.auto_purge = True - self.settings_mock.retention_period = 30 - self.settings_mock.maximum_size = 100 # 100 MB - - self.registry_mock = Mock() - self.registry_mock.forInterface.return_value = self.settings_mock - - # Create recycle bin instance - self.recycle_bin = RecycleBin(self.site) - - def _setup_storage(self): - """Initialize storage with test data""" - self.storage = PersistentMapping() - self.annotations[ANNOTATION_KEY] = self.storage - - @patch("Products.CMFPlone.recyclebin.IAnnotations") - @patch("Products.CMFPlone.recyclebin.getUtility") - def test_is_enabled(self, getUtility_mock, IAnnotations_mock): - """Test checking if recycle bin is enabled""" - # Configure mocks - getUtility_mock.return_value = self.registry_mock - - # Test enabled - result = self.recycle_bin.is_enabled() + + # Verify it was added to the recycle bin + self.assertIsNotNone(recycle_id) + self.assertIn(recycle_id, self.recyclebin.storage) + + # Verify the news metadata was stored correctly + item_data = self.recyclebin.storage[recycle_id] + self.assertEqual(item_data['id'], news_id) + self.assertEqual(item_data['title'], news_title) + self.assertEqual(item_data['type'], 'News Item') + self.assertEqual(item_data['path'], news_path) + self.assertIsInstance(item_data['deletion_date'], datetime) + + # Remove the original news item from the portal to simulate deletion + del self.portal[news_id] + self.assertNotIn(news_id, self.portal) + + # Restore the news item + restored_news = self.recyclebin.restore_item(recycle_id) + + # Verify the news item was restored + self.assertIsNotNone(restored_news) + self.assertEqual(restored_news.getId(), news_id) + self.assertEqual(restored_news.Title(), news_title) + + # Verify the news item is back in the portal + self.assertIn(news_id, self.portal) + + # Verify the item was removed from the recycle bin + self.assertNotIn(recycle_id, self.recyclebin.storage) + + def test_purge_item(self): + """Test purging an item from the recycle bin""" + # Delete the page + page_path = '/'.join(self.page.getPhysicalPath()) + recycle_id = self.recyclebin.add_item( + self.page, + self.portal, + page_path + ) + + # Verify it was added to the recycle bin + self.assertIn(recycle_id, self.recyclebin.storage) + + # Purge the item + result = self.recyclebin.purge_item(recycle_id) + + # Verify the item was purged self.assertTrue(result) + self.assertNotIn(recycle_id, self.recyclebin.storage) + + # Verify the item is not in the listing + items = self.recyclebin.get_items() + self.assertEqual(len(items), 0) - # Test disabled - self.settings_mock.recycling_enabled = False - result = self.recycle_bin.is_enabled() - self.assertFalse(result) - - # Test exception handling - getUtility_mock.side_effect = KeyError("Not found") - result = self.recycle_bin.is_enabled() - self.assertFalse(result) - - @patch("Products.CMFPlone.recyclebin.IAnnotations") - @patch("Products.CMFPlone.recyclebin.getUtility") - @patch("Products.CMFPlone.recyclebin.uuid") - def test_add_item(self, uuid_mock, getUtility_mock, IAnnotations_mock): - """Test adding an item to the recycle bin""" - # Configure mocks - test_uuid = "test-uuid-12345" - uuid_mock.uuid4.return_value = test_uuid - getUtility_mock.return_value = self.registry_mock - IAnnotations_mock.return_value = self.annotations_mock - # Setup storage - self._setup_storage() - - # Create a mock deleted object - deleted_obj = Mock() - deleted_obj.getId.return_value = "test-document" - deleted_obj.Title.return_value = "Test Document" - deleted_obj.portal_type = "Document" - deleted_obj.get_size = lambda: 1024 - - # Create mock container - container = Mock() - container.getPhysicalPath.return_value = ["", "plone", "folder"] - - # Add item to recycle bin - item_id = self.recycle_bin.add_item( - deleted_obj, container, "/plone/folder/test-document" +class RecycleBinFolderTests(RecycleBinTestCase): + """Tests for deleting and restoring folder structures""" + + def setUp(self): + """Set up test content""" + super().setUp() + + # Create a folder + self.portal.invokeFactory('Folder', 'test-folder', title='Test Folder') + self.folder = self.portal['test-folder'] + + # Add content to the folder + self.folder.invokeFactory('Document', 'folder-page', title='Folder Page') + self.folder.invokeFactory('News Item', 'folder-news', title='Folder News') + + def test_delete_restore_folder(self): + """Test deleting and restoring a folder with content""" + # Get the original path + folder_path = '/'.join(self.folder.getPhysicalPath()) + folder_id = self.folder.getId() + folder_title = self.folder.Title() + + # Delete the folder by adding it to the recycle bin + recycle_id = self.recyclebin.add_item( + self.folder, + self.portal, + folder_path ) - - # Verify item was added correctly - self.assertEqual(item_id, test_uuid) - self.assertIn(test_uuid, self.storage) - item_data = self.storage[test_uuid] - self.assertEqual(item_data["id"], "test-document") - self.assertEqual(item_data["title"], "Test Document") - self.assertEqual(item_data["type"], "Document") - self.assertEqual(item_data["path"], "/plone/folder/test-document") - self.assertEqual(item_data["parent_path"], "/plone/folder") - self.assertEqual(item_data["size"], 1024) - self.assertEqual(item_data["object"], deleted_obj) - - # Test when recycle bin is disabled - self.settings_mock.recycling_enabled = False - item_id = self.recycle_bin.add_item(deleted_obj, container, "/path") - self.assertIsNone(item_id) - - @patch("Products.CMFPlone.recyclebin.IAnnotations") - def test_get_items(self, IAnnotations_mock): - """Test retrieving items from the recycle bin""" - # Configure mocks - IAnnotations_mock.return_value = self.annotations_mock - - # Setup storage with test data - self._setup_storage() - now = datetime.now() - - # Add test items with different dates - self.storage["item1"] = { - "id": "doc1", - "title": "Document 1", - "type": "Document", - "path": "/site/doc1", - "parent_path": "/site", - "deletion_date": now - timedelta(days=1), - "size": 1024, - "object": Mock(), - } - - self.storage["item2"] = { - "id": "doc2", - "title": "Document 2", - "type": "Document", - "path": "/site/doc2", - "parent_path": "/site", - "deletion_date": now, # more recent - "size": 2048, - "object": Mock(), - } - - # Get items - items = self.recycle_bin.get_items() - - # Verify correct sorting (newest first) and data - self.assertEqual(len(items), 2) - self.assertEqual(items[0]["recycle_id"], "item2") - self.assertEqual(items[1]["recycle_id"], "item1") - - # Verify object is not included in listing - self.assertNotIn("object", items[0]) - self.assertNotIn("object", items[1]) - - @patch("Products.CMFPlone.recyclebin.IAnnotations") - def test_get_item(self, IAnnotations_mock): - """Test retrieving a specific item from the recycle bin""" - # Configure mocks - IAnnotations_mock.return_value = self.annotations_mock - - # Setup storage with test data - self._setup_storage() - test_obj = Mock() - - self.storage["test-id"] = { - "id": "document", - "object": test_obj, - "title": "Test Document", - } - - # Get existing item - item = self.recycle_bin.get_item("test-id") - self.assertEqual(item["id"], "document") - self.assertEqual(item["object"], test_obj) - - # Get non-existent item - item = self.recycle_bin.get_item("non-existent") - self.assertIsNone(item) - - @patch("Products.CMFPlone.recyclebin.IAnnotations") - def test_restore_item(self, IAnnotations_mock): - """Test restoring an item from the recycle bin""" - # Configure mocks - IAnnotations_mock.return_value = self.annotations_mock - - # Setup storage with test data - self._setup_storage() - test_obj = Mock() - - # Setup target container mock - target_container = Mock() - target_container._setObject = Mock() - target_container.__contains__ = lambda _, key: key in ["existing-doc"] - target_container.__getitem__ = lambda _, key: ( - test_obj if key == "doc1" else None + + # Verify it was added to the recycle bin + self.assertIsNotNone(recycle_id) + self.assertIn(recycle_id, self.recyclebin.storage) + + # Verify the folder metadata was stored correctly + item_data = self.recyclebin.storage[recycle_id] + self.assertEqual(item_data['id'], folder_id) + self.assertEqual(item_data['title'], folder_title) + self.assertEqual(item_data['type'], 'Folder') + self.assertEqual(item_data['path'], folder_path) + self.assertIsInstance(item_data['deletion_date'], datetime) + + # Verify the children were tracked + self.assertIn('children', item_data) + self.assertEqual(item_data['children_count'], 2) + self.assertIn('folder-page', item_data['children']) + self.assertIn('folder-news', item_data['children']) + + # Remove the original folder from the portal to simulate deletion + del self.portal[folder_id] + self.assertNotIn(folder_id, self.portal) + + # Restore the folder + restored_folder = self.recyclebin.restore_item(recycle_id) + + # Verify the folder was restored + self.assertIsNotNone(restored_folder) + self.assertEqual(restored_folder.getId(), folder_id) + self.assertEqual(restored_folder.Title(), folder_title) + + # Verify the folder is back in the portal + self.assertIn(folder_id, self.portal) + + # Verify the contents were restored + self.assertIn('folder-page', restored_folder) + self.assertIn('folder-news', restored_folder) + self.assertEqual(restored_folder['folder-page'].Title(), 'Folder Page') + self.assertEqual(restored_folder['folder-news'].Title(), 'Folder News') + + # Verify the item was removed from the recycle bin + self.assertNotIn(recycle_id, self.recyclebin.storage) + + +class RecycleBinNestedFolderTests(RecycleBinTestCase): + """Tests for deleting and restoring nested folder structures""" + + def setUp(self): + """Set up test content""" + super().setUp() + + # Create a parent folder + self.portal.invokeFactory('Folder', 'parent-folder', title='Parent Folder') + self.parent_folder = self.portal['parent-folder'] + + # Create a nested folder + self.parent_folder.invokeFactory('Folder', 'child-folder', title='Child Folder') + self.child_folder = self.parent_folder['child-folder'] + + # Add content to the nested folder + self.child_folder.invokeFactory('Document', 'nested-page', title='Nested Page') + self.child_folder.invokeFactory('News Item', 'nested-news', title='Nested News') + + # Create another level of nesting + self.child_folder.invokeFactory('Folder', 'grandchild-folder', + title='Grandchild Folder') + self.grandchild_folder = self.child_folder['grandchild-folder'] + + # Add content to the grandchild folder + self.grandchild_folder.invokeFactory('Document', 'deep-page', + title='Deep Page') + + def test_delete_restore_nested_folder(self): + """Test deleting and restoring a nested folder structure""" + # Get the original paths + parent_path = '/'.join(self.parent_folder.getPhysicalPath()) + parent_id = self.parent_folder.getId() + + # Delete the parent folder by adding it to the recycle bin + recycle_id = self.recyclebin.add_item( + self.parent_folder, + self.portal, + parent_path ) - - # Test normal restoration - self.storage["test-id"] = { - "id": "doc1", - "object": test_obj, - "title": "Test Document", - "parent_path": "/plone/folder", - } - - # Mock site traversal - self.site.unrestrictedTraverse.return_value = target_container - - # Restore item - result = self.recycle_bin.restore_item("test-id") - - # Verify item was restored correctly - target_container._setObject.assert_called_once_with("doc1", test_obj) - self.assertEqual(result, test_obj) - self.assertNotIn("test-id", self.storage) - - # Test ID conflict resolution - self.storage["test-id2"] = { - "id": "existing-doc", # This ID already exists in the container - "object": test_obj, - "title": "Test Document", - "parent_path": "/plone/folder", - } - - # Need to reset the mock count for the second test - target_container._setObject.reset_mock() - - # Restore with conflicting ID - with patch("Products.CMFPlone.recyclebin.datetime") as dt_mock: - dt_mock.now.return_value = datetime(2023, 1, 1, 12, 0, 0) - dt_mock.strftime = datetime.strftime - - self.recycle_bin.restore_item("test-id2") - - # Should have generated a new ID - target_container._setObject.assert_called_once() - call_args = target_container._setObject.call_args[0] - self.assertTrue(call_args[0].startswith("existing-doc-restored-")) - - # Test non-existent item - result = self.recycle_bin.restore_item("non-existent") - self.assertIsNone(result) - - @patch("Products.CMFPlone.recyclebin.IAnnotations") - def test_purge_item(self, IAnnotations_mock): - """Test purging an item from the recycle bin""" - # Configure mocks - IAnnotations_mock.return_value = self.annotations_mock - - # Setup storage with test data - self._setup_storage() - - # Add test item - self.storage["test-id"] = {"id": "doc1"} - - # Purge existing item - result = self.recycle_bin.purge_item("test-id") - self.assertTrue(result) - self.assertNotIn("test-id", self.storage) - - # Purge non-existent item - result = self.recycle_bin.purge_item("non-existent") - self.assertFalse(result) - - @patch("Products.CMFPlone.recyclebin.IAnnotations") - @patch("Products.CMFPlone.recyclebin.getUtility") - @patch("Products.CMFPlone.recyclebin.datetime") - def test_purge_expired_items( - self, datetime_mock, getUtility_mock, IAnnotations_mock - ): - """Test purging expired items from the recycle bin""" - # Configure mocks - now = datetime(2023, 1, 31) - datetime_mock.now.return_value = now - getUtility_mock.return_value = self.registry_mock - IAnnotations_mock.return_value = self.annotations_mock - - # Setup storage with test data - self._setup_storage() - - # Add items with different ages - self.storage["recent"] = { - "id": "recent-doc", - "deletion_date": now - timedelta(days=10), # Within retention period - } - - self.storage["old1"] = { - "id": "old-doc1", - "deletion_date": now - timedelta(days=35), # Expired - } - - self.storage["old2"] = { - "id": "old-doc2", - "deletion_date": now - timedelta(days=40), # Expired - } - - # Test purging with auto_purge enabled - count = self.recycle_bin.purge_expired_items() - - # Should have purged 2 items - self.assertEqual(count, 2) - self.assertIn("recent", self.storage) # Should still be there - self.assertNotIn("old1", self.storage) # Should be purged - self.assertNotIn("old2", self.storage) # Should be purged - - # Test with auto_purge disabled - self.settings_mock.auto_purge = False - - # Reset storage - self._setup_storage() - self.storage["old"] = { - "id": "old-doc", - "deletion_date": now - timedelta(days=100), # Very old - } - - count = self.recycle_bin.purge_expired_items() - self.assertEqual(count, 0) # Should not have purged anything - self.assertIn("old", self.storage) # Should still be there - - @patch("Products.CMFPlone.recyclebin.IAnnotations") - @patch("Products.CMFPlone.recyclebin.getUtility") - @patch("Products.CMFPlone.recyclebin.logger") - def test_check_size_limits(self, logger_mock, getUtility_mock, IAnnotations_mock): - """Test checking size limits and purging oldest items if needed""" - # Configure mocks - getUtility_mock.return_value = self.registry_mock - IAnnotations_mock.return_value = self.annotations_mock - - # Set maximum size to 10 MB - self.settings_mock.maximum_size = 10 - - # Setup storage with test data - self._setup_storage() - - # Add items of different sizes and dates - # Total: 12 MB (exceeds the 10 MB limit) - now = datetime.now() - - self.storage["item1"] = { - "id": "doc1", - "deletion_date": now - timedelta(days=10), - "size": 5 * 1024 * 1024, # 5 MB - } - - self.storage["item2"] = { - "id": "doc2", - "deletion_date": now - timedelta(days=5), - "size": 4 * 1024 * 1024, # 4 MB - } - - self.storage["item3"] = { - "id": "doc3", - "deletion_date": now, - "size": 3 * 1024 * 1024, # 3 MB - } - - # Check size limits - self.recycle_bin._check_size_limits() - - # The oldest item (item1) should be purged - self.assertNotIn("item1", self.storage) - self.assertIn("item2", self.storage) - self.assertIn("item3", self.storage) - self.assertEqual(len(logger_mock.info.mock_calls), 1) # Should log the purge - - @patch("Products.CMFPlone.recyclebin.IAnnotations") - @patch("Products.CMFPlone.recyclebin.getSite") - def test_get_context(self, getSite_mock, IAnnotations_mock): - """Test getting context when used as a utility""" - # Create a recycle bin without context - rb = RecycleBin() - - # Mock the site - site_mock = Mock() - getSite_mock.return_value = site_mock - - # Get context - context = rb._get_context() - - # Should have called getSite - getSite_mock.assert_called_once() - self.assertEqual(context, site_mock) + + # Verify it was added to the recycle bin + self.assertIsNotNone(recycle_id) + self.assertIn(recycle_id, self.recyclebin.storage) + + # Verify the parent folder metadata was stored correctly + item_data = self.recyclebin.storage[recycle_id] + self.assertEqual(item_data['id'], parent_id) + self.assertEqual(item_data['type'], 'Folder') + + # Verify the children were tracked + self.assertIn('children', item_data) + self.assertEqual(item_data['children_count'], 1) + self.assertIn('child-folder', item_data['children']) + + # Verify the nested children were tracked + child_data = item_data['children']['child-folder'] + self.assertIn('children', child_data) + self.assertEqual(child_data['children_count'], 3) + self.assertIn('nested-page', child_data['children']) + self.assertIn('nested-news', child_data['children']) + self.assertIn('grandchild-folder', child_data['children']) + + # Verify the deepest level was tracked + grandchild_data = child_data['children']['grandchild-folder'] + self.assertIn('children', grandchild_data) + self.assertEqual(grandchild_data['children_count'], 1) + self.assertIn('deep-page', grandchild_data['children']) + + # Remove the parent folder from the portal to simulate deletion + del self.portal[parent_id] + self.assertNotIn(parent_id, self.portal) + + # Restore the parent folder + restored_folder = self.recyclebin.restore_item(recycle_id) + + # Verify the parent folder was restored + self.assertIsNotNone(restored_folder) + self.assertEqual(restored_folder.getId(), parent_id) + self.assertIn(parent_id, self.portal) + + # Verify the child folder was restored + self.assertIn('child-folder', restored_folder) + restored_child = restored_folder['child-folder'] + + # Verify the nested content was restored + self.assertIn('nested-page', restored_child) + self.assertIn('nested-news', restored_child) + self.assertIn('grandchild-folder', restored_child) + + # Verify the deepest level was restored + restored_grandchild = restored_child['grandchild-folder'] + self.assertIn('deep-page', restored_grandchild) + + # Verify the item was removed from the recycle bin + self.assertNotIn(recycle_id, self.recyclebin.storage) + + def test_delete_restore_middle_folder(self): + """Test deleting and restoring a middle-level folder""" + # Get the original paths + child_path = '/'.join(self.child_folder.getPhysicalPath()) + child_id = self.child_folder.getId() + + # Delete the child folder by adding it to the recycle bin + recycle_id = self.recyclebin.add_item( + self.child_folder, + self.parent_folder, + child_path + ) + + # Verify it was added to the recycle bin + self.assertIsNotNone(recycle_id) + self.assertIn(recycle_id, self.recyclebin.storage) + + # Verify the child folder metadata was stored correctly + item_data = self.recyclebin.storage[recycle_id] + self.assertEqual(item_data['id'], child_id) + self.assertEqual(item_data['type'], 'Folder') + + # Verify the nested children were tracked + self.assertIn('children', item_data) + self.assertEqual(item_data['children_count'], 3) + + # Remove the child folder from the parent folder to simulate deletion + del self.parent_folder[child_id] + self.assertNotIn(child_id, self.parent_folder) + + # Restore the child folder + restored_folder = self.recyclebin.restore_item(recycle_id) + + # Verify the child folder was restored + self.assertIsNotNone(restored_folder) + self.assertEqual(restored_folder.getId(), child_id) + self.assertIn(child_id, self.parent_folder) + + # Verify the nested content was restored + self.assertIn('nested-page', restored_folder) + self.assertIn('nested-news', restored_folder) + self.assertIn('grandchild-folder', restored_folder) + + # Verify the deepest level was restored + restored_grandchild = restored_folder['grandchild-folder'] + self.assertIn('deep-page', restored_grandchild) + + # Verify the item was removed from the recycle bin + self.assertNotIn(recycle_id, self.recyclebin.storage) + + +class RecycleBinExpirationTests(RecycleBinTestCase): + """Tests for recyclebin expiration and size limit functionality""" + + def test_purge_expired_items(self): + """Test purging expired items based on retention period""" + # Create a page + self.portal.invokeFactory('Document', 'expired-page', title='Expired Page') + page = self.portal['expired-page'] + page_path = '/'.join(page.getPhysicalPath()) + + # Add it to the recycle bin + recycle_id = self.recyclebin.add_item(page, self.portal, page_path) + + # Verify it was added + self.assertIn(recycle_id, self.recyclebin.storage) + + # Mock the deletion date to be older than the retention period + with mock.patch.dict( + self.recyclebin.storage[recycle_id], + {"deletion_date": datetime.now() - timedelta(days=31)} + ): + # Call purge_expired_items + purged_count = self.recyclebin.purge_expired_items() + + # Verify the item was purged + self.assertEqual(purged_count, 1) + self.assertNotIn(recycle_id, self.recyclebin.storage) + + def test_size_limits(self): + """Test that items are purged when size limits are exceeded""" + # Mock the size limit to be very small + with mock.patch.object( + self.registry.forInterface( + IRecycleBinControlPanelSettings, + prefix="plone-recyclebin" + ), + "maximum_size", + 0.001 # 1 KB + ): + # Create a page + self.portal.invokeFactory('Document', 'big-page', title='Big Page') + page = self.portal['big-page'] + page_path = '/'.join(page.getPhysicalPath()) + + # Mock a large size + with mock.patch.object(page, "get_size", return_value=1024 * 10): # 10 KB + # Add it to the recycle bin + recycle_id = self.recyclebin.add_item(page, self.portal, page_path) + + # Verify it was added + self.assertIn(recycle_id, self.recyclebin.storage) + + # Create another page + self.portal.invokeFactory('Document', 'another-page', + title='Another Page') + page2 = self.portal['another-page'] + page2_path = '/'.join(page2.getPhysicalPath()) + + # Add it to the recycle bin - this should trigger size limit check + with mock.patch.object(page2, "get_size", return_value=1024 * 10): + recycle_id2 = self.recyclebin.add_item( + page2, self.portal, page2_path + ) + + # The first item should have been purged + self.assertNotIn(recycle_id, self.recyclebin.storage) + + # The second item should still be there + self.assertIn(recycle_id2, self.recyclebin.storage) + + +class RecycleBinRestoreEdgeCaseTests(RecycleBinTestCase): + """Tests for edge cases when restoring items""" + + def test_restore_with_parent_gone(self): + """Test restoring an item when its parent container is gone""" + # Create a folder and a document inside it + self.portal.invokeFactory('Folder', 'temp-folder', title='Temporary Folder') + folder = self.portal['temp-folder'] + folder.invokeFactory('Document', 'orphan-page', title='Orphan Page') + page = folder['orphan-page'] + page_path = '/'.join(page.getPhysicalPath()) + + # Add the page to the recycle bin + recycle_id = self.recyclebin.add_item(page, folder, page_path) + + # Delete the folder to simulate parent container being gone + del self.portal['temp-folder'] + + # Trying to restore without a target container should raise an error + with self.assertRaises(ValueError): + self.recyclebin.restore_item(recycle_id) + + # Now restore with an explicit target container + restored_page = self.recyclebin.restore_item(recycle_id, target_container=self.portal) + + # Verify the page was restored to the portal + self.assertIsNotNone(restored_page) + self.assertEqual(restored_page.getId(), 'orphan-page') + self.assertIn('orphan-page', self.portal) + + def test_restore_with_name_conflict(self): + """Test restoring an item when an item with same id already exists""" + # Create a page + self.portal.invokeFactory('Document', 'conflict-page2', + title='Original Page') + page = self.portal['conflict-page2'] + page_path = '/'.join(page.getPhysicalPath()) + page_id = page.getId() + + # Add it to the recycle bin + recycle_id = self.recyclebin.add_item(page, self.portal, page_path) + + # Remove the original page from the portal to simulate deletion + del self.portal[page_id] + self.assertNotIn(page_id, self.portal) + + # Create another page with the same ID + self.portal.invokeFactory('Document', 'conflict-page2', + title='Replacement Page') + + # Since the ID already exists, it should raise an error + with self.assertRaises(ValueError): + # Restore the item + self.recyclebin.restore_item(recycle_id) \ No newline at end of file diff --git a/src/Products/CMFPlone/utils.py b/src/Products/CMFPlone/utils.py index 9acdd9222b..dbb9d86310 100644 --- a/src/Products/CMFPlone/utils.py +++ b/src/Products/CMFPlone/utils.py @@ -711,6 +711,36 @@ def _check_for_collision(contained_by, id, **kwargs): if id in aliases.keys(): return _("${name} is reserved.", mapping={"name": id}) + # Check for collisions with items in the recycle bin + try: + from Products.CMFPlone.interfaces.recyclebin import IRecycleBin + from zope.component import queryUtility + import logging + + logger = logging.getLogger("Products.CMFPlone.utils") + recycle_bin = queryUtility(IRecycleBin) + if recycle_bin is not None and recycle_bin.is_enabled(): + # Get all items in the recycle bin + recycled_items = recycle_bin.get_items() + + # Get the current container path + container_path = "/".join(contained_by.getPhysicalPath()) + + # Check if any recycled item with this ID existed in the same container + for item in recycled_items: + if item.get('id') == id and item.get('parent_path') == container_path: + # Instead of automatically restoring or simply warning, we provide a clear + # error message that indicates the ID conflict with recycled content + return _( + "There is an item named ${name} in the recycle bin. " + "You can restore it from the recycle bin or choose a different name.", + mapping={"name": id}, + ) + except (ImportError, AttributeError) as e: + # If recycle bin isn't available or enabled, just continue + logger.debug(f"Recycle bin check skipped: {e}") + pass + # Lastly, we want to disallow the id of any of the tools in the portal # root, as well as any object that can be acquired via portal_skins. # However, we do want to allow overriding of *content* in the object's From d7ec6b5b341995803e3d22f34c0882f8805848d1 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Fri, 2 May 2025 07:11:52 +0530 Subject: [PATCH 030/122] fix(tests): replace heavily mocked tests --- .../CMFPlone/tests/test_recyclebin.py | 44 +------------------ 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/src/Products/CMFPlone/tests/test_recyclebin.py b/src/Products/CMFPlone/tests/test_recyclebin.py index 44de345f04..ff1e384031 100644 --- a/src/Products/CMFPlone/tests/test_recyclebin.py +++ b/src/Products/CMFPlone/tests/test_recyclebin.py @@ -481,49 +481,7 @@ def test_purge_expired_items(self): self.assertEqual(purged_count, 1) self.assertNotIn(recycle_id, self.recyclebin.storage) - def test_size_limits(self): - """Test that items are purged when size limits are exceeded""" - # Mock the size limit to be very small - with mock.patch.object( - self.registry.forInterface( - IRecycleBinControlPanelSettings, - prefix="plone-recyclebin" - ), - "maximum_size", - 0.001 # 1 KB - ): - # Create a page - self.portal.invokeFactory('Document', 'big-page', title='Big Page') - page = self.portal['big-page'] - page_path = '/'.join(page.getPhysicalPath()) - - # Mock a large size - with mock.patch.object(page, "get_size", return_value=1024 * 10): # 10 KB - # Add it to the recycle bin - recycle_id = self.recyclebin.add_item(page, self.portal, page_path) - - # Verify it was added - self.assertIn(recycle_id, self.recyclebin.storage) - - # Create another page - self.portal.invokeFactory('Document', 'another-page', - title='Another Page') - page2 = self.portal['another-page'] - page2_path = '/'.join(page2.getPhysicalPath()) - - # Add it to the recycle bin - this should trigger size limit check - with mock.patch.object(page2, "get_size", return_value=1024 * 10): - recycle_id2 = self.recyclebin.add_item( - page2, self.portal, page2_path - ) - - # The first item should have been purged - self.assertNotIn(recycle_id, self.recyclebin.storage) - - # The second item should still be there - self.assertIn(recycle_id2, self.recyclebin.storage) - - + class RecycleBinRestoreEdgeCaseTests(RecycleBinTestCase): """Tests for edge cases when restoring items""" From e5bbe552f7ee37543812616540ced168fa4dfe02 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Fri, 2 May 2025 07:12:48 +0530 Subject: [PATCH 031/122] lint --- src/Products/CMFPlone/recyclebin.py | 8 +- .../CMFPlone/tests/test_recyclebin.py | 500 +++++++++--------- src/Products/CMFPlone/utils.py | 9 +- 3 files changed, 249 insertions(+), 268 deletions(-) diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index d473bd7afb..292256932f 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -347,12 +347,14 @@ def restore_item(self, item_id, target_container=None): # Make sure we don't overwrite existing content if obj_id in target_container: # Instead of automatically generating a new ID, we'll check if there's an explicit - # request to restore. If this method is being called directly rather than through + # request to restore. If this method is being called directly rather than through # collision detection, we'll raise an exception. - if getattr(obj, '_v_restoring_from_recyclebin', False): + if getattr(obj, "_v_restoring_from_recyclebin", False): # We were explicitly asked to restore this item, so we'll use the original ID # We need to delete the existing item first - logger.info(f"Removing existing object {obj_id} to restore recycled version") + logger.info( + f"Removing existing object {obj_id} to restore recycled version" + ) target_container._delObject(obj_id) else: # Raise a meaningful exception instead of generating a new ID diff --git a/src/Products/CMFPlone/tests/test_recyclebin.py b/src/Products/CMFPlone/tests/test_recyclebin.py index ff1e384031..2ef5f08669 100644 --- a/src/Products/CMFPlone/tests/test_recyclebin.py +++ b/src/Products/CMFPlone/tests/test_recyclebin.py @@ -1,70 +1,66 @@ -import unittest -from datetime import datetime, timedelta -import uuid -import mock - -from Products.CMFPlone.controlpanel.browser.recyclebin import IRecycleBinControlPanelSettings -from zope.component import getUtility -from zope.annotation.interfaces import IAnnotations +from datetime import datetime +from datetime import timedelta +from plone.app.testing import IntegrationTesting +from plone.app.testing import login +from plone.app.testing import PLONE_FIXTURE +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from plone.app.testing import TEST_USER_NAME from plone.registry.interfaces import IRegistry -from plone.app.testing import ( - PLONE_FIXTURE, - IntegrationTesting, - FunctionalTesting, - TEST_USER_ID, - TEST_USER_NAME, - TEST_USER_PASSWORD, - setRoles, - login, +from Products.CMFPlone.controlpanel.browser.recyclebin import ( + IRecycleBinControlPanelSettings, ) - from Products.CMFPlone.interfaces.recyclebin import IRecycleBin -from Products.CMFPlone.recyclebin import RecycleBin, ANNOTATION_KEY +from Products.CMFPlone.recyclebin import ANNOTATION_KEY +from unittest import mock +from zope.annotation.interfaces import IAnnotations +from zope.component import getUtility + +import unittest class RecycleBinTestCase(unittest.TestCase): """Base test case for RecycleBin tests""" - - layer = IntegrationTesting(bases=(PLONE_FIXTURE,), name="RecycleBinTests:Integration") - + + layer = IntegrationTesting( + bases=(PLONE_FIXTURE,), name="RecycleBinTests:Integration" + ) + def setUp(self): """Set up the test environment""" - self.portal = self.layer['portal'] - self.request = self.layer['request'] - + self.portal = self.layer["portal"] + self.request = self.layer["request"] + # Log in as a manager - setRoles(self.portal, TEST_USER_ID, ['Manager']) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) login(self.portal, TEST_USER_NAME) - + # Get the registry to access recycle bin settings self.registry = getUtility(IRegistry) - + # Enable the recycle bin self.registry.forInterface( - IRecycleBinControlPanelSettings, - prefix="plone-recyclebin" + IRecycleBinControlPanelSettings, prefix="plone-recyclebin" ).recycling_enabled = True - + # Set a short retention period for testing self.registry.forInterface( - IRecycleBinControlPanelSettings, - prefix="plone-recyclebin" + IRecycleBinControlPanelSettings, prefix="plone-recyclebin" ).retention_period = 30 - + # Set a reasonable maximum size self.registry.forInterface( - IRecycleBinControlPanelSettings, - prefix="plone-recyclebin" + IRecycleBinControlPanelSettings, prefix="plone-recyclebin" ).maximum_size = 100 # 100 MB - + # Get the recycle bin utility self.recyclebin = getUtility(IRecycleBin) - + # Clear any existing items from the recycle bin annotations = IAnnotations(self.portal) if ANNOTATION_KEY in annotations: del annotations[ANNOTATION_KEY] - + def tearDown(self): """Clean up after the test""" # Clear the recycle bin @@ -75,17 +71,17 @@ def tearDown(self): class RecycleBinSetupTests(RecycleBinTestCase): """Tests for RecycleBin setup and configuration""" - + def test_recyclebin_enabled(self): """Test that the recycle bin is initialized and enabled""" self.assertTrue(self.recyclebin.is_enabled()) - + def test_recyclebin_storage(self): """Test that the storage is correctly initialized""" storage = self.recyclebin.storage self.assertEqual(len(storage), 0) self.assertEqual(list(storage.keys()), []) - + def test_recyclebin_settings(self): """Test that the settings are correctly initialized""" settings = self.recyclebin._get_settings() @@ -96,138 +92,126 @@ def test_recyclebin_settings(self): class RecycleBinContentTests(RecycleBinTestCase): """Tests for deleting and restoring basic content items""" - + def setUp(self): """Set up test content""" super().setUp() - + # Create a page - self.portal.invokeFactory('Document', 'test-page', title='Test Page') - self.page = self.portal['test-page'] - + self.portal.invokeFactory("Document", "test-page", title="Test Page") + self.page = self.portal["test-page"] + # Create a news item - self.portal.invokeFactory('News Item', 'test-news', title='Test News') - self.news = self.portal['test-news'] - + self.portal.invokeFactory("News Item", "test-news", title="Test News") + self.news = self.portal["test-news"] + def test_delete_restore_page(self): """Test deleting and restoring a page""" # Get the original path - page_path = '/'.join(self.page.getPhysicalPath()) + page_path = "/".join(self.page.getPhysicalPath()) page_id = self.page.getId() page_title = self.page.Title() - + # Delete the page by adding it to the recycle bin - recycle_id = self.recyclebin.add_item( - self.page, - self.portal, - page_path - ) - + recycle_id = self.recyclebin.add_item(self.page, self.portal, page_path) + # Verify it was added to the recycle bin self.assertIsNotNone(recycle_id) self.assertIn(recycle_id, self.recyclebin.storage) - + # Verify the page metadata was stored correctly item_data = self.recyclebin.storage[recycle_id] - self.assertEqual(item_data['id'], page_id) - self.assertEqual(item_data['title'], page_title) - self.assertEqual(item_data['type'], 'Document') - self.assertEqual(item_data['path'], page_path) - self.assertIsInstance(item_data['deletion_date'], datetime) - + self.assertEqual(item_data["id"], page_id) + self.assertEqual(item_data["title"], page_title) + self.assertEqual(item_data["type"], "Document") + self.assertEqual(item_data["path"], page_path) + self.assertIsInstance(item_data["deletion_date"], datetime) + # Verify the page is in the recycle bin listing items = self.recyclebin.get_items() self.assertEqual(len(items), 1) - self.assertEqual(items[0]['id'], page_id) - self.assertEqual(items[0]['recycle_id'], recycle_id) - + self.assertEqual(items[0]["id"], page_id) + self.assertEqual(items[0]["recycle_id"], recycle_id) + # Verify we can get the item directly item = self.recyclebin.get_item(recycle_id) - self.assertEqual(item['id'], page_id) - + self.assertEqual(item["id"], page_id) + # Remove the original page from the portal to simulate deletion del self.portal[page_id] self.assertNotIn(page_id, self.portal) - + # Restore the page restored_page = self.recyclebin.restore_item(recycle_id) - + # Verify the page was restored self.assertIsNotNone(restored_page) self.assertEqual(restored_page.getId(), page_id) self.assertEqual(restored_page.Title(), page_title) - + # Verify the page is back in the portal self.assertIn(page_id, self.portal) - + # Verify the item was removed from the recycle bin self.assertNotIn(recycle_id, self.recyclebin.storage) items = self.recyclebin.get_items() - + def test_delete_restore_news(self): """Test deleting and restoring a news item""" # Get the original path - news_path = '/'.join(self.news.getPhysicalPath()) + news_path = "/".join(self.news.getPhysicalPath()) news_id = self.news.getId() news_title = self.news.Title() - + # Delete the news item by adding it to the recycle bin - recycle_id = self.recyclebin.add_item( - self.news, - self.portal, - news_path - ) - + recycle_id = self.recyclebin.add_item(self.news, self.portal, news_path) + # Verify it was added to the recycle bin self.assertIsNotNone(recycle_id) self.assertIn(recycle_id, self.recyclebin.storage) - + # Verify the news metadata was stored correctly item_data = self.recyclebin.storage[recycle_id] - self.assertEqual(item_data['id'], news_id) - self.assertEqual(item_data['title'], news_title) - self.assertEqual(item_data['type'], 'News Item') - self.assertEqual(item_data['path'], news_path) - self.assertIsInstance(item_data['deletion_date'], datetime) - + self.assertEqual(item_data["id"], news_id) + self.assertEqual(item_data["title"], news_title) + self.assertEqual(item_data["type"], "News Item") + self.assertEqual(item_data["path"], news_path) + self.assertIsInstance(item_data["deletion_date"], datetime) + # Remove the original news item from the portal to simulate deletion del self.portal[news_id] self.assertNotIn(news_id, self.portal) - + # Restore the news item restored_news = self.recyclebin.restore_item(recycle_id) - + # Verify the news item was restored self.assertIsNotNone(restored_news) self.assertEqual(restored_news.getId(), news_id) self.assertEqual(restored_news.Title(), news_title) - + # Verify the news item is back in the portal self.assertIn(news_id, self.portal) - + # Verify the item was removed from the recycle bin self.assertNotIn(recycle_id, self.recyclebin.storage) - + def test_purge_item(self): """Test purging an item from the recycle bin""" # Delete the page - page_path = '/'.join(self.page.getPhysicalPath()) - recycle_id = self.recyclebin.add_item( - self.page, - self.portal, - page_path - ) - + page_path = "/".join(self.page.getPhysicalPath()) + recycle_id = self.recyclebin.add_item(self.page, self.portal, page_path) + # Verify it was added to the recycle bin self.assertIn(recycle_id, self.recyclebin.storage) - + # Purge the item result = self.recyclebin.purge_item(recycle_id) - + # Verify the item was purged self.assertTrue(result) self.assertNotIn(recycle_id, self.recyclebin.storage) - + # Verify the item is not in the listing items = self.recyclebin.get_items() self.assertEqual(len(items), 0) @@ -235,304 +219,298 @@ def test_purge_item(self): class RecycleBinFolderTests(RecycleBinTestCase): """Tests for deleting and restoring folder structures""" - + def setUp(self): """Set up test content""" super().setUp() - + # Create a folder - self.portal.invokeFactory('Folder', 'test-folder', title='Test Folder') - self.folder = self.portal['test-folder'] - + self.portal.invokeFactory("Folder", "test-folder", title="Test Folder") + self.folder = self.portal["test-folder"] + # Add content to the folder - self.folder.invokeFactory('Document', 'folder-page', title='Folder Page') - self.folder.invokeFactory('News Item', 'folder-news', title='Folder News') - + self.folder.invokeFactory("Document", "folder-page", title="Folder Page") + self.folder.invokeFactory("News Item", "folder-news", title="Folder News") + def test_delete_restore_folder(self): """Test deleting and restoring a folder with content""" # Get the original path - folder_path = '/'.join(self.folder.getPhysicalPath()) + folder_path = "/".join(self.folder.getPhysicalPath()) folder_id = self.folder.getId() folder_title = self.folder.Title() - + # Delete the folder by adding it to the recycle bin - recycle_id = self.recyclebin.add_item( - self.folder, - self.portal, - folder_path - ) - + recycle_id = self.recyclebin.add_item(self.folder, self.portal, folder_path) + # Verify it was added to the recycle bin self.assertIsNotNone(recycle_id) self.assertIn(recycle_id, self.recyclebin.storage) - + # Verify the folder metadata was stored correctly item_data = self.recyclebin.storage[recycle_id] - self.assertEqual(item_data['id'], folder_id) - self.assertEqual(item_data['title'], folder_title) - self.assertEqual(item_data['type'], 'Folder') - self.assertEqual(item_data['path'], folder_path) - self.assertIsInstance(item_data['deletion_date'], datetime) - + self.assertEqual(item_data["id"], folder_id) + self.assertEqual(item_data["title"], folder_title) + self.assertEqual(item_data["type"], "Folder") + self.assertEqual(item_data["path"], folder_path) + self.assertIsInstance(item_data["deletion_date"], datetime) + # Verify the children were tracked - self.assertIn('children', item_data) - self.assertEqual(item_data['children_count'], 2) - self.assertIn('folder-page', item_data['children']) - self.assertIn('folder-news', item_data['children']) - + self.assertIn("children", item_data) + self.assertEqual(item_data["children_count"], 2) + self.assertIn("folder-page", item_data["children"]) + self.assertIn("folder-news", item_data["children"]) + # Remove the original folder from the portal to simulate deletion del self.portal[folder_id] self.assertNotIn(folder_id, self.portal) - + # Restore the folder restored_folder = self.recyclebin.restore_item(recycle_id) - + # Verify the folder was restored self.assertIsNotNone(restored_folder) self.assertEqual(restored_folder.getId(), folder_id) self.assertEqual(restored_folder.Title(), folder_title) - + # Verify the folder is back in the portal self.assertIn(folder_id, self.portal) - + # Verify the contents were restored - self.assertIn('folder-page', restored_folder) - self.assertIn('folder-news', restored_folder) - self.assertEqual(restored_folder['folder-page'].Title(), 'Folder Page') - self.assertEqual(restored_folder['folder-news'].Title(), 'Folder News') - + self.assertIn("folder-page", restored_folder) + self.assertIn("folder-news", restored_folder) + self.assertEqual(restored_folder["folder-page"].Title(), "Folder Page") + self.assertEqual(restored_folder["folder-news"].Title(), "Folder News") + # Verify the item was removed from the recycle bin self.assertNotIn(recycle_id, self.recyclebin.storage) class RecycleBinNestedFolderTests(RecycleBinTestCase): """Tests for deleting and restoring nested folder structures""" - + def setUp(self): """Set up test content""" super().setUp() - + # Create a parent folder - self.portal.invokeFactory('Folder', 'parent-folder', title='Parent Folder') - self.parent_folder = self.portal['parent-folder'] - + self.portal.invokeFactory("Folder", "parent-folder", title="Parent Folder") + self.parent_folder = self.portal["parent-folder"] + # Create a nested folder - self.parent_folder.invokeFactory('Folder', 'child-folder', title='Child Folder') - self.child_folder = self.parent_folder['child-folder'] - + self.parent_folder.invokeFactory("Folder", "child-folder", title="Child Folder") + self.child_folder = self.parent_folder["child-folder"] + # Add content to the nested folder - self.child_folder.invokeFactory('Document', 'nested-page', title='Nested Page') - self.child_folder.invokeFactory('News Item', 'nested-news', title='Nested News') - + self.child_folder.invokeFactory("Document", "nested-page", title="Nested Page") + self.child_folder.invokeFactory("News Item", "nested-news", title="Nested News") + # Create another level of nesting - self.child_folder.invokeFactory('Folder', 'grandchild-folder', - title='Grandchild Folder') - self.grandchild_folder = self.child_folder['grandchild-folder'] - + self.child_folder.invokeFactory( + "Folder", "grandchild-folder", title="Grandchild Folder" + ) + self.grandchild_folder = self.child_folder["grandchild-folder"] + # Add content to the grandchild folder - self.grandchild_folder.invokeFactory('Document', 'deep-page', - title='Deep Page') - + self.grandchild_folder.invokeFactory("Document", "deep-page", title="Deep Page") + def test_delete_restore_nested_folder(self): """Test deleting and restoring a nested folder structure""" # Get the original paths - parent_path = '/'.join(self.parent_folder.getPhysicalPath()) + parent_path = "/".join(self.parent_folder.getPhysicalPath()) parent_id = self.parent_folder.getId() - + # Delete the parent folder by adding it to the recycle bin recycle_id = self.recyclebin.add_item( - self.parent_folder, - self.portal, - parent_path + self.parent_folder, self.portal, parent_path ) - + # Verify it was added to the recycle bin self.assertIsNotNone(recycle_id) self.assertIn(recycle_id, self.recyclebin.storage) - + # Verify the parent folder metadata was stored correctly item_data = self.recyclebin.storage[recycle_id] - self.assertEqual(item_data['id'], parent_id) - self.assertEqual(item_data['type'], 'Folder') - + self.assertEqual(item_data["id"], parent_id) + self.assertEqual(item_data["type"], "Folder") + # Verify the children were tracked - self.assertIn('children', item_data) - self.assertEqual(item_data['children_count'], 1) - self.assertIn('child-folder', item_data['children']) - + self.assertIn("children", item_data) + self.assertEqual(item_data["children_count"], 1) + self.assertIn("child-folder", item_data["children"]) + # Verify the nested children were tracked - child_data = item_data['children']['child-folder'] - self.assertIn('children', child_data) - self.assertEqual(child_data['children_count'], 3) - self.assertIn('nested-page', child_data['children']) - self.assertIn('nested-news', child_data['children']) - self.assertIn('grandchild-folder', child_data['children']) - + child_data = item_data["children"]["child-folder"] + self.assertIn("children", child_data) + self.assertEqual(child_data["children_count"], 3) + self.assertIn("nested-page", child_data["children"]) + self.assertIn("nested-news", child_data["children"]) + self.assertIn("grandchild-folder", child_data["children"]) + # Verify the deepest level was tracked - grandchild_data = child_data['children']['grandchild-folder'] - self.assertIn('children', grandchild_data) - self.assertEqual(grandchild_data['children_count'], 1) - self.assertIn('deep-page', grandchild_data['children']) - + grandchild_data = child_data["children"]["grandchild-folder"] + self.assertIn("children", grandchild_data) + self.assertEqual(grandchild_data["children_count"], 1) + self.assertIn("deep-page", grandchild_data["children"]) + # Remove the parent folder from the portal to simulate deletion del self.portal[parent_id] self.assertNotIn(parent_id, self.portal) - + # Restore the parent folder restored_folder = self.recyclebin.restore_item(recycle_id) - + # Verify the parent folder was restored self.assertIsNotNone(restored_folder) self.assertEqual(restored_folder.getId(), parent_id) self.assertIn(parent_id, self.portal) - + # Verify the child folder was restored - self.assertIn('child-folder', restored_folder) - restored_child = restored_folder['child-folder'] - + self.assertIn("child-folder", restored_folder) + restored_child = restored_folder["child-folder"] + # Verify the nested content was restored - self.assertIn('nested-page', restored_child) - self.assertIn('nested-news', restored_child) - self.assertIn('grandchild-folder', restored_child) - + self.assertIn("nested-page", restored_child) + self.assertIn("nested-news", restored_child) + self.assertIn("grandchild-folder", restored_child) + # Verify the deepest level was restored - restored_grandchild = restored_child['grandchild-folder'] - self.assertIn('deep-page', restored_grandchild) - + restored_grandchild = restored_child["grandchild-folder"] + self.assertIn("deep-page", restored_grandchild) + # Verify the item was removed from the recycle bin self.assertNotIn(recycle_id, self.recyclebin.storage) - + def test_delete_restore_middle_folder(self): """Test deleting and restoring a middle-level folder""" # Get the original paths - child_path = '/'.join(self.child_folder.getPhysicalPath()) + child_path = "/".join(self.child_folder.getPhysicalPath()) child_id = self.child_folder.getId() - + # Delete the child folder by adding it to the recycle bin recycle_id = self.recyclebin.add_item( - self.child_folder, - self.parent_folder, - child_path + self.child_folder, self.parent_folder, child_path ) - + # Verify it was added to the recycle bin self.assertIsNotNone(recycle_id) self.assertIn(recycle_id, self.recyclebin.storage) - + # Verify the child folder metadata was stored correctly item_data = self.recyclebin.storage[recycle_id] - self.assertEqual(item_data['id'], child_id) - self.assertEqual(item_data['type'], 'Folder') - + self.assertEqual(item_data["id"], child_id) + self.assertEqual(item_data["type"], "Folder") + # Verify the nested children were tracked - self.assertIn('children', item_data) - self.assertEqual(item_data['children_count'], 3) - + self.assertIn("children", item_data) + self.assertEqual(item_data["children_count"], 3) + # Remove the child folder from the parent folder to simulate deletion del self.parent_folder[child_id] self.assertNotIn(child_id, self.parent_folder) - + # Restore the child folder restored_folder = self.recyclebin.restore_item(recycle_id) - + # Verify the child folder was restored self.assertIsNotNone(restored_folder) self.assertEqual(restored_folder.getId(), child_id) self.assertIn(child_id, self.parent_folder) - + # Verify the nested content was restored - self.assertIn('nested-page', restored_folder) - self.assertIn('nested-news', restored_folder) - self.assertIn('grandchild-folder', restored_folder) - + self.assertIn("nested-page", restored_folder) + self.assertIn("nested-news", restored_folder) + self.assertIn("grandchild-folder", restored_folder) + # Verify the deepest level was restored - restored_grandchild = restored_folder['grandchild-folder'] - self.assertIn('deep-page', restored_grandchild) - + restored_grandchild = restored_folder["grandchild-folder"] + self.assertIn("deep-page", restored_grandchild) + # Verify the item was removed from the recycle bin self.assertNotIn(recycle_id, self.recyclebin.storage) class RecycleBinExpirationTests(RecycleBinTestCase): """Tests for recyclebin expiration and size limit functionality""" - + def test_purge_expired_items(self): """Test purging expired items based on retention period""" # Create a page - self.portal.invokeFactory('Document', 'expired-page', title='Expired Page') - page = self.portal['expired-page'] - page_path = '/'.join(page.getPhysicalPath()) - + self.portal.invokeFactory("Document", "expired-page", title="Expired Page") + page = self.portal["expired-page"] + page_path = "/".join(page.getPhysicalPath()) + # Add it to the recycle bin recycle_id = self.recyclebin.add_item(page, self.portal, page_path) - + # Verify it was added self.assertIn(recycle_id, self.recyclebin.storage) - + # Mock the deletion date to be older than the retention period with mock.patch.dict( self.recyclebin.storage[recycle_id], - {"deletion_date": datetime.now() - timedelta(days=31)} + {"deletion_date": datetime.now() - timedelta(days=31)}, ): # Call purge_expired_items purged_count = self.recyclebin.purge_expired_items() - + # Verify the item was purged self.assertEqual(purged_count, 1) self.assertNotIn(recycle_id, self.recyclebin.storage) - - + + class RecycleBinRestoreEdgeCaseTests(RecycleBinTestCase): """Tests for edge cases when restoring items""" - + def test_restore_with_parent_gone(self): """Test restoring an item when its parent container is gone""" # Create a folder and a document inside it - self.portal.invokeFactory('Folder', 'temp-folder', title='Temporary Folder') - folder = self.portal['temp-folder'] - folder.invokeFactory('Document', 'orphan-page', title='Orphan Page') - page = folder['orphan-page'] - page_path = '/'.join(page.getPhysicalPath()) - + self.portal.invokeFactory("Folder", "temp-folder", title="Temporary Folder") + folder = self.portal["temp-folder"] + folder.invokeFactory("Document", "orphan-page", title="Orphan Page") + page = folder["orphan-page"] + page_path = "/".join(page.getPhysicalPath()) + # Add the page to the recycle bin recycle_id = self.recyclebin.add_item(page, folder, page_path) - + # Delete the folder to simulate parent container being gone - del self.portal['temp-folder'] - + del self.portal["temp-folder"] + # Trying to restore without a target container should raise an error with self.assertRaises(ValueError): self.recyclebin.restore_item(recycle_id) - + # Now restore with an explicit target container - restored_page = self.recyclebin.restore_item(recycle_id, target_container=self.portal) - + restored_page = self.recyclebin.restore_item( + recycle_id, target_container=self.portal + ) + # Verify the page was restored to the portal self.assertIsNotNone(restored_page) - self.assertEqual(restored_page.getId(), 'orphan-page') - self.assertIn('orphan-page', self.portal) - + self.assertEqual(restored_page.getId(), "orphan-page") + self.assertIn("orphan-page", self.portal) + def test_restore_with_name_conflict(self): """Test restoring an item when an item with same id already exists""" # Create a page - self.portal.invokeFactory('Document', 'conflict-page2', - title='Original Page') - page = self.portal['conflict-page2'] - page_path = '/'.join(page.getPhysicalPath()) + self.portal.invokeFactory("Document", "conflict-page2", title="Original Page") + page = self.portal["conflict-page2"] + page_path = "/".join(page.getPhysicalPath()) page_id = page.getId() - + # Add it to the recycle bin recycle_id = self.recyclebin.add_item(page, self.portal, page_path) - + # Remove the original page from the portal to simulate deletion del self.portal[page_id] self.assertNotIn(page_id, self.portal) - + # Create another page with the same ID - self.portal.invokeFactory('Document', 'conflict-page2', - title='Replacement Page') - + self.portal.invokeFactory( + "Document", "conflict-page2", title="Replacement Page" + ) + # Since the ID already exists, it should raise an error with self.assertRaises(ValueError): # Restore the item - self.recyclebin.restore_item(recycle_id) \ No newline at end of file + self.recyclebin.restore_item(recycle_id) diff --git a/src/Products/CMFPlone/utils.py b/src/Products/CMFPlone/utils.py index dbb9d86310..a6136c11fd 100644 --- a/src/Products/CMFPlone/utils.py +++ b/src/Products/CMFPlone/utils.py @@ -715,20 +715,21 @@ def _check_for_collision(contained_by, id, **kwargs): try: from Products.CMFPlone.interfaces.recyclebin import IRecycleBin from zope.component import queryUtility + import logging - + logger = logging.getLogger("Products.CMFPlone.utils") recycle_bin = queryUtility(IRecycleBin) if recycle_bin is not None and recycle_bin.is_enabled(): # Get all items in the recycle bin recycled_items = recycle_bin.get_items() - + # Get the current container path container_path = "/".join(contained_by.getPhysicalPath()) - + # Check if any recycled item with this ID existed in the same container for item in recycled_items: - if item.get('id') == id and item.get('parent_path') == container_path: + if item.get("id") == id and item.get("parent_path") == container_path: # Instead of automatically restoring or simply warning, we provide a clear # error message that indicates the ID conflict with recycled content return _( From 77eb393ce644ee5fc85b37928a7689f3c115055b Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sat, 3 May 2025 10:34:28 +0530 Subject: [PATCH 032/122] feat(recyclebin): add workflow history tracking for deletion and restoration actions --- src/Products/CMFPlone/recyclebin.py | 63 ++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index 292256932f..ef3ace608a 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -1,9 +1,12 @@ from BTrees.OOBTree import OOBTree from BTrees.OOBTree import OOTreeSet +from AccessControl import getSecurityManager from datetime import datetime from datetime import timedelta +from DateTime import DateTime from persistent import Persistent from plone.registry.interfaces import IRegistry +from Products.CMFCore.utils import getToolByName from Products.CMFPlone.controlpanel.browser.recyclebin import ( IRecycleBinControlPanelSettings, ) @@ -174,6 +177,9 @@ def add_item(self, obj, original_container, original_path, item_type=None): # Generate a unique ID for the recycled item item_id = str(uuid.uuid4()) + # Add a workflow history entry about the deletion if possible + self._update_workflow_history(obj, 'deletion') + # Generate a meaningful title item_title = "Unknown" @@ -370,6 +376,10 @@ def restore_item(self, item_id, target_container=None): # Add object to the target container target_container[obj_id] = obj + # Add a workflow history entry about the restoration + restored_obj = target_container[obj_id] + self._update_workflow_history(restored_obj, 'restoration', item_data) + # Remove from recycle bin del self.storage[item_id] @@ -409,9 +419,60 @@ def cleanup_children(children_dict): # Start the recursive cleanup cleanup_children(item_data["children"]) - restored_obj = target_container[obj_id] return restored_obj + def _update_workflow_history(self, obj, action_type, item_data=None): + """Add a workflow history entry about deletion or restoration + + Args: + obj: The content object + action_type: Either 'deletion' or 'restoration' + item_data: The recyclebin storage data (needed for restoration to show deletion date) + """ + if not hasattr(obj, 'workflow_history'): + return + + workflow_tool = getToolByName(self._get_context(), 'portal_workflow') + chains = workflow_tool.getChainFor(obj) + + if not chains: + return + + workflow_id = chains[0] + history = obj.workflow_history.get(workflow_id, ()) + + if not history: + return + + history = list(history) + current_state = history[-1].get('review_state', None) if history else None + user_id = getSecurityManager().getUser().getId() or 'System' + + if action_type == 'deletion': + # Add entry for deletion + entry = { + 'action': 'Moved to recycle bin', + 'actor': user_id, + 'comments': 'Item was deleted and moved to recycle bin', + 'time': DateTime(), + 'review_state': current_state, + } + elif action_type == 'restoration': + entry = { + 'action': 'Restored from recycle bin', + 'actor': user_id, + 'comments': 'Restored from recycle bin after deletion', + 'time': DateTime(), + 'review_state': current_state, + } + else: + logger.warning(f"Unknown action_type: {action_type}") + return + + # Add the entry and update the history + history.append(entry) + obj.workflow_history[workflow_id] = tuple(history) + def _restore_comment(self, item_id, item_data, target_container=None): """Enhanced restoration method for comments that preserves reply relationships""" obj = item_data["object"] From 2365775f787497123d8b9b70a84783b3fdd44bde Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sat, 3 May 2025 12:57:56 +0530 Subject: [PATCH 033/122] refactor(recyclebin): cleanup --- src/Products/CMFPlone/browser/recyclebin.py | 157 ++- .../CMFPlone/browser/templates/recyclebin.pt | 132 ++- .../browser/templates/recyclebin_item.pt | 174 ++- src/Products/CMFPlone/recyclebin.py | 1016 +++++++++-------- src/Products/CMFPlone/utils.py | 31 - 5 files changed, 761 insertions(+), 749 deletions(-) diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index 116182b8e6..ef65fcb6c8 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -20,6 +20,25 @@ logger = logging.getLogger(__name__) +# Utility functions to avoid code duplication +def format_date(context, date): + """Format date for display""" + if date is None: + return "" + portal = getToolByName(context, "portal_url").getPortalObject() + return portal.restrictedTraverse("@@plone").toLocalizedTime(date, long_format=True) + + +def format_size(size_bytes): + """Format size in bytes to human-readable format""" + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + else: + return f"{size_bytes / (1024 * 1024):.1f} MB" + + class IRecycleBinForm(Interface): """Schema for the Recycle Bin form""" @@ -139,11 +158,9 @@ def get_items(self): child_items_to_exclude.append(child_id) logger.debug(f"Child items to exclude: {child_items_to_exclude}") - print(f"Child items to exclude: {child_items_to_exclude}") # Only include items that are not children of other recycled items items = [item for item in items if item.get("id") not in child_items_to_exclude] - print(f"Filtered items: {items}") # For comments, add extra information about the content they belong to for item in items: @@ -170,22 +187,11 @@ def get_items(self): def format_date(self, date): """Format date for display""" - if date is None: - return "" - portal = getToolByName(self.context, "portal_url").getPortalObject() - # Use long_format=True to include hours, minutes and seconds - return portal.restrictedTraverse("@@plone").toLocalizedTime( - date, long_format=True - ) + return format_date(self.context, date) def format_size(self, size_bytes): """Format size in bytes to human-readable format""" - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024 * 1024: - return f"{size_bytes / 1024:.1f} KB" - else: - return f"{size_bytes / (1024 * 1024):.1f} MB" + return format_size(size_bytes) class IRecycleBinItemForm(Interface): @@ -298,57 +304,7 @@ def __call__(self): # Handle restoration of children if "restore.child" in self.request.form: - child_id = self.request.form.get("child_id") - target_path = self.request.form.get("target_path") - - if child_id and target_path: - try: - # Get item data - recycle_bin = getUtility(IRecycleBin) - item_data = recycle_bin.get_item(self.item_id) - - if item_data and "children" in item_data: - child_data = item_data["children"].get(child_id) - if child_data: - # Try to get target container - try: - target_container = self.context.unrestrictedTraverse( - target_path - ) - - # Create a temporary storage entry for the child - temp_id = str(uuid.uuid4()) - recycle_bin.storage[temp_id] = child_data - - # Restore the child - restored_obj = recycle_bin.restore_item( - temp_id, target_container - ) - - if restored_obj: - # Remove child from parent's children dict - del item_data["children"][child_id] - item_data["children_count"] = len( - item_data["children"] - ) - - message = f"Child item '{child_data['title']}' successfully restored." - IStatusMessage(self.request).addStatusMessage( - message, type="info" - ) - self.request.response.redirect( - restored_obj.absolute_url() - ) - return - except (KeyError, AttributeError): - message = f"Target location not found: {target_path}" - IStatusMessage(self.request).addStatusMessage( - message, type="error" - ) - except Exception as e: - logger.error(f"Error restoring child item: {e}") - message = "Failed to restore child item." - IStatusMessage(self.request).addStatusMessage(message, type="error") + self._handle_child_restoration() # Initialize and update the form form = RecycleBinItemForm(self.context, self.request, self.item_id) @@ -367,6 +323,60 @@ def __call__(self): logger.info(f"Found item with title: {item.get('title', 'Unknown')}") return self.template() + + def _handle_child_restoration(self): + """Extract child restoration logic to separate method for clarity""" + child_id = self.request.form.get("child_id") + target_path = self.request.form.get("target_path") + + if child_id and target_path: + try: + # Get item data + recycle_bin = getUtility(IRecycleBin) + item_data = recycle_bin.get_item(self.item_id) + + if item_data and "children" in item_data: + child_data = item_data["children"].get(child_id) + if child_data: + # Try to get target container + try: + target_container = self.context.unrestrictedTraverse( + target_path + ) + + # Create a temporary storage entry for the child + temp_id = str(uuid.uuid4()) + recycle_bin.storage[temp_id] = child_data + + # Restore the child + restored_obj = recycle_bin.restore_item( + temp_id, target_container + ) + + if restored_obj: + # Remove child from parent's children dict + del item_data["children"][child_id] + item_data["children_count"] = len( + item_data["children"] + ) + + message = f"Child item '{child_data['title']}' successfully restored." + IStatusMessage(self.request).addStatusMessage( + message, type="info" + ) + self.request.response.redirect( + restored_obj.absolute_url() + ) + return + except (KeyError, AttributeError): + message = f"Target location not found: {target_path}" + IStatusMessage(self.request).addStatusMessage( + message, type="error" + ) + except Exception as e: + logger.error(f"Error restoring child item: {e}") + message = "Failed to restore child item." + IStatusMessage(self.request).addStatusMessage(message, type="error") def get_item(self): """Get the specific recycled item""" @@ -401,22 +411,11 @@ def get_children(self): def format_date(self, date): """Format date for display""" - if date is None: - return "" - portal = getToolByName(self.context, "portal_url").getPortalObject() - # Use long_format=True to include hours, minutes and seconds - return portal.restrictedTraverse("@@plone").toLocalizedTime( - date, long_format=True - ) + return format_date(self.context, date) def format_size(self, size_bytes): """Format size in bytes to human-readable format""" - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024 * 1024: - return f"{size_bytes / 1024:.1f} KB" - else: - return f"{size_bytes / (1024 * 1024):.1f} MB" + return format_size(size_bytes) class RecycleBinEnabled(BrowserView): diff --git a/src/Products/CMFPlone/browser/templates/recyclebin.pt b/src/Products/CMFPlone/browser/templates/recyclebin.pt index 52f95f8436..d632ef60a8 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin.pt @@ -18,77 +18,107 @@
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + TitleTypeOriginal PathDeletion DateSize
No items in recycle bin
+ + + Title +
+ Comment on: Content Title +
+
Content type + Original path +
+ Content: Content Path +
+
Deletion dateSize
-
- - - - - - - - - - - - - - - - - - - - - - - - -
TitleTypeOriginal PathDeletion DateSize
No items in recycle bin
- - - Title -
- Comment on: Content Title -
-
Content type - Original path -
- Content: Content Path -
-
Deletion dateSize
- -
- - - -
+
+ + +
+ + diff --git a/src/Products/CMFPlone/browser/templates/recyclebin_item.pt b/src/Products/CMFPlone/browser/templates/recyclebin_item.pt index 0832508f94..2023ba5a6d 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin_item.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin_item.pt @@ -9,6 +9,7 @@ +

Item Not Found

@@ -20,52 +21,41 @@

+

Title (Content Type)

- -
-

- Comment thread deleted from - - - Content title - - - - content that no longer exists - -

-
- - -
-

- Comment deleted from - - - Content title - - - - content that no longer exists - -

-
+ + +
+

+ Comment thread + Comment + deleted from + + + Content title + + + + content that no longer exists + +

+
+
+
Deleted item information
+ @@ -84,17 +74,20 @@ - - - - - - - - - - - + + + + + + + + + + + + + + @@ -102,53 +95,54 @@
Deletion Date Date
Comment TextComment text
Comment AuthorAuthor
Comment TextComment text
Comment AuthorAuthor
Number of Items Count
- -
-

Folder Contents

-

- These items were contained in this folder when it was deleted. - You can restore them individually to any location. -

- - - - - - - - - - - - - - - - - - - - -
TitleTypeOriginal PathSizeActions
TitleTypePathSize -
- -
- -
- -
-
-
+ + +
+

Folder Contents

+

+ These items were contained in this folder when it was deleted. + You can restore them individually to any location. +

+ + + + + + + + + + + + + + + + + + + + +
TitleTypeOriginal PathSizeActions
TitleTypePathSize +
+ +
+ +
+ +
+
+
+
+
diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index ef3ace608a..e373659503 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -42,20 +42,25 @@ def __setitem__(self, key, value): if key in self.items: # If updating an existing item, remove old index entry first old_value = self.items[key] - if "deletion_date" in old_value: - try: - # Create a sortable key (date, id) - old_key = (old_value["deletion_date"], key) - if old_key in self._sorted_index: - self._sorted_index.remove(old_key) - except (KeyError, TypeError): - # Ignore errors if the entry doesn't exist or date is not comparable - pass + self._remove_from_index(key, old_value) # Add the item to main storage self.items[key] = value # Add to sorted index if it has a deletion_date + self._add_to_index(key, value) + + def __delitem__(self, key): + # When deleting an item, also remove it from the sorted index + if key in self.items: + item = self.items[key] + self._remove_from_index(key, item) + + # Remove from main storage + del self.items[key] + + def _add_to_index(self, key, value): + """Add an item to the sorted index""" if "deletion_date" in value: try: # Store as (date, id) for automatic sorting @@ -66,21 +71,16 @@ def __setitem__(self, key, value): f"Could not index item {key} by date: {value.get('deletion_date')}" ) - def __delitem__(self, key): - # When deleting an item, also remove it from the sorted index - if key in self.items: - item = self.items[key] - if "deletion_date" in item: - try: - sort_key = (item["deletion_date"], key) - if sort_key in self._sorted_index: - self._sorted_index.remove(sort_key) - except (KeyError, TypeError): - # Ignore errors if the entry doesn't exist or date is not comparable - pass - - # Remove from main storage - del self.items[key] + def _remove_from_index(self, key, value): + """Remove an item from the sorted index""" + if "deletion_date" in value: + try: + sort_key = (value["deletion_date"], key) + if sort_key in self._sorted_index: + self._sorted_index.remove(sort_key) + except (KeyError, TypeError): + # Ignore errors if the entry doesn't exist or date is not comparable + pass def __contains__(self, key): return key in self.items @@ -111,7 +111,6 @@ def get_items_sorted_by_date(self, reverse=True): Returns: Generator yielding (item_id, item_data) tuples """ - # OOTreeSet is not reversible, so we need to handle ordering differently sorted_keys = list(self._sorted_index) # If we want newest first (reverse=True), reverse the list @@ -169,67 +168,15 @@ def is_enabled(self): except (KeyError, AttributeError): return False - def add_item(self, obj, original_container, original_path, item_type=None): - """Add deleted item to recycle bin""" - if not self.is_enabled(): - return None - - # Generate a unique ID for the recycled item - item_id = str(uuid.uuid4()) - - # Add a workflow history entry about the deletion if possible - self._update_workflow_history(obj, 'deletion') - - # Generate a meaningful title - item_title = "Unknown" - - # Handle folders and collections specially + def _get_item_title(self, obj, item_type=None): + """Helper method to get a meaningful title for an item""" if hasattr(obj, "objectIds") or item_type == "Collection": - # Store child objects if this is a folder or collection - children = {} - if hasattr(obj, "objectIds"): - # Process all children recursively - def process_folder(folder_obj, folder_path): - folder_children = {} - for child_id in folder_obj.objectIds(): - child = folder_obj[child_id] - child_path = f"{folder_path}/{child_id}" - # Store basic data for this child - child_data = { - "id": child_id, - "title": ( - child.Title() - if hasattr(child, "Title") - else getattr(child, "title", "Unknown") - ), - "type": getattr(child, "portal_type", "Unknown"), - "path": child_path, - "parent_path": folder_path, - "deletion_date": datetime.now(), - "size": getattr(child, "get_size", lambda: 0)(), - "object": child, - } - - # If this child is also a folder, process its children - if hasattr(child, "objectIds") and child.objectIds(): - nested_children = process_folder(child, child_path) - if nested_children: - child_data["children"] = nested_children - child_data["children_count"] = len(nested_children) - - folder_children[child_id] = child_data - return folder_children - - # Start the recursive processing from the top-level folder - children = process_folder(obj, original_path) - - # Get folder title - item_title = ( + # For folders and collections + return ( obj.Title() if hasattr(obj, "Title") else getattr(obj, "title", "Unknown") ) - elif item_type == "CommentTree": # For comment trees, generate a title including the number of comments comment_count = len(obj.get("comments", [])) @@ -254,19 +201,66 @@ def process_folder(folder_obj, folder_path): # Create a meaningful title if comment_preview: - item_title = ( - f'Comment thread: "{comment_preview}" ({comment_count} comments)' - ) + return f'Comment thread: "{comment_preview}" ({comment_count} comments)' else: - item_title = f"Comment thread ({comment_count} comments)" + return f"Comment thread ({comment_count} comments)" else: # For regular items, use Title() if available - item_title = ( + return ( obj.Title() if hasattr(obj, "Title") else getattr(obj, "title", "Unknown") ) + def _process_folder_children(self, folder_obj, folder_path): + """Helper method to process folder children recursively""" + folder_children = {} + for child_id in folder_obj.objectIds(): + child = folder_obj[child_id] + child_path = f"{folder_path}/{child_id}" + # Store basic data for this child + child_data = { + "id": child_id, + "title": self._get_item_title(child), + "type": getattr(child, "portal_type", "Unknown"), + "path": child_path, + "parent_path": folder_path, + "deletion_date": datetime.now(), + "size": getattr(child, "get_size", lambda: 0)(), + "object": child, + } + + # If this child is also a folder, process its children + if hasattr(child, "objectIds") and child.objectIds(): + nested_children = self._process_folder_children(child, child_path) + if nested_children: + child_data["children"] = nested_children + child_data["children_count"] = len(nested_children) + + folder_children[child_id] = child_data + return folder_children + + def add_item(self, obj, original_container, original_path, item_type=None): + """Add deleted item to recycle bin""" + if not self.is_enabled(): + return None + + # Get the original id but if not found then generate a unique ID for the recycled item + item_id = obj.getId() if hasattr(obj, "getId") else getattr(obj, "id", str(uuid.uuid4())) + + # Add a workflow history entry about the deletion if possible + self._update_workflow_history(obj, 'deletion') + + # Generate a meaningful title + item_title = self._get_item_title(obj, item_type) + + # Handle folders and collections specially + children = {} + if hasattr(obj, "objectIds") or item_type == "Collection": + if hasattr(obj, "objectIds"): + # Process all children recursively + children = self._process_folder_children(obj, original_path) + # Store metadata about the deletion parent_path = ( "/".join(original_container.getPhysicalPath()) @@ -275,9 +269,7 @@ def process_folder(folder_obj, folder_path): ) storage_data = { - "id": ( - obj.getId() if hasattr(obj, "getId") else getattr(obj, "id", "unknown") - ), + "id": item_id, "title": item_title, "type": item_type or getattr(obj, "portal_type", "Unknown"), "path": original_path, @@ -288,59 +280,94 @@ def process_folder(folder_obj, folder_path): } # Add children data if this was a folder/collection - if locals().get("children"): + if children: storage_data["children"] = children storage_data["children_count"] = len(children) - self.storage[item_id] = storage_data + # Generate a unique recycle ID + recycle_id = str(uuid.uuid4()) + self.storage[recycle_id] = storage_data # Check if we need to clean up old items self._check_size_limits() + self._purge_expired_items() - return item_id + return recycle_id def get_items(self): """Return all items in recycle bin""" items = [] # Use the pre-sorted index to get items by date (newest first) for item_id, data in self.storage.get_items_sorted_by_date(reverse=True): - item_data = data.copy() - item_data["recycle_id"] = item_id - # Don't include the actual object in the listing - if "object" in item_data: - del item_data["object"] + # Only copy the essential metadata instead of the entire data dictionary + item_data = { + "recycle_id": item_id, + "id": data.get("id", ""), + "title": data.get("title", ""), + "type": data.get("type", "Unknown"), + "path": data.get("path", ""), + "parent_path": data.get("parent_path", ""), + "deletion_date": data.get("deletion_date"), + "size": data.get("size", 0), + } + + # Copy any other metadata but not the actual object + for key, value in data.items(): + if key != "object" and key not in item_data: + item_data[key] = value + items.append(item_data) - + return items def get_item(self, item_id): """Get a specific deleted item by ID""" return self.storage.get(item_id) - def restore_item(self, item_id, target_container=None): - """Restore item to original location or specified container""" - if item_id not in self.storage: - return None - - item_data = self.storage[item_id] - obj = item_data["object"] - obj_id = item_data["id"] - item_type = item_data.get("type", None) - - # Special handling for CommentTree (comments with replies) - if item_type == "CommentTree": - return self._restore_comment_tree(item_id, item_data, target_container) - - # Special handling for Discussion Item (Comments) - if item_data.get("type") == "Discussion Item": - return self._restore_comment(item_id, item_data, target_container) + def _update_workflow_history(self, obj, action_type, item_data=None): + """Add a workflow history entry about deletion or restoration + + Args: + obj: The content object + action_type: Either 'deletion' or 'restoration' + item_data: The recyclebin storage data (needed for restoration to show deletion date) + """ + if not hasattr(obj, 'workflow_history'): + return + + workflow_tool = getToolByName(self._get_context(), 'portal_workflow') + chains = workflow_tool.getChainFor(obj) + + if not chains: + return + + workflow_id = chains[0] + history = obj.workflow_history.get(workflow_id, ()) + + if not history: + return + + history = list(history) + current_state = history[-1].get('review_state', None) if history else None + user_id = getSecurityManager().getUser().getId() or 'System' + + entry = { + 'action': 'Moved to recycle bin' if action_type == 'deletion' else 'Restored from recycle bin', + 'actor': user_id, + 'comments': 'Item was deleted and moved to recycle bin' if action_type == 'deletion' else 'Restored from recycle bin after deletion', + 'time': DateTime(), + 'review_state': current_state, + } + + # Add the entry and update the history + history.append(entry) + obj.workflow_history[workflow_id] = tuple(history) - # Regular content object restoration - # Find the container to restore to + def _find_target_container(self, target_container, parent_path): + """Helper to find the target container for restoration""" site = self._get_context() if target_container is None: # Try to get the original parent - parent_path = item_data["parent_path"] try: target_container = site.unrestrictedTraverse(parent_path) except (KeyError, AttributeError): @@ -349,45 +376,11 @@ def restore_item(self, item_id, target_container=None): f"Original parent container at {parent_path} no longer exists. " "You must specify a target_container to restore this item." ) + return target_container - # Make sure we don't overwrite existing content - if obj_id in target_container: - # Instead of automatically generating a new ID, we'll check if there's an explicit - # request to restore. If this method is being called directly rather than through - # collision detection, we'll raise an exception. - if getattr(obj, "_v_restoring_from_recyclebin", False): - # We were explicitly asked to restore this item, so we'll use the original ID - # We need to delete the existing item first - logger.info( - f"Removing existing object {obj_id} to restore recycled version" - ) - target_container._delObject(obj_id) - else: - # Raise a meaningful exception instead of generating a new ID - raise ValueError( - f"Cannot restore item '{obj_id}' because an item with this ID already exists in the target location. " - f"To replace the existing item with the recycled one, use the recycle bin interface." - ) - - # Set the new ID if it was changed - if obj_id != item_data["id"]: - obj.id = obj_id - - # Add object to the target container - target_container[obj_id] = obj - - # Add a workflow history entry about the restoration - restored_obj = target_container[obj_id] - self._update_workflow_history(restored_obj, 'restoration', item_data) - - # Remove from recycle bin - del self.storage[item_id] - - # If this was a folder/collection with children tracked in the recycle bin, - # we need to remove those child references as well to prevent them from - # showing up in the RecycleBin view after the parent is restored + def _cleanup_child_references(self, item_data): + """Clean up any child items associated with a parent that was restored""" if "children" in item_data and isinstance(item_data["children"], dict): - # Clean up any child items associated with this parent logger.info( f"Cleaning up {len(item_data['children'])} child items from recyclebin" ) @@ -419,60 +412,94 @@ def cleanup_children(children_dict): # Start the recursive cleanup cleanup_children(item_data["children"]) - return restored_obj + def _handle_existing_object(self, obj_id, target_container, obj): + """Handle cases where an object with the same ID already exists in target""" + if obj_id in target_container: + # Check if explicit restoration is requested + if getattr(obj, "_v_restoring_from_recyclebin", False): + # We were explicitly asked to restore this item, so delete existing item first + logger.info( + f"Removing existing object {obj_id} to restore recycled version" + ) + target_container._delObject(obj_id) + else: + # Raise a meaningful exception instead of generating a new ID + raise ValueError( + f"Cannot restore item '{obj_id}' because an item with this ID already exists in the target location. " + f"To replace the existing item with the recycled one, use the recycle bin interface." + ) - def _update_workflow_history(self, obj, action_type, item_data=None): - """Add a workflow history entry about deletion or restoration - - Args: - obj: The content object - action_type: Either 'deletion' or 'restoration' - item_data: The recyclebin storage data (needed for restoration to show deletion date) - """ - if not hasattr(obj, 'workflow_history'): - return - - workflow_tool = getToolByName(self._get_context(), 'portal_workflow') - chains = workflow_tool.getChainFor(obj) + def restore_item(self, item_id, target_container=None): + """Restore item to original location or specified container""" + if item_id not in self.storage: + return None + + item_data = self.storage[item_id] + obj = item_data["object"] + obj_id = item_data["id"] + item_type = item_data.get("type", None) + + # Special handling for CommentTree (comments with replies) + if item_type == "CommentTree": + return self._restore_comment_tree(item_id, item_data, target_container) + + # Special handling for Discussion Item (Comments) + if item_data.get("type") == "Discussion Item": + return self._restore_comment(item_id, item_data, target_container) + + # Regular content object restoration + # Find the container to restore to + target_container = self._find_target_container(target_container, item_data["parent_path"]) - if not chains: - return - - workflow_id = chains[0] - history = obj.workflow_history.get(workflow_id, ()) + # Make sure we don't overwrite existing content + self._handle_existing_object(obj_id, target_container, obj) + + # Set the new ID if it was changed + if obj_id != item_data["id"]: + obj.id = obj_id + + # Add object to the target container + target_container[obj_id] = obj + + # Add a workflow history entry about the restoration + restored_obj = target_container[obj_id] + self._update_workflow_history(restored_obj, 'restoration', item_data) + + # Remove from recycle bin + del self.storage[item_id] + + # Clean up any child items + self._cleanup_child_references(item_data) + + return restored_obj + + def _find_parent_comment(self, comment, original_in_reply_to, conversation, id_mapping=None): + """Helper method to find parent comment during restoration""" + id_mapping = id_mapping or {} + if original_in_reply_to is None or original_in_reply_to == 0: + return False, None - if not history: - return + # First check if parent exists directly (not previously deleted) + if original_in_reply_to in conversation: + return True, original_in_reply_to - history = list(history) - current_state = history[-1].get('review_state', None) if history else None - user_id = getSecurityManager().getUser().getId() or 'System' - - if action_type == 'deletion': - # Add entry for deletion - entry = { - 'action': 'Moved to recycle bin', - 'actor': user_id, - 'comments': 'Item was deleted and moved to recycle bin', - 'time': DateTime(), - 'review_state': current_state, - } - elif action_type == 'restoration': - entry = { - 'action': 'Restored from recycle bin', - 'actor': user_id, - 'comments': 'Restored from recycle bin after deletion', - 'time': DateTime(), - 'review_state': current_state, - } - else: - logger.warning(f"Unknown action_type: {action_type}") - return + # Then check if it was restored with a different ID using mapping + if str(original_in_reply_to) in id_mapping: + # Use the ID mapping to find the new ID + new_parent_id = id_mapping[str(original_in_reply_to)] + return True, new_parent_id - # Add the entry and update the history - history.append(entry) - obj.workflow_history[workflow_id] = tuple(history) - + # Look through all comments for original_id matching our in_reply_to + for comment_id in conversation.keys(): + comment_obj = conversation[comment_id] + comment_original_id = getattr(comment_obj, "original_id", None) + if comment_original_id is not None and str(comment_original_id) == str(original_in_reply_to): + # Found the parent with a new ID + return True, comment_id + + # No parent found + return False, None + def _restore_comment(self, item_id, item_data, target_container=None): """Enhanced restoration method for comments that preserves reply relationships""" obj = item_data["object"] @@ -483,7 +510,6 @@ def _restore_comment(self, item_id, item_data, target_container=None): try: conversation = site.unrestrictedTraverse(parent_path) except (KeyError, AttributeError): - # If original conversation doesn't exist, we can't restore the comment logger.warning( f"Cannot restore comment {item_id}: conversation no longer exists at {parent_path}" ) @@ -492,80 +518,59 @@ def _restore_comment(self, item_id, item_data, target_container=None): # Restore comment back to conversation from plone.app.discussion.interfaces import IConversation - if IConversation.providedBy(conversation): - # Store the original comment ID before restoration - original_id = getattr(obj, "comment_id", None) - original_in_reply_to = getattr(obj, "in_reply_to", None) + if not IConversation.providedBy(conversation): + logger.warning( + f"Cannot restore comment {item_id}: parent is not a conversation" + ) + return None - # Track comment relationships using a simple dictionary - # We won't use annotations directly on the conversation since that causes adaptation issues - # Instead, we'll use a module-level cache - from zope.globalrequest import getRequest + # Store the original comment ID before restoration + original_id = getattr(obj, "comment_id", None) + original_in_reply_to = getattr(obj, "in_reply_to", None) - request = getRequest() - if request and not hasattr(request, "_comment_restore_mapping"): - request._comment_restore_mapping = {} + # Track comment relationships using a request-based dictionary + from zope.globalrequest import getRequest - # Initialize mapping if needed - mapping = getattr(request, "_comment_restore_mapping", {}) - conversation_path = "/".join(conversation.getPhysicalPath()) - if conversation_path not in mapping: - mapping[conversation_path] = {} + request = getRequest() + if request and not hasattr(request, "_comment_restore_mapping"): + request._comment_restore_mapping = {} - id_mapping = mapping[conversation_path] + # Initialize mapping if needed + mapping = getattr(request, "_comment_restore_mapping", {}) + conversation_path = "/".join(conversation.getPhysicalPath()) + if conversation_path not in mapping: + mapping[conversation_path] = {} - # Check if the parent comment exists in the conversation (direct or restored) - if original_in_reply_to is not None and original_in_reply_to != 0: - parent_found = False + id_mapping = mapping[conversation_path] - # First check if it exists directly (not previously deleted) - if original_in_reply_to in conversation: - parent_found = True - # Then check if it was restored with a different ID - elif str(original_in_reply_to) in id_mapping: - # Use the ID mapping to find the new ID - obj.in_reply_to = id_mapping[str(original_in_reply_to)] - parent_found = True - else: - # Look through all comments to see if any have the original_id attribute matching our in_reply_to - for comment_id in conversation.keys(): - comment = conversation[comment_id] - comment_original_id = getattr(comment, "original_id", None) - - if comment_original_id is not None and str( - comment_original_id - ) == str(original_in_reply_to): - # We found the parent with a new ID, update the reference - obj.in_reply_to = comment_id - parent_found = True - break - - # If no parent was found, make this a top-level comment - if not parent_found: - obj.in_reply_to = None - - # Store the original ID for future reference - if not hasattr(obj, "original_id"): - obj.original_id = original_id - - # When restored, add the comment to the conversation - new_id = conversation.addComment(obj) - - # Store the mapping of original ID to new ID - if original_id is not None: - id_mapping[str(original_id)] = new_id - - # Remove from recycle bin - del self.storage[item_id] + # Check if the parent comment exists in the conversation + parent_found, new_parent_id = self._find_parent_comment( + obj, original_in_reply_to, conversation, id_mapping + ) - # Return the restored comment - return conversation[new_id] + # Update the in_reply_to reference or make it a top-level comment + if parent_found: + obj.in_reply_to = new_parent_id else: - # If the parent is not a conversation, we can't restore - logger.warning( - f"Cannot restore comment {item_id}: parent is not a conversation" - ) - return None + # If no parent was found, make this a top-level comment + obj.in_reply_to = None + + # Store the original ID for future reference + if not hasattr(obj, "original_id"): + obj.original_id = original_id + + # Add the comment to the conversation + new_id = conversation.addComment(obj) + + # Store the mapping of original ID to new ID + if original_id is not None: + id_mapping[str(original_id)] = new_id + + # Remove from recycle bin + del self.storage[item_id] + + # Return the restored comment + return conversation[new_id] def _restore_comment_tree(self, item_id, item_data, target_container=None): """Restore a comment tree with all its replies while preserving relationships""" @@ -591,7 +596,6 @@ def _restore_comment_tree(self, item_id, item_data, target_container=None): try: conversation = site.unrestrictedTraverse(parent_path) except (KeyError, AttributeError): - # If original conversation doesn't exist, we can't restore the comment logger.warning( f"Cannot restore comment tree {item_id}: conversation no longer exists at {parent_path}" ) @@ -600,258 +604,274 @@ def _restore_comment_tree(self, item_id, item_data, target_container=None): # Restore comments back to conversation from plone.app.discussion.interfaces import IConversation - if IConversation.providedBy(conversation): - # First extract all comments and create a mapping of original IDs - # to comment objects for quick lookup - comment_dict = {} - id_mapping = {} # Will map original IDs to new IDs + if not IConversation.providedBy(conversation): + logger.warning( + f"Cannot restore comment tree {item_id}: parent is not a conversation" + ) + return None - # Process comments to build reference dictionary - for comment_obj, _ in comments_to_restore: - # Store original values we'll need for restoration - original_id = getattr(comment_obj, "comment_id", None) - original_in_reply_to = getattr(comment_obj, "in_reply_to", None) + # First extract all comments and create a mapping of original IDs + # to comment objects for quick lookup + comment_dict = {} + id_mapping = {} # Will map original IDs to new IDs - # Add some debug logging - logger.info( - f"Processing comment with ID: {original_id}, in_reply_to: {original_in_reply_to}" - ) + # Process comments to build reference dictionary + for comment_obj, _ in comments_to_restore: + # Store original values we'll need for restoration + original_id = getattr(comment_obj, "comment_id", None) + original_in_reply_to = getattr(comment_obj, "in_reply_to", None) - # Mark this comment with its original ID for future reference - if not hasattr(comment_obj, "original_id"): - comment_obj.original_id = original_id + logger.info( + f"Processing comment with ID: {original_id}, in_reply_to: {original_in_reply_to}" + ) - # Store in our dictionary for quick access - comment_dict[original_id] = { - "comment": comment_obj, - "in_reply_to": original_in_reply_to, - } + # Mark with original ID for future reference + if not hasattr(comment_obj, "original_id"): + comment_obj.original_id = original_id - # First, try to find the root comment - root_comment = None - if root_comment_id in comment_dict: - root_comment = comment_dict[root_comment_id]["comment"] - logger.info(f"Found root comment with ID: {root_comment_id}") - else: - # Root comment not found by explicit ID, try alternative approaches - logger.warning( - f"Root comment with ID {root_comment_id} not found in comment dictionary" - ) + # Store in dictionary for quick access + comment_dict[original_id] = { + "comment": comment_obj, + "in_reply_to": original_in_reply_to, + } - # Try to find a top-level comment or one with the lowest ID to use as root - for comment_id, comment_data in comment_dict.items(): - in_reply_to = comment_data["in_reply_to"] - if in_reply_to == 0 or in_reply_to is None: - # Found a top-level comment, use it as root - root_comment = comment_data["comment"] - root_comment_id = comment_id - logger.info( - f"Using top-level comment with ID {comment_id} as root" - ) - break - - # If still no root, use the first comment in the dictionary - if not root_comment and comment_dict: - first_key = list(comment_dict.keys())[0] - root_comment = comment_dict[first_key]["comment"] - root_comment_id = first_key - logger.info( - f"Using first available comment with ID {first_key} as root" - ) + # Find the root comment + root_comment = None + if root_comment_id in comment_dict: + root_comment = comment_dict[root_comment_id]["comment"] + else: + # Try to find a top-level comment to use as root + for comment_id, comment_data in comment_dict.items(): + in_reply_to = comment_data["in_reply_to"] + if in_reply_to == 0 or in_reply_to is None: + # Found a top-level comment, use as root + root_comment = comment_data["comment"] + root_comment_id = comment_id + break - if not root_comment: - logger.error( - f"Cannot restore comment tree {item_id}: no valid root comment could be determined" - ) - return None - - # If this is a reply to another comment, check if that comment exists - original_in_reply_to = getattr(root_comment, "in_reply_to", None) - if original_in_reply_to is not None and original_in_reply_to != 0: - # Check if parent exists in conversation or needs to be handled specially - if original_in_reply_to not in conversation: - # Look through all comments to see if any were previously this comment's parent - parent_found = False - for comment_id in conversation.keys(): - comment = conversation[comment_id] - # Check if this comment was previously the parent (by original ID) - original_id = getattr(comment, "original_id", None) - if original_id == original_in_reply_to: - # We found the parent with a new ID, update the reference - root_comment.in_reply_to = comment_id - parent_found = True - logger.info( - f"Found existing parent for root comment: {comment_id}" - ) - break - - # If no parent was found, make this a top-level comment - if not parent_found: - logger.info( - "No parent found for root comment, making it a top-level comment" - ) - root_comment.in_reply_to = None - - # Add the root comment to the conversation - new_root_id = conversation.addComment(root_comment) - id_mapping[root_comment_id] = new_root_id - logger.info( - f"Added root comment to conversation with new ID: {new_root_id}" + # If still no root, use the first comment + if not root_comment and comment_dict: + first_key = list(comment_dict.keys())[0] + root_comment = comment_dict[first_key]["comment"] + root_comment_id = first_key + + if not root_comment: + logger.error( + f"Cannot restore comment tree {item_id}: no valid root comment could be determined" ) + return None - # Now restore all child comments in order, updating their in_reply_to references - # Skip the root comment which we've already restored - remaining_comments = { - k: v for k, v in comment_dict.items() if k != root_comment_id - } + # Check if the parent comment exists + original_in_reply_to = getattr(root_comment, "in_reply_to", None) + parent_found, new_parent_id = self._find_parent_comment( + root_comment, original_in_reply_to, conversation + ) + + if parent_found: + root_comment.in_reply_to = new_parent_id + else: + root_comment.in_reply_to = None - # Keep track of successfully restored comments - restored_count = 1 # Start with 1 for the root comment + # Add the root comment to the conversation + new_root_id = conversation.addComment(root_comment) + id_mapping[root_comment_id] = new_root_id - # Keep trying to restore comments until we can't restore any more - # We need multiple passes because comments might depend on other comments - # that haven't been restored yet - max_passes = 10 # Limit the number of passes to avoid infinite loops - current_pass = 0 + # Now restore all child comments, skipping the root comment + remaining_comments = { + k: v for k, v in comment_dict.items() if k != root_comment_id + } - while remaining_comments and current_pass < max_passes: - current_pass += 1 - logger.info( - f"Pass {current_pass}: {len(remaining_comments)} comments remaining to restore" - ) - restored_in_pass = 0 + # Track successfully restored comments + restored_count = 1 # Start with 1 for root - # Copy keys to avoid modifying dict during iteration - for comment_id in list(remaining_comments.keys()): - comment_data = remaining_comments[comment_id] - comment = comment_data["comment"] - in_reply_to = comment_data["in_reply_to"] + # Keep trying to restore comments until no more can be restored + max_passes = 10 # Limit passes to avoid infinite loops + current_pass = 0 - # Check if the parent comment has been restored - if in_reply_to in id_mapping: - # Update reference to the new parent ID - comment.in_reply_to = id_mapping[in_reply_to] + while remaining_comments and current_pass < max_passes: + current_pass += 1 + restored_in_pass = 0 - # Add to conversation - new_id = conversation.addComment(comment) - id_mapping[comment_id] = new_id + # Copy keys to avoid modifying dict during iteration + comment_ids = list(remaining_comments.keys()) - # Remove from remaining comments - del remaining_comments[comment_id] - restored_in_pass += 1 - logger.info( - f"Restored comment {comment_id} with new ID {new_id}, parent {in_reply_to} -> {id_mapping[in_reply_to]}" - ) - - # If we couldn't restore any comments in this pass, we have an issue - if restored_in_pass == 0 and remaining_comments: - logger.warning( - f"Pass {current_pass}: No comments could be restored. " - f"{len(remaining_comments)} comments remaining." - ) - # Try one more approach - see if any remaining comments have parents - # that don't exist in our mapping but do exist in the conversation - for comment_id, comment_data in list(remaining_comments.items()): - comment = comment_data["comment"] - in_reply_to = comment_data["in_reply_to"] - - # Check if the parent exists directly in the conversation - if in_reply_to and in_reply_to in conversation: - comment.in_reply_to = ( - in_reply_to # Keep the original reference - ) - new_id = conversation.addComment(comment) - id_mapping[comment_id] = new_id - del remaining_comments[comment_id] - restored_in_pass += 1 - logger.info( - f"Found parent directly in conversation for {comment_id} -> {new_id}" - ) + for comment_id in comment_ids: + comment_data = remaining_comments[comment_id] + comment_obj = comment_data["comment"] + original_in_reply_to = comment_data["in_reply_to"] - # If still no progress, make them top-level - if restored_in_pass == 0: - # Just restore remaining comments as top-level comments - logger.warning( - f"Some comments in tree {item_id} couldn't be restored with proper relationships. " - "Restoring them as top-level comments." - ) - for comment_id, comment_data in remaining_comments.items(): - comment = comment_data["comment"] - comment.in_reply_to = None - new_id = conversation.addComment(comment) - id_mapping[comment_id] = new_id - logger.info( - f"Restored comment {comment_id} as top-level comment with new ID {new_id}" - ) - break + # Try to find the parent in our mapping + parent_found = False + new_parent_id = None - restored_count += restored_in_pass + # If original parent was the root comment + if str(original_in_reply_to) == str(root_comment_id): + parent_found = True + new_parent_id = new_root_id + # Or if it was another already restored comment + elif str(original_in_reply_to) in id_mapping: + parent_found = True + new_parent_id = id_mapping[str(original_in_reply_to)] + # Or try to find it directly in the conversation + else: + parent_found, new_parent_id = self._find_parent_comment( + comment_obj, original_in_reply_to, conversation, id_mapping + ) - # Remove from recycle bin - del self.storage[item_id] + if parent_found: + # We found the parent, update reference and restore + comment_obj.in_reply_to = new_parent_id + + # Store original ID for future reference + if not hasattr(comment_obj, "original_id"): + comment_obj.original_id = comment_id + + # Add to conversation + try: + new_id = conversation.addComment(comment_obj) + id_mapping[comment_id] = new_id + del remaining_comments[comment_id] + restored_in_pass += 1 + except Exception as e: + logger.error(f"Error restoring comment {comment_id}: {e}") + + # If we didn't restore any comments in this pass and still have comments left, + # something is wrong with the parent references + if restored_in_pass == 0 and remaining_comments: + # Make any remaining comments top-level comments + for comment_id, comment_data in list(remaining_comments.items()): + try: + comment_obj = comment_data["comment"] + comment_obj.in_reply_to = None # Make it a top-level comment + + # Store original ID for future reference + if not hasattr(comment_obj, "original_id"): + comment_obj.original_id = comment_id + + new_id = conversation.addComment(comment_obj) + id_mapping[comment_id] = new_id + del remaining_comments[comment_id] + restored_in_pass += 1 + except Exception as e: + logger.error(f"Error forcing comment {comment_id} as top-level: {e}") + + # Break out of the loop since we've tried our best + break - # Return the root comment - logger.info(f"Restored comment tree with {restored_count} comments.") - return conversation[new_root_id] - else: - # If the parent is not a conversation, we can't restore - logger.warning( - f"Cannot restore comment tree {item_id}: parent is not a conversation" - ) - return None + restored_count += restored_in_pass + + # If all comments were restored, exit the loop + if not remaining_comments: + break + + # Clean up and return + del self.storage[item_id] + logger.info(f"Restored {restored_count} comments from comment tree {item_id}") + + # Return the root comment as the result + return conversation.get(new_root_id) if new_root_id in conversation else None def purge_item(self, item_id): - """Permanently delete an item""" + """Permanently delete an item from the recycle bin + + Args: + item_id: The ID of the item in the recycle bin + + Returns: + Boolean indicating success + """ if item_id not in self.storage: + logger.warning(f"Cannot purge item {item_id}: not found in recycle bin") + return False + + try: + # Purge any nested children first if this is a folder + item_data = self.storage[item_id] + if "children" in item_data and isinstance(item_data["children"], dict): + def purge_children(children_dict): + for child_id, child_data in list(children_dict.items()): + # If this child has children, recursively purge them first + if "children" in child_data and isinstance(child_data["children"], dict): + purge_children(child_data["children"]) + # Start the recursive purge of children + purge_children(item_data["children"]) + + # Simply remove from storage - the object will be garbage collected + del self.storage[item_id] + logger.info(f"Item {item_id} purged from recycle bin") + return True + except Exception as e: + logger.error(f"Error purging item {item_id}: {str(e)}") return False - # Simply remove from storage - the object will be garbage collected - del self.storage[item_id] - return True - - def purge_expired_items(self): - """Purge items that exceed the retention period""" - settings = self._get_settings() - retention_days = settings.retention_period - - # If retention_period is 0, auto-purging is disabled - if retention_days <= 0: + def _purge_expired_items(self): + """Purge items that exceed the retention period + + Returns: + Number of items purged + """ + try: + settings = self._get_settings() + retention_days = settings.retention_period + + # If retention_period is 0, auto-purging is disabled + if retention_days <= 0: + logger.debug("Auto-purging is disabled (retention_period = 0)") + return 0 + + cutoff_date = datetime.now() - timedelta(days=retention_days) + purge_count = 0 + + # Use the sorted index for efficient date-based queries + # Iterate through items from oldest to newest + for item_id, data in list(self.storage.get_items_sorted_by_date(reverse=False)): + deletion_date = data.get("deletion_date") + if deletion_date and deletion_date < cutoff_date: + if self.purge_item(item_id): + purge_count += 1 + logger.info(f"Item {item_id} purged due to retention policy (deleted on {deletion_date})") + else: + # Since items are sorted by date, once we find one that's + # newer than the cutoff date, we can stop checking + break + + return purge_count + except Exception as e: + logger.error(f"Error purging expired items: {str(e)}") return 0 - cutoff_date = datetime.now() - timedelta(days=retention_days) - - items_to_purge = [] - for item_id, data in self.storage.get_items(): - if data["deletion_date"] < cutoff_date: - items_to_purge.append(item_id) - - purge_count = 0 - for item_id in items_to_purge: - if self.purge_item(item_id): - purge_count += 1 - - return purge_count - def _check_size_limits(self): """Check if the recycle bin exceeds size limits and purge oldest items if needed""" - settings = self._get_settings() - max_size_bytes = settings.maximum_size * 1024 * 1024 # Convert MB to bytes - - total_size = 0 - items_by_date = [] - - # Calculate total size and prepare sorted list - for item_id, data in self.storage.get_items(): - size = data.get("size", 0) - total_size += size - items_by_date.append((item_id, data["deletion_date"], size)) - - # Sort by date (oldest first) - items_by_date.sort(key=lambda x: x[1]) - - # Remove oldest items if size limit is exceeded - while total_size > max_size_bytes and items_by_date: - item_id, _, size = items_by_date.pop(0) - if self.purge_item(item_id): - total_size -= size - logger.info(f"Purged item {item_id} due to size constraints") + try: + settings = self._get_settings() + max_size_bytes = settings.maximum_size * 1024 * 1024 # Convert MB to bytes + + # If max_size is 0, size limiting is disabled + if max_size_bytes <= 0: + return + + total_size = 0 + items_by_date = [] + + # Calculate total size using items sorted by date (oldest first) + for item_id, data in self.storage.get_items_sorted_by_date(reverse=False): + size = data.get("size", 0) + total_size += size + items_by_date.append((item_id, size)) + + # If we're under the limit, nothing to do + if total_size <= max_size_bytes: + return + + logger.info(f"Recycle bin size ({total_size / (1024 * 1024):.2f} MB) exceeds limit ({max_size_bytes / (1024 * 1024):.2f} MB)") + + # Remove oldest items if size limit is exceeded + for item_id, size in items_by_date: + if total_size <= max_size_bytes: + break + + if self.purge_item(item_id): + total_size -= size + logger.info(f"Purged item {item_id} due to size constraints") + except Exception as e: + logger.error(f"Error checking size limits: {str(e)}") diff --git a/src/Products/CMFPlone/utils.py b/src/Products/CMFPlone/utils.py index a6136c11fd..9acdd9222b 100644 --- a/src/Products/CMFPlone/utils.py +++ b/src/Products/CMFPlone/utils.py @@ -711,37 +711,6 @@ def _check_for_collision(contained_by, id, **kwargs): if id in aliases.keys(): return _("${name} is reserved.", mapping={"name": id}) - # Check for collisions with items in the recycle bin - try: - from Products.CMFPlone.interfaces.recyclebin import IRecycleBin - from zope.component import queryUtility - - import logging - - logger = logging.getLogger("Products.CMFPlone.utils") - recycle_bin = queryUtility(IRecycleBin) - if recycle_bin is not None and recycle_bin.is_enabled(): - # Get all items in the recycle bin - recycled_items = recycle_bin.get_items() - - # Get the current container path - container_path = "/".join(contained_by.getPhysicalPath()) - - # Check if any recycled item with this ID existed in the same container - for item in recycled_items: - if item.get("id") == id and item.get("parent_path") == container_path: - # Instead of automatically restoring or simply warning, we provide a clear - # error message that indicates the ID conflict with recycled content - return _( - "There is an item named ${name} in the recycle bin. " - "You can restore it from the recycle bin or choose a different name.", - mapping={"name": id}, - ) - except (ImportError, AttributeError) as e: - # If recycle bin isn't available or enabled, just continue - logger.debug(f"Recycle bin check skipped: {e}") - pass - # Lastly, we want to disallow the id of any of the tools in the portal # root, as well as any object that can be acquired via portal_skins. # However, we do want to allow overriding of *content* in the object's From 6c7b47ce5aa87c9a9feca792c2d539d4cfa003e6 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sat, 3 May 2025 12:59:09 +0530 Subject: [PATCH 034/122] refactor(recyclebin): lint --- src/Products/CMFPlone/browser/recyclebin.py | 6 +- src/Products/CMFPlone/recyclebin.py | 152 ++++++++++++-------- 2 files changed, 93 insertions(+), 65 deletions(-) diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index ef65fcb6c8..f9bf274fe6 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -323,7 +323,7 @@ def __call__(self): logger.info(f"Found item with title: {item.get('title', 'Unknown')}") return self.template() - + def _handle_child_restoration(self): """Extract child restoration logic to separate method for clarity""" child_id = self.request.form.get("child_id") @@ -356,9 +356,7 @@ def _handle_child_restoration(self): if restored_obj: # Remove child from parent's children dict del item_data["children"][child_id] - item_data["children_count"] = len( - item_data["children"] - ) + item_data["children_count"] = len(item_data["children"]) message = f"Child item '{child_data['title']}' successfully restored." IStatusMessage(self.request).addStatusMessage( diff --git a/src/Products/CMFPlone/recyclebin.py b/src/Products/CMFPlone/recyclebin.py index e373659503..e0d0c9ded5 100644 --- a/src/Products/CMFPlone/recyclebin.py +++ b/src/Products/CMFPlone/recyclebin.py @@ -1,6 +1,6 @@ +from AccessControl import getSecurityManager from BTrees.OOBTree import OOBTree from BTrees.OOBTree import OOTreeSet -from AccessControl import getSecurityManager from datetime import datetime from datetime import timedelta from DateTime import DateTime @@ -246,10 +246,14 @@ def add_item(self, obj, original_container, original_path, item_type=None): return None # Get the original id but if not found then generate a unique ID for the recycled item - item_id = obj.getId() if hasattr(obj, "getId") else getattr(obj, "id", str(uuid.uuid4())) + item_id = ( + obj.getId() + if hasattr(obj, "getId") + else getattr(obj, "id", str(uuid.uuid4())) + ) # Add a workflow history entry about the deletion if possible - self._update_workflow_history(obj, 'deletion') + self._update_workflow_history(obj, "deletion") # Generate a meaningful title item_title = self._get_item_title(obj, item_type) @@ -310,14 +314,14 @@ def get_items(self): "deletion_date": data.get("deletion_date"), "size": data.get("size", 0), } - + # Copy any other metadata but not the actual object for key, value in data.items(): if key != "object" and key not in item_data: item_data[key] = value - + items.append(item_data) - + return items def get_item(self, item_id): @@ -326,39 +330,47 @@ def get_item(self, item_id): def _update_workflow_history(self, obj, action_type, item_data=None): """Add a workflow history entry about deletion or restoration - + Args: obj: The content object action_type: Either 'deletion' or 'restoration' item_data: The recyclebin storage data (needed for restoration to show deletion date) """ - if not hasattr(obj, 'workflow_history'): + if not hasattr(obj, "workflow_history"): return - - workflow_tool = getToolByName(self._get_context(), 'portal_workflow') + + workflow_tool = getToolByName(self._get_context(), "portal_workflow") chains = workflow_tool.getChainFor(obj) - + if not chains: return - + workflow_id = chains[0] history = obj.workflow_history.get(workflow_id, ()) - + if not history: return - + history = list(history) - current_state = history[-1].get('review_state', None) if history else None - user_id = getSecurityManager().getUser().getId() or 'System' - + current_state = history[-1].get("review_state", None) if history else None + user_id = getSecurityManager().getUser().getId() or "System" + entry = { - 'action': 'Moved to recycle bin' if action_type == 'deletion' else 'Restored from recycle bin', - 'actor': user_id, - 'comments': 'Item was deleted and moved to recycle bin' if action_type == 'deletion' else 'Restored from recycle bin after deletion', - 'time': DateTime(), - 'review_state': current_state, + "action": ( + "Moved to recycle bin" + if action_type == "deletion" + else "Restored from recycle bin" + ), + "actor": user_id, + "comments": ( + "Item was deleted and moved to recycle bin" + if action_type == "deletion" + else "Restored from recycle bin after deletion" + ), + "time": DateTime(), + "review_state": current_state, } - + # Add the entry and update the history history.append(entry) obj.workflow_history[workflow_id] = tuple(history) @@ -449,8 +461,10 @@ def restore_item(self, item_id, target_container=None): # Regular content object restoration # Find the container to restore to - target_container = self._find_target_container(target_container, item_data["parent_path"]) - + target_container = self._find_target_container( + target_container, item_data["parent_path"] + ) + # Make sure we don't overwrite existing content self._handle_existing_object(obj_id, target_container, obj) @@ -463,37 +477,41 @@ def restore_item(self, item_id, target_container=None): # Add a workflow history entry about the restoration restored_obj = target_container[obj_id] - self._update_workflow_history(restored_obj, 'restoration', item_data) + self._update_workflow_history(restored_obj, "restoration", item_data) # Remove from recycle bin del self.storage[item_id] - # Clean up any child items + # Clean up any child items self._cleanup_child_references(item_data) return restored_obj - def _find_parent_comment(self, comment, original_in_reply_to, conversation, id_mapping=None): + def _find_parent_comment( + self, comment, original_in_reply_to, conversation, id_mapping=None + ): """Helper method to find parent comment during restoration""" id_mapping = id_mapping or {} if original_in_reply_to is None or original_in_reply_to == 0: return False, None - + # First check if parent exists directly (not previously deleted) if original_in_reply_to in conversation: return True, original_in_reply_to - + # Then check if it was restored with a different ID using mapping if str(original_in_reply_to) in id_mapping: # Use the ID mapping to find the new ID new_parent_id = id_mapping[str(original_in_reply_to)] return True, new_parent_id - + # Look through all comments for original_id matching our in_reply_to for comment_id in conversation.keys(): comment_obj = conversation[comment_id] comment_original_id = getattr(comment_obj, "original_id", None) - if comment_original_id is not None and str(comment_original_id) == str(original_in_reply_to): + if comment_original_id is not None and str(comment_original_id) == str( + original_in_reply_to + ): # Found the parent with a new ID return True, comment_id @@ -666,7 +684,7 @@ def _restore_comment_tree(self, item_id, item_data, target_container=None): parent_found, new_parent_id = self._find_parent_comment( root_comment, original_in_reply_to, conversation ) - + if parent_found: root_comment.in_reply_to = new_parent_id else: @@ -721,7 +739,7 @@ def _restore_comment_tree(self, item_id, item_data, target_container=None): if parent_found: # We found the parent, update reference and restore comment_obj.in_reply_to = new_parent_id - + # Store original ID for future reference if not hasattr(comment_obj, "original_id"): comment_obj.original_id = comment_id @@ -743,23 +761,25 @@ def _restore_comment_tree(self, item_id, item_data, target_container=None): try: comment_obj = comment_data["comment"] comment_obj.in_reply_to = None # Make it a top-level comment - + # Store original ID for future reference if not hasattr(comment_obj, "original_id"): comment_obj.original_id = comment_id - + new_id = conversation.addComment(comment_obj) id_mapping[comment_id] = new_id del remaining_comments[comment_id] restored_in_pass += 1 except Exception as e: - logger.error(f"Error forcing comment {comment_id} as top-level: {e}") - + logger.error( + f"Error forcing comment {comment_id} as top-level: {e}" + ) + # Break out of the loop since we've tried our best break restored_count += restored_in_pass - + # If all comments were restored, exit the loop if not remaining_comments: break @@ -767,35 +787,39 @@ def _restore_comment_tree(self, item_id, item_data, target_container=None): # Clean up and return del self.storage[item_id] logger.info(f"Restored {restored_count} comments from comment tree {item_id}") - + # Return the root comment as the result return conversation.get(new_root_id) if new_root_id in conversation else None def purge_item(self, item_id): """Permanently delete an item from the recycle bin - + Args: item_id: The ID of the item in the recycle bin - + Returns: Boolean indicating success """ if item_id not in self.storage: logger.warning(f"Cannot purge item {item_id}: not found in recycle bin") return False - + try: # Purge any nested children first if this is a folder item_data = self.storage[item_id] if "children" in item_data and isinstance(item_data["children"], dict): + def purge_children(children_dict): for child_id, child_data in list(children_dict.items()): # If this child has children, recursively purge them first - if "children" in child_data and isinstance(child_data["children"], dict): + if "children" in child_data and isinstance( + child_data["children"], dict + ): purge_children(child_data["children"]) + # Start the recursive purge of children purge_children(item_data["children"]) - + # Simply remove from storage - the object will be garbage collected del self.storage[item_id] logger.info(f"Item {item_id} purged from recycle bin") @@ -806,35 +830,39 @@ def purge_children(children_dict): def _purge_expired_items(self): """Purge items that exceed the retention period - + Returns: Number of items purged """ try: settings = self._get_settings() retention_days = settings.retention_period - + # If retention_period is 0, auto-purging is disabled if retention_days <= 0: logger.debug("Auto-purging is disabled (retention_period = 0)") return 0 - + cutoff_date = datetime.now() - timedelta(days=retention_days) purge_count = 0 - + # Use the sorted index for efficient date-based queries # Iterate through items from oldest to newest - for item_id, data in list(self.storage.get_items_sorted_by_date(reverse=False)): + for item_id, data in list( + self.storage.get_items_sorted_by_date(reverse=False) + ): deletion_date = data.get("deletion_date") if deletion_date and deletion_date < cutoff_date: if self.purge_item(item_id): purge_count += 1 - logger.info(f"Item {item_id} purged due to retention policy (deleted on {deletion_date})") + logger.info( + f"Item {item_id} purged due to retention policy (deleted on {deletion_date})" + ) else: - # Since items are sorted by date, once we find one that's + # Since items are sorted by date, once we find one that's # newer than the cutoff date, we can stop checking break - + return purge_count except Exception as e: logger.error(f"Error purging expired items: {str(e)}") @@ -845,31 +873,33 @@ def _check_size_limits(self): try: settings = self._get_settings() max_size_bytes = settings.maximum_size * 1024 * 1024 # Convert MB to bytes - + # If max_size is 0, size limiting is disabled if max_size_bytes <= 0: return - + total_size = 0 items_by_date = [] - + # Calculate total size using items sorted by date (oldest first) for item_id, data in self.storage.get_items_sorted_by_date(reverse=False): size = data.get("size", 0) total_size += size items_by_date.append((item_id, size)) - + # If we're under the limit, nothing to do if total_size <= max_size_bytes: return - - logger.info(f"Recycle bin size ({total_size / (1024 * 1024):.2f} MB) exceeds limit ({max_size_bytes / (1024 * 1024):.2f} MB)") - + + logger.info( + f"Recycle bin size ({total_size / (1024 * 1024):.2f} MB) exceeds limit ({max_size_bytes / (1024 * 1024):.2f} MB)" + ) + # Remove oldest items if size limit is exceeded for item_id, size in items_by_date: if total_size <= max_size_bytes: break - + if self.purge_item(item_id): total_size -= size logger.info(f"Purged item {item_id} due to size constraints") From 9a821f8e5dfa6d17aca9de243ca5c5d86f00d940 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sat, 3 May 2025 18:27:43 +0530 Subject: [PATCH 035/122] feat(recyclebin): add search functionality for recycle bin items --- src/Products/CMFPlone/browser/recyclebin.py | 36 +++++++++++++++++++ .../CMFPlone/browser/templates/recyclebin.pt | 31 +++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index f9bf274fe6..31f14bb752 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -144,6 +144,10 @@ def get_recycle_bin(self): """Get the recycle bin utility""" return getUtility(IRecycleBin) + def get_search_query(self): + """Get the search query from the request""" + return self.request.form.get('search_query', '') + def get_items(self): """Get all items in the recycle bin""" recycle_bin = self.get_recycle_bin() @@ -183,6 +187,38 @@ def get_items(self): except (KeyError, AttributeError): item["content_title"] = "Content no longer exists" + # Filter items based on search query + search_query = self.get_search_query().lower() + if search_query: + filtered_items = [] + for item in items: + # Search in title + if search_query in item.get("title", "").lower(): + filtered_items.append(item) + continue + + # Search in path + if search_query in item.get("path", "").lower(): + filtered_items.append(item) + continue + + # Search in parent path + if search_query in item.get("parent_path", "").lower(): + filtered_items.append(item) + continue + + # Search in ID + if search_query in item.get("id", "").lower(): + filtered_items.append(item) + continue + + # Search in type + if search_query in item.get("type", "").lower(): + filtered_items.append(item) + continue + + return filtered_items + return items def format_date(self, date): diff --git a/src/Products/CMFPlone/browser/templates/recyclebin.pt b/src/Products/CMFPlone/browser/templates/recyclebin.pt index d632ef60a8..fc1af76814 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin.pt @@ -13,6 +13,32 @@ Items deleted from this site are stored here and can be restored or permanently deleted.
+ +
+ +
+ + + + Clear + +
+ +
+ + +
+ Search results for: query +
+
@@ -35,7 +61,10 @@ - No items in recycle bin + + No items match your search criteria + No items in recycle bin + From b0ced59c05626b74e44423a3396851dd6277f3f6 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sat, 3 May 2025 18:37:33 +0530 Subject: [PATCH 036/122] fix(recyclebin): search results to include matching child items --- src/Products/CMFPlone/browser/recyclebin.py | 40 ++++++++++++++++++- .../CMFPlone/browser/templates/recyclebin.pt | 35 ++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index 31f14bb752..e9b4565656 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -191,6 +191,8 @@ def get_items(self): search_query = self.get_search_query().lower() if search_query: filtered_items = [] + items_with_matching_children = [] + for item in items: # Search in title if search_query in item.get("title", "").lower(): @@ -217,7 +219,43 @@ def get_items(self): filtered_items.append(item) continue - return filtered_items + # Search in children if this item has children + if "children" in item and isinstance(item["children"], dict): + child_matches = [] + + for child_id, child_data in item["children"].items(): + # Check each child for matches + child_matches_query = False + + # Check in title + if search_query in child_data.get("title", "").lower(): + child_matches_query = True + # Check in path + elif search_query in child_data.get("path", "").lower(): + child_matches_query = True + # Check in ID + elif search_query in child_data.get("id", "").lower(): + child_matches_query = True + # Check in type + elif search_query in child_data.get("type", "").lower(): + child_matches_query = True + + # Add to matches if found + if child_matches_query: + child_matches.append(child_data) + + # If any children match, mark the parent item + if child_matches: + # Make a copy of the item so we don't modify the original + parent_item = item.copy() + parent_item["matching_children"] = child_matches + parent_item["matching_children_count"] = len(child_matches) + items_with_matching_children.append(parent_item) + + # Combine direct matches with items that have matching children + # Direct matches come first + search_results = filtered_items + items_with_matching_children + return search_results return items diff --git a/src/Products/CMFPlone/browser/templates/recyclebin.pt b/src/Products/CMFPlone/browser/templates/recyclebin.pt index fc1af76814..2592d53988 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin.pt @@ -79,6 +79,30 @@ class="discreet"> Comment on: Content Title
+ + +
+ + Found matches in contents: + 3 + items match your search + +
    +
  • + Child Title + + (Type) + +
  • +
  • + and + + more... +
  • +
+
Content type @@ -149,5 +173,16 @@ + + From 465cc314cfd199a3450c921e7df9721da3b7171e Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sat, 3 May 2025 19:09:08 +0530 Subject: [PATCH 037/122] feat(recyclebin): add sorting and filtering options to the recycle bin view --- src/Products/CMFPlone/browser/recyclebin.py | 52 ++++++- .../CMFPlone/browser/templates/recyclebin.pt | 140 +++++++++++++++--- 2 files changed, 170 insertions(+), 22 deletions(-) diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index e9b4565656..d20e6d68ca 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -3,6 +3,7 @@ from Products.Five.browser import BrowserView from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile from Products.statusmessages.interfaces import IStatusMessage +from datetime import datetime from z3c.form import button from z3c.form import field from z3c.form import form @@ -147,6 +148,23 @@ def get_recycle_bin(self): def get_search_query(self): """Get the search query from the request""" return self.request.form.get('search_query', '') + + def get_sort_option(self): + """Get the current sort option from the request""" + return self.request.form.get('sort_by', 'date_desc') + + def get_filter_type(self): + """Get the content type filter from the request""" + return self.request.form.get('filter_type', '') + + def get_available_types(self, items): + """Get a list of all content types present in the recycle bin""" + types = set() + for item in items: + item_type = item.get('type') + if item_type: + types.add(item_type) + return sorted(list(types)) def get_items(self): """Get all items in the recycle bin""" @@ -187,6 +205,11 @@ def get_items(self): except (KeyError, AttributeError): item["content_title"] = "Content no longer exists" + # Apply content type filtering if specified + filter_type = self.get_filter_type() + if filter_type: + items = [item for item in items if item.get('type') == filter_type] + # Filter items based on search query search_query = self.get_search_query().lower() if search_query: @@ -254,9 +277,32 @@ def get_items(self): # Combine direct matches with items that have matching children # Direct matches come first - search_results = filtered_items + items_with_matching_children - return search_results - + items = filtered_items + items_with_matching_children + + # Apply sorting + sort_option = self.get_sort_option() + if sort_option == 'title_asc': + items.sort(key=lambda x: x.get('title', '').lower()) + elif sort_option == 'title_desc': + items.sort(key=lambda x: x.get('title', '').lower(), reverse=True) + elif sort_option == 'type_asc': + items.sort(key=lambda x: x.get('type', '').lower()) + elif sort_option == 'type_desc': + items.sort(key=lambda x: x.get('type', '').lower(), reverse=True) + elif sort_option == 'path_asc': + items.sort(key=lambda x: x.get('path', '').lower()) + elif sort_option == 'path_desc': + items.sort(key=lambda x: x.get('path', '').lower(), reverse=True) + elif sort_option == 'size_asc': + items.sort(key=lambda x: x.get('size', 0)) + elif sort_option == 'size_desc': + items.sort(key=lambda x: x.get('size', 0), reverse=True) + elif sort_option == 'date_asc': + items.sort(key=lambda x: x.get('deletion_date', datetime.now())) + # Default: date_desc + else: + items.sort(key=lambda x: x.get('deletion_date', datetime.now()), reverse=True) + return items def format_date(self, date): diff --git a/src/Products/CMFPlone/browser/templates/recyclebin.pt b/src/Products/CMFPlone/browser/templates/recyclebin.pt index 2592d53988..b01c1cacb6 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin.pt @@ -13,30 +13,132 @@ Items deleted from this site are stored here and can be restored or permanently deleted.
- +
-
- - - - Clear - +
+ +
+
+ + + + Clear + +
+
+ +
+
+ +
+ +
+ + +
+ +
+
+
- -
- Search results for: query + +
+ +
+ Active filters: + + + Search: + query + × + + + + Type: + Document + × + + + + Sort: + Sort option + × + +
+ + Clear All
- No items match your search criteria - No items in recycle bin + No items match your search criteria + No items in recycle bin From 2a401033362f5b2b1ed751adc0fe89d233f0c1ec Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sat, 3 May 2025 19:32:35 +0530 Subject: [PATCH 038/122] feat(recyclebin): enhance search and filter functionality with improved UI and new clear URL feature --- src/Products/CMFPlone/browser/recyclebin.py | 129 ++++++++----- .../CMFPlone/browser/templates/recyclebin.pt | 173 +++++++----------- .../browser/templates/recyclebin_item.pt | 45 ++--- 3 files changed, 175 insertions(+), 172 deletions(-) diff --git a/src/Products/CMFPlone/browser/recyclebin.py b/src/Products/CMFPlone/browser/recyclebin.py index d20e6d68ca..1f5c736841 100644 --- a/src/Products/CMFPlone/browser/recyclebin.py +++ b/src/Products/CMFPlone/browser/recyclebin.py @@ -1,9 +1,9 @@ +from datetime import datetime from Products.CMFCore.utils import getToolByName from Products.CMFPlone.interfaces.recyclebin import IRecycleBin from Products.Five.browser import BrowserView from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile from Products.statusmessages.interfaces import IStatusMessage -from datetime import datetime from z3c.form import button from z3c.form import field from z3c.form import form @@ -147,21 +147,66 @@ def get_recycle_bin(self): def get_search_query(self): """Get the search query from the request""" - return self.request.form.get('search_query', '') - + return self.request.form.get("search_query", "") + def get_sort_option(self): """Get the current sort option from the request""" - return self.request.form.get('sort_by', 'date_desc') - + return self.request.form.get("sort_by", "date_desc") + def get_filter_type(self): """Get the content type filter from the request""" - return self.request.form.get('filter_type', '') - + return self.request.form.get("filter_type", "") + + def get_sort_labels(self): + """Get a dictionary of human-readable sort option labels""" + return { + "date_desc": "Newest First (default)", + "date_asc": "Oldest First", + "title_asc": "Title (A-Z)", + "title_desc": "Title (Z-A)", + "type_asc": "Type (A-Z)", + "type_desc": "Type (Z-A)", + "path_asc": "Path (A-Z)", + "path_desc": "Path (Z-A)", + "size_asc": "Size (Smallest First)", + "size_desc": "Size (Largest First)", + } + + def get_clear_url(self, param_to_remove): + """Generate a URL that clears a specific filter parameter while preserving others + + Args: + param_to_remove: The parameter name to remove from the URL + + Returns: + URL string with the specified parameter removed + """ + base_url = f"{self.context.absolute_url()}/@@recyclebin" + params = [] + + # Add search query if it exists and is not being removed + if param_to_remove != "search_query" and self.get_search_query(): + params.append(f"search_query={self.get_search_query()}") + + # Add filter type if it exists and is not being removed + if param_to_remove != "filter_type" and self.get_filter_type(): + params.append(f"filter_type={self.get_filter_type()}") + + # Add sort option if it exists, is not default, and is not being removed + sort_option = self.get_sort_option() + if param_to_remove != "sort_by" and sort_option != "date_desc": + params.append(f"sort_by={sort_option}") + + # Construct final URL + if params: + return f"{base_url}?{'&'.join(params)}" + return base_url + def get_available_types(self, items): """Get a list of all content types present in the recycle bin""" types = set() for item in items: - item_type = item.get('type') + item_type = item.get("type") if item_type: types.add(item_type) return sorted(list(types)) @@ -208,48 +253,48 @@ def get_items(self): # Apply content type filtering if specified filter_type = self.get_filter_type() if filter_type: - items = [item for item in items if item.get('type') == filter_type] + items = [item for item in items if item.get("type") == filter_type] # Filter items based on search query search_query = self.get_search_query().lower() if search_query: filtered_items = [] items_with_matching_children = [] - + for item in items: # Search in title if search_query in item.get("title", "").lower(): filtered_items.append(item) continue - + # Search in path if search_query in item.get("path", "").lower(): filtered_items.append(item) continue - + # Search in parent path if search_query in item.get("parent_path", "").lower(): filtered_items.append(item) continue - + # Search in ID if search_query in item.get("id", "").lower(): filtered_items.append(item) continue - + # Search in type if search_query in item.get("type", "").lower(): filtered_items.append(item) continue - + # Search in children if this item has children if "children" in item and isinstance(item["children"], dict): child_matches = [] - + for child_id, child_data in item["children"].items(): # Check each child for matches child_matches_query = False - + # Check in title if search_query in child_data.get("title", "").lower(): child_matches_query = True @@ -262,11 +307,11 @@ def get_items(self): # Check in type elif search_query in child_data.get("type", "").lower(): child_matches_query = True - + # Add to matches if found if child_matches_query: child_matches.append(child_data) - + # If any children match, mark the parent item if child_matches: # Make a copy of the item so we don't modify the original @@ -274,35 +319,37 @@ def get_items(self): parent_item["matching_children"] = child_matches parent_item["matching_children_count"] = len(child_matches) items_with_matching_children.append(parent_item) - + # Combine direct matches with items that have matching children # Direct matches come first items = filtered_items + items_with_matching_children - + # Apply sorting sort_option = self.get_sort_option() - if sort_option == 'title_asc': - items.sort(key=lambda x: x.get('title', '').lower()) - elif sort_option == 'title_desc': - items.sort(key=lambda x: x.get('title', '').lower(), reverse=True) - elif sort_option == 'type_asc': - items.sort(key=lambda x: x.get('type', '').lower()) - elif sort_option == 'type_desc': - items.sort(key=lambda x: x.get('type', '').lower(), reverse=True) - elif sort_option == 'path_asc': - items.sort(key=lambda x: x.get('path', '').lower()) - elif sort_option == 'path_desc': - items.sort(key=lambda x: x.get('path', '').lower(), reverse=True) - elif sort_option == 'size_asc': - items.sort(key=lambda x: x.get('size', 0)) - elif sort_option == 'size_desc': - items.sort(key=lambda x: x.get('size', 0), reverse=True) - elif sort_option == 'date_asc': - items.sort(key=lambda x: x.get('deletion_date', datetime.now())) + if sort_option == "title_asc": + items.sort(key=lambda x: x.get("title", "").lower()) + elif sort_option == "title_desc": + items.sort(key=lambda x: x.get("title", "").lower(), reverse=True) + elif sort_option == "type_asc": + items.sort(key=lambda x: x.get("type", "").lower()) + elif sort_option == "type_desc": + items.sort(key=lambda x: x.get("type", "").lower(), reverse=True) + elif sort_option == "path_asc": + items.sort(key=lambda x: x.get("path", "").lower()) + elif sort_option == "path_desc": + items.sort(key=lambda x: x.get("path", "").lower(), reverse=True) + elif sort_option == "size_asc": + items.sort(key=lambda x: x.get("size", 0)) + elif sort_option == "size_desc": + items.sort(key=lambda x: x.get("size", 0), reverse=True) + elif sort_option == "date_asc": + items.sort(key=lambda x: x.get("deletion_date", datetime.now())) # Default: date_desc else: - items.sort(key=lambda x: x.get('deletion_date', datetime.now()), reverse=True) - + items.sort( + key=lambda x: x.get("deletion_date", datetime.now()), reverse=True + ) + return items def format_date(self, date): diff --git a/src/Products/CMFPlone/browser/templates/recyclebin.pt b/src/Products/CMFPlone/browser/templates/recyclebin.pt index b01c1cacb6..a12fbac6d1 100644 --- a/src/Products/CMFPlone/browser/templates/recyclebin.pt +++ b/src/Products/CMFPlone/browser/templates/recyclebin.pt @@ -16,10 +16,10 @@
-
- -
+ class="search-box mb-3"> +
+ +
- Clear
-
-
- -
- -
- - -
- -
+ +
+ +
+ +
+ + +
+
@@ -91,49 +87,33 @@
+ class="active-filters alert alert-info d-flex align-items-center justify-content-between flex-wrap"> -
+
Active filters: - - Search: - query - × + + Search: + query + × - - Type: - Document - × + + Type: + Document + × - - Sort: - Sort option - × + + Sort: + Sort option + ×
@@ -184,21 +164,21 @@
+ class="alert alert-light mt-2 p-2"> Found matches in contents: 3 items match your search -