Browse Source

Merge pull request #33 from buxx/dev/content

Tracim 8 years ago
parent
commit
9e5fa9809a

+ 187 - 0
tracim/migration/versions/da12239d9da0_delete_content_view.py View File

@@ -0,0 +1,187 @@
1
+"""delete_content_view
2
+
3
+Revision ID: da12239d9da0
4
+Revises: b73e57760b36
5
+Create Date: 2016-03-04 15:59:05.828757
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+revision = 'da12239d9da0'
11
+down_revision = 'b73e57760b36'
12
+
13
+import sqlalchemy as sa
14
+from alembic import op
15
+from sqlalchemy.dialects import postgresql
16
+
17
+
18
+def set_field_where_null(field_name, value="''"):
19
+    op.execute("UPDATE content_revisions SET %s = %s WHERE %s IS NULL" % (field_name, value, field_name))
20
+
21
+
22
+def set_field_to_null_where_empty_string(field_name):
23
+    op.execute("UPDATE content_revisions SET %s = NULL WHERE %s = ''" % (field_name, field_name))
24
+
25
+fields_names_to_empty_string = ('file_mimetype', 'file_name', 'label', 'properties',
26
+                                'revision_type', 'status', 'description', 'label')
27
+
28
+
29
+def upgrade():
30
+    ### commands auto generated by Alembic - please adjust! ###
31
+
32
+    # Drop triggers
33
+    op.execute("DROP TRIGGER trg__contents__on_insert__set_created ON content_revisions")
34
+    op.execute("DROP TRIGGER trg__contents__on_update__set_updated ON content_revisions")
35
+    op.execute("DROP TRIGGER trg__contents__on_update On contents")
36
+    op.execute("DROP TRIGGER trg__workspaces__on_insert__set_created ON workspaces")
37
+    op.execute("DROP TRIGGER trg__workspaces__on_update__set_updated ON workspaces")
38
+    op.execute("DROP VIEW contents")
39
+
40
+    # Set empty string on future non null fields
41
+    for field_name in fields_names_to_empty_string:
42
+        set_field_where_null(field_name)
43
+
44
+    op.create_table('content',
45
+                    sa.Column('id', sa.Integer(), nullable=False),
46
+                    sa.PrimaryKeyConstraint('id', name=op.f('pk__content'))
47
+                    )
48
+
49
+    # Create contents and reinit auto increment
50
+    op.execute("INSERT INTO content (id) SELECT DISTINCT(content_id) FROM content_revisions;")
51
+    op.execute("select setval('content_id_seq', (select max(id)+1 from content), false)")
52
+
53
+    op.alter_column('content_revisions', 'created',
54
+                    existing_type=postgresql.TIMESTAMP(),
55
+                    nullable=False,
56
+                    server_default=sa.func.now())
57
+    op.alter_column('content_revisions', 'file_mimetype',
58
+                    existing_type=sa.VARCHAR(length=255),
59
+                    nullable=False,
60
+                    server_default='')
61
+    op.alter_column('content_revisions', 'file_name',
62
+                    existing_type=sa.VARCHAR(length=255),
63
+                    nullable=False,
64
+                    server_default='')
65
+    op.alter_column('content_revisions', 'label',
66
+                    existing_type=sa.VARCHAR(length=1024),
67
+                    nullable=False,
68
+                    server_default='')
69
+    op.alter_column('content_revisions', 'properties',
70
+                    existing_type=sa.TEXT(),
71
+                    nullable=False,
72
+                    server_default='')
73
+    op.alter_column('content_revisions', 'revision_type',
74
+                    existing_type=sa.VARCHAR(length=32),
75
+                    nullable=False,
76
+                    server_default='')
77
+    op.alter_column('content_revisions', 'status',
78
+                    existing_type=sa.VARCHAR(length=32),
79
+                    nullable=False,
80
+                    existing_server_default=sa.text("'new'::character varying"),
81
+                    server_default='')
82
+    op.alter_column('content_revisions', 'updated',
83
+                    existing_type=postgresql.TIMESTAMP(),
84
+                    nullable=False,
85
+                    server_default=sa.func.now())
86
+    op.create_foreign_key(op.f('fk__content_revisions__content_id__content'), 'content_revisions', 'content',
87
+                          ['content_id'], ['id'])
88
+    op.create_foreign_key(op.f('fk__content_revisions__workspace_id__workspaces'), 'content_revisions', 'workspaces',
89
+                          ['workspace_id'], ['workspace_id'])
90
+    op.create_foreign_key(op.f('fk__content_revisions__parent_id__content'), 'content_revisions', 'content',
91
+                          ['parent_id'], ['id'])
92
+    op.alter_column('user_workspace', 'role',
93
+                    existing_type=sa.INTEGER(),
94
+                    nullable=False)
95
+    op.drop_constraint('fk__user_workspace__user_id', 'user_workspace', type_='foreignkey')
96
+    op.drop_constraint('fk__user_workspace__workspace_id', 'user_workspace', type_='foreignkey')
97
+    op.create_foreign_key(op.f('fk__user_workspace__user_id__users'), 'user_workspace', 'users', ['user_id'],
98
+                          ['user_id'])
99
+    op.create_foreign_key(op.f('fk__user_workspace__workspace_id__workspaces'), 'user_workspace', 'workspaces',
100
+                          ['workspace_id'], ['workspace_id'])
101
+    op.alter_column('workspaces', 'created',
102
+                    existing_type=postgresql.TIMESTAMP(),
103
+                    nullable=False,
104
+                    server_default=sa.func.now())
105
+    op.alter_column('workspaces', 'description',
106
+                    existing_type=sa.TEXT(),
107
+                    nullable=False,
108
+                    server_default='')
109
+    op.alter_column('workspaces', 'label',
110
+                    existing_type=sa.VARCHAR(length=1024),
111
+                    nullable=False,
112
+                    server_default='')
113
+    op.alter_column('workspaces', 'updated',
114
+                    existing_type=postgresql.TIMESTAMP(),
115
+                    nullable=False,
116
+                    server_default=sa.func.now())
117
+    ### end Alembic commands ###
118
+
119
+
120
+def downgrade():
121
+    ### commands auto generated by Alembic - please adjust! ###
122
+    op.alter_column('workspaces', 'updated',
123
+                    existing_type=postgresql.TIMESTAMP(),
124
+                    nullable=True)
125
+    op.alter_column('workspaces', 'label',
126
+                    existing_type=sa.VARCHAR(length=1024),
127
+                    nullable=True)
128
+    op.alter_column('workspaces', 'description',
129
+                    existing_type=sa.TEXT(),
130
+                    nullable=True)
131
+    op.alter_column('workspaces', 'created',
132
+                    existing_type=postgresql.TIMESTAMP(),
133
+                    nullable=True)
134
+    op.drop_constraint(op.f('fk__user_workspace__workspace_id__workspaces'), 'user_workspace', type_='foreignkey')
135
+    op.drop_constraint(op.f('fk__user_workspace__user_id__users'), 'user_workspace', type_='foreignkey')
136
+    op.create_foreign_key('fk__user_workspace__workspace_id', 'user_workspace', 'workspaces', ['workspace_id'],
137
+                          ['workspace_id'], onupdate='CASCADE', ondelete='CASCADE')
138
+    op.create_foreign_key('fk__user_workspace__user_id', 'user_workspace', 'users', ['user_id'], ['user_id'],
139
+                          onupdate='CASCADE', ondelete='CASCADE')
140
+    op.alter_column('user_workspace', 'role',
141
+                    existing_type=sa.INTEGER(),
142
+                    nullable=True)
143
+    op.drop_constraint(op.f('fk__content_revisions__parent_id__content'), 'content_revisions', type_='foreignkey')
144
+    op.drop_constraint(op.f('fk__content_revisions__workspace_id__workspaces'), 'content_revisions', type_='foreignkey')
145
+    op.drop_constraint(op.f('fk__content_revisions__content_id__content'), 'content_revisions', type_='foreignkey')
146
+    op.alter_column('content_revisions', 'updated',
147
+                    existing_type=postgresql.TIMESTAMP(),
148
+                    nullable=True)
149
+    op.alter_column('content_revisions', 'status',
150
+                    existing_type=sa.VARCHAR(length=32),
151
+                    nullable=True,
152
+                    existing_server_default=sa.text("'new'::character varying"))
153
+    op.alter_column('content_revisions', 'revision_type',
154
+                    existing_type=sa.VARCHAR(length=32),
155
+                    nullable=True)
156
+    op.alter_column('content_revisions', 'properties',
157
+                    existing_type=sa.TEXT(),
158
+                    nullable=True)
159
+    op.alter_column('content_revisions', 'label',
160
+                    existing_type=sa.VARCHAR(length=1024),
161
+                    nullable=True)
162
+    op.alter_column('content_revisions', 'file_name',
163
+                    existing_type=sa.VARCHAR(length=255),
164
+                    nullable=True)
165
+    op.alter_column('content_revisions', 'file_mimetype',
166
+                    existing_type=sa.VARCHAR(length=255),
167
+                    nullable=True)
168
+    op.alter_column('content_revisions', 'created',
169
+                    existing_type=postgresql.TIMESTAMP(),
170
+                    nullable=True)
171
+    op.drop_table('content')
172
+
173
+    for field_name in fields_names_to_empty_string:
174
+        set_field_to_null_where_empty_string(field_name)
175
+
176
+    op.execute("""
177
+CREATE VIEW contents AS
178
+    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;
179
+
180
+
181
+CREATE TRIGGER trg__contents__on_insert__set_created BEFORE INSERT ON content_revisions FOR EACH ROW EXECUTE PROCEDURE set_created();
182
+CREATE TRIGGER trg__contents__on_update__set_updated BEFORE UPDATE ON content_revisions FOR EACH ROW EXECUTE PROCEDURE set_updated();
183
+CREATE TRIGGER trg__contents__on_update INSTEAD OF UPDATE ON contents FOR EACH ROW EXECUTE PROCEDURE update_node();
184
+CREATE TRIGGER trg__workspaces__on_insert__set_created BEFORE INSERT ON workspaces FOR EACH ROW EXECUTE PROCEDURE set_created();
185
+CREATE TRIGGER trg__workspaces__on_update__set_updated BEFORE UPDATE ON workspaces FOR EACH ROW EXECUTE PROCEDURE set_updated();
186
+""")
187
+    ### end Alembic commands ###

+ 19 - 12
tracim/tracim/controllers/__init__.py View File

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

+ 71 - 54
tracim/tracim/controllers/content.py View File

@@ -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
@@ -28,6 +30,7 @@ from tracim.lib.predicates import require_current_user_is_owner
28 30
 
29 31
 from tracim.model.serializers import Context, CTX, DictLikeClass
30 32
 from tracim.model.data import ActionDescription
33
+from tracim.model import new_revision
31 34
 from tracim.model.data import Content
32 35
 from tracim.model.data import ContentType
33 36
 from tracim.model.data import UserRoleInWorkspace
@@ -88,8 +91,9 @@ class UserWorkspaceFolderThreadCommentRestController(TIMRestController):
88 91
                                                                                                          item_id)
89 92
 
90 93
             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)
94
+            with new_revision(item):
95
+                content_api.delete(item)
96
+                content_api.save(item, ActionDescription.DELETION)
93 97
 
94 98
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
95 99
             tg.redirect(next_url)
@@ -116,8 +120,9 @@ class UserWorkspaceFolderThreadCommentRestController(TIMRestController):
116 120
                                                                              tmpl_context.folder_id,
117 121
                                                                              tmpl_context.thread_id)
118 122
             msg = _('{} undeleted.').format(self._item_type_label)
119
-            content_api.undelete(item)
120
-            content_api.save(item, ActionDescription.UNDELETION)
123
+            with new_revision(item):
124
+                content_api.undelete(item)
125
+                content_api.save(item, ActionDescription.UNDELETION)
121 126
 
122 127
             tg.flash(msg, CST.STATUS_OK)
123 128
             tg.redirect(next_url)
@@ -277,38 +282,40 @@ class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
277 282
             # TODO - D.A. - 2015-03-19
278 283
             # refactor this method in order to make code easier to understand
279 284
 
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)
285
+            with new_revision(item):
286
+
287
+                if comment and label:
288
+                    updated_item = api.update_content(
289
+                        item, label if label else item.label,
290
+                        comment if comment else ''
291
+                    )
292
+                    api.save(updated_item, ActionDescription.EDITION)
293
+
294
+                    # This case is the default "file title and description update"
295
+                    # In this case the file itself is not revisionned
296
+
297
+                else:
298
+                    # So, now we may have a comment and/or a file revision
299
+                    if comment and ''==label:
300
+                        comment_item = api.create_comment(workspace,
301
+                                                          item, comment,
302
+                                                          do_save=False)
303
+
304
+                        if not isinstance(file_data, FieldStorage):
305
+                            api.save(comment_item, ActionDescription.COMMENT)
306
+                        else:
307
+                            # The notification is only sent
308
+                            # if the file is NOT updated
309
+                            #
310
+                            #  If the file is also updated,
311
+                            #  then a 'file revision' notification will be sent.
312
+                            api.save(comment_item,
313
+                                     ActionDescription.COMMENT,
314
+                                     do_notify=False)
315
+
316
+                    if isinstance(file_data, FieldStorage):
317
+                        api.update_file_data(item, file_data.filename, file_data.type, file_data.file.read())
318
+                        api.save(item, ActionDescription.REVISION)
312 319
 
313 320
             msg = _('{} updated').format(self._item_type_label)
314 321
             tg.flash(msg, CST.STATUS_OK)
@@ -424,8 +431,9 @@ class UserWorkspaceFolderPageRestController(TIMWorkspaceContentRestController):
424 431
         try:
425 432
             api = ContentApi(tmpl_context.current_user)
426 433
             item = api.get_one(int(item_id), self._item_type, workspace)
427
-            api.update_content(item, label, content)
428
-            api.save(item, ActionDescription.REVISION)
434
+            with new_revision(item):
435
+                api.update_content(item, label, content)
436
+                api.save(item, ActionDescription.REVISION)
429 437
 
430 438
             msg = _('{} updated').format(self._item_type_label)
431 439
             tg.flash(msg, CST.STATUS_OK)
@@ -604,7 +612,9 @@ class ItemLocationController(TIMWorkspaceContentRestController, BaseController):
604 612
 
605 613
             api = ContentApi(tmpl_context.current_user)
606 614
             item = api.get_one(item_id, ContentType.Any, workspace)
607
-            api.move_recursively(item, new_parent, new_workspace)
615
+
616
+            with new_revision(item):
617
+                api.move_recursively(item, new_parent, new_workspace)
608 618
 
609 619
             next_url = tg.url('/workspaces/{}/folders/{}'.format(
610 620
                 new_workspace.workspace_id, item_id))
@@ -622,7 +632,8 @@ class ItemLocationController(TIMWorkspaceContentRestController, BaseController):
622 632
             # Default move inside same workspace
623 633
             api = ContentApi(tmpl_context.current_user)
624 634
             item = api.get_one(item_id, ContentType.Any, workspace)
625
-            api.move(item, new_parent)
635
+            with new_revision(item):
636
+                api.move(item, new_parent)
626 637
             next_url = self.parent_controller.url(item_id)
627 638
             if new_parent:
628 639
                 tg.flash(_('Item moved to {}').format(new_parent.label), CST.STATUS_OK)
@@ -765,7 +776,8 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
765 776
             logger.error(self, 'An unexpected exception has been catched. Look at the traceback below.')
766 777
             traceback.print_exc()
767 778
 
768
-            tg.flash(_('Folder not created: {}').format(e.with_traceback()), CST.STATUS_ERROR)
779
+            tb = sys.exc_info()[2]
780
+            tg.flash(_('Folder not created: {}').format(e.with_traceback(tb)), CST.STATUS_ERROR)
769 781
             if parent_id:
770 782
                 redirect_url = redirect_url_tmpl.format(tmpl_context.workspace_id, parent_id)
771 783
             else:
@@ -796,11 +808,12 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
796 808
                 file = True if can_contain_files=='on' else False,
797 809
                 page = True if can_contain_pages=='on' else False
798 810
             )
799
-            if label != folder.label:
800
-                # TODO - D.A. - 2015-05-25 - Allow to set folder description
801
-                api.update_content(folder, label, folder.description)
802
-            api.set_allowed_content(folder, subcontent)
803
-            api.save(folder)
811
+            with new_revision(folder):
812
+                if label != folder.label:
813
+                    # TODO - D.A. - 2015-05-25 - Allow to set folder description
814
+                    api.update_content(folder, label, folder.description)
815
+                api.set_allowed_content(folder, subcontent)
816
+                api.save(folder)
804 817
 
805 818
             tg.flash(_('Folder updated'), CST.STATUS_OK)
806 819
 
@@ -842,8 +855,9 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
842 855
             undo_url = self._std_url.format(item.workspace_id, item.content_id)+'/put_archive_undo'
843 856
             msg = _('{} archived. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
844 857
 
845
-            content_api.archive(item)
846
-            content_api.save(item, ActionDescription.ARCHIVING)
858
+            with new_revision(item):
859
+                content_api.archive(item)
860
+                content_api.save(item, ActionDescription.ARCHIVING)
847 861
 
848 862
             tg.flash(msg, CST.STATUS_OK, no_escape=True) # TODO allow to come back
849 863
             tg.redirect(next_url)
@@ -864,8 +878,9 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
864 878
         try:
865 879
             next_url = self._std_url.format(item.workspace_id, item.content_id)
866 880
             msg = _('{} unarchived.').format(self._item_type_label)
867
-            content_api.unarchive(item)
868
-            content_api.save(item, ActionDescription.UNARCHIVING)
881
+            with new_revision(item):
882
+                content_api.unarchive(item)
883
+                content_api.save(item, ActionDescription.UNARCHIVING)
869 884
 
870 885
             tg.flash(msg, CST.STATUS_OK)
871 886
             tg.redirect(next_url )
@@ -889,8 +904,9 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
889 904
             next_url = self._parent_url.format(item.workspace_id, item.parent_id)
890 905
             undo_url = self._std_url.format(item.workspace_id, item.content_id)+'/put_delete_undo'
891 906
             msg = _('{} deleted. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
892
-            content_api.delete(item)
893
-            content_api.save(item, ActionDescription.DELETION)
907
+            with new_revision(item):
908
+                content_api.delete(item)
909
+                content_api.save(item, ActionDescription.DELETION)
894 910
 
895 911
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
896 912
             tg.redirect(next_url)
@@ -913,8 +929,9 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
913 929
         try:
914 930
             next_url = self._std_url.format(item.workspace_id, item.content_id)
915 931
             msg = _('{} undeleted.').format(self._item_type_label)
916
-            content_api.undelete(item)
917
-            content_api.save(item, ActionDescription.UNDELETION)
932
+            with new_revision(item):
933
+                content_api.undelete(item)
934
+                content_api.save(item, ActionDescription.UNDELETION)
918 935
 
919 936
             tg.flash(msg, CST.STATUS_OK)
920 937
             tg.redirect(next_url)

+ 30 - 8
tracim/tracim/lib/content.py View File

@@ -1,5 +1,4 @@
1 1
 # -*- coding: utf-8 -*-
2
-
3 2
 __author__ = 'damien'
4 3
 
5 4
 import datetime
@@ -14,12 +13,12 @@ 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
-from tracim.model import DBSession
21
+from tracim.model import DBSession, new_revision
23 22
 from tracim.model.auth import User
24 23
 from tracim.model.data import ActionDescription
25 24
 from tracim.model.data import BreadcrumbItem
@@ -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_canonical_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_canonical_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 View File

@@ -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
 

+ 74 - 6
tracim/tracim/model/__init__.py View File

@@ -1,10 +1,41 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 """The application's model objects"""
3
-
4
-from zope.sqlalchemy import ZopeTransactionExtension
5
-from sqlalchemy.orm import scoped_session, sessionmaker
6
-#from sqlalchemy import MetaData
3
+from decorator import contextmanager
4
+from sqlalchemy import event, inspect, MetaData
7 5
 from sqlalchemy.ext.declarative import declarative_base
