瀏覽代碼

Delete Content database view

Bastien Sevajol 9 年之前
父節點
當前提交
712ca48573

+ 19 - 13
tracim/tracim/controllers/__init__.py 查看文件

16
 from tracim.lib.predicates import current_user_is_content_manager
16
 from tracim.lib.predicates import current_user_is_content_manager
17
 
17
 
18
 from tracim.model.auth import User
18
 from tracim.model.auth import User
19
-from tracim.model.data import ActionDescription
19
+from tracim.model.data import ActionDescription, new_revision
20
 from tracim.model.data import BreadcrumbItem
20
 from tracim.model.data import BreadcrumbItem
21
 from tracim.model.data import Content
21
 from tracim.model.data import Content
22
 from tracim.model.data import ContentType
22
 from tracim.model.data import ContentType
267
         try:
267
         try:
268
             api = ContentApi(tmpl_context.current_user)
268
             api = ContentApi(tmpl_context.current_user)
269
             item = api.get_one(int(item_id), self._item_type, workspace)
269
             item = api.get_one(int(item_id), self._item_type, workspace)
270
-            api.update_content(item, label, content)
271
-            api.save(item, ActionDescription.REVISION)
270
+            with new_revision(item):
271
+                api.update_content(item, label, content)
272
+                api.save(item, ActionDescription.REVISION)
272
 
273
 
273
             msg = _('{} updated').format(self._item_type_label)
274
             msg = _('{} updated').format(self._item_type_label)
274
             tg.flash(msg, CST.STATUS_OK)
275
             tg.flash(msg, CST.STATUS_OK)
292
         content_api = ContentApi(tmpl_context.current_user)
293
         content_api = ContentApi(tmpl_context.current_user)
293
         item = content_api.get_one(item_id, self._item_type, tmpl_context.workspace)
294
         item = content_api.get_one(item_id, self._item_type, tmpl_context.workspace)
294
         try:
295
         try:
295
-            content_api.set_status(item, status)
296
-            content_api.save(item, ActionDescription.STATUS_UPDATE)
296
+            with new_revision(item):
297
+                content_api.set_status(item, status)
298
+                content_api.save(item, ActionDescription.STATUS_UPDATE)
297
             msg = _('{} status updated').format(self._item_type_label)
299
             msg = _('{} status updated').format(self._item_type_label)
298
             tg.flash(msg, CST.STATUS_OK)
300
             tg.flash(msg, CST.STATUS_OK)
299
             tg.redirect(self._std_url.format(item.workspace_id, item.parent_id, item.content_id))
301
             tg.redirect(self._std_url.format(item.workspace_id, item.parent_id, item.content_id))
332
             undo_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)+'/put_archive_undo'
334
             undo_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)+'/put_archive_undo'
333
             msg = _('{} archived. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
335
             msg = _('{} archived. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
334
 
336
 
335
-            content_api.archive(item)
336
-            content_api.save(item, ActionDescription.ARCHIVING)
337
+            with new_revision(item):
338
+                content_api.archive(item)
339
+                content_api.save(item, ActionDescription.ARCHIVING)
337
 
340
 
338
             tg.flash(msg, CST.STATUS_OK, no_escape=True) # TODO allow to come back
341
             tg.flash(msg, CST.STATUS_OK, no_escape=True) # TODO allow to come back
339
             tg.redirect(next_url)
342
             tg.redirect(next_url)
353
         try:
356
         try:
354
             next_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)
357
             next_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)
355
             msg = _('{} unarchived.').format(self._item_type_label)
358
             msg = _('{} unarchived.').format(self._item_type_label)
356
-            content_api.unarchive(item)
357
-            content_api.save(item, ActionDescription.UNARCHIVING)
359
+            with new_revision(item):
360
+                content_api.unarchive(item)
361
+                content_api.save(item, ActionDescription.UNARCHIVING)
358
 
362
 
359
             tg.flash(msg, CST.STATUS_OK)
363
             tg.flash(msg, CST.STATUS_OK)
360
             tg.redirect(next_url )
364
             tg.redirect(next_url )
379
             next_url = self._parent_url.format(item.workspace_id, item.parent_id)
383
             next_url = self._parent_url.format(item.workspace_id, item.parent_id)
380
             undo_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)+'/put_delete_undo'
384
             undo_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)+'/put_delete_undo'
381
             msg = _('{} deleted. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
385
             msg = _('{} deleted. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
382
-            content_api.delete(item)
383
-            content_api.save(item, ActionDescription.DELETION)
386
+            with new_revision(item):
387
+                content_api.delete(item)
388
+                content_api.save(item, ActionDescription.DELETION)
384
 
389
 
385
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
390
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
386
             tg.redirect(next_url)
391
             tg.redirect(next_url)
403
         try:
408
         try:
404
             next_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)
409
             next_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)
405
             msg = _('{} undeleted.').format(self._item_type_label)
410
             msg = _('{} undeleted.').format(self._item_type_label)
406
-            content_api.undelete(item)
407
-            content_api.save(item, ActionDescription.UNDELETION)
411
+            with new_revision(item):
412
+                content_api.undelete(item)
413
+                content_api.save(item, ActionDescription.UNDELETION)
408
 
414
 
409
             tg.flash(msg, CST.STATUS_OK)
415
             tg.flash(msg, CST.STATUS_OK)
410
             tg.redirect(next_url)
416
             tg.redirect(next_url)

+ 71 - 55
tracim/tracim/controllers/content.py 查看文件

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
+import sys
3
+
2
 __author__ = 'damien'
4
 __author__ = 'damien'
3
 
5
 
4
 from cgi import FieldStorage
6
 from cgi import FieldStorage
27
 from tracim.lib.predicates import require_current_user_is_owner
29
 from tracim.lib.predicates import require_current_user_is_owner
28
 
30
 
29
 from tracim.model.serializers import Context, CTX, DictLikeClass
31
 from tracim.model.serializers import Context, CTX, DictLikeClass
30
-from tracim.model.data import ActionDescription
32
+from tracim.model.data import ActionDescription, new_revision
31
 from tracim.model.data import Content
33
 from tracim.model.data import Content
32
 from tracim.model.data import ContentType
34
 from tracim.model.data import ContentType
33
 from tracim.model.data import UserRoleInWorkspace
35
 from tracim.model.data import UserRoleInWorkspace
88
                                                                                                          item_id)
90
                                                                                                          item_id)
89
 
91
 
90
             msg = _('{} deleted. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
92
             msg = _('{} deleted. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
91
-            content_api.delete(item)
92
-            content_api.save(item, ActionDescription.DELETION)
93
+            with new_revision(item):
94
+                content_api.delete(item)
95
+                content_api.save(item, ActionDescription.DELETION)
93
 
96
 
94
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
97
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
95
             tg.redirect(next_url)
98
             tg.redirect(next_url)
116
                                                                              tmpl_context.folder_id,
119
                                                                              tmpl_context.folder_id,
117
                                                                              tmpl_context.thread_id)
120
                                                                              tmpl_context.thread_id)
118
             msg = _('{} undeleted.').format(self._item_type_label)
121
             msg = _('{} undeleted.').format(self._item_type_label)
119
-            content_api.undelete(item)
120
-            content_api.save(item, ActionDescription.UNDELETION)
122
+            with new_revision(item):
123
+                content_api.undelete(item)
124
+                content_api.save(item, ActionDescription.UNDELETION)
121
 
125
 
122
             tg.flash(msg, CST.STATUS_OK)
126
             tg.flash(msg, CST.STATUS_OK)
123
             tg.redirect(next_url)
127
             tg.redirect(next_url)
277
             # TODO - D.A. - 2015-03-19
281
             # TODO - D.A. - 2015-03-19
278
             # refactor this method in order to make code easier to understand
282
             # refactor this method in order to make code easier to understand
279
 
283
 
280
-            if comment and label:
281
-                updated_item = api.update_content(
282
-                    item, label if label else item.label,
283
-                    comment if comment else ''
284
-                )
285
-                api.save(updated_item, ActionDescription.EDITION)
286
-
287
-                # This case is the default "file title and description update"
288
-                # In this case the file itself is not revisionned
289
-
290
-            else:
291
-                # So, now we may have a comment and/or a file revision
292
-                if comment and ''==label:
293
-                    comment_item = api.create_comment(workspace,
294
-                                                      item, comment,
295
-                                                      do_save=False)
296
-
297
-                    if not isinstance(file_data, FieldStorage):
298
-                        api.save(comment_item, ActionDescription.COMMENT)
299
-                    else:
300
-                        # The notification is only sent
301
-                        # if the file is NOT updated
302
-                        #
303
-                        #  If the file is also updated,
304
-                        #  then a 'file revision' notification will be sent.
305
-                        api.save(comment_item,
306
-                                 ActionDescription.COMMENT,
307
-                                 do_notify=False)
308
-
309
-                if isinstance(file_data, FieldStorage):
310
-                    api.update_file_data(item, file_data.filename, file_data.type, file_data.file.read())
311
-                    api.save(item, ActionDescription.REVISION)
284
+            with new_revision(item):
285
+
286
+                if comment and label:
287
+                    updated_item = api.update_content(
288
+                        item, label if label else item.label,
289
+                        comment if comment else ''
290
+                    )
291
+                    api.save(updated_item, ActionDescription.EDITION)
292
+
293
+                    # This case is the default "file title and description update"
294
+                    # In this case the file itself is not revisionned
295
+
296
+                else:
297
+                    # So, now we may have a comment and/or a file revision
298
+                    if comment and ''==label:
299
+                        comment_item = api.create_comment(workspace,
300
+                                                          item, comment,
301
+                                                          do_save=False)
302
+
303
+                        if not isinstance(file_data, FieldStorage):
304
+                            api.save(comment_item, ActionDescription.COMMENT)
305
+                        else:
306
+                            # The notification is only sent
307
+                            # if the file is NOT updated
308
+                            #
309
+                            #  If the file is also updated,
310
+                            #  then a 'file revision' notification will be sent.
311
+                            api.save(comment_item,
312
+                                     ActionDescription.COMMENT,
313
+                                     do_notify=False)
314
+
315
+                    if isinstance(file_data, FieldStorage):
316
+                        api.update_file_data(item, file_data.filename, file_data.type, file_data.file.read())
317
+                        api.save(item, ActionDescription.REVISION)
312
 
318
 
313
             msg = _('{} updated').format(self._item_type_label)
319
             msg = _('{} updated').format(self._item_type_label)
314
             tg.flash(msg, CST.STATUS_OK)
320
             tg.flash(msg, CST.STATUS_OK)
420
         try:
426
         try:
421
             api = ContentApi(tmpl_context.current_user)
427
             api = ContentApi(tmpl_context.current_user)
422
             item = api.get_one(int(item_id), self._item_type, workspace)
428
             item = api.get_one(int(item_id), self._item_type, workspace)
423
-            api.update_content(item, label, content)
424
-            api.save(item, ActionDescription.REVISION)
429
+            with new_revision(item):
430
+                api.update_content(item, label, content)
431
+                api.save(item, ActionDescription.REVISION)
425
 
432
 
426
             msg = _('{} updated').format(self._item_type_label)
433
             msg = _('{} updated').format(self._item_type_label)
427
             tg.flash(msg, CST.STATUS_OK)
434
             tg.flash(msg, CST.STATUS_OK)
600
 
607
 
601
             api = ContentApi(tmpl_context.current_user)
608
             api = ContentApi(tmpl_context.current_user)
602
             item = api.get_one(item_id, ContentType.Any, workspace)
609
             item = api.get_one(item_id, ContentType.Any, workspace)
603
-            api.move_recursively(item, new_parent, new_workspace)
610
+
611
+            with new_revision(item):
612
+                api.move_recursively(item, new_parent, new_workspace)
604
 
613
 
605
             next_url = tg.url('/workspaces/{}/folders/{}'.format(
614
             next_url = tg.url('/workspaces/{}/folders/{}'.format(
606
                 new_workspace.workspace_id, item_id))
615
                 new_workspace.workspace_id, item_id))
618
             # Default move inside same workspace
627
             # Default move inside same workspace
619
             api = ContentApi(tmpl_context.current_user)
628
             api = ContentApi(tmpl_context.current_user)
620
             item = api.get_one(item_id, ContentType.Any, workspace)
629
             item = api.get_one(item_id, ContentType.Any, workspace)
621
-            api.move(item, new_parent)
630
+            with new_revision(item):
631
+                api.move(item, new_parent)
622
             next_url = self.parent_controller.url(item_id)
