Browse Source

allow users to delete comments. This is based on the development.ini 'content.update.allowed.duration' parameter. Value may be -1 0 or a duration in seconds. -1 means always deletable, 0 (default) means not deletable, and a positive value will allow the author to delete his comment for the given duration in seconds

Damien ACCORSI 10 years ago
parent
commit
621af1ed37

+ 11 - 0
tracim/development.ini.base View File

115
 resetpassword.smtp_passwd = smtp.password
115
 resetpassword.smtp_passwd = smtp.password
116
 
116
 
117
 
117
 
118
+# Specifies if the update of comments and attached files is allowed (by the owner only).
119
+# Examples:
120
+#    600 means 10 minutes (ie 600 seconds)
121
+#   3600 means 1 hour (60x60 seconds)
122
+#
123
+# Allowed values:
124
+#  -1 means that content update is allowed for ever
125
+#   0 means that content update is not allowed
126
+#   x means that content update is allowed for x seconds (with x>0)
127
+content.update.allowed.duration = 3600
128
+
118
 # The following parameters allow to personalize the home page
129
 # The following parameters allow to personalize the home page
119
 # They are html ready (you can put html tags they will be interpreted)
130
 # They are html ready (you can put html tags they will be interpreted)
120
 website.title = TRACIM
131
 website.title = TRACIM

+ 5 - 0
tracim/tracim/config/app_cfg.py View File

197
             # At the time of configuration setup, it can't be evaluated
197
             # At the time of configuration setup, it can't be evaluated
198
             # We do not show CONTENT in order not to pollute log files
198
             # We do not show CONTENT in order not to pollute log files
199
             logger.info(self, 'CONFIG: [ {} | {} ]'.format(key, value))
199
             logger.info(self, 'CONFIG: [ {} | {} ]'.format(key, value))
200
+        else:
201
+            logger.info(self, 'CONFIG: [ {} | <value not shown> ]'.format(key))
200
 
202
 
201
         self.__dict__[key] = value
203
         self.__dict__[key] = value
202
 
204
 
203
     def __init__(self):
205
     def __init__(self):
206
+
207
+        self.DATA_UPDATE_ALLOWED_DURATION = int(tg.config.get('content.update.allowed.duration', 0))
208
+
204
         self.WEBSITE_TITLE = tg.config.get('website.title', 'TRACIM')
209
         self.WEBSITE_TITLE = tg.config.get('website.title', 'TRACIM')
205
         self.WEBSITE_HOME_TITLE_COLOR = tg.config.get('website.title.color', '#555')
210
         self.WEBSITE_HOME_TITLE_COLOR = tg.config.get('website.title.color', '#555')
206
         self.WEBSITE_HOME_IMAGE_URL = tg.lurl('/assets/img/home_illustration.jpg')
211
         self.WEBSITE_HOME_IMAGE_URL = tg.lurl('/assets/img/home_illustration.jpg')

+ 76 - 2
tracim/tracim/controllers/content.py View File

6
 import tg
6
 import tg
7
 from tg import tmpl_context
7
 from tg import tmpl_context
8
 from tg.i18n import ugettext as _
8
 from tg.i18n import ugettext as _
9
+from tg.predicates import not_anonymous
10
+
9
 import traceback
11
 import traceback
10
 
12
 
11
 from tracim.controllers import TIMRestController
13
 from tracim.controllers import TIMRestController
21
 from tracim.lib.predicates import current_user_is_reader
23
 from tracim.lib.predicates import current_user_is_reader
22
 from tracim.lib.predicates import current_user_is_contributor
24
 from tracim.lib.predicates import current_user_is_contributor
23
 from tracim.lib.predicates import current_user_is_content_manager
25
 from tracim.lib.predicates import current_user_is_content_manager
26
+from tracim.lib.predicates import require_current_user_is_owner
24
 
27
 
25
 from tracim.model.serializers import Context, CTX, DictLikeClass
28
 from tracim.model.serializers import Context, CTX, DictLikeClass
26
 from tracim.model.data import ActionDescription
29
 from tracim.model.data import ActionDescription
28
 from tracim.model.data import ContentType
31
 from tracim.model.data import ContentType
29
 from tracim.model.data import Workspace
32
 from tracim.model.data import Workspace
30
 
33
 