6
+from sqlalchemy.orm import scoped_session, sessionmaker, Session
7
+from sqlalchemy.orm.unitofwork import UOWTransaction
8
+from zope.sqlalchemy import ZopeTransactionExtension
9
+
10
+from tracim.lib.exception import ContentRevisionUpdateError, ContentRevisionDeleteError
11
+
12
+
13
+class RevisionsIntegrity(object):
14
+    """
15
+    Simple static used class to manage a list with list of ContentRevisionRO who are allowed to be updated.
16
+
17
+    When modify an already existing (understood have an identity in databse) ContentRevisionRO, if it's not in
18
+    RevisionsIntegrity._updatable_revisions list, a ContentRevisionUpdateError thrown.
19
+
20
+    This class is used by tracim.model.new_revision context manager.
21
+    """
22
+    _updatable_revisions = []
23
+
24
+    @classmethod
25
+    def add_to_updatable(cls, revision: 'ContentRevisionRO') -> None:
26
+        if inspect(revision).has_identity:
27
+            raise ContentRevisionUpdateError("ContentRevision is not updatable. %s already have identity." % revision)
28
+
29
+        if revision not in cls._updatable_revisions:
30
+            cls._updatable_revisions.append(revision)
31
+
32
+    @classmethod
33
+    def remove_from_updatable(cls, revision: 'ContentRevisionRO') -> None:
34
+        cls._updatable_revisions.remove(revision)
35
+
36
+    @classmethod
37
+    def is_updatable(cls, revision: 'ContentRevisionRO') -> bool:
38
+        return revision in cls._updatable_revisions
8 39
 