632
             next_url = self.parent_controller.url(item_id)
623
             if new_parent:
633
             if new_parent:
624
                 tg.flash(_('Item moved to {}').format(new_parent.label), CST.STATUS_OK)
634
                 tg.flash(_('Item moved to {}').format(new_parent.label), CST.STATUS_OK)
761
             logger.error(self, 'An unexpected exception has been catched. Look at the traceback below.')
771
             logger.error(self, 'An unexpected exception has been catched. Look at the traceback below.')
762
             traceback.print_exc()
772
             traceback.print_exc()
763
 
773
 
764
-            tg.flash(_('Folder not created: {}').format(e.with_traceback()), CST.STATUS_ERROR)
774
+            tb = sys.exc_info()[2]
775
+            tg.flash(_('Folder not created: {}').format(e.with_traceback(tb)), CST.STATUS_ERROR)
765
             if parent_id:
776
             if parent_id:
766
                 redirect_url = redirect_url_tmpl.format(tmpl_context.workspace_id, parent_id)
777
                 redirect_url = redirect_url_tmpl.format(tmpl_context.workspace_id, parent_id)
767
             else:
778
             else:
792
                 file = True if can_contain_files=='on' else False,
803
                 file = True if can_contain_files=='on' else False,
793
                 page = True if can_contain_pages=='on' else False
804
                 page = True if can_contain_pages=='on' else False
794
             )
805
             )
795
-            if label != folder.label:
796
-                # TODO - D.A. - 2015-05-25 - Allow to set folder description
797
-                api.update_content(folder, label, folder.description)
798
-            api.set_allowed_content(folder, subcontent)
799
-            api.save(folder)
806
+            with new_revision(folder):
807
+                if label != folder.label:
808
+                    # TODO - D.A. - 2015-05-25 - Allow to set folder description
809
+                    api.update_content(folder, label, folder.description)
810
+                api.set_allowed_content(folder, subcontent)
811
+                api.save(folder)
800
 
812
 
801
             tg.flash(_('Folder updated'), CST.STATUS_OK)
813
             tg.flash(_('Folder updated'), CST.STATUS_OK)
802
 
814
 
838
             undo_url = self._std_url.format(item.workspace_id, item.content_id)+'/put_archive_undo'
850
             undo_url = self._std_url.format(item.workspace_id, item.content_id)+'/put_archive_undo'