31
-
32
 class UserWorkspaceFolderThreadCommentRestController(TIMRestController):
34
 class UserWorkspaceFolderThreadCommentRestController(TIMRestController):
33
 
35
 
36
+    @property
37
+    def _item_type(self):
38
+        return ContentType.Comment
39
+
40
+    @property
41
+    def _item_type_label(self):
42
+        return _('Comment')
43
+
34
     def _before(self, *args, **kw):
44
     def _before(self, *args, **kw):
35
         TIMRestPathContextSetup.current_user()
45
         TIMRestPathContextSetup.current_user()
36
         TIMRestPathContextSetup.current_workspace()
46
         TIMRestPathContextSetup.current_workspace()
37
         TIMRestPathContextSetup.current_folder()
47
         TIMRestPathContextSetup.current_folder()
38
         TIMRestPathContextSetup.current_thread()
48
         TIMRestPathContextSetup.current_thread()
39
 
49
 
40
-    @tg.require(current_user_is_contributor())
41
     @tg.expose()
50
     @tg.expose()
51
+    @tg.require(current_user_is_contributor())
42
     def post(self, content=''):
52
     def post(self, content=''):
43
         # TODO - SECURE THIS
53
         # TODO - SECURE THIS
44
         workspace = tmpl_context.workspace
54
         workspace = tmpl_context.workspace
55
         tg.flash(_('Comment added'), CST.STATUS_OK)
65
         tg.flash(_('Comment added'), CST.STATUS_OK)
56
         tg.redirect(next_url)
66
         tg.redirect(next_url)
57
 
67
 
