Browse Source

Merge pull request #6 from lebouquetin/master

Tracim 10 years ago
parent
commit
dcba4ce7a7

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

@@ -115,6 +115,17 @@ resetpassword.smtp_login = smtp.login
115 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 129
 # The following parameters allow to personalize the home page
119 130
 # They are html ready (you can put html tags they will be interpreted)
120 131
 website.title = TRACIM

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

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

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

@@ -6,6 +6,8 @@ from cgi import FieldStorage
6 6
 import tg
7 7
 from tg import tmpl_context
8 8
 from tg.i18n import ugettext as _
9
+from tg.predicates import not_anonymous
10
+
9 11
 import traceback
10 12
 
11 13
 from tracim.controllers import TIMRestController
@@ -21,6 +23,7 @@ from tracim.lib.helpers import convert_id_into_instances
21 23
 from tracim.lib.predicates import current_user_is_reader
22 24
 from tracim.lib.predicates import current_user_is_contributor
23 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 28
 from tracim.model.serializers import Context, CTX, DictLikeClass
26 29
 from tracim.model.data import ActionDescription
@@ -28,17 +31,24 @@ from tracim.model.data import Content
28 31
 from tracim.model.data import ContentType
29 32
 from tracim.model.data import Workspace
30 33
 
31
-
32 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 44
     def _before(self, *args, **kw):
35 45
         TIMRestPathContextSetup.current_user()
36 46
         TIMRestPathContextSetup.current_workspace()
37 47
         TIMRestPathContextSetup.current_folder()
38 48
         TIMRestPathContextSetup.current_thread()
39 49
 
40
-    @tg.require(current_user_is_contributor())
41 50
     @tg.expose()
51
+    @tg.require(current_user_is_contributor())
42 52
     def post(self, content=''):
43 53
         # TODO - SECURE THIS
44 54
         workspace = tmpl_context.workspace
@@ -55,6 +65,70 @@ class UserWorkspaceFolderThreadCommentRestController(TIMRestController):
55 65
         tg.flash(_('Comment added'), CST.STATUS_OK)
56 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 133
 class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