9 40
 # Global session manager: DBSession() returns the Thread-local
10 41
 # session object appropriate for the current web request.
@@ -15,7 +46,16 @@ DBSession = scoped_session(maker)
15 46
 # Base class for all of our model classes: By default, the data model is
16 47
 # defined with SQLAlchemy's declarative extension, but if you need more
17 48
 # control, you can switch to the traditional method.
18
-DeclarativeBase = declarative_base()
49
+convention = {
50
+  "ix": 'ix__%(column_0_label)s',  # Indexes
51
+  "uq": "uq__%(table_name)s__%(column_0_name)s",  # Unique constrains
52
+  "ck": "ck__%(table_name)s__%(constraint_name)s",  # Other column constrains
53
+  "fk": "fk__%(table_name)s__%(column_0_name)s__%(referred_table_name)s",  # Foreign keys
54
+  "pk": "pk__%(table_name)s"  # Primary keys
55
+}
56
+
57
+metadata = MetaData(naming_convention=convention)
58
+DeclarativeBase = declarative_base(metadata=metadata)
19 59
 
20 60
 # There are two convenient ways for you to spare some typing.
21 61
 # You can have a query property on all your model classes by doing this:
@@ -38,6 +78,7 @@ metadata = DeclarativeBase.metadata
38 78
 #
