Bladeren bron

Delete Content database view

Bastien Sevajol 8 jaren geleden
bovenliggende
commit
712ca48573

+ 19 - 13
tracim/tracim/controllers/__init__.py Bestand weergeven

@@ -16,7 +16,7 @@ from tracim.lib.predicates import current_user_is_contributor
16 16
 from tracim.lib.predicates import current_user_is_content_manager
17 17
 
18 18
 from tracim.model.auth import User
19
-from tracim.model.data import ActionDescription
19
+from tracim.model.data import ActionDescription, new_revision
20 20
 from tracim.model.data import BreadcrumbItem
21 21
 from tracim.model.data import Content
22 22
 from tracim.model.data import ContentType
@@ -267,8 +267,9 @@ class TIMWorkspaceContentRestController(TIMRestControllerWithBreadcrumb):
267 267
         try:
268 268
             api = ContentApi(tmpl_context.current_user)
269 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 274
             msg = _('{} updated').format(self._item_type_label)
274 275
             tg.flash(msg, CST.STATUS_OK)
@@ -292,8 +293,9 @@ class TIMWorkspaceContentRestController(TIMRestControllerWithBreadcrumb):
292 293
         content_api = ContentApi(tmpl_context.current_user)
293 294
         item = content_api.get_one(item_id, self._item_type, tmpl_context.workspace)
294 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 299
             msg = _('{} status updated').format(self._item_type_label)
298 300
             tg.flash(msg, CST.STATUS_OK)
299 301
             tg.redirect(self._std_url.format(item.workspace_id, item.parent_id, item.content_id))
@@ -332,8 +334,9 @@ class TIMWorkspaceContentRestController(TIMRestControllerWithBreadcrumb):
332 334
             undo_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)+'/put_archive_undo'
333 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 341
             tg.flash(msg, CST.STATUS_OK, no_escape=True) # TODO allow to come back
339 342
             tg.redirect(next_url)
@@ -353,8 +356,9 @@ class TIMWorkspaceContentRestController(TIMRestControllerWithBreadcrumb):
353 356
         try:
354 357
             next_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)
355 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 363
             tg.flash(msg, CST.STATUS_OK)
360 364
             tg.redirect(next_url )
@@ -379,8 +383,9 @@ class TIMWorkspaceContentRestController(TIMRestControllerWithBreadcrumb):
379 383
             next_url = self._parent_url.format(item.workspace_id, item.parent_id)
380 384
             undo_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)+'/put_delete_undo'
381 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 390
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
386 391
             tg.redirect(next_url)
@@ -403,8 +408,9 @@ class TIMWorkspaceContentRestController(TIMRestControllerWithBreadcrumb):
403 408
         try:
404 409
             next_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)
405 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 415
             tg.flash(msg, CST.STATUS_OK)
410 416
             tg.redirect(next_url)

+ 71 - 55
tracim/tracim/controllers/content.py Bestand weergeven

@@ -1,4 +1,6 @@
1 1
 # -*- coding: utf-8 -*-
2
+import sys
3
+
2 4
 __author__ = 'damien'
3 5
 
4 6
 from cgi import FieldStorage
@@ -27,7 +29,7 @@ from tracim.lib.predicates import current_user_is_content_manager
27 29
 from tracim.lib.predicates import require_current_user_is_owner
28 30
 
29 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 33
 from tracim.model.data import Content
32 34
 from tracim.model.data import ContentType
33 35
 from tracim.model.data import UserRoleInWorkspace
@@ -88,8 +90,9 @@ class UserWorkspaceFolderThreadCommentRestController(TIMRestController):
88 90
                                                                                                          item_id)
89 91
 
90 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 97
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
95 98
             tg.redirect(next_url)
@@ -116,8 +119,9 @@ class UserWorkspaceFolderThreadCommentRestController(TIMRestController):
116 119
                                                                              tmpl_context.folder_id,
117 120
                                                                              tmpl_context.thread_id)
118 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 126
             tg.flash(msg, CST.STATUS_OK)
123 127
             tg.redirect(next_url)
@@ -277,38 +281,40 @@ class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
277 281
             # TODO - D.A. - 2015-03-19
278 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 319
             msg = _('{} updated').format(self._item_type_label)
314 320
             tg.flash(msg, CST.STATUS_OK)
@@ -420,8 +426,9 @@ class UserWorkspaceFolderPageRestController(TIMWorkspaceContentRestController):
420 426
         try:
421 427
             api = ContentApi(tmpl_context.current_user)