839
             msg = _('{} archived. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
851
             msg = _('{} archived. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
840
 
852
 
841
-            content_api.archive(item)
842
-            content_api.save(item, ActionDescription.ARCHIVING)
853
+            with new_revision(item):
854
+                content_api.archive(item)
855
+                content_api.save(item, ActionDescription.ARCHIVING)
843
 
856
 
844
             tg.flash(msg, CST.STATUS_OK, no_escape=True) # TODO allow to come back
857
             tg.flash(msg, CST.STATUS_OK, no_escape=True) # TODO allow to come back
845
             tg.redirect(next_url)
858
             tg.redirect(next_url)
860
         try:
873
         try:
861
             next_url = self._std_url.format(item.workspace_id, item.content_id)
874
             next_url = self._std_url.format(item.workspace_id, item.content_id)
862
             msg = _('{} unarchived.').format(self._item_type_label)
875
             msg = _('{} unarchived.').format(self._item_type_label)
863
-            content_api.unarchive(item)
864
-            content_api.save(item, ActionDescription.UNARCHIVING)
876
+            with new_revision(item):
877
+                content_api.unarchive(item)
878
+                content_api.save(item, ActionDescription.UNARCHIVING)
865
 
879
 
866
             tg.flash(msg, CST.STATUS_OK)
880
             tg.flash(msg, CST.STATUS_OK)
867
             tg.redirect(next_url )
881
             tg.redirect(next_url )
885
             next_url = self._parent_url.format(item.workspace_id, item.parent_id)
899
             next_url = self._parent_url.format(item.workspace_id, item.parent_id)
886
             undo_url = self._std_url.format(item.workspace_id, item.content_id)+'/put_delete_undo'
900
             undo_url = self._std_url.format(item.workspace_id, item.content_id)+'/put_delete_undo'
887
             msg = _('{} deleted. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
901
             msg = _('{} deleted. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
888
-            content_api.delete(item)
889
-            content_api.save(item, ActionDescription.DELETION)
902
+            with new_revision(item):
903
+                content_api.delete(item)
904
+                content_api.save(item, ActionDescription.DELETION)
890
 
905
 
891
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
906
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
892
             tg.redirect(next_url)
907
             tg.redirect(next_url)
909
         try:
924
         try:
910
             next_url = self._std_url.format(item.workspace_id, item.content_id)
925
             next_url = self._std_url.format(item.workspace_id, item.content_id)
911
             msg = _('{} undeleted.').format(self._item_type_label)
926
             msg = _('{} undeleted.').format(self._item_type_label)
912
-            content_api.undelete(item)
913
-            content_api.save(item, ActionDescription.UNDELETION)
927
+            with new_revision(item):
928
+                content_api.undelete(item)
929
+                content_api.save(item, ActionDescription.UNDELETION)
914
 
930
 
915
             tg.flash(msg, CST.STATUS_OK)
931
             tg.flash(msg, CST.STATUS_OK)
916
             tg.redirect(next_url)
932
             tg.redirect(next_url)

+ 30 - 8
tracim/tracim/lib/content.py 查看文件

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
-
3
 __author__ = 'damien'
2
 __author__ = 'damien'
4
 
3
 
5
 import datetime
4
 import datetime
14
 from sqlalchemy.orm.attributes import get_history
13
 from sqlalchemy.orm.attributes import get_history
15
 from sqlalchemy import desc
14
 from sqlalchemy import desc
16
 from sqlalchemy import distinct
15
 from sqlalchemy import distinct
17
-from sqlalchemy import not_
18
 from sqlalchemy import or_
16
 from sqlalchemy import or_
17
+from sqlalchemy.sql.elements import and_
19
 from tracim.lib import cmp_to_key
18
 from tracim.lib import cmp_to_key
20
 from tracim.lib.notifications import NotifierFactory
19
 from tracim.lib.notifications import NotifierFactory
21
 from tracim.lib.utils import SameValueError
20
 from tracim.lib.utils import SameValueError
22
 from tracim.model import DBSession
21
 from tracim.model import DBSession
23
 from tracim.model.auth import User
22
 from tracim.model.auth import User
24
-from tracim.model.data import ActionDescription
23
+from tracim.model.data import ActionDescription, new_revision
25
 from tracim.model.data import BreadcrumbItem
24
 from tracim.model.data import BreadcrumbItem
26
 from tracim.model.data import ContentStatus
25
 from tracim.model.data import ContentStatus
27
 from tracim.model.data import ContentRevisionRO
26
 from tracim.model.data import ContentRevisionRO
76
         self._show_deleted = show_deleted
75
         self._show_deleted = show_deleted
77
         self._show_all_type_of_contents_in_treeview = all_content_in_treeview
76
         self._show_all_type_of_contents_in_treeview = all_content_in_treeview
78
 
77
 
78
+    @classmethod
79
+    def get_revision_join(cls):
80
+        """
81
+        Return the Content/ContentRevision query join condition
82
+        :return: Content/ContentRevision query join condition
83
+        :rtype sqlalchemy.sql.elements.BooleanClauseList
84
+        """
85
+        return and_(Content.id == ContentRevisionRO.content_id,
86
+                    ContentRevisionRO.revision_id == DBSession.query(
87
+                        ContentRevisionRO.revision_id)
88
+                    .filter(ContentRevisionRO.content_id == Content.id)
89
+                    .order_by(ContentRevisionRO.revision_id.desc())
90
+                    .limit(1)
91
+                    .correlate(Content))
92
+
93
+    @classmethod
94
+    def get_base_query(cls):
95
+        """
96
+        Return the Content/ContentRevision base query who join these table on the last revision.
97
+        :return: Content/ContentRevision Query
98
+        :rtype sqlalchemy.orm.query.Query
99
+        """
100
+        return DBSession.query(Content).join(ContentRevisionRO, cls.get_revision_join())
79
 
101
 
80
     @classmethod
102
     @classmethod
81
     def sort_tree_items(cls, content_list: [NodeTreeItem])-> [Content]:
103
     def sort_tree_items(cls, content_list: [NodeTreeItem])-> [Content]:
134
         return breadcrumb
156
         return breadcrumb
135
 
157
 
136
     def __real_base_query(self, workspace: Workspace=None):
158
     def __real_base_query(self, workspace: Workspace=None):
137
-        result = DBSession.query(Content)
159
+        result = self.get_base_query()
138
 
160
 
139
         if workspace:
161
         if workspace:
140
             result = result.filter(Content.workspace_id==workspace.workspace_id)
162
             result = result.filter(Content.workspace_id==workspace.workspace_id)
458
         self.save(item, do_notify=False)
480
         self.save(item, do_notify=False)
459
 
481
 
460
         for child in item.children:
482
         for child in item.children:
461
-            self.move_recursively(child, item, new_workspace)
483
+            with new_revision(child):
484
+                self.move_recursively(child, item, new_workspace)
462
         return
485
         return
463
 
486
 
464
     def update_content(self, item: Content, new_label: str, new_content: str=None) -> Content:
487
     def update_content(self, item: Content, new_label: str, new_content: str=None) -> Content:
488
         content.is_archived = False
511
         content.is_archived = False
489
         content.revision_type = ActionDescription.UNARCHIVING
512
         content.revision_type = ActionDescription.UNARCHIVING
490
 
513
 
491
-
492
     def delete(self, content: Content):
514
     def delete(self, content: Content):
493
         content.owner = self._user
515
         content.owner = self._user
494
         content.is_deleted = True
516
         content.is_deleted = True
576
 
598
 
577
         if not action_description:
599
         if not action_description:
578
             # See if the last action has been modified
600
             # See if the last action has been modified
579
-            if content.revision_type==None or len(get_history(content, 'revision_type'))<=0:
601
+            if content.revision_type==None or len(get_history(content.revision, 'revision_type'))<=0:
580
                 # The action has not been modified, so we set it to default edition
602
                 # The action has not been modified, so we set it to default edition
581
                 action_description = ActionDescription.EDITION
603
                 action_description = ActionDescription.EDITION
582
 
604
 
638
         filter_group_desc = list(Content.description.ilike('%{}%'.format(keyword)) for keyword in keywords)
660
         filter_group_desc = list(Content.description.ilike('%{}%'.format(keyword)) for keyword in keywords)
639
         title_keyworded_items = self._hard_filtered_base_query().\
661
         title_keyworded_items = self._hard_filtered_base_query().\
640
             filter(or_(*(filter_group_label+filter_group_desc))).\
662
             filter(or_(*(filter_group_label+filter_group_desc))).\
641
-            options(joinedload('children')).\
663
+            options(joinedload('children_revisions')).\
642
             options(joinedload('parent'))
664
             options(joinedload('parent'))
643
 
665
 
644
         return title_keyworded_items
666
         return title_keyworded_items

+ 12 - 0
tracim/tracim/lib/exception.py 查看文件

5
     pass
5
     pass
6
 
6
 
7
 
7
 
8
+class RunTimeError(TracimError):
9
+    pass
10
+
11
+
12
+class ContentRevisionUpdateError(RuntimeError):
13
+    pass
14
+
15
+
16
+class ContentRevisionDeleteError(ContentRevisionUpdateError):
17
+    pass
18
+
19
+
8
 class ConfigurationError(TracimError):
20
 class ConfigurationError(TracimError):
9
     pass
21
     pass
10
 
22
 

+ 36 - 4
tracim/tracim/model/__init__.py 查看文件

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 """The application's model objects"""
2
 """The application's model objects"""
3
+from sqlalchemy import event, inspect
4
+from sqlalchemy.ext.declarative import declarative_base
5
+from sqlalchemy.orm import scoped_session, sessionmaker
3
 
6
 
4
 from zope.sqlalchemy import ZopeTransactionExtension
7
 from zope.sqlalchemy import ZopeTransactionExtension
5
-from sqlalchemy.orm import scoped_session, sessionmaker
6
-#from sqlalchemy import MetaData
7
-from sqlalchemy.ext.declarative import declarative_base
8
+
9
+from tracim.lib.exception import ContentRevisionUpdateError, ContentRevisionDeleteError
10
+
11
+
12
+class RevisionsIntegrity(object):
13
+    _updatable_revisions = []
14
+
15
+    @classmethod
16
+    def add_to_updatable(cls, revision):
17
+        if inspect(revision).has_identity:
18
+            raise ContentRevisionUpdateError("ContentRevision is not updatable. %s already have identity." % revision)
19
+
20
+        if revision not in cls._updatable_revisions:
21
+            cls._updatable_revisions.append(revision)
22
+
23
+    @classmethod
24
+    def remove_from_updatable(cls, revision):
25
+        cls._updatable_revisions.remove(revision)
26
+
27
+    @classmethod
28
+    def is_updatable(cls, revision):
29
+        return revision in cls._updatable_revisions
8
 
30
 
9
 # Global session manager: DBSession() returns the Thread-local
31
 # Global session manager: DBSession() returns the Thread-local
10
 # session object appropriate for the current web request.
32
 # session object appropriate for the current web request.
38
 #
60
 #
39
 ######
61
 ######
40
 
62
 
63
+
41
 def init_model(engine):
64
 def init_model(engine):
42
     """Call me before using any of the tables or classes in the model."""
65
     """Call me before using any of the tables or classes in the model."""
43
     DBSession.configure(bind=engine)
66
     DBSession.configure(bind=engine)
60
 
83
 
61
 # Import your model modules here.
84
 # Import your model modules here.
62
 from tracim.model.auth import User, Group, Permission
85
 from tracim.model.auth import User, Group, Permission
63
-from tracim.model.data import Content
86
+from tracim.model.data import Content, ContentRevisionRO
87
+
88
+
89
+@event.listens_for(DBSession, 'before_flush')
90
+def prevent_content_revision_delete(session, flush_context, instances):
91
+    for instance in session.deleted:
92
+        if isinstance(instance, ContentRevisionRO) and instance.revision_id is not None:
93
+            raise ContentRevisionDeleteError("ContentRevision is not deletable. You must make a new revision with" +
94
+                                             "is_deleted set to True. Look at tracim.model.data.new_revision context " +
95
+                                             "manager to make a new revision")

+ 520 - 152
tracim/tracim/model/data.py 查看文件

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 
2
 
3
-import tg
4
-from datetime import datetime
5
-from babel.dates import format_timedelta
6
-
7
-from bs4 import BeautifulSoup
8
 import datetime as datetime_root
3
 import datetime as datetime_root
9
 import json
4
 import json
5
+from datetime import datetime
10
 
6
 
11
-from sqlalchemy.ext.associationproxy import association_proxy
12
-from sqlalchemy import Column
13
-from sqlalchemy import func
7
+import tg
8
+from babel.dates import format_timedelta
9
+from bs4 import BeautifulSoup
10
+from decorator import contextmanager
11
+from sqlalchemy import Column, inspect
14
 from sqlalchemy import ForeignKey
12
 from sqlalchemy import ForeignKey
15
 from sqlalchemy import Sequence
13
 from sqlalchemy import Sequence
16
-
14
+from sqlalchemy import func
15
+from sqlalchemy.ext.associationproxy import association_proxy
17
 from sqlalchemy.ext.hybrid import hybrid_property
16
 from sqlalchemy.ext.hybrid import hybrid_property
18
-
19
 from sqlalchemy.orm import backref
17
 from sqlalchemy.orm import backref
20
-from sqlalchemy.orm import relationship
21
 from sqlalchemy.orm import deferred
18
 from sqlalchemy.orm import deferred
19
+from sqlalchemy.orm import relationship
22
 from sqlalchemy.orm.collections import attribute_mapped_collection
20
 from sqlalchemy.orm.collections import attribute_mapped_collection
23
-
24
 from sqlalchemy.types import Boolean
21
 from sqlalchemy.types import Boolean
25
 from sqlalchemy.types import DateTime
22
 from sqlalchemy.types import DateTime
26
 from sqlalchemy.types import Integer
23
 from sqlalchemy.types import Integer
27
 from sqlalchemy.types import LargeBinary
24
 from sqlalchemy.types import LargeBinary
28
 from sqlalchemy.types import Text
25
 from sqlalchemy.types import Text
29
 from sqlalchemy.types import Unicode
26
 from sqlalchemy.types import Unicode
30
-
31
 from tg.i18n import lazy_ugettext as l_, ugettext as _
27
 from tg.i18n import lazy_ugettext as l_, ugettext as _
32
 
28
 
33
-from tracim.model import DeclarativeBase
29
+from tracim.lib.exception import ContentRevisionUpdateError
30
+from tracim.model import DeclarativeBase, DBSession, RevisionsIntegrity
34
 from tracim.model.auth import User
31
 from tracim.model.auth import User
35
 
32
 
33
+
36
 class BreadcrumbItem(object):
34
 class BreadcrumbItem(object):
37
 
35
 
38
     def __init__(self, icon_string: str, label: str, url: str, is_active: bool = False):
36
     def __init__(self, icon_string: str, label: str, url: str, is_active: bool = False):
54
     label   = Column(Unicode(1024), unique=False, nullable=False, default='')
52
     label   = Column(Unicode(1024), unique=False, nullable=False, default='')
55
     description = Column(Text(), unique=False, nullable=False, default='')
53
     description = Column(Text(), unique=False, nullable=False, default='')
56
 
54
 
57
-    created = Column(DateTime, unique=False, nullable=False)
58
-    updated = Column(DateTime, unique=False, nullable=False)
55
+    created = Column(DateTime, unique=False, nullable=False, default=datetime.now())
56
+    updated = Column(DateTime, unique=False, nullable=False, default=datetime.now())
59
 
57
 
60
     is_deleted = Column(Boolean, unique=False, nullable=False, default=False)
58
     is_deleted = Column(Boolean, unique=False, nullable=False, default=False)
61
 
59
 
60
+    revisions = relationship("ContentRevisionRO")
61
+
62
+    @hybrid_property
63
+    def contents(self):
64
+        # Return a list of unique revisions parent content
65
+        return list(set([revision.node for revision in self.revisions]))
66
+
62
     def get_user_role(self, user: User) -> int:
67
     def get_user_role(self, user: User) -> int:
63
         for role in user.roles:
68
         for role in user.roles:
64
             if role.workspace.workspace_id==self.workspace_id:
69
             if role.workspace.workspace_id==self.workspace_id:
435
                     label=self.label,
440
                     label=self.label,
436
                     priority=self.priority)
441
                     priority=self.priority)
437
 
442
 
438
-class Content(DeclarativeBase):
439
 
443
 
440
-    __tablename__ = 'contents'
444
+class ContentChecker(object):
441
 
445
 
442
-    revision_to_serialize = -0  # This flag allow to serialize a given revision if required by the user
446
+    @classmethod
447
+    def check_properties(cls, item):
448
+        if item.type==ContentType.Folder:
449
+            properties = item.properties
450
+            if 'allowed_content' not in properties.keys():
451
+                return False
452
+            if 'folders' not in properties['allowed_content']:
453
+                return False
454
+            if 'files' not in properties['allowed_content']:
455
+                return False
456
+            if 'pages' not in properties['allowed_content']:
457
+                return False
458
+            if 'threads' not in properties['allowed_content']:
459
+                return False
443
 
460
 
444
-    content_id = Column(Integer, Sequence('seq__contents__content_id'), autoincrement=True, primary_key=True)
445
-    parent_id = Column(Integer, ForeignKey('contents.content_id'), nullable=True, default=None)
446
-    owner_id = Column(Integer, ForeignKey('users.user_id'), nullable=True, default=None)
461
+            return True
447
 
462
 
448
-    type = Column(Unicode(32), unique=False, nullable=False)
449
-    status = Column(Unicode(32), unique=False, nullable=False, default=ContentStatus.OPEN)
463
+        raise NotImplementedError
450
 
464
 
451
-    created = Column(DateTime, unique=False, nullable=False)
452
-    updated = Column(DateTime, unique=False, nullable=False)
465
+    @classmethod
466
+    def reset_properties(cls, item):
467
+        if item.type==ContentType.Folder:
468
+            item.properties = dict(
469
+                allowed_content = dict (
470
+                    folder = True,
471
+                    file = True,
472
+                    page = True,
473
+                    thread = True
474
+                )
475
+            )
476
+            return
453
 
477
 
454
-    workspace_id = Column(Integer, ForeignKey('workspaces.workspace_id'), unique=False, nullable=True)
478
+        raise NotImplementedError
455
 
479
 
456
-    workspace = relationship('Workspace', remote_side=[Workspace.workspace_id], backref='contents')
457
 
480
 
481
+class ContentRevisionRO(DeclarativeBase):
482
+    """
483
+    Revision of Content. It's immutable, update or delete an existing ContentRevisionRO will throw
484
+    ContentRevisionUpdateError errors.
485
+    """
458
 
486
 
459
-    is_deleted = Column(Boolean, unique=False, nullable=False, default=False)
460
-    is_archived = Column(Boolean, unique=False, nullable=False, default=False)
487
+    __tablename__ = 'content_revisions'
461
 
488
 
462
-    label = Column(Unicode(1024), unique=False, nullable=False, default='')
463
-    description = Column(Text(), unique=False, nullable=False, default='')
464
-    _properties = Column('properties', Text(), unique=False, nullable=False, default='')
489
+    revision_id = Column(Integer, primary_key=True)
490
+    content_id = Column(Integer, ForeignKey('content.id'))
491
+    owner_id = Column(Integer, ForeignKey('users.user_id'), nullable=True)
465
 
492
 
493
+    label = Column(Unicode(1024), unique=False, nullable=False)
494
+    description = Column(Text(), unique=False, nullable=False, default='')
466
     file_name = Column(Unicode(255),  unique=False, nullable=False, default='')
495
     file_name = Column(Unicode(255),  unique=False, nullable=False, default='')
467
     file_mimetype = Column(Unicode(255),  unique=False, nullable=False, default='')
496
     file_mimetype = Column(Unicode(255),  unique=False, nullable=False, default='')
468
-    file_content = deferred(Column(LargeBinary(), unique=False, nullable=False, default=None))
497
+    file_content = deferred(Column(LargeBinary(), unique=False, nullable=True))
498
+    properties = Column('properties', Text(), unique=False, nullable=False, default='')
469
 
499
 
500
+    type = Column(Unicode(32), unique=False, nullable=False)
501
+    status = Column(Unicode(32), unique=False, nullable=False, default=ContentStatus.OPEN)
502
+    created = Column(DateTime, unique=False, nullable=False, default=datetime.now())
503
+    updated = Column(DateTime, unique=False, nullable=False, default=datetime.now())
504
+    is_deleted = Column(Boolean, unique=False, nullable=False, default=False)
505
+    is_archived = Column(Boolean, unique=False, nullable=False, default=False)
470
     revision_type = Column(Unicode(32), unique=False, nullable=False, default='')
506
     revision_type = Column(Unicode(32), unique=False, nullable=False, default='')
471
 
507
 
472
-    parent = relationship('Content', remote_side=[content_id], backref='children')
508
+    workspace_id = Column(Integer, ForeignKey('workspaces.workspace_id'), unique=False, nullable=True)
509
+    workspace = relationship('Workspace', remote_side=[Workspace.workspace_id])
510
+
511
+    parent_id = Column(Integer, ForeignKey('content.id'), nullable=True, default=None)
512
+    parent = relationship("Content", foreign_keys=[parent_id], back_populates="children_revisions")
513
+
514
+    node = relationship("Content", foreign_keys=[content_id], back_populates="revisions")
473
     owner = relationship('User', remote_side=[User.user_id])
515
     owner = relationship('User', remote_side=[User.user_id])
474
 
516
 
517
+    """ List of column copied when make a new revision from another """
518
+    _cloned_columns = (
519
+        'content_id', 'owner_id', 'label', 'description', 'file_name', 'file_mimetype',
520
+        'file_content', 'type', 'status', 'created', 'updated', 'is_deleted', 'is_archived',
521
+        'revision_type', 'workspace_id', 'workspace', 'parent_id', 'parent', 'node', 'owner'
522
+    )
523
+
524
+    @classmethod
525
+    def new_from(cls, revision):
526
+        """
527
+
528
+        Return new instance of ContentRevisionRO where properties are copied from revision parameter.
529
+        Look at ContentRevisionRO._cloned_columns to see what columns are copieds.
530
+
531
+        :param revision: revision to copy
532
+        :type revision: ContentRevisionRO
533
+        :return: new revision from revision parameter
534
+        :rtype: ContentRevisionRO
535
+        """
536
+        new_rev = cls()
537
+
538
+        for column_name in cls._cloned_columns:
539
+            column_value = getattr(revision, column_name)
540
+            setattr(new_rev, column_name, column_value)
541
+
542
+        return new_rev
543
+
544
+    def __setattr__(self, key, value):
545
+        """
546
+        ContentRevisionUpdateError is raised if tried to update column and revision own identity
547
+        :param key: attribute name
548
+        :param value: attribute value
549
+        :return:
550
+        """
551
+        if key in ('_sa_instance_state', ):  # Prevent infinite loop from SQLAlchemy code and altered set
552
+            return super().__setattr__(key, value)
553
+
554
+        if inspect(self).has_identity \
555
+                and key in self._cloned_columns \
556
+                and not RevisionsIntegrity.is_updatable(self):
557
+                raise ContentRevisionUpdateError(
558
+                    "Can't modify revision. To work on new revision use tracim.model.data.new_revision " +
559
+                    "context manager.")
560
+
561
+        super().__setattr__(key, value)
562
+
563
+    def get_status(self):
564
+        return ContentStatus(self.status)
565
+
566
+    def get_label(self):
567
+        return self.label if self.label else self.file_name if self.file_name else ''
568
+
569
+    def get_last_action(self) -> ActionDescription:
570
+        return ActionDescription(self.revision_type)
571
+
572
+    # Read by must be used like this:
573
+    # read_datetime = revision.ready_by[<User instance>]
574
+    # if user did not read the content, then a key error is raised
575
+    read_by = association_proxy(
576
+        'revision_read_statuses',  # name of the attribute
577
+        'view_datetime',  # attribute the value is taken from
578
+        creator=lambda k, v: \
579
+            RevisionReadStatus(user=k, view_datetime=v)
580
+    )
581
+
582
+    def has_new_information_for(self, user: User) -> bool:
583
+        """
584
+        :param user: the session current user
585
+        :return: bool, True if there is new information for given user else False
586
+                       False if the user is None
587
+        """
588
+        if not user:
589
+            return False
590
+
591
+        if user not in self.read_by.keys():
592
+            return True
593
+
594
+        return False
595
+
596
+
597
+class Content(DeclarativeBase):
598
+    """
599
+    Content is used as a virtual representation of ContentRevisionRO.
600
+    content.PROPERTY (except for content.id, content.revisions, content.children_revisions) will return
601
+    value of most recent revision of content.
602
+
603
+    # UPDATE A CONTENT
604
+
605
+    To update an existing Content, you must use tracim.model.data.new_revision context manager:
606
+    content = my_sontent_getter_method()
607
+    with new_revision(content):
608
+        content.description = 'foo bar baz'
609
+    DBSession.flush()
610
+
611
+    # QUERY CONTENTS
612
+
613
+    To query contents you will need to join your content query with ContentRevisionRO. Join
614
+    condition is available at tracim.lib.content.ContentApi#get_revision_join:
615
+
616
+    content = DBSession.query(Content).join(ContentRevisionRO, ContentApi.get_revision_join())
617
+                  .filter(Content.label == 'foo')
618
+                  .one()
619
+
620
+    ContentApi provide also prepared Content at tracim.lib.content.ContentApi#get_base_query:
621
+
622
+    content = ContentApi.get_base_query()
623
+              .filter(Content.label == 'foo')
624
+              .one()
625
+    """
626
+
627
+    __tablename__ = 'content'
628
+
629
+    revision_to_serialize = -0  # This flag allow to serialize a given revision if required by the user
630
+
631
+    id = Column(Integer, primary_key=True)
632
+    revisions = relationship("ContentRevisionRO",
633
+                             foreign_keys=[ContentRevisionRO.content_id],
634
+                             back_populates="node")
635
+    children_revisions = relationship("ContentRevisionRO",
636
+                                      foreign_keys=[ContentRevisionRO.parent_id],
637
+                                      back_populates="parent")
638
+
639
+    @hybrid_property
640
+    def content_id(self):
641
+        return self.revision.content_id
642
+
643
+    @content_id.setter
644
+    def content_id(self, value):
645
+        self.revision.content_id = value
646
+
647
+    @content_id.expression
648
+    def content_id(cls):
649
+        return ContentRevisionRO.content_id
650
+
651
+    @hybrid_property
652
+    def revision_id(self):
653
+        return self.revision.revision_id
654
+
655
+    @revision_id.setter
656
+    def revision_id(self, value):
657
+        self.revision.revision_id = value
658
+
659
+    @revision_id.expression
660
+    def revision_id(cls):
661
+        return ContentRevisionRO.revision_id
662
+
663
+    @hybrid_property
664
+    def owner_id(self):
665
+        return self.revision.owner_id
666
+
667
+    @owner_id.setter
668
+    def owner_id(self, value):
669
+        self.revision.owner_id = value
670
+
671
+    @owner_id.expression
672
+    def owner_id(cls):
673
+        return ContentRevisionRO.owner_id
674
+
675
+    @hybrid_property
676
+    def label(self):
677
+        return self.revision.label
678
+
679
+    @label.setter
680
+    def label(self, value):
681
+        self.revision.label = value
682
+
683
+    @label.expression
684
+    def label(cls):
685
+        return ContentRevisionRO.label
686
+
687
+    @hybrid_property
688
+    def description(self):
689
+        return self.revision.description
690
+
691
+    @description.setter
692
+    def description(self, value):
693
+        self.revision.description = value
694
+
695
+    @description.expression
696
+    def description(cls):
697
+        return ContentRevisionRO.description
698
+
699
+    @hybrid_property
700
+    def file_name(self):
701
+        return self.revision.file_name
702
+
703
+    @file_name.setter
704
+    def file_name(self, value):
705
+        self.revision.file_name = value
706
+
707
+    @file_name.expression
708
+    def file_name(cls):
709
+        return ContentRevisionRO.file_name
710
+
711
+    @hybrid_property
712
+    def file_mimetype(self):
713
+        return self.revision.file_mimetype
714
+
715
+    @file_mimetype.setter
716
+    def file_mimetype(self, value):
717
+        self.revision.file_mimetype = value
718
+
719
+    @file_mimetype.expression
720
+    def file_mimetype(cls):
721
+        return ContentRevisionRO.file_mimetype
722
+
723
+    @hybrid_property
724
+    def file_content(self):
725
+        return self.revision.file_content
726
+
727
+    @file_content.setter
728
+    def file_content(self, value):
729
+        self.revision.file_content = value
730
+
731
+    @file_content.expression
732
+    def file_content(cls):
733
+        return ContentRevisionRO.file_content
734
+
735
+    @hybrid_property
736
+    def _properties(self):
737
+        return self.revision.properties
738
+
739
+    @_properties.setter
740
+    def _properties(self, value):
741
+        self.revision.properties = value
742
+
743
+    @_properties.expression
744
+    def _properties(cls):
745
+        return ContentRevisionRO.properties
746
+
747
+    @hybrid_property
748
+    def type(self):
749
+        return self.revision.type
750
+
751
+    @type.setter
752
+    def type(self, value):
753
+        self.revision.type = value
754
+
755
+    @type.expression
756
+    def type(cls):
757
+        return ContentRevisionRO.type
758
+
759
+    @hybrid_property
760
+    def status(self):
761
+        return self.revision.status
762
+
763
+    @status.setter
764
+    def status(self, value):
765
+        self.revision.status = value
766
+
767
+    @status.expression
768
+    def status(cls):
769
+        return ContentRevisionRO.status
770
+
771
+    @hybrid_property
772
+    def created(self):
773
+        return self.revision.created
774
+
775
+    @created.setter
776
+    def created(self, value):
777
+        self.revision.created = value
778
+
779
+    @created.expression
780
+    def created(cls):
781
+        return ContentRevisionRO.created
782
+
783
+    @hybrid_property
784
+    def updated(self):
785
+        return self.revision.updated
786
+
787
+    @updated.setter
788
+    def updated(self, value):
789
+        self.revision.updated = value
790
+
791
+    @updated.expression
792
+    def updated(cls):
793
+        return ContentRevisionRO.updated
794
+
795
+    @hybrid_property
796
+    def is_deleted(self):
797
+        return self.revision.is_deleted
798
+
799
+    @is_deleted.setter
800
+    def is_deleted(self, value):
801
+        self.revision.is_deleted = value
802
+
803
+    @is_deleted.expression
804
+    def is_deleted(cls):
805
+        return ContentRevisionRO.is_deleted
806
+
807
+    @hybrid_property
808
+    def is_archived(self):
809
+        return self.revision.is_archived
810
+
811
+    @is_archived.setter
812
+    def is_archived(self, value):
813
+        self.revision.is_archived = value
814
+
815
+    @is_archived.expression
816
+    def is_archived(cls):
817
+        return ContentRevisionRO.is_archived
818
+
819
+    @hybrid_property
820
+    def revision_type(self):
821
+        return self.revision.revision_type
822
+
823
+    @revision_type.setter
824
+    def revision_type(self, value):
825
+        self.revision.revision_type = value
826
+
827
+    @revision_type.expression
828
+    def revision_type(cls):
829
+        return ContentRevisionRO.revision_type
830
+
831
+    @hybrid_property
832
+    def workspace_id(self):
833
+        return self.revision.workspace_id
834
+
835
+    @workspace_id.setter
836
+    def workspace_id(self, value):
837
+        self.revision.workspace_id = value
838
+
839
+    @workspace_id.expression
840
+    def workspace_id(cls):
841
+        return ContentRevisionRO.workspace_id
842
+
843
+    @hybrid_property
844
+    def workspace(self):
845
+        return self.revision.workspace
846
+
847
+    @workspace.setter
848
+    def workspace(self, value):
849
+        self.revision.workspace = value
850
+
851
+    @workspace.expression
852
+    def workspace(cls):
853
+        return ContentRevisionRO.workspace
854
+
855
+    @hybrid_property
856
+    def parent_id(self):
857
+        return self.revision.parent_id
858
+
859
+    @parent_id.setter
860
+    def parent_id(self, value):
861
+        self.revision.parent_id = value
862
+
863
+    @parent_id.expression
864
+    def parent_id(cls):
865
+        return ContentRevisionRO.parent_id
866
+
867
+    @hybrid_property
868
+    def parent(self):
869
+        return self.revision.parent
870
+
871
+    @parent.setter
872
+    def parent(self, value):
873
+        self.revision.parent = value
874
+
875
+    @parent.expression
876
+    def parent(cls):
877
+        return ContentRevisionRO.parent
878
+
879
+    @hybrid_property
880
+    def node(self):
881
+        return self.revision.node
882
+
883
+    @node.setter
884
+    def node(self, value):
885
+        self.revision.node = value
886
+
887
+    @node.expression
888
+    def node(cls):
889
+        return ContentRevisionRO.node
890
+
891
+    @hybrid_property
892
+    def owner(self):
893
+        return self.revision.owner
894
+
895
+    @owner.setter
896
+    def owner(self, value):
897
+        self.revision.owner = value
898
+
899
+    @owner.expression
900
+    def owner(cls):
901
+        return ContentRevisionRO.owner
902
+
903
+    @hybrid_property
904
+    def children(self):
905
+        """
906
+        :return: list of children Content
907
+        :rtype Content
908
+        """
909
+        # Return a list of unique revisions parent content
910
+        return list(set([revision.node for revision in self.children_revisions]))
911
+
912
+    @property
913
+    def revision(self):
914
+        return self.get_current_revision()
915
+
916
+    def get_current_revision(self):
917
+        if not self.revisions:
918
+            return self.new_revision()
919
+
920
+        # If last revisions revision don't have revision_id, return it we just add it.
921
+        if self.revisions[-1].revision_id is None:
922
+            return self.revisions[-1]
923
+
924
+        # Revisions should be ordred by revision_id but we ensure that here
925
+        revisions = sorted(self.revisions, key=lambda revision: revision.revision_id)
926
+        return revisions[-1]
927
+
928
+    def new_revision(self):
929
+        """
930
+        Return and assign to this content a new revision.
931
+        If it's a new content, revision is totally new.
932
+        If this content already own revision, revision is build from last revision.
933
+        :return:
934
+        """
935
+        if not self.revisions:
936
+            self.revisions.append(ContentRevisionRO())
937
+            return self.revisions[0]
938
+
939
+        new_rev = ContentRevisionRO.new_from(self.get_current_revision())
940
+        self.revisions.append(new_rev)
941
+        return new_rev
942
+
475
     def get_valid_children(self, content_types: list=None) -> ['Content']:
943
     def get_valid_children(self, content_types: list=None) -> ['Content']:
476
         for child in self.children:
944
         for child in self.children:
477
             if not child.is_deleted and not child.is_archived:
945
             if not child.is_deleted and not child.is_archived:
478
                 if not content_types or child.type in content_types:
946
                 if not content_types or child.type in content_types:
479
-                    yield child
947
+                    yield child.node
480
 
948
 
481
     @hybrid_property
949
     @hybrid_property
482
     def properties(self):
950
     def properties(self):
519
             'html.parser'  # Fixes hanging bug - http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
987
             'html.parser'  # Fixes hanging bug - http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
520
         )
988
         )
