-
-
Notifications
You must be signed in to change notification settings - Fork 206
Implement Recycle Bin #4166
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Implement Recycle Bin #4166
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
9910c26
Add Recycle Bin control panel and settings configuration
rohnsha0 cb19f5f
Implement Recycle Bin functionality with management views and settings
rohnsha0 39f2730
Add Recycle Bin action to actions.xml
rohnsha0 c80f1fa
lint
rohnsha0 b617181
Remove obsolete recycle bin event handler code
rohnsha0 cada718
changelog
rohnsha0 518c251
add tests
rohnsha0 94a019a
Fix action index in test cases for adding and changing category in Ac…
rohnsha0 d5966ff
Refactor button classes in recycle bin template for consistency
rohnsha0 baa6750
remove dedundant format_date logic
rohnsha0 2b2fbb6
ui cleanup
rohnsha0 566d648
Apply suggestions from code review
rohnsha0 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}" | ||
| 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") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would use a z3c.form form here