422 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 433
             msg = _('{} updated').format(self._item_type_label)
427 434
             tg.flash(msg, CST.STATUS_OK)
@@ -600,7 +607,9 @@ class ItemLocationController(TIMWorkspaceContentRestController, BaseController):
600 607
 
601 608
             api = ContentApi(tmpl_context.current_user)
602 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 614
             next_url = tg.url('/workspaces/{}/folders/{}'.format(
606 615
                 new_workspace.workspace_id, item_id))
@@ -618,7 +627,8 @@ class ItemLocationController(TIMWorkspaceContentRestController, BaseController):
618 627
             # Default move inside same workspace
619 628
             api = ContentApi(tmpl_context.current_user)
620 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 632
             next_url = self.parent_controller.url(item_id)
623 633
             if new_parent:
624 634
                 tg.flash(_('Item moved to {}').format(new_parent.label), CST.STATUS_OK)
@@ -761,7 +771,8 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
761 771
             logger.error(self, 'An unexpected exception has been catched. Look at the traceback below.')
762 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 776
             if parent_id:
766 777
                 redirect_url = redirect_url_tmpl.format(tmpl_context.workspace_id, parent_id)
767 778
             else:
@@ -792,11 +803,12 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
792 803
                 file = True if can_contain_files=='on' else False,
793 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 813
             tg.flash(_('Folder updated'), CST.STATUS_OK)
802 814
 
@@ -838,8 +850,9 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
838 850
             undo_url = self._std_url.format(item.workspace_id, item.content_id)+'/put_archive_undo'
839 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 857
             tg.flash(msg, CST.STATUS_OK, no_escape=True) # TODO allow to come back
845 858
             tg.redirect(next_url)
@@ -860,8 +873,9 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
860 873
         try:
861 874
             next_url = self._std_url.format(item.workspace_id, item.content_id)
862 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 880
             tg.flash(msg, CST.STATUS_OK)
867 881
             tg.redirect(next_url )
@@ -885,8 +899,9 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
885 899
             next_url = self._parent_url.format(item.workspace_id, item.parent_id)
886 900
             undo_url = self._std_url.format(item.workspace_id, item.content_id)+'/put_delete_undo'
887 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 906
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
892 907
             tg.redirect(next_url)
@@ -909,8 +924,9 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
909 924
         try:
910 925
             next_url = self._std_url.format(item.workspace_id, item.content_id)
911 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 931
             tg.flash(msg, CST.STATUS_OK)
916 932
             tg.redirect(next_url)

+ 30 - 8
tracim/tracim/lib/content.py Bestand weergeven

@@ -1,5 +1,4 @@
1 1
 # -*- coding: utf-8 -*-
2
-
3 2
 __author__ = 'damien'
4 3
 
5 4
 import datetime
@@ -14,14 +13,14 @@ from sqlalchemy.orm import joinedload
14 13
 from sqlalchemy.orm.attributes import get_history
15 14
 from sqlalchemy import desc
16 15
 from sqlalchemy import distinct
17
-from sqlalchemy import not_
18 16
 from sqlalchemy import or_
17
+from sqlalchemy.sql.elements import and_
19 18
 from tracim.lib import cmp_to_key
20 19
 from tracim.lib.notifications import NotifierFactory
21 20
 from tracim.lib.utils import SameValueError
22 21
 from tracim.model import DBSession
23 22
 from tracim.model.auth import User
24
-from tracim.model.data import ActionDescription
23
+from tracim.model.data import ActionDescription, new_revision
25 24
 from tracim.model.data import BreadcrumbItem
26 25
 from tracim.model.data import ContentStatus
27 26
 from tracim.model.data import ContentRevisionRO
@@ -76,6 +75,29 @@ class ContentApi(object):
76 75
         self._show_deleted = show_deleted
77 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 102
     @classmethod
81 103
     def sort_tree_items(cls, content_list: [NodeTreeItem])-> [Content]:
@@ -134,7 +156,7 @@ class ContentApi(object):
134 156
         return breadcrumb
135 157
 
136 158
     def __real_base_query(self, workspace: Workspace=None):
137
-        result = DBSession.query(Content)
159
+        result = self.get_base_query()
138 160
 
139 161
         if workspace:
140 162
             result = result.filter(Content.workspace_id==workspace.workspace_id)
@@ -458,7 +480,8 @@ class ContentApi(object):
458 480
         self.save(item, do_notify=False)
459 481
 
460 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 485
         return
463 486
 
