Skip to content
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
647faa7
Add Recycle Bin control panel and settings configuration
rohnsha0 Apr 27, 2025
5f7c305
Implement Recycle Bin functionality with management views and settings
rohnsha0 Apr 27, 2025
ad85ec8
Add Recycle Bin action to actions.xml
rohnsha0 Apr 27, 2025
3389289
lint
rohnsha0 Apr 27, 2025
2b6b7e4
Remove obsolete recycle bin event handler code
rohnsha0 Apr 27, 2025
ad77b73
changelog
rohnsha0 Apr 27, 2025
f4de4c0
add tests
rohnsha0 Apr 27, 2025
7a9094c
Fix action index in test cases for adding and changing category in Ac…
rohnsha0 Apr 27, 2025
52c9e1f
Refactor button classes in recycle bin template for consistency
rohnsha0 Apr 28, 2025
9534b79
remove dedundant format_date logic
rohnsha0 Apr 28, 2025
b8cebee
ui cleanup
rohnsha0 Apr 28, 2025
5c65330
Apply suggestions from code review
rohnsha0 Apr 29, 2025
5687b42
implement z3c.form
rohnsha0 Apr 29, 2025
28aab1a
fix(recyclebin): added support for discussions
rohnsha0 Apr 30, 2025
07fc9b4
feat(recyclebin): enhance comment tree handling with meaningful titles
rohnsha0 Apr 30, 2025
838a401
lint
rohnsha0 Apr 30, 2025
4c3ff02
refactor(recyclebin): rename module and update interface references
rohnsha0 Apr 30, 2025
712add7
refactor(recyclebin): simplify initialization and context retrieval i…
rohnsha0 May 1, 2025
01b908e
fix(recyclebin): simplify error handling in content removal by allowi…
rohnsha0 May 1, 2025
1ee283e
fix(recyclebin): add conditions to actions.xml and update icon
rohnsha0 May 1, 2025
e2d6022
refactor(recyclebin): replace PersistentMapping with RecycleBinStorag…
rohnsha0 May 1, 2025
32c7fdb
fix(recyclebin): avoid copying and sorting objects
rohnsha0 May 1, 2025
6e0c571
fix(recyclebin): fix broken link to itemView
rohnsha0 May 1, 2025
b22d1c5
feat(recyclebin): enhance child item handling in recycle bin with res…
rohnsha0 May 1, 2025
1dcc59e
Update Products/CMFPlone/recyclebin.py
rohnsha0 May 1, 2025
df30fef
fix(recyclebin): update retention period handling and remove auto pur…
rohnsha0 May 1, 2025
520d440
fix(recyclebin): set recycling_enabled to False and remove auto purge…
rohnsha0 May 1, 2025
5dc17a7
lint
rohnsha0 May 1, 2025
8437275
fix(recyclebin): improve restore logic to prevent ID collisions and p…
rohnsha0 May 2, 2025
ec0e0f2
fix(tests): replace heavily mocked tests
rohnsha0 May 2, 2025
79490c5
lint
rohnsha0 May 2, 2025
3ecb7ee
feat(recyclebin): add workflow history tracking for deletion and rest…
rohnsha0 May 3, 2025
cd6878d
refactor(recyclebin): cleanup
rohnsha0 May 3, 2025
c35b4ae
refactor(recyclebin): lint
rohnsha0 May 3, 2025
574bb85
feat(recyclebin): add search functionality for recycle bin items
rohnsha0 May 3, 2025
c7fedfe
fix(recyclebin): search results to include matching child items
rohnsha0 May 3, 2025
33f41b3
feat(recyclebin): add sorting and filtering options to the recycle bi…
rohnsha0 May 3, 2025
90ecc08
feat(recyclebin): enhance search and filter functionality with improv…
rohnsha0 May 3, 2025
d08e1b3
feat(recyclebin): enhance UI for recycle bin and item details with im…
rohnsha0 May 3, 2025
0eecabb
refactor(recyclebin): remove shadow from cards, update button styles …
rohnsha0 May 3, 2025
7231110
fix(recyclebin): failing tests
rohnsha0 May 3, 2025
4cb2d2f
Apply suggestions from code review
rohnsha0 May 4, 2025
6e9bf76
feat(recyclebin): add comments tree display and functionality to recy…
rohnsha0 May 4, 2025
66b8017
feat(recyclebin): refactor orphaned comment handling and improve size…
rohnsha0 May 4, 2025
9a5dbd6
feat(recyclebin): disable sidebar
rohnsha0 May 4, 2025
a388437
apply suggestions
rohnsha0 May 4, 2025
7de4733
feat(recyclebin): remove icons and improve spacing in recycle bin tem…
rohnsha0 May 5, 2025
55e2b9a
feat(recyclebin): cleaned up date/size formatting implementations, re…
rohnsha0 May 5, 2025
aa2eddd
feat(recyclebin): refactor recycle bin forms and enhance item restora…
rohnsha0 May 5, 2025
2ed81c8
refactor(recyclebin): moved interfaces to plone.base https://github.c…
rohnsha0 May 6, 2025
0807c8d
feat(recyclebin): enhance internationalization support in recycle bin…
rohnsha0 May 6, 2025
17ad613
feat(recyclebin): refactor forms to utilize z3c.form patterns and imp…
rohnsha0 May 6, 2025
feca1ab
refactor(recyclebin): cleanup
rohnsha0 May 6, 2025
18a3008
apply suggestions
rohnsha0 May 6, 2025
0fa623c
feat(recyclebin): enhance redirect logic for restored items by append…
rohnsha0 May 6, 2025
84d226b
fix(recyclebin): lint
rohnsha0 May 6, 2025
da965d4
feat(recyclebin): add process_children parameter to add_item for opti…
rohnsha0 May 9, 2025
8c8fb47
lint
rohnsha0 May 9, 2025
7079b08
fix(recyclebin): improve error message for missing target location du…
rohnsha0 May 11, 2025
fa502d9
fix(recyclebin): dont break when checking is_enabled-recyclebin while…
rohnsha0 May 30, 2025
be104f9
Remove Acquisition wrapper from object when storing it in the recycle…
davisagli Jun 17, 2025
0d8815c
fix(recyclebin): fix childrens not being purged for folders
rohnsha0 Jun 17, 2025
05f5f7d
Merge branch 'master' into rohnsha0-plip-recyclebin
rohnsha0 Jul 10, 2025
8615a27
[RECYCLE BIN] Fixes broken redirection after restoration and handle v…
rohnsha0 Aug 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions Products/CMFPlone/browser/recyclebin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
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 zExceptions import NotFound
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):
"""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 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"""
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 = "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):
"""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}"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very technical. What does it mean to the editor?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so can be change it to?!

                message = translate(
                    _(
                        "The folder '${path}' where you are trying to restore this item cannot be found. It may have been moved or deleted. Please choose a different location.",
                        mapping={"path": target_path},
                    ),
                    context=self.request,
                )

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+100, @stevepiercy as I my native language is not English, may you have a look?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rohnsha0 Your proposed change looks pretty good to me.

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")
68 changes: 68 additions & 0 deletions Products/CMFPlone/browser/templates/recyclebin.pt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:metal="http://xml.zope.org/namespaces/metal"
xmlns:tal="http://xml.zope.org/namespaces/tal"
xmlns:i18n="http://xml.zope.org/namespaces/i18n"
metal:use-macro="context/main_template/macros/master"
i18n:domain="plone">
<body>

<metal:main fill-slot="main">
<h1 class="documentFirstHeading" i18n:translate="">Recycle Bin</h1>

<div class="documentDescription" i18n:translate="">
Items deleted from this site are stored here and can be restored or permanently deleted.
</div>

<form method="post" tal:attributes="action string:${context/absolute_url}/@@recyclebin"
tal:define="items view/get_items">
<input type="hidden" name="form.submitted" value="1" />

<div class="pat-autotoc autotabs">

<div id="all-items">
<table class="table table-striped listing">
<thead>
<tr>
<th><input type="checkbox" name="select_all" id="select_all" /></th>
<th i18n:translate="">Title</th>
<th i18n:translate="">Type</th>
<th i18n:translate="">Original Path</th>
<th i18n:translate="">Deletion Date</th>
<th i18n:translate="">Size</th>
</tr>
</thead>
<tbody>
<tr tal:condition="not:items">
<td colspan="6" i18n:translate="">No items in recycle bin</td>
</tr>
<tr tal:repeat="item items">
<td>
<input type="checkbox" name="selected_items:list"
tal:attributes="value item/recycle_id" />
</td>
<td>
<a tal:attributes="href string:${context/absolute_url}/@@recyclebin/item/${item/recycle_id}"
tal:content="item/title">Title</a>
</td>
<td tal:content="item/type">Content type</td>
<td tal:content="item/path">Original path</td>
<td tal:content="python:view.format_date(item['deletion_date'])">Deletion date</td>
<td tal:content="python:view.format_size(item.get('size', 0))">Size</td>
</tr>
</tbody>
</table>

<div class="formControls" tal:condition="items">
<input class="btn btn-primary" type="submit" name="form.button.Restore" value="Restore Selected" i18n:attributes="value" />
<input class="btn btn-danger" type="submit" name="form.button.Delete" value="Delete Selected" i18n:attributes="value" />
<input class="btn btn-danger" type="submit" name="form.button.Empty" value="Empty Recycle Bin"
onclick="return confirm('Are you sure you want to permanently delete all items in the recycle bin?');"
i18n:attributes="value" />
</div>
</div>
</div>
</form>
</metal:main>

</body>
</html>
85 changes: 85 additions & 0 deletions Products/CMFPlone/browser/templates/recyclebin_item.pt
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:metal="http://xml.zope.org/namespaces/metal"
xmlns:tal="http://xml.zope.org/namespaces/tal"
xmlns:i18n="http://xml.zope.org/namespaces/i18n"
metal:use-macro="context/main_template/macros/master"
i18n:domain="plone">
<body>

<metal:main fill-slot="main">
<tal:item define="item view/get_item">
<div tal:condition="not:item">
<h1 class="documentFirstHeading" i18n:translate="">Item Not Found</h1>
<p i18n:translate="">
The requested item was not found in the recycle bin. It may have been already restored or deleted.
</p>
<p>
<a tal:attributes="href string:${context/absolute_url}/@@recyclebin"
i18n:translate="">Return to recycle bin</a>
</p>
</div>

<div tal:condition="item">
<h1 class="documentFirstHeading">
<span tal:content="item/title">Title</span>
<span class="discreet"> (<span tal:content="item/type">Content Type</span>)</span>
</h1>

<div class="documentDescription" i18n:translate="">
Deleted item information
</div>

<table class="listing">
<tbody>
<tr>
<th i18n:translate="">Original ID</th>
<td tal:content="item/id">ID</td>
</tr>
<tr>
<th i18n:translate="">Original Path</th>
<td tal:content="item/path">Path</td>
</tr>
<tr>
<th i18n:translate="">Parent Path</th>
<td tal:content="item/parent_path">Parent</td>
</tr>
<tr>
<th i18n:translate="">Deletion Date</th>
<td tal:content="python:view.format_date(item['deletion_date'])">Date</td>
</tr>
</tbody>
</table>

<form method="post"
tal:attributes="action string:${context/absolute_url}/@@recyclebin/item/${view/item_id}">
<input type="hidden" name="form.submitted" value="1" />

<div class="field">
<label i18n:translate="">Custom Restore Location (optional)</label>
<div class="formHelp" i18n:translate="">
Leave blank to restore to the original location. If the original location no longer exists,
the item will be restored to the site root.
</div>
<input type="text" name="target_container" size="50" />
</div>

<div class="formControls">
<input class="context" type="submit" name="form.button.Restore" value="Restore Item" i18n:attributes="value" />
<input class="destructive" type="submit" name="form.button.Delete" value="Permanently Delete"
onclick="return confirm('Are you sure you want to permanently delete this item?');"
i18n:attributes="value" />
</div>
</form>

<div class="visualClear"><!-- --></div>

<p>
<a tal:attributes="href string:${context/absolute_url}/@@recyclebin"
i18n:translate="">Return to recycle bin</a>
</p>
</div>
</tal:item>
</metal:main>

</body>
</html>
26 changes: 26 additions & 0 deletions Products/CMFPlone/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,30 @@
for="zope.pagetemplate.engine.ZopeBaseEngine"
/>

<!-- Recycle Bin utility -->
<utility
factory=".recyclebin.RecycleBin"
provides=".interfaces.recyclebin.IRecycleBin" />

<!-- Event handlers -->
<subscriber
for="Products.CMFCore.interfaces.IContentish
zope.lifecycleevent.interfaces.IObjectRemovedEvent"
handler=".events.handle_content_removal" />

<!-- Browser views -->
<browser:page
name="recyclebin"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is located in plone.base.interfaces - you should get a deprecation warning using it this way.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in 18a3008

class=".browser.recyclebin.RecycleBinView"
permission="cmf.ManagePortal"
/>

<browser:page
name="recyclebin/item"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in 18a3008

class=".browser.recyclebin.RecycleBinItemView"
permission="cmf.ManagePortal"
/>

</configure>
Loading