39 79
 ######
40 80
 
81
+
41 82
 def init_model(engine):
42 83
     """Call me before using any of the tables or classes in the model."""
43 84
     DBSession.configure(bind=engine)
@@ -60,4 +101,31 @@ def init_model(engine):
60 101
 
61 102
 # Import your model modules here.
62 103
 from tracim.model.auth import User, Group, Permission
63
-from tracim.model.data import Content
104
+from tracim.model.data import Content, ContentRevisionRO
105
+
106
+
107
+@event.listens_for(DBSession, 'before_flush')
108
+def prevent_content_revision_delete(session: Session, flush_context: UOWTransaction,
109
+                                    instances: [DeclarativeBase]) -> None:
110
+    for instance in session.deleted:
111
+        if isinstance(instance, ContentRevisionRO) and instance.revision_id is not None:
112
+            raise ContentRevisionDeleteError("ContentRevision is not deletable. You must make a new revision with" +
113
+                                             "is_deleted set to True. Look at tracim.model.new_revision context " +
114
+                                             "manager to make a new revision")
115
+
116
+
117
+@contextmanager
118
+def new_revision(content: Content) -> Content:
119
+    """
120
+    Prepare context to update a Content. It will add a new updatable revision to the content.
121
+    :param content: Content instance to update
122
+    :return:
123
+    """
124
+    with DBSession.no_autoflush:
125
+        try:
126
+            if inspect(content.revision).has_identity:
127
+                content.new_revision()
128
+            RevisionsIntegrity.add_to_updatable(content.revision)
129
+            yield content
130
+        finally:
131
+            RevisionsIntegrity.remove_from_updatable(content.revision)