521
 
989
 
522
-
523
         for link in soup.findAll('a'):
990
         for link in soup.findAll('a'):
524
             href = link.get('href')
991
             href = link.get('href')
525
             label = link.contents
992
             label = link.contents
530
         ## FIXME - Does this return a sorted list ???!
997
         ## FIXME - Does this return a sorted list ???!
531
         return sorted_links
998
         return sorted_links
532
 
999
 
533
-
534
     def get_child_nb(self, content_type: ContentType, content_status = ''):
1000
     def get_child_nb(self, content_type: ContentType, content_status = ''):
535
         child_nb = 0
1001
         child_nb = 0
536
         for child in self.get_valid_children():
1002
         for child in self.get_valid_children():
586
         children = []
1052
         children = []
587
         for child in self.children:
1053
         for child in self.children:
588
             if ContentType.Comment==child.type and not child.is_deleted and not child.is_archived:
1054
             if ContentType.Comment==child.type and not child.is_deleted and not child.is_archived:
589
-                children.append(child)
1055
+                children.append(child.node)
590
         return children
1056
         return children
591
 
1057
 
592
     def get_last_comment_from(self, user: User) -> 'Content':
1058
     def get_last_comment_from(self, user: User) -> 'Content':
615
 
1081
 
616
         return None
1082
         return None