60 134
     """

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

@@ -3,14 +3,18 @@
3 3
 """WebHelpers used in tracim."""
4 4
 
5 5
 #from webhelpers import date, feedgenerator, html, number, misc, text
6
+
7
+import datetime
6 8
 from markupsafe import Markup
7
-from datetime import datetime
8 9
 
9 10
 import tg
10 11
 
12
+from tracim.config.app_cfg import CFG
13
+
11 14
 from tracim.lib import app_globals as plag
12 15
 
13 16
 from tracim.lib import CST
17
+from tracim.lib.base import logger
14 18
 from tracim.lib.content import ContentApi
15 19
 from tracim.lib.workspace import WorkspaceApi
16 20
 
@@ -39,7 +43,7 @@ def user_friendly_file_size(file_size: int):
39 43
             return '{:.3f} Mo'.format(int(mega_size))
40 44
 
41 45
 def current_year():
42
-  now = datetime.now()
46
+  now = datetime.datetime.now()
43 47
   return now.strftime('%Y')
44 48
 
45 49
 def formatLongDateAndTime(datetime_object, format=''):
@@ -150,5 +154,31 @@ def user_role(user, workspace) -> int:
150 154
 
151 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 183
 from tracim.config.app_cfg import CFG as CFG_ORI
154 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,10 +1,15 @@
1 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 6
 from tg.i18n import lazy_ugettext as l_
7
+from tg.i18n import ugettext as _
5 8
 from tg.predicates import Predicate
6 9
 
10
+from tracim.model.data import ContentType
7 11
 from tracim.lib.base import logger
12
+from tracim.lib.content import ContentApi
8 13
 
9 14
 from tracim.model.data import UserRoleInWorkspace
10 15
 
@@ -56,3 +61,9 @@ class current_user_is_workspace_manager(WorkspaceRelatedPredicate):
56 61
     def minimal_role_level(self):
57 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,6 +287,16 @@ class ContentType(object):
287 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 300
     @classmethod
291 301
     def icon(cls, type: str):
292 302
         assert(type in ContentType._ICONS) # DYN_REMOVE
@@ -429,7 +439,7 @@ class Content(DeclarativeBase):
429 439
     def get_comments(self):
430 440
         children = []
431 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 443
                 children.append(child)
434 444
         return children
435 445
 

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

@@ -282,7 +282,7 @@ def serialize_content_for_menu_api(content: Content, context: Context):
282 282
         a_attr = { 'href' : context.url(ContentType.fill_url(content)) },
283 283
         li_attr = { 'title': content.get_label(), 'class': 'tracim-tree-item-is-a-folder' },
284 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 287
     return result
288 288
 
@@ -405,6 +405,9 @@ def serialize_node_for_page(item: Content, context: Context):
405 405
             owner = context.toDict(item.owner),
406 406
             # REMOVE parent = context.toDict(item.parent),
407 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 413
     if item.type==ContentType.Folder:
@@ -771,6 +774,7 @@ def serialize_workspace_for_menu_api(workspace: Workspace, context: Context):
771 774
 def serialize_node_tree_item_for_menu_api_tree(item: NodeTreeItem, context: Context):
772 775
     if isinstance(item.node, Content):
773 776
         ContentType.fill_url(item.node)
777
+
774 778
         return DictLikeClass(
775 779
             id=CST.TREEVIEW_MENU.ID_TEMPLATE__FULL.format(item.node.workspace_id, item.node.content_id),
776 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,8 +155,8 @@ iframe { border: 5px solid #b3e7ff; }
155 155
 .tracim-status-selected { background-color: #EEE; }
156 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 161
 #tracim-footer-separator { margin-bottom: 30px; }
162 162
 .pod-footer {

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

@@ -74,26 +74,7 @@ ${WIDGETS.BREADCRUMB('current-page-breadcrumb', fake_api.breadcrumb)}
74 74
 % endif 
75 75
 
76 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 78
 % endfor
98 79
 
99 80
 ## <hr class="tracim-panel-separator"/>

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

@@ -264,3 +264,28 @@
264 264
         % endif
265 265
     </div>
266 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

@@ -0,0 +1,40 @@
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))

+ 30 - 47
tracim/tracim/tests/library/test_notification.py View File

@@ -8,59 +8,42 @@ from sqlalchemy.orm.exc import NoResultFound
8 8
 
9 9
 import transaction
10 10
 
11
-from tracim.lib.user import UserApi
12
-from tracim.tests import TestStandard
13
-
14
-
15
-
16
-class TestUserApi(TestStandard):
11
+from tracim.config.app_cfg import CFG
12
+from tracim.lib.notifications import DummyNotifier
13
+from tracim.lib.notifications import EST
14
+from tracim.lib.notifications import NotifierFactory
15
+from tracim.lib.notifications import RealNotifier
16
+from tracim.model.auth import User
17
+from tracim.model.data import Content
17 18
 
18
-    def test_create_and_update_user(self):
19
-        api = UserApi(None)
20
-        u = api.create_user()
21
-        api.update(u, 'bob', 'bob@bob', True)
22
-
23
-        nu = api.get_one_by_email('bob@bob')
24
-        ok_(nu!=None)
25
-        eq_('bob@bob', nu.email)
26
-        eq_('bob', nu.display_name)
27
-
28
-
29
-    def test_user_with_email_exists(self):
30
-        api = UserApi(None)
31
-        u = api.create_user()
32
-        api.update(u, 'bibi', 'bibi@bibi', True)
33
-        transaction.commit()
19
+from tracim.tests import TestStandard
34 20
 
35
-        eq_(True, api.user_with_email_exists('bibi@bibi'))
36
-        eq_(False, api.user_with_email_exists('unknown'))
37 21
 
22
+class TestDummyNotifier(TestStandard):
38 23
 
39
-    def test_get_one_by_email(self):
40
-        api = UserApi(None)
41
-        u = api.create_user()
42
-        api.update(u, 'bibi', 'bibi@bibi', True)
43
-        uid = u.user_id
44
-        transaction.commit()
24
+    def test_dummy_notifier__notify_content_update(self):
25
+        c = Content()
26
+        notifier = DummyNotifier()
27
+        notifier.notify_content_update(c)
28
+        # INFO - D.A. - 2014-12-09 - Old notification_content_update raised an exception
45 29
 
46
-        eq_(uid, api.get_one_by_email('bibi@bibi').user_id)
30
+    def test_notifier_factory_method(self):
31
+        u = User()
47 32
 
48
-    @raises(NoResultFound)
49
-    def test_get_one_by_email_exception(self):
50
-        api = UserApi(None)
51
-        api.get_one_by_email('unknown')
33
+        cfg = CFG.get_instance()
34
+        cfg.EMAIL_NOTIFICATION_ACTIVATED = True
35
+        notifier = NotifierFactory.create(u)
36
+        eq_(RealNotifier, notifier.__class__)
52 37
 
53
-    def test_get_all(self):
54
-        api = UserApi(None)
55
-        # u1 = api.create_user(True)
56
-        # u2 = api.create_user(True)
38
+        cfg.EMAIL_NOTIFICATION_ACTIVATED = False
39
+        notifier = NotifierFactory.create(u)
40
+        eq_(DummyNotifier, notifier.__class__)
57 41
 
58
-        # users = api.get_all()
59
-        # ok_(2==len(users))
42
+    def test_email_subject_tag_list(self):
43
+        tags = EST.all()
60 44
 
61
-    def test_get_one(self):
62
-        api = UserApi(None)
63
-        u = api.create_user()
64
-        api.update(u, 'titi', 'titi@titi', True)
65
-        one = api.get_one(u.user_id)
66
-        eq_(u.user_id, one.user_id)
45
+        eq_(4,len(tags))
46
+        ok_('{website_title}' in tags)
47
+        ok_('{workspace_label}' in tags)
48
+        ok_('{content_label}' in tags)
49
+        ok_('{content_status_label}' in tags)

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

@@ -10,6 +10,8 @@ import transaction
10 10
 
11 11
 
12 12
 from tracim.model.data import Content
13
+from tracim.model.data import ContentType
14
+from tracim.model.data import Workspace
13 15
 
14 16
 from tracim.model.serializers import Context
15 17
 from tracim.model.serializers import ContextConverterNotFoundException
@@ -89,3 +91,53 @@ class TestSerializers(TestStandard):
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)