464 487
     def update_content(self, item: Content, new_label: str, new_content: str=None) -> Content:
@@ -488,7 +511,6 @@ class ContentApi(object):
488 511
         content.is_archived = False
489 512
         content.revision_type = ActionDescription.UNARCHIVING
490 513
 
491
-
492 514
     def delete(self, content: Content):
493 515
         content.owner = self._user
494 516
         content.is_deleted = True
@@ -576,7 +598,7 @@ class ContentApi(object):
576 598
 
577 599
         if not action_description:
578 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 602
                 # The action has not been modified, so we set it to default edition
581 603
                 action_description = ActionDescription.EDITION
582 604
 
@@ -638,7 +660,7 @@ class ContentApi(object):
638 660
         filter_group_desc = list(Content.description.ilike('%{}%'.format(keyword)) for keyword in keywords)
639 661
         title_keyworded_items = self._hard_filtered_base_query().\
640 662
             filter(or_(*(filter_group_label+filter_group_desc))).\
641
-            options(joinedload('children')).\
663
+            options(joinedload('children_revisions')).\
642 664
             options(joinedload('parent'))
643 665
 
644 666
         return title_keyworded_items

+ 12 - 0
tracim/tracim/lib/exception.py Bestand weergeven

@@ -5,6 +5,18 @@ class TracimError(Exception):
5 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 20
 class ConfigurationError(TracimError):
9 21
     pass
10 22
 

+ 36 - 4
tracim/tracim/model/__init__.py Bestand weergeven

@@ -1,10 +1,32 @@
1 1
 # -*- coding: utf-8 -*-
2 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 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 31
 # Global session manager: DBSession() returns the Thread-local
10 32
 # session object appropriate for the current web request.
@@ -38,6 +60,7 @@ metadata = DeclarativeBase.metadata
38 60
 #
39 61
 ######
40 62
 
63
+
41 64
 def init_model(engine):
42 65
     """Call me before using any of the tables or classes in the model."""
43 66
     DBSession.configure(bind=engine)
@@ -60,4 +83,13 @@ def init_model(engine):
60 83
 
61 84
 # Import your model modules here.
62 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 Bestand weergeven

@@ -1,38 +1,36 @@
1 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 3
 import datetime as datetime_root
9 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 12
 from sqlalchemy import ForeignKey
15 13
 from sqlalchemy import Sequence
16
-
14
+from sqlalchemy import func
15
+from sqlalchemy.ext.associationproxy import association_proxy
17 16
 from sqlalchemy.ext.hybrid import hybrid_property
18
-
19 17
 from sqlalchemy.orm import backref
20
-from sqlalchemy.orm import relationship
21 18
 from sqlalchemy.orm import deferred
19
+from sqlalchemy.orm import relationship
22 20
 from sqlalchemy.orm.collections import attribute_mapped_collection
23
-
24 21
 from sqlalchemy.types import Boolean
25 22
 from sqlalchemy.types import DateTime
26 23
 from sqlalchemy.types import Integer
27 24
 from sqlalchemy.types import LargeBinary
28 25
 from sqlalchemy.types import Text
29 26
 from sqlalchemy.types import Unicode
30
-
31 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 31
 from tracim.model.auth import User
35 32
 
33
+
36 34
 class BreadcrumbItem(object):
37 35
 
38 36
     def __init__(self, icon_string: str, label: str, url: str, is_active: bool = False):
@@ -54,11 +52,18 @@ class Workspace(DeclarativeBase):
54 52
     label   = Column(Unicode(1024), unique=False, nullable=False, default='')
55 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 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 67
     def get_user_role(self, user: User) -> int:
63 68
         for role in user.roles:
64 69
             if role.workspace.workspace_id==self.workspace_id:
@@ -435,48 +440,511 @@ class ContentType(object):
435 440
                     label=self.label,
436 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 495
     file_name = Column(Unicode(255),  unique=False, nullable=False, default='')
467 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 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 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 943
     def get_valid_children(self, content_types: list=None) -> ['Content']:
476 944
         for child in self.children:
477 945
             if not child.is_deleted and not child.is_archived:
478 946
                 if not content_types or child.type in content_types:
479
-                    yield child
947
+                    yield child.node
480 948
 
481 949
     @hybrid_property
482 950
     def properties(self):
@@ -519,7 +987,6 @@ class Content(DeclarativeBase):
519 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 990
         for link in soup.findAll('a'):
524 991
             href = link.get('href')
525 992
             label = link.contents