617
 
1083
 
618
-    def get_current_revision(self) -> 'ContentRevisionRO':
619
-        # TODO - D.A. - 2015-08-26
620
-        # This code is not efficient at all!!!
621
-        # We should get the revision id directly from the view
622
-        rev_ids = [revision.revision_id for revision in self.revisions]
623
-        rev_ids.sort()
624
-
625
-        for revision in self.revisions:
626
-            if revision.revision_id == rev_ids[-1]:
627
-                return revision
628
-
629
-        return None
630
-
631
     def description_as_raw_text(self):
1084
     def description_as_raw_text(self):
632
         # 'html.parser' fixes a hanging bug
1085
         # 'html.parser' fixes a hanging bug
633
         # see http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
1086
         # see http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
667
         return url_template.format(wid=wid, fid=fid, ctype=ctype, cid=cid)
1120
         return url_template.format(wid=wid, fid=fid, ctype=ctype, cid=cid)
668
 
1121
 
669
 
1122
 
670
-class ContentChecker(object):
671
-
672
-    @classmethod
673
-    def check_properties(cls, item: Content):
674
-        if item.type==ContentType.Folder:
675
-            properties = item.properties
676
-            if 'allowed_content' not in properties.keys():
677
-                return False
678
-            if 'folders' not in properties['allowed_content']:
679
-                return False
680
-            if 'files' not in properties['allowed_content']:
681
-                return False
682
-            if 'pages' not in properties['allowed_content']:
683
-                return False
684
-            if 'threads' not in properties['allowed_content']:
685
-                return False
686
-
687
-            return True
688
-
689
-        raise NotImplementedError
690
-
691
-    @classmethod
692
-    def reset_properties(cls, item: Content):
693
-        if item.type==ContentType.Folder:
694
-            item.properties = dict(
695
-                allowed_content = dict (
696
-                    folder = True,
697
-                    file = True,
698
-                    page = True,
699
-                    thread = True
700
-                )
701
-            )
702
-            return
703
-
704
-        raise NotImplementedError
705
-
706
-
707
-class ContentRevisionRO(DeclarativeBase):
708
-
709
-    __tablename__ = 'content_revisions'
710
-
711
-    revision_id = Column(Integer, Sequence('seq__content_revisions__revision_id'), primary_key=True)
712
-    content_id = Column(Integer, ForeignKey('contents.content_id'))
713
-    owner_id = Column(Integer, ForeignKey('users.user_id'), nullable=True)
714
-    label = Column(Unicode(1024), unique=False, nullable=False)
715
-    description = Column(Text(), unique=False, nullable=False, default='')
716
-    file_name = Column(Unicode(255),  unique=False, nullable=False, default='')
717
-    file_mimetype = Column(Unicode(255),  unique=False, nullable=False, default='')
718
-    file_content = deferred(Column(LargeBinary(), unique=False, nullable=False, default=None))
719
-
720
-    type = Column(Unicode(32), unique=False, nullable=False)
721
-    status = Column(Unicode(32), unique=False, nullable=False)
722
-    created = Column(DateTime, unique=False, nullable=False)
723
-    updated = Column(DateTime, unique=False, nullable=False)
724
-    is_deleted = Column(Boolean, unique=False, nullable=False)
725
-    is_archived = Column(Boolean, unique=False, nullable=False)
726
-    revision_type = Column(Unicode(32), unique=False, nullable=False, default='')
727
-
728
-    workspace_id = Column(Integer, ForeignKey('workspaces.workspace_id'), unique=False, nullable=True)
729
-    workspace = relationship('Workspace', remote_side=[Workspace.workspace_id])
730
-
731
-    node = relationship('Content', remote_side=[Content.content_id], backref='revisions')
732
-    owner = relationship('User', remote_side=[User.user_id])
733
-
734
-    def get_status(self):
735
-        return ContentStatus(self.status)
736
-
737
-    def get_label(self):
738
-        return self.label if self.label else self.file_name if self.file_name else ''
739
-
740
-    def get_last_action(self) -> ActionDescription:
741
-        return ActionDescription(self.revision_type)
742
-
743
-    # Read by must be used like this:
744
-    # read_datetime = revision.ready_by[<User instance>]
745
-    # if user did not read the content, then a key error is raised
746
-    read_by = association_proxy(
747
-        'revision_read_statuses',  # name of the attribute
748
-        'view_datetime',  # attribute the value is taken from
749
-        creator=lambda k, v: \
750
-            RevisionReadStatus(user=k, view_datetime=v)
751
-    )
752
-
753
-    def has_new_information_for(self, user: User) -> bool:
754
-        """
755
-        :param user: the session current user
756
-        :return: bool, True if there is new information for given user else False
757
-                       False if the user is None
758
-        """
759
-        if not user:
760
-            return False
761
-
762
-        if user not in self.read_by.keys():
763
-            return True
1123
+@contextmanager
1124
+def new_revision(content):
1125
+    """
1126
+    Prepare context to update a Content. It will add a new updatable revision to the content.
1127
+    :param content: Content instance to update
1128
+    :return:
1129
+    """
1130
+    with DBSession.no_autoflush:
1131
+        try:
1132
+            if inspect(content.revision).has_identity:
1133
+                content.new_revision()
1134
+            RevisionsIntegrity.add_to_updatable(content.revision)
1135
+            yield content.revision
1136
+        finally:
1137
+            RevisionsIntegrity.remove_from_updatable(content.revision)
764
 
1138
 
765
-        return False
766
 
1139
 
767
 class RevisionReadStatus(DeclarativeBase):
1140
 class RevisionReadStatus(DeclarativeBase):
768
 
1141
 