+ 517 - 158
tracim/tracim/model/data.py View File

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

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

@@ -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]

+ 46 - 2
tracim/tracim/tests/__init__.py View File

@@ -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,44 @@ 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_and_test(self, name, *args, **kwargs) -> Workspace:
304
+        """
305
+        All extra parameters (*args, **kwargs) are for Workspace init
306
+        :return: Created workspace instance
307
+        """
308
+        workspace = Workspace(label=name, *args, **kwargs)
309
+        DBSession.add(workspace)
310
+        DBSession.flush()
311
+
312
+        eq_(1, DBSession.query(Workspace).filter(Workspace.label == name).count())
313
+        return DBSession.query(Workspace).filter(Workspace.label == name).one()
314
+
315
+    def _create_content_and_test(self, name, workspace, *args, **kwargs) -> Content:
316
+        """
317
+        All extra parameters (*args, **kwargs) are for Content init
318
+        :return: Created Content instance
319
+        """
320
+        content = Content(*args, **kwargs)
321
+        content.label = name
322
+        content.workspace = workspace
323
+        DBSession.add(content)
324
+        DBSession.flush()
325
+
326
+        eq_(1, ContentApi.get_canonical_query().filter(Content.label == name).count())
327
+        return ContentApi.get_canonical_query().filter(Content.label == name).one()
328
+
329
+
330
+class BaseTestThread(BaseTest):
331
+
332
+    def _create_thread_and_test(self, workspace_name='workspace_1', folder_name='folder_1', thread_name='thread_1') -> Content:
333
+        """
334
+        :return: Thread
335
+        """
336
+        workspace = self._create_workspace_and_test(workspace_name)
337
+        folder = self._create_content_and_test(folder_name, workspace, type=ContentType.Folder)
338
+        thread = self._create_content_and_test(thread_name, workspace, type=ContentType.Thread, parent=folder)
339
+        return thread