68
+    @tg.expose()
69
+    @tg.require(not_anonymous())
70
+    def put_delete(self, item_id):
71
+        require_current_user_is_owner(int(item_id))
72
+
73
+        # TODO - CHECK RIGHTS
74
+        item_id = int(item_id)
75
+        content_api = ContentApi(tmpl_context.current_user)
76
+        item = content_api.get_one(item_id, self._item_type, tmpl_context.workspace)
77
+
78
+        try:
79
+
80
+            next_url = tg.url('/workspaces/{}/folders/{}/threads/{}').format(tmpl_context.workspace_id,
81
+                                                                             tmpl_context.folder_id,
82
+                                                                             tmpl_context.thread_id)
83
+            undo_url = tg.url('/workspaces/{}/folders/{}/threads/{}/comments/{}/put_delete_undo').format(tmpl_context.workspace_id,
84
+                                                                                                         tmpl_context.folder_id,
85
+                                                                                                         tmpl_context.thread_id,
86
+                                                                                                         item_id)
87
+
88
+            msg = _('{} deleted. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
89
+            content_api.delete(item)
90
+            content_api.save(item, ActionDescription.DELETION)
91
+
92
+            tg.flash(msg, CST.STATUS_OK, no_escape=True)
93
+            tg.redirect(next_url)
94
+
95
+        except ValueError as e:
96
+            back_url = tg.url('/workspaces/{}/folders/{}/threads/{}').format(tmpl_context.workspace_id,
97
+                                                                             tmpl_context.folder_id,
98
+                                                                             tmpl_context.thread_id)
99
+            msg = _('{} not deleted: {}').format(self._item_type_label, str(e))
100
+            tg.flash(msg, CST.STATUS_ERROR)
101
+            tg.redirect(back_url)
102
+
103
+
104
+    @tg.expose()
105
+    @tg.require(not_anonymous())
106
+    def put_delete_undo(self, item_id):
107
+        require_current_user_is_owner(int(item_id))
108
+
109
+        item_id = int(item_id)
110
+        content_api = ContentApi(tmpl_context.current_user, True, True) # Here we do not filter deleted items
111
+        item = content_api.get_one(item_id, self._item_type, tmpl_context.workspace)
112
+        try:
113
+            next_url = tg.url('/workspaces/{}/folders/{}/threads/{}').format(tmpl_context.workspace_id,
114
+                                                                             tmpl_context.folder_id,
115
+                                                                             tmpl_context.thread_id)
116
+            msg = _('{} undeleted.').format(self._item_type_label)
117
+            content_api.undelete(item)
118
+            content_api.save(item, ActionDescription.UNDELETION)
119
+
120
+            tg.flash(msg, CST.STATUS_OK)
121
+            tg.redirect(next_url)
122
+
123
+        except ValueError as e:
124
+            logger.debug(self, 'Exception: {}'.format(e.__str__))
125
+            back_url = tg.url('/workspaces/{}/folders/{}/threads/{}').format(tmpl_context.workspace_id,
126
+                                                                             tmpl_context.folder_id,
127
+                                                                             tmpl_context.thread_id)
128
+            msg = _('{} not un-deleted: {}').format(self._item_type_label, str(e))
129
+            tg.flash(msg, CST.STATUS_ERROR)
130
+            tg.redirect(back_url)
131
+
58
 
132
 
59
 class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
133
 class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
60
     """
134
     """

+ 32 - 2
tracim/tracim/lib/helpers.py View File

3
 """WebHelpers used in tracim."""
3
 """WebHelpers used in tracim."""
4
 
4
 
5
 #from webhelpers import date, feedgenerator, html, number, misc, text
5
 #from webhelpers import date, feedgenerator, html, number, misc, text
6
+
7
+import datetime
6
 from markupsafe import Markup
8
 from markupsafe import Markup
7
-from datetime import datetime
8
 
9
 
9
 import tg
10
 import tg
10
 
11
 
12
+from tracim.config.app_cfg import CFG
13
+
11
 from tracim.lib import app_globals as plag
14
 from tracim.lib import app_globals as plag
12
 
15
 
13
 from tracim.lib import CST
16
 from tracim.lib import CST
17
+from tracim.lib.base import logger
14
 from tracim.lib.content import ContentApi
18
 from tracim.lib.content import ContentApi
15
 from tracim.lib.workspace import WorkspaceApi
19
 from tracim.lib.workspace import WorkspaceApi
16
 
20
 
39
             return '{:.3f} Mo'.format(int(mega_size))
43
             return '{:.3f} Mo'.format(int(mega_size))
40
 
44
 
41
 def current_year():
45
 def current_year():
42
-  now = datetime.now()
46
+  now = datetime.datetime.now()
43
   return now.strftime('%Y')
47
   return now.strftime('%Y')
44
 
48
 
45
 def formatLongDateAndTime(datetime_object, format=''):
49
 def formatLongDateAndTime(datetime_object, format=''):
150
 
154
 
151
     return 0
155
     return 0
152
 
156
 
157
+def delete_label_for_item(item) -> str:
158
+    """
159
+    :param item: is a serialized Content item (be carefull; it's not an instance of 'Content')
160
+    :return: the delete label to show to the user (in the right language)
161
+    """
162
+    return ContentType._DELETE_LABEL[item.type]
163
+
164
+def is_item_still_editable(item):
165
+    # HACK - D.A - 2014-12-24 - item contains a datetime object!!!
166
+    # 'item' is a variable which is created by serialization and it should be an instance of DictLikeClass.
167
+    # therefore, it contains strins, integers and booleans (something json-ready or almost json-ready)
168
+    #
169
+    # BUT, the property 'created' is still a datetime object
170
+    #
171
+    edit_duration = CFG.get_instance().DATA_UPDATE_ALLOWED_DURATION
172
+    if edit_duration<0:
173
+        return True
174
+    elif edit_duration==0:
175
+        return False
176
+    else:
177
+        time_limit = item.created + datetime.timedelta(0, edit_duration)
178
+        logger.warning(is_item_still_editable, 'limit is: {}'.format(time_limit))
179
+        if datetime.datetime.now() < time_limit:
180
+            return True
181
+    return False
182
+
153
 from tracim.config.app_cfg import CFG as CFG_ORI
183
 from tracim.config.app_cfg import CFG as CFG_ORI
154
 CFG = CFG_ORI.get_instance() # local CFG var is an instance of CFG class found in app_cfg
184
 CFG = CFG_ORI.get_instance() # local CFG var is an instance of CFG class found in app_cfg

+ 12 - 1
tracim/tracim/lib/predicates.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 
2
 
3
-from tg import expose, flash, require, url, lurl, request, redirect, tmpl_context
3
+from tg import abort
4
+from tg import request
5
+from tg import tmpl_context
4
 from tg.i18n import lazy_ugettext as l_
6
 from tg.i18n import lazy_ugettext as l_
7
+from tg.i18n import ugettext as _
5
 from tg.predicates import Predicate
8
 from tg.predicates import Predicate
6
 
9
 
10
+from tracim.model.data import ContentType
7
 from tracim.lib.base import logger
11
 from tracim.lib.base import logger
12
+from tracim.lib.content import ContentApi
8
 
13
 
9
 from tracim.model.data import UserRoleInWorkspace
14
 from tracim.model.data import UserRoleInWorkspace
10
 
15
 
56
     def minimal_role_level(self):
61
     def minimal_role_level(self):
57
         return UserRoleInWorkspace.WORKSPACE_MANAGER
62
         return UserRoleInWorkspace.WORKSPACE_MANAGER
58
 
63
 
64
+def require_current_user_is_owner(item_id: int):
65
+    current_user = tmpl_context.current_user
66
+    item = ContentApi(current_user, True, True).get_one(item_id, ContentType.Any)
67
+
68
+    if item.owner_id!=current_user.user_id:
69
+        abort(403, _('You\'re not allowed to access this resource'))

+ 11 - 1
tracim/tracim/model/data.py View File

287
         'comment': 4,
287
         'comment': 4,
288
     }