772
     user_id = Column(Integer, ForeignKey('users.user_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
1145
     user_id = Column(Integer, ForeignKey('users.user_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
773
     view_datetime = Column(DateTime, unique=False, nullable=False, server_default=func.now())
1146
     view_datetime = Column(DateTime, unique=False, nullable=False, server_default=func.now())
774
 
1147
 
775
-    # content_revision = relationship(
776
-    #     'ContentRevisionRO',
777
-    #     remote_side=[ContentRevisionRO.revision_id],
778
-    #     backref='revision_read_statuses')
779
-
780
     content_revision = relationship(
1148
     content_revision = relationship(
781
         'ContentRevisionRO',
1149
         'ContentRevisionRO',
782
         backref=backref(
1150
         backref=backref(

+ 1 - 1
tracim/tracim/model/serializers.py 查看文件

138
         try:
138
         try:
139
             converter = Context._converters[context_string][model_class]
139
             converter = Context._converters[context_string][model_class]
140
             return converter
140
             return converter
141
-        except:
141
+        except KeyError:
142
             if CTX.DEFAULT in Context._converters:
142
             if CTX.DEFAULT in Context._converters:
143
                 if model_class in Context._converters[CTX.DEFAULT]:
143
                 if model_class in Context._converters[CTX.DEFAULT]:
144
                     return Context._converters[CTX.DEFAULT][model_class]
144
                     return Context._converters[CTX.DEFAULT][model_class]

+ 49 - 2
tracim/tracim/tests/__init__.py 查看文件

2
 """Unit and functional test suite for tracim."""
2
 """Unit and functional test suite for tracim."""
3
 import argparse
3
 import argparse
4
 import os
4
 import os
5
+import time
5
 from os import getcwd
6
 from os import getcwd
6
 
7
 
7
 import ldap3
8
 import ldap3
8
 import tg
9
 import tg
9
-import time
10
 import transaction
10
 import transaction
11
 from gearbox.commands.setup_app import SetupAppCommand
11
 from gearbox.commands.setup_app import SetupAppCommand
12
 from ldap_test import LdapServer
12
 from ldap_test import LdapServer
13
+from nose.tools import eq_
13
 from nose.tools import ok_
14
 from nose.tools import ok_
14
 from paste.deploy import loadapp
15
 from paste.deploy import loadapp
15
 from sqlalchemy.engine import reflection
16
 from sqlalchemy.engine import reflection
28
 from tracim.fixtures import FixturesLoader
29
 from tracim.fixtures import FixturesLoader
29
 from tracim.fixtures.users_and_groups import Base as BaseFixture
30
 from tracim.fixtures.users_and_groups import Base as BaseFixture
30
 from tracim.lib.base import logger
31
 from tracim.lib.base import logger
31
-from tracim.model import DBSession
32
+from tracim.lib.content import ContentApi
33
+from tracim.model import DBSession, Content
34
+from tracim.model.data import Workspace, ContentType, ContentRevisionRO
32
 
35
 
33
 __all__ = ['setup_app', 'setup_db', 'teardown_db', 'TestController']
36
 __all__ = ['setup_app', 'setup_db', 'teardown_db', 'TestController']
34
 
37
 
293
 class ArgumentParser(argparse.ArgumentParser):
296
 class ArgumentParser(argparse.ArgumentParser):
294
     def exit(self, status=0, message=None):
297
     def exit(self, status=0, message=None):
295
         raise Exception(message)
298
         raise Exception(message)
299
+
300
+
301
+class BaseTest(object):
302
+
303
+    def _create_workspace(self, name, *args, **kwargs):
304
+        """
305
+        All extra parameters (*args, **kwargs) are for Workspace init
306
+        :return: Created workspace instance
307
+        :rtype: Workspace
308
+        """
309
+        workspace = Workspace(label=name, *args, **kwargs)
310
+        DBSession.add(workspace)
311
+        DBSession.flush()
312
+
313
+        eq_(1, DBSession.query(Workspace).filter(Workspace.label == name).count())
314
+        return DBSession.query(Workspace).filter(Workspace.label == name).one()
315
+
316
+    def _create_content(self, name, workspace, *args, **kwargs):
317
+        """
318
+        All extra parameters (*args, **kwargs) are for Content init
319
+        :return: Created Content instance
320
+        :rtype: Content
321
+        """
322
+        content = Content(*args, **kwargs)
323
+        content.label = name
324
+        content.workspace = workspace
325
+        DBSession.add(content)
326
+        DBSession.flush()
327
+
328
+        eq_(1, ContentApi.get_base_query().filter(Content.label == name).count())
329
+        return ContentApi.get_base_query().filter(Content.label == name).one()
330
+
331
+
332
+class BaseTestThread(BaseTest):
333
+
334
+    def _create_thread(self, workspace_name='workspace_1', folder_name='folder_1', thread_name='thread_1'):
335
+        """
336
+        :return: Thread
337
+        :rtype: Content
338
+        """
339
+        workspace = self._create_workspace(workspace_name)
340
+        folder = self._create_content(folder_name, workspace, type=ContentType.Folder)
341
+        thread = self._create_content(thread_name, workspace, type=ContentType.Thread, parent=folder)
342
+        return thread

+ 50 - 20
tracim/tracim/tests/library/test_content_api.py 查看文件

12
 from tracim.lib.user import UserApi
12
 from tracim.lib.user import UserApi
13
 from tracim.lib.workspace import RoleApi
13
 from tracim.lib.workspace import RoleApi
14
 from tracim.lib.workspace import WorkspaceApi
14
 from tracim.lib.workspace import WorkspaceApi
15
+from tracim.model import DBSession
15
 
16
 
16
 from tracim.model.auth import Group
17
 from tracim.model.auth import Group
17
 
18
 
18
-from tracim.model.data import ActionDescription
19
+from tracim.model.data import ActionDescription, ContentRevisionRO, Workspace, new_revision
19
 from tracim.model.data import Content
20
 from tracim.model.data import Content
20
 from tracim.model.data import ContentType
21
 from tracim.model.data import ContentType
21
 from tracim.model.data import UserRoleInWorkspace
22
 from tracim.model.data import UserRoleInWorkspace
123
         eq_(2, len(items))
124
         eq_(2, len(items))
124
 
125
 
125
         items = api.get_all(None, ContentType.Any, workspace)
126
         items = api.get_all(None, ContentType.Any, workspace)
126
-        api.delete(items[0])
127
+        with new_revision(items[0]):
128
+            api.delete(items[0])
127
         transaction.commit()
129
         transaction.commit()
128
 
130
 
129
         # Refresh instances after commit
131
         # Refresh instances after commit
172
         eq_(2, len(items))
174
         eq_(2, len(items))
173
 
175
 
174
         items = api.get_all(None, ContentType.Any, workspace)
176
         items = api.get_all(None, ContentType.Any, workspace)
175
-        api.archive(items[0])
177
+        with new_revision(items[0]):
178
+            api.archive(items[0])
176
         transaction.commit()
179
         transaction.commit()
177
 
180
 
178
         # Refresh instances after commit
181
         # Refresh instances after commit
276
                                                         save_now=True)
279
                                                         save_now=True)
277
         api = ContentApi(user)
280
         api = ContentApi(user)
278
         c = api.create(ContentType.Folder, workspace, None, 'parent', True)
281
         c = api.create(ContentType.Folder, workspace, None, 'parent', True)
279
-        api.set_status(c, 'unknown-status')
282
+        with new_revision(c):
283
+            api.set_status(c, 'unknown-status')
280
 
284
 
281
     def test_set_status_ok(self):
285
     def test_set_status_ok(self):
282
         uapi = UserApi(None)
286
         uapi = UserApi(None)
291
                                                         save_now=True)
295
                                                         save_now=True)
292
         api = ContentApi(user)
296
         api = ContentApi(user)
293
         c = api.create(ContentType.Folder, workspace, None, 'parent', True)
297
         c = api.create(ContentType.Folder, workspace, None, 'parent', True)
294
-        for new_status in ['open', 'closed-validated', 'closed-unvalidated',
295
-                           'closed-deprecated']:
296
-            api.set_status(c, new_status)
297
-            eq_(new_status, c.status)
298
-            eq_(ActionDescription.STATUS_UPDATE, c.revision_type)
298
+        with new_revision(c):
299
+            for new_status in ['open', 'closed-validated', 'closed-unvalidated',
300
+                               'closed-deprecated']:
301
+                api.set_status(c, new_status)
302
+
303
+                eq_(new_status, c.status)
304
+                eq_(ActionDescription.STATUS_UPDATE, c.revision_type)
299
 
305
 
300
     def test_create_comment_ok(self):
306
     def test_create_comment_ok(self):
301
         uapi = UserApi(None)
307
         uapi = UserApi(None)
370
         u2 = UserApi(None).get_one(u2id)
376
         u2 = UserApi(None).get_one(u2id)
371
         api2 = ContentApi(u2)
377
         api2 = ContentApi(u2)
372
         content2 = api2.get_one(pcid, ContentType.Any, workspace)
378
         content2 = api2.get_one(pcid, ContentType.Any, workspace)
373
-        api2.update_content(content2, 'this is an updated page', 'new content')
379
+        with new_revision(content2):
380
+            api2.update_content(content2, 'this is an updated page', 'new content')
374
         api2.save(content2)
381
         api2.save(content2)
375
         transaction.commit()
382
         transaction.commit()
376
 
383
 
435
         u2 = UserApi(None).get_one(u2id)
442
         u2 = UserApi(None).get_one(u2id)
436
         api2 = ContentApi(u2)
443
         api2 = ContentApi(u2)
437
         content2 = api2.get_one(pcid, ContentType.Any, workspace)
444
         content2 = api2.get_one(pcid, ContentType.Any, workspace)
438
-        api2.update_file_data(content2, 'index.html', 'text/html',
439
-                              b'<html>hello world</html>')
445
+        with new_revision(content2):
446
+            api2.update_file_data(content2, 'index.html', 'text/html',
447
+                                  b'<html>hello world</html>')
440
         api2.save(content2)
448
         api2.save(content2)
441
         transaction.commit()
449
         transaction.commit()
442
 
450
 
501
         u2 = UserApi(None).get_one(u2id)
509
         u2 = UserApi(None).get_one(u2id)
502
         api2 = ContentApi(u2, show_archived=True)
510
         api2 = ContentApi(u2, show_archived=True)
503
         content2 = api2.get_one(pcid, ContentType.Any, workspace)
511
         content2 = api2.get_one(pcid, ContentType.Any, workspace)
504
-        api2.archive(content2)
512
+        with new_revision(content2):
513
+            api2.archive(content2)
505
         api2.save(content2)
514
         api2.save(content2)
506
         transaction.commit()
515
         transaction.commit()
507
 
516
 
522
         ####
531
         ####
523
 
532
 
524
         updated2 = api.get_one(pcid, ContentType.Any, workspace)
533
         updated2 = api.get_one(pcid, ContentType.Any, workspace)
525
-        api.unarchive(updated)
534
+        with new_revision(updated):
535
+            api.unarchive(updated)
526
         api.save(updated2)
536
         api.save(updated2)
527
         eq_(False, updated2.is_archived)
537
         eq_(False, updated2.is_archived)
528
         eq_(ActionDescription.UNARCHIVING, updated2.revision_type)
538
         eq_(ActionDescription.UNARCHIVING, updated2.revision_type)
574
         u2 = UserApi(None).get_one(u2id)
584
         u2 = UserApi(None).get_one(u2id)
575
         api2 = ContentApi(u2, show_deleted=True)
585
         api2 = ContentApi(u2, show_deleted=True)
576
         content2 = api2.get_one(pcid, ContentType.Any, workspace)
586
         content2 = api2.get_one(pcid, ContentType.Any, workspace)
577
-        api2.delete(content2)
587
+        with new_revision(content2):
588
+            api2.delete(content2)
578
         api2.save(content2)
589
         api2.save(content2)
579
         transaction.commit()
590
         transaction.commit()
580
 
591
 
597
         ####
608
         ####
598
 
609
 
599
         updated2 = api.get_one(pcid, ContentType.Any, workspace)
610
         updated2 = api.get_one(pcid, ContentType.Any, workspace)
600
-        api.undelete(updated)
611
+        with new_revision(updated2):
612
+            api.undelete(updated2)
601
         api.save(updated2)
613
         api.save(updated2)
602
         eq_(False, updated2.is_deleted)
614
         eq_(False, updated2.is_deleted)
603
         eq_(ActionDescription.UNDELETION, updated2.revision_type)
615
         eq_(ActionDescription.UNDELETION, updated2.revision_type)
623
                        'this is randomized folder', True)
635
                        'this is randomized folder', True)
624
         p = api.create(ContentType.Page, workspace, a,
636
         p = api.create(ContentType.Page, workspace, a,
625
                        'this is randomized label content', True)
637
                        'this is randomized label content', True)
626
-        p.description = 'This is some amazing test'
638
+
639
+        with new_revision(p):
640
+            p.description = 'This is some amazing test'
641
+
627
         api.save(p)
642
         api.save(p)
628
         original_id = p.content_id
643
         original_id = p.content_id
629
 
644
 
653
                        'this is randomized folder', True)
668
                        'this is randomized folder', True)
654
         p = api.create(ContentType.Page, workspace, a,
669
         p = api.create(ContentType.Page, workspace, a,
655
                        'this is dummy label content', True)
670
                        'this is dummy label content', True)
656
-        p.description = 'This is some amazing test'
671
+
672
+        with new_revision(p):
673
+            p.description = 'This is some amazing test'
674
+
657
         api.save(p)
675
         api.save(p)
658
         original_id = p.content_id
676
         original_id = p.content_id
659
 
677
 
685
                        'this is randomized folder', True)
703
                        'this is randomized folder', True)
686
         p1 = api.create(ContentType.Page, workspace, a,
704
         p1 = api.create(ContentType.Page, workspace, a,
687
                         'this is dummy label content', True)
705
                         'this is dummy label content', True)
688
-        p1.description = 'This is some amazing test'
689
         p2 = api.create(ContentType.Page, workspace, a, 'Hey ! Jon !', True)
706
         p2 = api.create(ContentType.Page, workspace, a, 'Hey ! Jon !', True)
690
-        p2.description = 'What\'s up ?'
707
+
708
+        with new_revision(p1):
709
+            p1.description = 'This is some amazing test'
710
+
711
+        with new_revision(p2):
712
+            p2.description = 'What\'s up ?'
713
+
691
         api.save(p1)
714
         api.save(p1)
692
         api.save(p2)
715
         api.save(p2)
693
 
716
 
694
         id1 = p1.content_id
717
         id1 = p1.content_id
695
         id2 = p2.content_id
718
         id2 = p2.content_id
696
 
719
 
720
+        eq_(1, DBSession.query(Workspace).filter(Workspace.label == 'test workspace').count())
721
+        eq_(1, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'this is randomized folder').count())
722
+        eq_(2, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'this is dummy label content').count())
723
+        eq_(1, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.description == 'This is some amazing test').count())
724
+        eq_(2, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'Hey ! Jon !').count())
725
+        eq_(1, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.description == 'What\'s up ?').count())
726
+
697
         res = api.search(['dummy', 'jon'])
727
         res = api.search(['dummy', 'jon'])
698
         eq_(2, len(res.all()))
728
         eq_(2, len(res.all()))
699
 
729
 

+ 4 - 5
tracim/tracim/tests/library/test_serializers.py 查看文件

5
 from nose.tools import ok_
5
 from nose.tools import ok_
6
 from nose.tools import raises
6
 from nose.tools import raises
7
 
7
 
8
-from sqlalchemy.orm.exc import NoResultFound
9
-
10
-import transaction
11
 import tg
8
 import tg
12
 
9
 
13
 from tracim.model import DBSession
10
 from tracim.model import DBSession
25
 
22
 
26
 from tracim.model.data import ActionDescription
23
 from tracim.model.data import ActionDescription
27
 
24
 
28
-from tracim.lib.user import UserApi
29
-
30
 from tracim.tests import TestStandard
25
 from tracim.tests import TestStandard
31
 
26
 
32
 
27
 
221
     def test_serializer_content__menui_api_context__children(self):
216
     def test_serializer_content__menui_api_context__children(self):
222
         folder_without_child = Content()
217
         folder_without_child = Content()
223
         folder_without_child.type = ContentType.Folder
218
         folder_without_child.type = ContentType.Folder
219
+        folder_without_child.label = 'folder_without_child'
224
         res = Context(CTX.MENU_API).toDict(folder_without_child)
220
         res = Context(CTX.MENU_API).toDict(folder_without_child)
225
         eq_(False, res['children'])
221
         eq_(False, res['children'])
226
 
222
 
227
         folder_with_child = Content()
223
         folder_with_child = Content()
228
         folder_with_child.type = ContentType.Folder
224
         folder_with_child.type = ContentType.Folder
225
+        folder_with_child.label = 'folder_with_child'
229
         folder_without_child.parent = folder_with_child
226
         folder_without_child.parent = folder_with_child
230
         DBSession.add(folder_with_child)
227
         DBSession.add(folder_with_child)
231
         DBSession.add(folder_without_child)
228
         DBSession.add(folder_without_child)
238
             if curtype not in (ContentType.Folder, ContentType.Comment):
235
             if curtype not in (ContentType.Folder, ContentType.Comment):
239
                 item = Content()
236
                 item = Content()
240
                 item.type = curtype
237
                 item.type = curtype
238
+                item.label = 'item'
241
 
239
 
242
                 fake_child = Content()
240
                 fake_child = Content()