@@ -530,7 +997,6 @@ class Content(DeclarativeBase):
530 997
         ## FIXME - Does this return a sorted list ???!
531 998
         return sorted_links
532 999
 
533
-
534 1000
     def get_child_nb(self, content_type: ContentType, content_status = ''):
535 1001
         child_nb = 0
536 1002
         for child in self.get_valid_children():
@@ -586,7 +1052,7 @@ class Content(DeclarativeBase):
586 1052
         children = []
587 1053
         for child in self.children:
588 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 1056
         return children
591 1057
 
592 1058
     def get_last_comment_from(self, user: User) -> 'Content':
@@ -615,19 +1081,6 @@ class Content(DeclarativeBase):
615 1081
 
616 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 1084
     def description_as_raw_text(self):
632 1085
         # 'html.parser' fixes a hanging bug
633 1086
         # see http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
@@ -667,102 +1120,22 @@ class Content(DeclarativeBase):
667 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 1140
 class RevisionReadStatus(DeclarativeBase):
768 1141
 
@@ -772,11 +1145,6 @@ class RevisionReadStatus(DeclarativeBase):
772 1145
     user_id = Column(Integer, ForeignKey('users.user_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
773 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 1148
     content_revision = relationship(
781 1149
         'ContentRevisionRO',
782 1150
         backref=backref(

+ 1 - 1
tracim/tracim/model/serializers.py Bestand weergeven

@@ -138,7 +138,7 @@ class Context(object):
138 138
         try:
139 139
             converter = Context._converters[context_string][model_class]
140 140
             return converter
141
-        except:
141
+        except KeyError:
142 142
             if CTX.DEFAULT in Context._converters:
143 143
                 if model_class in Context._converters[CTX.DEFAULT]:
144 144
                     return Context._converters[CTX.DEFAULT][model_class]

+ 49 - 2
tracim/tracim/tests/__init__.py Bestand weergeven

@@ -2,14 +2,15 @@
2 2
 """Unit and functional test suite for tracim."""
3 3
 import argparse
4 4
 import os
5
+import time
5 6
 from os import getcwd
6 7
 
7 8
 import ldap3
8 9
 import tg
9
-import time
10 10
 import transaction
11 11
 from gearbox.commands.setup_app import SetupAppCommand
12 12
 from ldap_test import LdapServer
13
+from nose.tools import eq_
13 14
 from nose.tools import ok_
14 15
 from paste.deploy import loadapp
15 16
 from sqlalchemy.engine import reflection
@@ -28,7 +29,9 @@ from who_ldap import make_connection
28 29
 from tracim.fixtures import FixturesLoader
29 30
 from tracim.fixtures.users_and_groups import Base as BaseFixture
30 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 36
 __all__ = ['setup_app', 'setup_db', 'teardown_db', 'TestController']
34 37
 
@@ -293,3 +296,47 @@ class LDAPTest(object):
293 296
 class ArgumentParser(argparse.ArgumentParser):
294 297
     def exit(self, status=0, message=None):
295 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 Bestand weergeven

@@ -12,10 +12,11 @@ from tracim.lib.group import GroupApi
12 12
 from tracim.lib.user import UserApi
13 13
 from tracim.lib.workspace import RoleApi
14 14
 from tracim.lib.workspace import WorkspaceApi
15
+from tracim.model import DBSession
15 16
 
16 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 20
 from tracim.model.data import Content
20 21
 from tracim.model.data import ContentType
21 22
 from tracim.model.data import UserRoleInWorkspace
@@ -123,7 +124,8 @@ class TestContentApi(TestStandard):
123 124
         eq_(2, len(items))
124 125
 
125 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 129
         transaction.commit()
128 130
 
129 131
         # Refresh instances after commit
@@ -172,7 +174,8 @@ class TestContentApi(TestStandard):
172 174
         eq_(2, len(items))
173 175
 
174 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 179
         transaction.commit()
177 180
 
178 181
         # Refresh instances after commit
@@ -276,7 +279,8 @@ class TestContentApi(TestStandard):
276 279
                                                         save_now=True)
277 280
         api = ContentApi(user)
278 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 285
     def test_set_status_ok(self):
282 286
         uapi = UserApi(None)
@@ -291,11 +295,13 @@ class TestContentApi(TestStandard):
291 295
                                                         save_now=True)
292 296
         api = ContentApi(user)
293 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 306
     def test_create_comment_ok(self):
301 307
         uapi = UserApi(None)
@@ -370,7 +376,8 @@ class TestContentApi(TestStandard):
370 376
         u2 = UserApi(None).get_one(u2id)
371 377
         api2 = ContentApi(u2)
372 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 381
         api2.save(content2)
375 382
         transaction.commit()
376 383
 
@@ -435,8 +442,9 @@ class TestContentApi(TestStandard):
435 442
         u2 = UserApi(None).get_one(u2id)
436 443
         api2 = ContentApi(u2)
437 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 448
         api2.save(content2)
441 449
         transaction.commit()
442 450
 
@@ -501,7 +509,8 @@ class TestContentApi(TestStandard):
501 509
         u2 = UserApi(None).get_one(u2id)
502 510
         api2 = ContentApi(u2, show_archived=True)
503 511
         content2 = api2.get_one(pcid, ContentType.Any, workspace)
504
-        api2.archive(content2)
512
+        with new_revision(content2):
513
+            api2.archive(content2)
505 514
         api2.save(content2)
506 515
         transaction.commit()
507 516
 
@@ -522,7 +531,8 @@ class TestContentApi(TestStandard):
522 531
         ####
523 532
 
524 533
         updated2 = api.get_one(pcid, ContentType.Any, workspace)
525
-        api.unarchive(updated)
534
+        with new_revision(updated):
535
+            api.unarchive(updated)
526 536
         api.save(updated2)
527 537
         eq_(False, updated2.is_archived)
528 538
         eq_(ActionDescription.UNARCHIVING, updated2.revision_type)
@@ -574,7 +584,8 @@ class TestContentApi(TestStandard):
574 584
         u2 = UserApi(None).get_one(u2id)
575 585
         api2 = ContentApi(u2, show_deleted=True)
576 586
         content2 = api2.get_one(pcid, ContentType.Any, workspace)
577
-        api2.delete(content2)
587
+        with new_revision(content2):
588
+            api2.delete(content2)
578 589
         api2.save(content2)
579 590
         transaction.commit()
580 591
 
@@ -597,7 +608,8 @@ class TestContentApi(TestStandard):
597 608
         ####
598 609
 
599 610
         updated2 = api.get_one(pcid, ContentType.Any, workspace)
600
-        api.undelete(updated)
611
+        with new_revision(updated2):
612
+            api.undelete(updated2)
601 613
         api.save(updated2)
602 614
         eq_(False, updated2.is_deleted)
603 615
         eq_(ActionDescription.UNDELETION, updated2.revision_type)
@@ -623,7 +635,10 @@ class TestContentApi(TestStandard):
623 635
                        'this is randomized folder', True)
624 636
         p = api.create(ContentType.Page, workspace, a,
625 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 642
         api.save(p)
628 643
         original_id = p.content_id
629 644
 
@@ -653,7 +668,10 @@ class TestContentApi(TestStandard):
653 668
                        'this is randomized folder', True)
654 669
         p = api.create(ContentType.Page, workspace, a,
655 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 675
         api.save(p)
658 676
         original_id = p.content_id
659 677
 
@@ -685,15 +703,27 @@ class TestContentApi(TestStandard):
685 703
                        'this is randomized folder', True)
686 704
         p1 = api.create(ContentType.Page, workspace, a,
687 705
                         'this is dummy label content', True)
688
-        p1.description = 'This is some amazing test'
689 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 714
         api.save(p1)
692 715
         api.save(p2)
693 716
 
694 717
         id1 = p1.content_id
695 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 727
         res = api.search(['dummy', 'jon'])
698 728
         eq_(2, len(res.all()))
699 729
 

+ 4 - 5
tracim/tracim/tests/library/test_serializers.py Bestand weergeven

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

+ 15 - 0
tracim/tracim/tests/library/test_thread.py Bestand weergeven

@@ -0,0 +1,15 @@
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 Bestand weergeven

@@ -0,0 +1,21 @@
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 Bestand weergeven

@@ -0,0 +1,157 @@
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 Bestand weergeven

@@ -17,12 +17,7 @@ def setup_schema(command, conf, vars):
17 17
     
18 18
     # <websetup.websetup.schema.before.metadata.create_all>
19 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 22
     #ALTER TABLE bibi ADD COLUMN popo integer;
28 23
 
@@ -37,266 +32,3 @@ def setup_schema(command, conf, vars):
37 32
     alembic_cfg.set_main_option("script_location", "migration")
38 33
     alembic_cfg.set_main_option("sqlalchemy.url", config['sqlalchemy.url'])
39 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
-"""