288
     }
289
 
289
 
290
+    _DELETE_LABEL = {
291
+        'dashboard': '',
292
+        'workspace': l_('Delete this workspace'),
293
+        'folder': l_('Delete this folder'),
294
+        'file': l_('Delete this file'),
295
+        'page': l_('Delete this page'),
296
+        'thread': l_('Delete this thread'),
297
+        'comment': l_('Delete this comment'),
298
+    }
299
+
290
     @classmethod
300
     @classmethod
291
     def icon(cls, type: str):
301
     def icon(cls, type: str):
292
         assert(type in ContentType._ICONS) # DYN_REMOVE
302
         assert(type in ContentType._ICONS) # DYN_REMOVE
429
     def get_comments(self):
439
     def get_comments(self):
430
         children = []
440
         children = []
431
         for child in self.children:
441
         for child in self.children:
432
-            if child.type==ContentType.Comment:
442
+            if ContentType.Comment==child.type and not child.is_deleted and not child.is_archived:
433
                 children.append(child)
443
                 children.append(child)
434
         return children
444
         return children
435
 
445
 

+ 5 - 1
tracim/tracim/model/serializers.py View File

282
         a_attr = { 'href' : context.url(ContentType.fill_url(content)) },
282
         a_attr = { 'href' : context.url(ContentType.fill_url(content)) },
283
         li_attr = { 'title': content.get_label(), 'class': 'tracim-tree-item-is-a-folder' },
283
         li_attr = { 'title': content.get_label(), 'class': 'tracim-tree-item-is-a-folder' },
284
         type = content.type,
284
         type = content.type,
285
-        state = { 'opened': False, 'selected': False }
285
+        state = { 'opened': True if ContentType.Folder!=content.type else False, 'selected': False }
286
     )
286
     )
287
     return result
287
     return result
288
 
288
 
405
             owner = context.toDict(item.owner),
405
             owner = context.toDict(item.owner),
406
             # REMOVE parent = context.toDict(item.parent),
406
             # REMOVE parent = context.toDict(item.parent),
407
             type = item.type,
407
             type = item.type,
408
+            urls = context.toDict({
409
+                'delete': context.url('/workspaces/{wid}/folders/{fid}/{ctype}/{cid}/comments/{commentid}/put_delete'.format(wid = item.workspace_id, fid=item.parent.parent_id, ctype=item.parent.type+'s', cid=item.parent.content_id, commentid=item.content_id))
410
+            })
408
         )
411
         )
409
 
412
 
410
     if item.type==ContentType.Folder:
413
     if item.type==ContentType.Folder:
771
 def serialize_node_tree_item_for_menu_api_tree(item: NodeTreeItem, context: Context):
774
 def serialize_node_tree_item_for_menu_api_tree(item: NodeTreeItem, context: Context):
772
     if isinstance(item.node, Content):
775
     if isinstance(item.node, Content):
773
         ContentType.fill_url(item.node)
776
         ContentType.fill_url(item.node)
