-
-
Notifications
You must be signed in to change notification settings - Fork 206
Implement Recycle Bin #4168
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
Implement Recycle Bin #4168
Changes from 12 commits
647faa7
5f7c305
ad85ec8
3389289
2b6b7e4
ad77b73
f4de4c0
7a9094c
52c9e1f
9534b79
b8cebee
5c65330
5687b42
28aab1a
07fc9b4
838a401
4c3ff02
712add7
01b908e
1ee283e
e2d6022
32c7fdb
6e0c571
b22d1c5
1dcc59e
df30fef
520d440
5dc17a7
8437275
ec0e0f2
79490c5
3ecb7ee
cd6878d
c35b4ae
574bb85
c7fedfe
33f41b3
90ecc08
d08e1b3
0eecabb
7231110
4cb2d2f
6e9bf76
66b8017
9a5dbd6
a388437
7de4733
55e2b9a
aa2eddd
2ed81c8
0807c8d
17ad613
feca1ab
18a3008
0fa623c
84d226b
da965d4
8c8fb47
7079b08
fa502d9
be104f9
0d8815c
05f5f7d
8615a27
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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") | ||
| 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> |
| 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> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is located in
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same here
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done in 18a3008 |
||
| class=".browser.recyclebin.RecycleBinItemView" | ||
| permission="cmf.ManagePortal" | ||
| /> | ||
|
|
||
| </configure> | ||
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.
This is very technical. What does it mean to the editor?
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.
so can be change it to?!
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.
+100, @stevepiercy as I my native language is not English, may you have a look?
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.
@rohnsha0 Your proposed change looks pretty good to me.