+ 50 - 20
tracim/tracim/tests/library/test_content_api.py View File

@@ -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, new_revision
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
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 View File

@@ -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 View File

@@ -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_and_test(
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 View File

@@ -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_and_test(
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_canonical_query().filter(Content.label == 'folder_1').one()
21
+        eq_([folder, ], list(workspace.get_valid_children()))

+ 171 - 0
tracim/tracim/tests/models/test_content.py View File

@@ -0,0 +1,171 @@
1
+# -*- coding: utf-8 -*-
2
+import time
3
+from nose.tools import raises, ok_
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, new_revision
10
+from tracim.model.data import ContentRevisionRO, Workspace, ActionDescription, ContentType
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
+            time.sleep(0.00001)
68
+            content.description = 'TEST_CONTENT_DESCRIPTION_1_UPDATED'
69
+        DBSession.flush()
70
+
71
+        eq_(2, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'TEST_CONTENT_1').count())
72
+        eq_(1, DBSession.query(Content).filter(Content.id == created_content.id).count())
73
+
74
+        with new_revision(content):
75
+            time.sleep(0.00001)
76
+            content.description = 'TEST_CONTENT_DESCRIPTION_1_UPDATED_2'
77
+            content.label = 'TEST_CONTENT_1_UPDATED_2'
78
+        DBSession.flush()
79
+
80
+        eq_(1, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'TEST_CONTENT_1_UPDATED_2').count())
81
+        eq_(1, DBSession.query(Content).filter(Content.id == created_content.id).count())
82
+
83
+        revision_1 = DBSession.query(ContentRevisionRO)\
84
+            .filter(ContentRevisionRO.description == 'TEST_CONTENT_DESCRIPTION_1').one()
85
+        revision_2 = DBSession.query(ContentRevisionRO)\
86
+            .filter(ContentRevisionRO.description == 'TEST_CONTENT_DESCRIPTION_1_UPDATED').one()
87
+        revision_3 = DBSession.query(ContentRevisionRO)\
88
+            .filter(ContentRevisionRO.description == 'TEST_CONTENT_DESCRIPTION_1_UPDATED_2').one()
89
+
90
+        # Updated dates must be different
91
+        ok_(revision_1.updated < revision_2.updated < revision_3.updated)
92
+        # Created dates must be equal
93
+        ok_(revision_1.created == revision_2.created == revision_3.created)
94
+
95
+    def test_creates(self):
96
+        eq_(0, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'TEST_CONTENT_1').count())
97
+        eq_(0, DBSession.query(Workspace).filter(Workspace.label == 'TEST_WORKSPACE_1').count())
98
+
99
+        user_admin = DBSession.query(User).filter(User.email == 'admin@admin.admin').one()
100
+        workspace = Workspace(label="TEST_WORKSPACE_1")
101
+        DBSession.add(workspace)
102
+        DBSession.flush()
103
+        eq_(1, DBSession.query(Workspace).filter(Workspace.label == 'TEST_WORKSPACE_1').count())
104
+
105
+        first_content = self._create_content(
106
+            owner=user_admin,
107
+            workspace=workspace,
108
+            type=ContentType.Page,
109
+            label='TEST_CONTENT_1',
110
+            description='TEST_CONTENT_DESCRIPTION_1',
111
+            revision_type=ActionDescription.CREATION,
112
+            is_deleted=False,  # TODO: pk ?
113
+            is_archived=False,  # TODO: pk ?
114
+            #file_content=None,  # TODO: pk ? (J'ai du mettre nullable=True)
115
+        )
116
+
117
+        eq_(1, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'TEST_CONTENT_1').count())
118
+
119
+        content = DBSession.query(Content).filter(Content.id == first_content.id).one()
120
+        eq_('TEST_CONTENT_1', content.label)
121
+        eq_('TEST_CONTENT_DESCRIPTION_1', content.description)
122
+
123
+        # Create a second content
124
+        second_content = self._create_content(
125
+            owner=user_admin,
126
+            workspace=workspace,
127
+            type=ContentType.Page,
128
+            label='TEST_CONTENT_2',
129
+            description='TEST_CONTENT_DESCRIPTION_2',
130
+            revision_type=ActionDescription.CREATION
131
+        )
132
+
133
+        eq_(1, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'TEST_CONTENT_2').count())
134
+
135
+        content = DBSession.query(Content).filter(Content.id == second_content.id).one()
136
+        eq_('TEST_CONTENT_2', content.label)
137
+        eq_('TEST_CONTENT_DESCRIPTION_2', content.description)
138
+
139
+    def test_create(self, key='1'):
140
+        eq_(0, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'TEST_CONTENT_%s' % key).count())
141
+        eq_(0, DBSession.query(Workspace).filter(Workspace.label == 'TEST_WORKSPACE_%s' % key).count())
142
+
143
+        user_admin = DBSession.query(User).filter(User.email == 'admin@admin.admin').one()
144
+        workspace = Workspace(label="TEST_WORKSPACE_%s" % key)
145
+        DBSession.add(workspace)
146
+        DBSession.flush()
147
+        eq_(1, DBSession.query(Workspace).filter(Workspace.label == 'TEST_WORKSPACE_%s' % key).count())
148
+
149
+        created_content = self._create_content(
150
+            owner=user_admin,
151
+            workspace=workspace,
152
+            type=ContentType.Page,
153
+            label='TEST_CONTENT_%s' % key,
154
+            description='TEST_CONTENT_DESCRIPTION_%s' % key,
155
+            revision_type=ActionDescription.CREATION
156
+        )
157
+
158
+        eq_(1, DBSession.query(ContentRevisionRO).filter(ContentRevisionRO.label == 'TEST_CONTENT_%s' % key).count())
159
+
160
+        content = DBSession.query(Content).filter(Content.id == created_content.id).one()
161
+        eq_('TEST_CONTENT_%s' % key, content.label)
162
+        eq_('TEST_CONTENT_DESCRIPTION_%s' % key, content.description)
163
+
164
+        return created_content
165
+
166
+    def _create_content(self, *args, **kwargs):
167
+        content = Content(*args, **kwargs)
168
+        DBSession.add(content)
169
+        DBSession.flush()
170
+
171
+        return content