777
+
774
         return DictLikeClass(
778
         return DictLikeClass(
775
             id=CST.TREEVIEW_MENU.ID_TEMPLATE__FULL.format(item.node.workspace_id, item.node.content_id),
779
             id=CST.TREEVIEW_MENU.ID_TEMPLATE__FULL.format(item.node.workspace_id, item.node.content_id),
776
             children=True if ContentType.Folder==item.node.type and len(item.children)<=0 else context.toDict(item.children),
780
             children=True if ContentType.Folder==item.node.type and len(item.children)<=0 else context.toDict(item.children),

+ 2 - 2
tracim/tracim/public/assets/css/dashboard.css View File

155
 .tracim-status-selected { background-color: #EEE; }
155
 .tracim-status-selected { background-color: #EEE; }
156
 .tracim-panel-separator { border-width: 12px 0 0 0; }
156
 .tracim-panel-separator { border-width: 12px 0 0 0; }
157
 
157
 
158
-.tracim-thread-item {}
159
-.tracim-thread-item-content { margin-left: 12px; border-left: 8px solid #EEE; padding: 0 0.5em; }
158
+.tracim-timeline-item {}
159
+.tracim-timeline-item-content { margin-left: 12px; border-left: 8px solid #EEE; padding: 0 0.5em; }
160
 
160
 
161
 #tracim-footer-separator { margin-bottom: 30px; }
161
 #tracim-footer-separator { margin-bottom: 30px; }
162
 .pod-footer {
162
 .pod-footer {

+ 1 - 20
tracim/tracim/templates/user_workspace_folder_thread_get_one.mak View File

74
 % endif 
74
 % endif 
75
 
75
 
76
 % for comment in result.thread.comments:
76
 % for comment in result.thread.comments:
77
-    <div class="tracim-thread-item">
78
-        <h5 style="margin: 0;">
79
-            <div class="pull-right text-right">
80
-                <div class="label" style="font-size: 10px; border: 1px solid #CCC; color: #777; ">
81
-                    ${h.format_short(comment.created)|n}
82
-                </div>
83
-                <br/>
84
-## SHOW REMOVE ACTION                <a class="btn btn-default btn-xs" style="margin-top: 6px;" href="">
85
-##                <img src="assets/icons/16x16/places/user-trash.png" title="Supprimer ce commentaire"></a>
86
-            </div>
87
-   
88
-            ${TIM.ICO(32, comment.icon)}
89
-            <span class="tracim-less-visible">${_('<strong>{}</strong> wrote:').format(comment.owner.name)|n}</span>
90
-        </h5>
91
-        <div class="tracim-thread-item-content">
92
-            <div>${comment.content|n}</div>
93
-            <br/>
94
-        </div>
95
-    </div>
96
-
77
+    ${WIDGETS.SECURED_TIMELINE_ITEM(fake_api.current_user, comment)}
97
 % endfor
78
 % endfor
98
 
79
 
99
 ## <hr class="tracim-panel-separator"/>
80
 ## <hr class="tracim-panel-separator"/>

+ 25 - 0
tracim/tracim/templates/user_workspace_widgets.mak View File

264
         % endif
264
         % endif
265
     </div>
265
     </div>
266
 </%def>
266
 </%def>
267
+
268
+<%def name="SECURED_TIMELINE_ITEM(user, item)">
269
+    <div class="tracim-timeline-item">
270
+        <h5 style="margin: 0;">
271
+            ${TIM.ICO(32, item.icon)}
272
+            <span class="tracim-less-visible">${_('<strong>{}</strong> wrote:').format(item.owner.name)|n}</span>
273
+
274
+            <div class="pull-right text-right">
275
+                <div class="label" style="font-size: 10px; border: 1px solid #CCC; color: #777; ">
276
+                    ${h.format_short(item.created)|n}
277
+                </div>
278
+                % if h.is_item_still_editable(item) and item.owner.id==user.id:
279
+                    <br/>
280
+                    <div class="btn-group">
281
+                    <a class="btn btn-default btn-xs" style="margin-top: 8px; padding-bottom: 3px;" href="${item.urls.delete}">${TIM.ICO_TOOLTIP(16, 'status/user-trash-full', h.delete_label_for_item(item))}</a>
282
+                    </div>
283
+                % endif
284
+            </div>
285
+        </h5>
286
+        <div class="tracim-timeline-item-content">
287
+            <div>${item.content|n}</div>
288
+            <br/>
289
+        </div>
290
+    </div>
291
+</%def>

+ 40 - 0
tracim/tracim/tests/library/test_helpers.py View File

1
+# -*- coding: utf-8 -*-
2
+
3
+import datetime
4
+
5
+from nose.tools import eq_
6
+from nose.tools import ok_
7
+
8
+import tracim.lib.helpers as h
9
+from tracim.model.data import Content
10
+from tracim.model.data import ContentType
11
+from tracim.model.data import Workspace
12
+
13
+from tracim.model.serializers import Context
14
+from tracim.model.serializers import CTX
15
+from tracim.model.serializers import DictLikeClass
16
+
17
+from tracim.tests import TestStandard
18
+
19
+
20
+
21
+class TestHelpers(TestStandard):
22
+
23
+    def test_is_item_still_editable(self):
24
+        item = DictLikeClass()
25
+
26
+        h.CFG.DATA_UPDATE_ALLOWED_DURATION = 0
27
+        item.created = datetime.datetime.now() - datetime.timedelta(0, 10)
28
+        eq_(False, h.is_item_still_editable(item))
29
+
30
+        h.CFG.DATA_UPDATE_ALLOWED_DURATION = -1
31
+        item.created = datetime.datetime.now() - datetime.timedelta(0, 10)
32
+        eq_(True, h.is_item_still_editable(item))
33
+
34
+        h.CFG.DATA_UPDATE_ALLOWED_DURATION = 12
35
+        item.created = datetime.datetime.now() - datetime.timedelta(0, 10)
36
+        eq_(True, h.is_item_still_editable(item), 'created: {}, now: {}'.format(item.created, datetime.datetime.now())) # This test will pass only if the test duration is less than 120s !!!
37
+
38
+        h.CFG.DATA_UPDATE_ALLOWED_DURATION = 8
39
+        item.created = datetime.datetime.now() - datetime.timedelta(0, 10)
40
+        eq_(False, h.is_item_still_editable(item))

+ 52 - 0
tracim/tracim/tests/library/test_serializers.py View File

10
 
10
 
11
 
11
 
12
 from tracim.model.data import Content
12
 from tracim.model.data import Content
13
+from tracim.model.data import ContentType
14
+from tracim.model.data import Workspace
13
 
15
 
14
 from tracim.model.serializers import Context
16
 from tracim.model.serializers import Context
15
 from tracim.model.serializers import ContextConverterNotFoundException
17
 from tracim.model.serializers import ContextConverterNotFoundException
89
 
91
 
90
 
92
 
91
 
93
 
94
+    def test_serialize_Content_comment_THREAD(self):
95
+
96
+        wor = Workspace()
97
+        wor.workspace_id = 4
98
+
99
+        fol = Content()
100
+        fol.type = ContentType.Folder
101
+        fol.content_id = 72
102
+        fol.workspace = wor
103
+
104
+        par = Content()
105
+        par.type = ContentType.Thread
106
+        par.content_id = 37
107
+        par.parent = fol
108
+        par.workspace = wor
109
+
110
+        obj = Content()
111
+        obj.type = ContentType.Comment
112
+        obj.content_id = 132
113
+        obj.label = 'some label'
114
+        obj.description = 'Some Description'
115
+        obj.parent = par
116
+
117
+        res = Context(CTX.THREAD).toDict(obj)
118
+        eq_(res.__class__, DictLikeClass, res)
119
+
120
+        ok_('label' in res.keys())
121
+        eq_(obj.label, res.label, res)
122
+
123
+        ok_('content' in res.keys())
124
+        eq_(obj.description, res.content, res)
125
+
126
+        ok_('created' in res.keys())
127
+
128
+        ok_('icon' in res.keys())
129
+        eq_(ContentType.icon(obj.type), res.icon, res)
130
+
131
+        ok_('id' in res.folder.keys())
132
+        eq_(obj.content_id, res.id, res)
133
+
134
+        ok_('owner' in res.folder.keys())
135
+        eq_(None, res.owner, res) # TODO - test with a owner value
136
+
137
+        ok_('type' in res.folder.keys())
138
+        eq_(obj.type, res.type, res)
139
+
140
+        ok_('urls' in res.folder.keys())
141
+        ok_('delete' in res.urls.keys())
142
+
143
+        eq_(8, len(res.keys()), res)