243
                 fake_child.type = curtype
241
                 fake_child.type = curtype
242
+                fake_child.label = 'fake_child'
244
                 fake_child.parent = item
243
                 fake_child.parent = item
245
 
244
 
246
                 DBSession.add(item)
245
                 DBSession.add(item)

+ 15 - 0
tracim/tracim/tests/library/test_thread.py 查看文件

1
+# -*- coding: utf-8 -*-
2
+import transaction
3
+from nose.tools import eq_
4
+
5
+from tracim.tests import BaseTestThread, TestStandard
6
+
7
+
8
+class TestThread(BaseTestThread, TestStandard):
9
+
10
+    def test_create_thread(self, key='1'):
11
+        return self._create_thread(
12
+            workspace_name='workspace_%s' % key,
13
+            folder_name='folder_%s' % key,
14
+            thread_name='thread_%s' % key,
15
+        )

+ 21 - 0
tracim/tracim/tests/library/test_workspace.py 查看文件

1
+# -*- coding: utf-8 -*-
2
+import transaction
3
+from nose.tools import eq_
4
+
5
+from tracim.lib.content import ContentApi
6
+from tracim.model import DBSession, Content
7
+from tracim.model.data import Workspace
8
+from tracim.tests import BaseTestThread, TestStandard
9
+
10
+
11
+class TestThread(BaseTestThread, TestStandard):
12
+
13
+    def test_children(self):
14
+        self._create_thread(
15
+            workspace_name='workspace_1',
16
+            folder_name='folder_1',
17
+            thread_name='thread_1',
18
+        )
19
+        workspace = DBSession.query(Workspace).filter(Workspace.label == 'workspace_1').one()
20
+        folder = ContentApi.get_base_query().filter(Content.label == 'folder_1').one()
21
+        eq_([folder, ], list(workspace.get_valid_children()))

+ 157 - 0
tracim/tracim/tests/models/test_content.py 查看文件

1
+# -*- coding: utf-8 -*-
2
+from nose.tools import raises
3
+from sqlalchemy.orm import aliased
4
+from sqlalchemy.sql.elements import and_
5
+from sqlalchemy.testing import eq_
6
+
7
+from tracim.lib.content import ContentApi
8
+from tracim.lib.exception import ContentRevisionUpdateError
9
+from tracim.model import DBSession, User, Content
10
+from tracim.model.data import ContentRevisionRO, Workspace, ActionDescription, ContentType, new_revision
11
+from tracim.tests import TestStandard
12
+
13
+
14
+class TestContent(TestStandard):
15
+
16
+    @raises(ContentRevisionUpdateError)
17
+    def test_update_without_prepare(self):
18
+        content1 = self.test_create()
19
+        content1.description = 'FOO'  # Raise ContentRevisionUpdateError because revision can't be updated
20
+
21
+    def test_query(self):
22
+        content1 = self.test_create()
23
+        with new_revision(content1):
24
+            content1.description = 'TEST_CONTENT_DESCRIPTION_1_UPDATED'
25
+        DBSession.flush()
26
+
27
+        content2 = self.test_create(key='2')
28
+        with new_revision(content2):
29
+            content2.description = 'TEST_CONTENT_DESCRIPTION_2_UPDATED'
30
+        DBSession.flush()
31
+
32
+        workspace1 = DBSession.query(Workspace).filter(Workspace.label == 'TEST_WORKSPACE_1').one()
33
+        workspace2 = DBSession.query(Workspace).filter(Workspace.label == 'TEST_WORKSPACE_2').one()
34
+
35
+        # To get Content in database we have to join Content and ContentRevisionRO with particular condition:
36
+        # Join have to be on most recent revision
37
+        join_sub_query = DBSession.query(ContentRevisionRO.revision_id)\
38
+            .filter(ContentRevisionRO.content_id == Content.id)\
39
+            .order_by(ContentRevisionRO.revision_id.desc())\
40
+            .limit(1)\
41
+            .correlate(Content)
42
+
43
+        base_query = DBSession.query(Content)\
44
+            .join(ContentRevisionRO, and_(Content.id == ContentRevisionRO.content_id,
45
+                                          ContentRevisionRO.revision_id == join_sub_query))
46
+
47
+        eq_(2, base_query.count())
48
+
49
+        eq_(1, base_query.filter(Content.workspace == workspace1).count())
50
+        eq_(1, base_query.filter(Content.workspace == workspace2).count())
51
+
52
+        content1_from_query = base_query.filter(Content.workspace == workspace1).one()
53
+        eq_(content1.id, content1_from_query.id)
54
+        eq_('TEST_CONTENT_DESCRIPTION_1_UPDATED', content1_from_query.description)
55
+
56
+        user_admin = DBSession.query(User).filter(User.email == 'admin@admin.admin').one()
57
+        api = ContentApi(None)
58
+
59
+        content1_from_api = api.get_one(content1.id, ContentType.Page, workspace1)
60
+
61
+    def test_update(self):
62
+        created_content = self.test_create()
63
+        content = DBSession.query(Content).filter(Content.id == created_content.id).one()
64
+        eq_(1, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'TEST_CONTENT_1').count())
65
+
66
+        with new_revision(content):
67
+            content.description = 'TEST_CONTENT_DESCRIPTION_1_UPDATED'
68
+        DBSession.flush()
69
+
70
+        eq_(2, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'TEST_CONTENT_1').count())
71
+        eq_(1, DBSession.query(Content).filter(Content.id == created_content.id).count())
72
+
73
+        with new_revision(content):
74
+            content.description = 'TEST_CONTENT_DESCRIPTION_1_UPDATED_2'
75
+            content.label = 'TEST_CONTENT_1_UPDATED_2'
76
+        DBSession.flush()
77
+
78
+        eq_(1, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'TEST_CONTENT_1_UPDATED_2').count())
79
+        eq_(1, DBSession.query(Content).filter(Content.id == created_content.id).count())
80
+
81
+    def test_creates(self):
82
+        eq_(0, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'TEST_CONTENT_1').count())
83
+        eq_(0, DBSession.query(Workspace).filter(Workspace.label == 'TEST_WORKSPACE_1').count())
84
+
85
+        user_admin = DBSession.query(User).filter(User.email == 'admin@admin.admin').one()
86
+        workspace = Workspace(label="TEST_WORKSPACE_1")
87
+        DBSession.add(workspace)
88
+        DBSession.flush()
89
+        eq_(1, DBSession.query(Workspace).filter(Workspace.label == 'TEST_WORKSPACE_1').count())
90
+
91
+        first_content = self._create_content(
92
+            owner=user_admin,
93
+            workspace=workspace,
94
+            type=ContentType.Page,
95
+            label='TEST_CONTENT_1',
96
+            description='TEST_CONTENT_DESCRIPTION_1',
97
+            revision_type=ActionDescription.CREATION,
98
+            is_deleted=False,  # TODO: pk ?
99
+            is_archived=False,  # TODO: pk ?
100
+            #file_content=None,  # TODO: pk ? (J'ai du mettre nullable=True)
101
+        )
102
+
103
+        eq_(1, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'TEST_CONTENT_1').count())
104
+
105
+        content = DBSession.query(Content).filter(Content.id == first_content.id).one()
106
+        eq_('TEST_CONTENT_1', content.label)
107
+        eq_('TEST_CONTENT_DESCRIPTION_1', content.description)
108
+
109
+        # Create a second content
110
+        second_content = self._create_content(
111
+            owner=user_admin,
112
+            workspace=workspace,
113
+            type=ContentType.Page,
114
+            label='TEST_CONTENT_2',
115
+            description='TEST_CONTENT_DESCRIPTION_2',
116
+            revision_type=ActionDescription.CREATION
117
+        )
118
+
119
+        eq_(1, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'TEST_CONTENT_2').count())
120
+
121
+        content = DBSession.query(Content).filter(Content.id == second_content.id).one()
122
+        eq_('TEST_CONTENT_2', content.label)
123
+        eq_('TEST_CONTENT_DESCRIPTION_2', content.description)
124
+
125
+    def test_create(self, key='1'):
126
+        eq_(0, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'TEST_CONTENT_%s' % key).count())
127
+        eq_(0, DBSession.query(Workspace).filter(Workspace.label == 'TEST_WORKSPACE_%s' % key).count())
128
+
129
+        user_admin = DBSession.query(User).filter(User.email == 'admin@admin.admin').one()
130
+        workspace = Workspace(label="TEST_WORKSPACE_%s" % key)
131
+        DBSession.add(workspace)
132
+        DBSession.flush()
133
+        eq_(1, DBSession.query(Workspace).filter(Workspace.label == 'TEST_WORKSPACE_%s' % key).count())
134
+
135
+        created_content = self._create_content(
136
+            owner=user_admin,
137
+            workspace=workspace,
138
+            type=ContentType.Page,
139
+            label='TEST_CONTENT_%s' % key,
140
+            description='TEST_CONTENT_DESCRIPTION_%s' % key,
141
+            revision_type=ActionDescription.CREATION
142
+        )
143
+
144
+        eq_(1, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'TEST_CONTENT_%s' % key).count())
145
+
146
+        content = DBSession.query(Content).filter(Content.id == created_content.id).one()
147
+        eq_('TEST_CONTENT_%s' % key, content.label)
148
+        eq_('TEST_CONTENT_DESCRIPTION_%s' % key, content.description)
149
+
150
+        return created_content
151
+
152
+    def _create_content(self, *args, **kwargs):
153
+        content = Content(*args, **kwargs)
154
+        DBSession.add(content)
155
+        DBSession.flush()
156
+
157
+        return content

+ 1 - 263
tracim/tracim/websetup/schema.py 查看文件

17
     
17
     
18
     # <websetup.websetup.schema.before.metadata.create_all>
18
     # <websetup.websetup.schema.before.metadata.create_all>
19
     print("Creating tables")
19
     print("Creating tables")
20
-    # model.metadata.create_all(bind=config['tg.app_globals'].sa_engine)
21
-
22
-    # result = config['tg.app_globals'].sa_engine.execute(get_initial_schema())
23
-    from sqlalchemy import DDL
24
-    result = model.DBSession.execute(DDL(get_initial_schema()))
25
-    print("Initial schema created.")
20
+    model.metadata.create_all(bind=config['tg.app_globals'].sa_engine)
26
 
21
 
27
     #ALTER TABLE bibi ADD COLUMN popo integer;
22
     #ALTER TABLE bibi ADD COLUMN popo integer;
28
 
23
 
37
     alembic_cfg.set_main_option("script_location", "migration")
32
     alembic_cfg.set_main_option("script_location", "migration")
38
     alembic_cfg.set_main_option("sqlalchemy.url", config['sqlalchemy.url'])
33
     alembic_cfg.set_main_option("sqlalchemy.url", config['sqlalchemy.url'])
39
     alembic.command.stamp(alembic_cfg, "head")
34
     alembic.command.stamp(alembic_cfg, "head")