+ 57 - 0
tracim/tracim/tests/models/test_content_revision.py View File

@@ -0,0 +1,57 @@
1
+# -*- coding: utf-8 -*-
2
+from collections import OrderedDict
3
+
4
+from nose.tools import eq_
5
+from sqlalchemy import inspect
6
+
7
+from tracim.model import DBSession, ContentRevisionRO
8
+from tracim.model import User
9
+from tracim.model.data import ContentType
10
+from tracim.tests import TestStandard, BaseTest
11
+
12
+
13
+class TestContentRevision(BaseTest, TestStandard):
14
+
15
+    def _new_from(self, revision):
16
+        excluded_columns = ('revision_id', '_sa_instance_state')
17
+        revision_columns = [attr.key for attr in inspect(revision).attrs if not attr.key in excluded_columns]
18
+        new_revision = ContentRevisionRO()
19
+
20
+        for revision_column in revision_columns:
21
+            old_revision_column_value = getattr(revision, revision_column)
22
+            setattr(new_revision, revision_column, old_revision_column_value)
23
+
24
+        return new_revision
25
+
26
+    def _get_dict_representation(self, revision):
27
+        keys_to_remove = ('updated', '_sa_instance_state')
28
+        dict_repr = OrderedDict(sorted(revision.__dict__.items()))
29
+        for key_to_remove in keys_to_remove:
30
+            dict_repr.pop(key_to_remove, None)
31
+        return dict_repr
32
+
33
+    def test_new_revision(self):
34
+        admin = DBSession.query(User).filter(User.email == 'admin@admin.admin').one()
35
+        workspace = self._create_workspace_and_test(name='workspace_1')
36
+        folder = self._create_content_and_test(name='folder_1', workspace=workspace, type=ContentType.Folder)
37
+        page = self._create_content_and_test(
38
+            workspace=workspace,
39
+            parent=folder,
40
+            name='file_1',
41
+            description='content of file_1',
42
+            type=ContentType.Page,
43
+            owner=admin
44
+        )
45
+
46
+        DBSession.flush()
47
+
48
+        # Model create a new instance with list of column
49
+        new_revision_by_model = ContentRevisionRO.new_from(page.revision)
50
+        # Test create a new instance from dynamic listing of model columns mapping
51
+        new_revision_by_test = self._new_from(page.revision)
52
+
53
+        new_revision_by_model_dict = self._get_dict_representation(new_revision_by_model)
54
+        new_revision_by_test_dict = self._get_dict_representation(new_revision_by_test)
55
+
56
+        # They must be identical
57
+        eq_(new_revision_by_model_dict, new_revision_by_test_dict)

+ 1 - 263
tracim/tracim/websetup/schema.py View File

@@ -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
-"""