40
-
41
-
42
-def get_initial_schema():
43
-    return """
44
-SET statement_timeout = 0;
45
-SET client_encoding = 'UTF8';
46
-SET standard_conforming_strings = on;
47
-SET check_function_bodies = false;
48
-SET client_min_messages = warning;
49
-
50
-SET search_path = public, pg_catalog;
51
-
52
-
53
-CREATE OR REPLACE FUNCTION update_node() RETURNS trigger
54
-    LANGUAGE plpgsql
55
-    AS $$
56
-BEGIN
57
-INSERT INTO content_revisions (content_id, parent_id, type, created, updated,
58
-       label, description, status,
59
-       file_name, file_content, file_mimetype,
60
-       owner_id, revision_id, workspace_id, is_deleted, is_archived, properties, revision_type) VALUES (NEW.content_id, NEW.parent_id, NEW.type, NEW.created, NEW.updated, NEW.label, NEW.description, NEW.status, NEW.file_name, NEW.file_content, NEW.file_mimetype, NEW.owner_id, nextval('seq__content_revisions__revision_id'), NEW.workspace_id, NEW.is_deleted, NEW.is_archived, NEW.properties, NEW.revision_type);
61
-return new;
62
-END;
63
-$$;
64
-
65
-CREATE OR REPLACE FUNCTION set_created() RETURNS trigger
66
-    LANGUAGE plpgsql
67
-    AS $$
68
-BEGIN
69
-    NEW.created = CURRENT_TIMESTAMP;
70
-    NEW.updated = CURRENT_TIMESTAMP;
71
-    RETURN NEW;
72
-END;
73
-$$;
74
-
75
-CREATE OR REPLACE FUNCTION set_updated() RETURNS trigger
76
-    LANGUAGE plpgsql
77
-    AS $$
78
-BEGIN
79
-    NEW.updated = CURRENT_TIMESTAMP;
80
-    RETURN NEW;
81
-END;
82
-$$;
83
-
84
-SET default_tablespace = '';
85
-SET default_with_oids = false;
86
-
87
-
88
-CREATE TABLE groups (
89
-    group_id integer NOT NULL,
90
-    group_name character varying(16) NOT NULL,
91
-    display_name character varying(255),
92
-    created timestamp without time zone
93
-);
94
-
95
-CREATE SEQUENCE seq__groups__group_id
96
-    START WITH 1
97
-    INCREMENT BY 1
98
-    NO MINVALUE
99
-    NO MAXVALUE
100
-    CACHE 1;
101
-
102
-ALTER SEQUENCE seq__groups__group_id OWNED BY groups.group_id;
103
-
104
-CREATE TABLE group_permission (
105
-    group_id integer NOT NULL,
106
-    permission_id integer NOT NULL
107
-);
108
-
109
-CREATE SEQUENCE seq__content_revisions__revision_id
110
-    START WITH 1
111
-    INCREMENT BY 1
112
-    NO MINVALUE
113
-    NO MAXVALUE
114
-    CACHE 1;
115
-
116
-CREATE TABLE content_revisions (
117
-    content_id integer NOT NULL,
118
-    parent_id integer,
119
-    type character varying(16) DEFAULT 'data'::character varying NOT NULL,
120
-    created timestamp without time zone,
121
-    updated timestamp without time zone,
122
-    label character varying(1024),
123
-    description text DEFAULT ''::text NOT NULL,
124
-    status character varying(32) DEFAULT 'new'::character varying,
125
-    file_name character varying(255),
126
-    file_content bytea,
127
-    file_mimetype character varying(255),
128
-    owner_id integer,
129
-    revision_id integer DEFAULT nextval('seq__content_revisions__revision_id'::regclass) NOT NULL,
130
-    workspace_id integer,
131
-    is_deleted boolean DEFAULT false NOT NULL,
132
-    is_archived boolean DEFAULT false NOT NULL,
133
-    properties text,
134
-    revision_type character varying(32)
135
-);
136
-
137
-COMMENT ON COLUMN content_revisions.properties IS 'This column contain properties specific to a given type. these properties are json encoded (so there is no structure "a priori")';
138
-
139
-CREATE VIEW contents AS
140
-    SELECT DISTINCT ON (content_revisions.content_id) content_revisions.content_id, content_revisions.parent_id, content_revisions.type, content_revisions.created, content_revisions.updated, content_revisions.label, content_revisions.description, content_revisions.status, content_revisions.file_name, content_revisions.file_content, content_revisions.file_mimetype, content_revisions.owner_id, content_revisions.workspace_id, content_revisions.is_deleted, content_revisions.is_archived, content_revisions.properties, content_revisions.revision_type FROM content_revisions ORDER BY content_revisions.content_id, content_revisions.updated DESC, content_revisions.created DESC;
141
-
142
-CREATE SEQUENCE seq__contents__content_id
143
-    START WITH 1
144
-    INCREMENT BY 1
145
-    NO MINVALUE
146
-    NO MAXVALUE
147
-    CACHE 1;
148
-
149
-ALTER SEQUENCE seq__contents__content_id OWNED BY content_revisions.content_id;
150
-
151
-CREATE TABLE permissions (
152
-    permission_id integer NOT NULL,
153
-    permission_name character varying(63) NOT NULL,
154
-    description character varying(255)
155
-);
156
-
157
-CREATE SEQUENCE seq__permissions__permission_id
158
-    START WITH 1
159
-    INCREMENT BY 1
160
-    NO MINVALUE
161
-    NO MAXVALUE
162
-    CACHE 1;
163
-
164
-ALTER SEQUENCE seq__permissions__permission_id OWNED BY permissions.permission_id;
165
-
166
-CREATE TABLE users (
167
-    user_id integer NOT NULL,
168
-    email character varying(255) NOT NULL,
169
-    display_name character varying(255),
170
-    password character varying(128),
171
-    created timestamp without time zone,
172
-    is_active boolean DEFAULT true NOT NULL,
173
-    imported_from character varying(32)
174
-);
175
-
176
-CREATE TABLE user_group (
177
-    user_id integer NOT NULL,
178
-    group_id integer NOT NULL
179
-);
180
-
181
-CREATE SEQUENCE seq__users__user_id
182
-    START WITH 1
183
-    INCREMENT BY 1
184
-    NO MINVALUE
185
-    NO MAXVALUE
186
-    CACHE 1;
187
-
188
-ALTER SEQUENCE seq__users__user_id OWNED BY users.user_id;
189
-
190
-CREATE TABLE user_workspace (
191
-    user_id integer NOT NULL,
192
-    workspace_id integer NOT NULL,
193
-    role integer,
194
-    do_notify boolean DEFAULT FALSE NOT NULL
195
-);
196
-
197
-CREATE TABLE workspaces (
198
-    workspace_id integer NOT NULL,
199
-    label character varying(1024),
200
-    description text,
201
-    created timestamp without time zone,
202
-    updated timestamp without time zone,
203
-    is_deleted boolean DEFAULT false NOT NULL
204
-);
205
-
206
-CREATE SEQUENCE seq__workspaces__workspace_id
207
-    START WITH 11
208
-    INCREMENT BY 1
209
-    NO MINVALUE
210
-    NO MAXVALUE
211
-    CACHE 1;
212
-
213
-ALTER TABLE ONLY groups ALTER COLUMN group_id SET DEFAULT nextval('seq__groups__group_id'::regclass);
214
-ALTER TABLE ONLY content_revisions ALTER COLUMN content_id SET DEFAULT nextval('seq__contents__content_id'::regclass);
215
-ALTER TABLE ONLY permissions ALTER COLUMN permission_id SET DEFAULT nextval('seq__permissions__permission_id'::regclass);
216
-ALTER TABLE ONLY users ALTER COLUMN user_id SET DEFAULT nextval('seq__users__user_id'::regclass);
217
-ALTER TABLE ONLY workspaces ALTER COLUMN workspace_id SET DEFAULT nextval('seq__workspaces__workspace_id'::regclass);
218
-
219
-
220
-SELECT pg_catalog.setval('seq__groups__group_id', 4, true);
221
-SELECT pg_catalog.setval('seq__contents__content_id', 1, true);
222
-SELECT pg_catalog.setval('seq__content_revisions__revision_id', 2568, true);
223
-SELECT pg_catalog.setval('seq__permissions__permission_id', 1, true);
224
-SELECT pg_catalog.setval('seq__users__user_id', 2, true);
225
-
226
-SELECT pg_catalog.setval('seq__workspaces__workspace_id', 1, true);
227
-
228
-ALTER TABLE ONLY user_workspace
229
-    ADD CONSTRAINT pk__user_workspace__user_id__workspace_id PRIMARY KEY (user_id, workspace_id);
230
-
231
-ALTER TABLE ONLY workspaces
232
-    ADD CONSTRAINT pk__workspace__workspace_id PRIMARY KEY (workspace_id);
233
-
234
-ALTER TABLE ONLY groups
235
-    ADD CONSTRAINT uk__groups__group_name UNIQUE (group_name);
236
-
237
-ALTER TABLE ONLY group_permission
238
-    ADD CONSTRAINT pk__group_permission__group_id__permission_id PRIMARY KEY (group_id, permission_id);
239
-
240
-ALTER TABLE ONLY groups
241
-    ADD CONSTRAINT pk__groups__group_id PRIMARY KEY (group_id);
242
-
243
-ALTER TABLE ONLY content_revisions
244
-    ADD CONSTRAINT pk__content_revisions__revision_id PRIMARY KEY (revision_id);
245
-
246
-ALTER TABLE ONLY permissions
247
-    ADD CONSTRAINT uk__permissions__permission_name UNIQUE (permission_name);
248
-
249
-ALTER TABLE ONLY permissions
250
-    ADD CONSTRAINT pk__permissions__permission_id PRIMARY KEY (permission_id);
251
-
252
-ALTER TABLE ONLY users
253
-    ADD CONSTRAINT uk__users__email UNIQUE (email);
254
-
255
-ALTER TABLE ONLY user_group
256
-    ADD CONSTRAINT pk__user_group__user_id__group_id PRIMARY KEY (user_id, group_id);
257
-
258
-ALTER TABLE ONLY users
259
-    ADD CONSTRAINT pk__users__user_id PRIMARY KEY (user_id);
260
-
261
-CREATE INDEX idx__content_revisions__owner_id ON content_revisions USING btree (owner_id);
262
-
263
-CREATE INDEX idx__content_revisions__parent_id ON content_revisions USING btree (parent_id);
264
-
265
-CREATE RULE rul__insert__new_node AS ON INSERT TO contents DO INSTEAD INSERT INTO content_revisions (content_id, parent_id, type, created, updated, label, description, status, file_name, file_content, file_mimetype, owner_id, revision_id, workspace_id, is_deleted, is_archived, properties, revision_type) VALUES (nextval('seq__contents__content_id'::regclass), new.parent_id, new.type, new.created, new.updated, new.label, new.description, new.status, new.file_name, new.file_content, new.file_mimetype, new.owner_id, nextval('seq__content_revisions__revision_id'::regclass), new.workspace_id, new.is_deleted, new.is_archived, new.properties, new.revision_type) RETURNING content_revisions.content_id, content_revisions.parent_id, content_revisions.type, content_revisions.created, content_revisions.updated, content_revisions.label, content_revisions.description, content_revisions.status, content_revisions.file_name, content_revisions.file_content, content_revisions.file_mimetype, content_revisions.owner_id, content_revisions.workspace_id, content_revisions.is_deleted, content_revisions.is_archived, content_revisions.properties, content_revisions.revision_type;
266
-
267
-CREATE TRIGGER trg__contents__on_insert__set_created BEFORE INSERT ON content_revisions FOR EACH ROW EXECUTE PROCEDURE set_created();
268
-CREATE TRIGGER trg__contents__on_update__set_updated BEFORE UPDATE ON content_revisions FOR EACH ROW EXECUTE PROCEDURE set_updated();
269
-
270
-CREATE TRIGGER trg__contents__on_update INSTEAD OF UPDATE ON contents FOR EACH ROW EXECUTE PROCEDURE update_node();
271
-CREATE TRIGGER trg__workspaces__on_insert__set_created BEFORE INSERT ON workspaces FOR EACH ROW EXECUTE PROCEDURE set_created();
272
-CREATE TRIGGER trg__workspaces__on_update__set_updated BEFORE UPDATE ON workspaces FOR EACH ROW EXECUTE PROCEDURE set_updated();
273
-
274
-ALTER TABLE ONLY user_workspace
275
-    ADD CONSTRAINT fk__user_workspace__user_id FOREIGN KEY (user_id) REFERENCES users(user_id) ON UPDATE CASCADE ON DELETE CASCADE;
276
-
277
-ALTER TABLE ONLY user_workspace
278
-    ADD CONSTRAINT fk__user_workspace__workspace_id FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON UPDATE CASCADE ON DELETE CASCADE;
279
-
280
-ALTER TABLE ONLY group_permission
281
-    ADD CONSTRAINT fk__group_permission__group_id FOREIGN KEY (group_id) REFERENCES groups(group_id) ON UPDATE CASCADE ON DELETE CASCADE;
282
-
283
-ALTER TABLE ONLY group_permission
284
-    ADD CONSTRAINT fk__group_permission__permission_id FOREIGN KEY (permission_id) REFERENCES permissions(permission_id) ON UPDATE CASCADE ON DELETE CASCADE;
285
-
286
-ALTER TABLE ONLY content_revisions
287
-    ADD CONSTRAINT fk__content_revisions__owner_id FOREIGN KEY (owner_id) REFERENCES users(user_id);
288
-
289
-ALTER TABLE ONLY user_group
290
-    ADD CONSTRAINT fk__user_group__group_id FOREIGN KEY (group_id) REFERENCES groups(group_id) ON UPDATE CASCADE ON DELETE CASCADE;
291
-
292
-ALTER TABLE ONLY user_group
293
-    ADD CONSTRAINT fk__user_group__user_id FOREIGN KEY (user_id) REFERENCES users(user_id) ON UPDATE CASCADE ON DELETE CASCADE;
294
-
295
-COMMIT;
296
-"""