Bläddra i källkod

Merge pull request #33 from buxx/dev/content

Tracim 9 år sedan
förälder
incheckning
9e5fa9809a

+ 187 - 0
tracim/migration/versions/da12239d9da0_delete_content_view.py Visa fil

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 Visa fil

17
 
17
 
18
 from tracim.model.auth import User
18
 from tracim.model.auth import User
19
 from tracim.model.data import ActionDescription
19
 from tracim.model.data import ActionDescription
20
+from tracim.model import new_revision
20
 from tracim.model.data import BreadcrumbItem
21
 from tracim.model.data import BreadcrumbItem
21
 from tracim.model.data import Content
22
 from tracim.model.data import Content
22
 from tracim.model.data import ContentType
23
 from tracim.model.data import ContentType
267
         try:
268
         try:
268
             api = ContentApi(tmpl_context.current_user)
269
             api = ContentApi(tmpl_context.current_user)
269
             item = api.get_one(int(item_id), self._item_type, workspace)
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
             msg = _('{} updated').format(self._item_type_label)
275
             msg = _('{} updated').format(self._item_type_label)
274
             tg.flash(msg, CST.STATUS_OK)
276
             tg.flash(msg, CST.STATUS_OK)
292
         content_api = ContentApi(tmpl_context.current_user)
294
         content_api = ContentApi(tmpl_context.current_user)
293
         item = content_api.get_one(item_id, self._item_type, tmpl_context.workspace)
295
         item = content_api.get_one(item_id, self._item_type, tmpl_context.workspace)
294
         try:
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
             msg = _('{} status updated').format(self._item_type_label)
300
             msg = _('{} status updated').format(self._item_type_label)
298
             tg.flash(msg, CST.STATUS_OK)
301
             tg.flash(msg, CST.STATUS_OK)
299
             tg.redirect(self._std_url.format(item.workspace_id, item.parent_id, item.content_id))
302
             tg.redirect(self._std_url.format(item.workspace_id, item.parent_id, item.content_id))
332
             undo_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)+'/put_archive_undo'
335
             undo_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)+'/put_archive_undo'
333
             msg = _('{} archived. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
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
             tg.flash(msg, CST.STATUS_OK, no_escape=True) # TODO allow to come back
342
             tg.flash(msg, CST.STATUS_OK, no_escape=True) # TODO allow to come back
339
             tg.redirect(next_url)
343
             tg.redirect(next_url)
353
         try:
357
         try:
354
             next_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)
358
             next_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)
355
             msg = _('{} unarchived.').format(self._item_type_label)
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
             tg.flash(msg, CST.STATUS_OK)
364
             tg.flash(msg, CST.STATUS_OK)
360
             tg.redirect(next_url )
365
             tg.redirect(next_url )
379
             next_url = self._parent_url.format(item.workspace_id, item.parent_id)
384
             next_url = self._parent_url.format(item.workspace_id, item.parent_id)
380
             undo_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)+'/put_delete_undo'
385
             undo_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)+'/put_delete_undo'
381
             msg = _('{} deleted. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
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
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
391
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
386
             tg.redirect(next_url)
392
             tg.redirect(next_url)
403
         try:
409
         try:
404
             next_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)
410
             next_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)
405
             msg = _('{} undeleted.').format(self._item_type_label)
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
             tg.flash(msg, CST.STATUS_OK)
416
             tg.flash(msg, CST.STATUS_OK)
410
             tg.redirect(next_url)
417
             tg.redirect(next_url)

+ 71 - 54
tracim/tracim/controllers/content.py Visa fil

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
+import sys
3
+
2
 __author__ = 'damien'
4
 __author__ = 'damien'
3
 
5
 
4
 from cgi import FieldStorage
6
 from cgi import FieldStorage
28
 
30
 
29
 from tracim.model.serializers import Context, CTX, DictLikeClass
31
 from tracim.model.serializers import Context, CTX, DictLikeClass
30
 from tracim.model.data import ActionDescription
32
 from tracim.model.data import ActionDescription
33
+from tracim.model import new_revision
31
 from tracim.model.data import Content
34
 from tracim.model.data import Content
32
 from tracim.model.data import ContentType
35
 from tracim.model.data import ContentType
33
 from tracim.model.data import UserRoleInWorkspace
36
 from tracim.model.data import UserRoleInWorkspace
88
                                                                                                          item_id)
91
                                                                                                          item_id)
89
 
92
 
90
             msg = _('{} deleted. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
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
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
98
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
95
             tg.redirect(next_url)
99
             tg.redirect(next_url)
116
                                                                              tmpl_context.folder_id,
120
                                                                              tmpl_context.folder_id,
117
                                                                              tmpl_context.thread_id)
121
                                                                              tmpl_context.thread_id)
118
             msg = _('{} undeleted.').format(self._item_type_label)
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
             tg.flash(msg, CST.STATUS_OK)
127
             tg.flash(msg, CST.STATUS_OK)
123
             tg.redirect(next_url)
128
             tg.redirect(next_url)
277
             # TODO - D.A. - 2015-03-19
282
             # TODO - D.A. - 2015-03-19
278
             # refactor this method in order to make code easier to understand
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
             msg = _('{} updated').format(self._item_type_label)
320
             msg = _('{} updated').format(self._item_type_label)
314
             tg.flash(msg, CST.STATUS_OK)
321
             tg.flash(msg, CST.STATUS_OK)
424
         try:
431
         try:
425
             api = ContentApi(tmpl_context.current_user)
432
             api = ContentApi(tmpl_context.current_user)
426
             item = api.get_one(int(item_id), self._item_type, workspace)
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
             msg = _('{} updated').format(self._item_type_label)
438
             msg = _('{} updated').format(self._item_type_label)
431
             tg.flash(msg, CST.STATUS_OK)
439
             tg.flash(msg, CST.STATUS_OK)
604
 
612
 
605
             api = ContentApi(tmpl_context.current_user)
613
             api = ContentApi(tmpl_context.current_user)
606
             item = api.get_one(item_id, ContentType.Any, workspace)
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
             next_url = tg.url('/workspaces/{}/folders/{}'.format(
619
             next_url = tg.url('/workspaces/{}/folders/{}'.format(
610
                 new_workspace.workspace_id, item_id))
620
                 new_workspace.workspace_id, item_id))
622
             # Default move inside same workspace
632
             # Default move inside same workspace
623
             api = ContentApi(tmpl_context.current_user)
633
             api = ContentApi(tmpl_context.current_user)
624
             item = api.get_one(item_id, ContentType.Any, workspace)
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
             next_url = self.parent_controller.url(item_id)
637
             next_url = self.parent_controller.url(item_id)
627
             if new_parent:
638
             if new_parent:
628
                 tg.flash(_('Item moved to {}').format(new_parent.label), CST.STATUS_OK)
639
                 tg.flash(_('Item moved to {}').format(new_parent.label), CST.STATUS_OK)
765
             logger.error(self, 'An unexpected exception has been catched. Look at the traceback below.')
776
             logger.error(self, 'An unexpected exception has been catched. Look at the traceback below.')
766
             traceback.print_exc()
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
             if parent_id:
781
             if parent_id:
770
                 redirect_url = redirect_url_tmpl.format(tmpl_context.workspace_id, parent_id)
782
                 redirect_url = redirect_url_tmpl.format(tmpl_context.workspace_id, parent_id)
771
             else:
783
             else:
796
                 file = True if can_contain_files=='on' else False,
808
                 file = True if can_contain_files=='on' else False,
797
                 page = True if can_contain_pages=='on' else False
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
             tg.flash(_('Folder updated'), CST.STATUS_OK)
818
             tg.flash(_('Folder updated'), CST.STATUS_OK)
806
 
819
 
842
             undo_url = self._std_url.format(item.workspace_id, item.content_id)+'/put_archive_undo'
855
             undo_url = self._std_url.format(item.workspace_id, item.content_id)+'/put_archive_undo'
843
             msg = _('{} archived. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
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
             tg.flash(msg, CST.STATUS_OK, no_escape=True) # TODO allow to come back
862
             tg.flash(msg, CST.STATUS_OK, no_escape=True) # TODO allow to come back
849
             tg.redirect(next_url)
863
             tg.redirect(next_url)
864
         try:
878
         try:
865
             next_url = self._std_url.format(item.workspace_id, item.content_id)
879
             next_url = self._std_url.format(item.workspace_id, item.content_id)
866
             msg = _('{} unarchived.').format(self._item_type_label)
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
             tg.flash(msg, CST.STATUS_OK)
885
             tg.flash(msg, CST.STATUS_OK)
871
             tg.redirect(next_url )
886
             tg.redirect(next_url )
889
             next_url = self._parent_url.format(item.workspace_id, item.parent_id)
904
             next_url = self._parent_url.format(item.workspace_id, item.parent_id)
890
             undo_url = self._std_url.format(item.workspace_id, item.content_id)+'/put_delete_undo'
905
             undo_url = self._std_url.format(item.workspace_id, item.content_id)+'/put_delete_undo'
891
             msg = _('{} deleted. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
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
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
911
             tg.flash(msg, CST.STATUS_OK, no_escape=True)
896
             tg.redirect(next_url)
912
             tg.redirect(next_url)
913
         try:
929
         try:
914
             next_url = self._std_url.format(item.workspace_id, item.content_id)
930
             next_url = self._std_url.format(item.workspace_id, item.content_id)
915
             msg = _('{} undeleted.').format(self._item_type_label)
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
             tg.flash(msg, CST.STATUS_OK)
936
             tg.flash(msg, CST.STATUS_OK)
920
             tg.redirect(next_url)
937
             tg.redirect(next_url)

+ 30 - 8
tracim/tracim/lib/content.py Visa fil

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

+ 12 - 0
tracim/tracim/lib/exception.py Visa fil

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

+ 74 - 6
tracim/tracim/model/__init__.py Visa fil

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 """The application's model objects"""
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
 from sqlalchemy.ext.declarative import declarative_base
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
 # Global session manager: DBSession() returns the Thread-local
40
 # Global session manager: DBSession() returns the Thread-local
10
 # session object appropriate for the current web request.
41
 # session object appropriate for the current web request.
15
 # Base class for all of our model classes: By default, the data model is
46
 # Base class for all of our model classes: By default, the data model is
16
 # defined with SQLAlchemy's declarative extension, but if you need more
47
 # defined with SQLAlchemy's declarative extension, but if you need more
17
 # control, you can switch to the traditional method.
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
 # There are two convenient ways for you to spare some typing.
60
 # There are two convenient ways for you to spare some typing.
21
 # You can have a query property on all your model classes by doing this:
61
 # You can have a query property on all your model classes by doing this:
38
 #
78
 #
39
 ######
79
 ######
40
 
80
 
81
+
41
 def init_model(engine):
82
 def init_model(engine):
42
     """Call me before using any of the tables or classes in the model."""
83
     """Call me before using any of the tables or classes in the model."""
43
     DBSession.configure(bind=engine)
84
     DBSession.configure(bind=engine)
60
 
101
 
61
 # Import your model modules here.
102
 # Import your model modules here.
62
 from tracim.model.auth import User, Group, Permission
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 Visa fil

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 
2
 
3
-import tg
4
-from datetime import datetime
5
-from babel.dates import format_timedelta
6
-
7
-from bs4 import BeautifulSoup
8
 import datetime as datetime_root
3
 import datetime as datetime_root
9
 import json
4
 import json
5
+from datetime import datetime
10
 
6
 
11
-from sqlalchemy.ext.associationproxy import association_proxy
12
-from sqlalchemy import Column
13
-from sqlalchemy import func
7
+import tg
8
+from babel.dates import format_timedelta
9
+from bs4 import BeautifulSoup
10
+from sqlalchemy import Column, inspect, Index
14
 from sqlalchemy import ForeignKey
11
 from sqlalchemy import ForeignKey
15
 from sqlalchemy import Sequence
12
 from sqlalchemy import Sequence
16
-
13
+from sqlalchemy import func
14
+from sqlalchemy.ext.associationproxy import association_proxy
17
 from sqlalchemy.ext.hybrid import hybrid_property
15
 from sqlalchemy.ext.hybrid import hybrid_property
18
-
19
 from sqlalchemy.orm import backref
16
 from sqlalchemy.orm import backref
20
-from sqlalchemy.orm import relationship
21
 from sqlalchemy.orm import deferred
17
 from sqlalchemy.orm import deferred
18
+from sqlalchemy.orm import relationship
19
+from sqlalchemy.orm.attributes import InstrumentedAttribute
22
 from sqlalchemy.orm.collections import attribute_mapped_collection
20
 from sqlalchemy.orm.collections import attribute_mapped_collection
23
-
24
 from sqlalchemy.types import Boolean
21
 from sqlalchemy.types import Boolean
25
 from sqlalchemy.types import DateTime
22
 from sqlalchemy.types import DateTime
26
 from sqlalchemy.types import Integer
23
 from sqlalchemy.types import Integer
27
 from sqlalchemy.types import LargeBinary
24
 from sqlalchemy.types import LargeBinary
28
 from sqlalchemy.types import Text
25
 from sqlalchemy.types import Text
29
 from sqlalchemy.types import Unicode
26
 from sqlalchemy.types import Unicode
30
-
31
 from tg.i18n import lazy_ugettext as l_, ugettext as _
27
 from tg.i18n import lazy_ugettext as l_, ugettext as _
32
 
28
 
33
-from tracim.model import DeclarativeBase
29
+from tracim.lib.exception import ContentRevisionUpdateError
30
+from tracim.model import DeclarativeBase, RevisionsIntegrity
34
 from tracim.model.auth import User
31
 from tracim.model.auth import User
35
 
32
 
33
+
36
 class BreadcrumbItem(object):
34
 class BreadcrumbItem(object):
37
 
35
 
38
     def __init__(self, icon_string: str, label: str, url: str, is_active: bool = False):
36
     def __init__(self, icon_string: str, label: str, url: str, is_active: bool = False):
54
     label   = Column(Unicode(1024), unique=False, nullable=False, default='')
52
     label   = Column(Unicode(1024), unique=False, nullable=False, default='')
55
     description = Column(Text(), unique=False, nullable=False, default='')
53
     description = Column(Text(), unique=False, nullable=False, default='')
56
 
54
 
57
-    created = Column(DateTime, unique=False, nullable=False)
58
-    updated = Column(DateTime, unique=False, nullable=False)
55
+    #  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
     is_deleted = Column(Boolean, unique=False, nullable=False, default=False)
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
     def get_user_role(self, user: User) -> int:
69
     def get_user_role(self, user: User) -> int:
63
         for role in user.roles:
70
         for role in user.roles:
64
             if role.workspace.workspace_id==self.workspace_id:
71
             if role.workspace.workspace_id==self.workspace_id:
435
                     label=self.label,
442
                     label=self.label,
436
                     priority=self.priority)
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
     file_name = Column(Unicode(255),  unique=False, nullable=False, default='')
497
     file_name = Column(Unicode(255),  unique=False, nullable=False, default='')
467
     file_mimetype = Column(Unicode(255),  unique=False, nullable=False, default='')
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
     revision_type = Column(Unicode(32), unique=False, nullable=False, default='')
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
     owner = relationship('User', remote_side=[User.user_id])
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
     def get_valid_children(self, content_types: list=None) -> ['Content']:
950
     def get_valid_children(self, content_types: list=None) -> ['Content']:
476
         for child in self.children:
951
         for child in self.children:
477
             if not child.is_deleted and not child.is_archived:
952
             if not child.is_deleted and not child.is_archived:
478
                 if not content_types or child.type in content_types:
953
                 if not content_types or child.type in content_types:
479
-                    yield child
954
+                    yield child.node
480
 
955
 
481
     @hybrid_property
956
     @hybrid_property
482
-    def properties(self):
957
+    def properties(self) -> dict:
483
         """ return a structure decoded from json content of _properties """
958
         """ return a structure decoded from json content of _properties """
484
         if not self._properties:
959
         if not self._properties:
485
             ContentChecker.reset_properties(self)
960
             ContentChecker.reset_properties(self)
486
         return json.loads(self._properties)
961
         return json.loads(self._properties)
487
 
962
 
488
     @properties.setter
963
     @properties.setter
489
-    def properties(self, properties_struct):
964
+    def properties(self, properties_struct: dict) -> None:
490
         """ encode a given structure into json and store it in _properties attribute"""
965
         """ encode a given structure into json and store it in _properties attribute"""
491
         self._properties = json.dumps(properties_struct)
966
         self._properties = json.dumps(properties_struct)
492
         ContentChecker.check_properties(self)
967
         ContentChecker.check_properties(self)
519
             'html.parser'  # Fixes hanging bug - http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
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
         for link in soup.findAll('a'):
997
         for link in soup.findAll('a'):
524
             href = link.get('href')
998
             href = link.get('href')
525
             label = link.contents
999
             label = link.contents
530
         ## FIXME - Does this return a sorted list ???!
1004
         ## FIXME - Does this return a sorted list ???!
531
         return sorted_links
1005
         return sorted_links
532
 
1006
 
533
-
534
     def get_child_nb(self, content_type: ContentType, content_status = ''):
1007
     def get_child_nb(self, content_type: ContentType, content_status = ''):
535
         child_nb = 0
1008
         child_nb = 0
536
         for child in self.get_valid_children():
1009
         for child in self.get_valid_children():
586
         children = []
1059
         children = []
587
         for child in self.children:
1060
         for child in self.children:
588
             if ContentType.Comment==child.type and not child.is_deleted and not child.is_archived:
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
         return children
1063
         return children
591
 
1064
 
592
     def get_last_comment_from(self, user: User) -> 'Content':
1065
     def get_last_comment_from(self, user: User) -> 'Content':
615
 
1088
 
616
         return None
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
     def description_as_raw_text(self):
1091
     def description_as_raw_text(self):
632
         # 'html.parser' fixes a hanging bug
1092
         # 'html.parser' fixes a hanging bug
633
         # see http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
1093
         # see http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
667
         return url_template.format(wid=wid, fid=fid, ctype=ctype, cid=cid)
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
 class RevisionReadStatus(DeclarativeBase):
1130
 class RevisionReadStatus(DeclarativeBase):
768
 
1131
 
769
     __tablename__ = 'revision_read_status'
1132
     __tablename__ = 'revision_read_status'
770
 
1133
 
771
     revision_id = Column(Integer, ForeignKey('content_revisions.revision_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
1134
     revision_id = Column(Integer, ForeignKey('content_revisions.revision_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
772
     user_id = Column(Integer, ForeignKey('users.user_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
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
     content_revision = relationship(
1139
     content_revision = relationship(
781
         'ContentRevisionRO',
1140
         'ContentRevisionRO',
831
         action_description = ActionDescription(revision.revision_type)
1190
         action_description = ActionDescription(revision.revision_type)
832
 
1191
 
833
         return VirtualEvent(id=revision.revision_id,
1192
         return VirtualEvent(id=revision.revision_id,
834
-                            created=revision.created,
1193
+                            created=revision.updated,
835
                             owner=revision.owner,
1194
                             owner=revision.owner,
836
                             type=action_description,
1195
                             type=action_description,
837
                             label=action_description.label,
1196
                             label=action_description.label,

+ 1 - 1
tracim/tracim/model/serializers.py Visa fil

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

+ 46 - 2
tracim/tracim/tests/__init__.py Visa fil

2
 """Unit and functional test suite for tracim."""
2
 """Unit and functional test suite for tracim."""
3
 import argparse
3
 import argparse
4
 import os
4
 import os
5
+import time
5
 from os import getcwd
6
 from os import getcwd
6
 
7
 
7
 import ldap3
8
 import ldap3
8
 import tg
9
 import tg
9
-import time
10
 import transaction
10
 import transaction
11
 from gearbox.commands.setup_app import SetupAppCommand
11
 from gearbox.commands.setup_app import SetupAppCommand
12
 from ldap_test import LdapServer
12
 from ldap_test import LdapServer
13
+from nose.tools import eq_
13
 from nose.tools import ok_
14
 from nose.tools import ok_
14
 from paste.deploy import loadapp
15
 from paste.deploy import loadapp
15
 from sqlalchemy.engine import reflection
16
 from sqlalchemy.engine import reflection
28
 from tracim.fixtures import FixturesLoader
29
 from tracim.fixtures import FixturesLoader
29
 from tracim.fixtures.users_and_groups import Base as BaseFixture
30
 from tracim.fixtures.users_and_groups import Base as BaseFixture
30
 from tracim.lib.base import logger
31
 from tracim.lib.base import logger
31
-from tracim.model import DBSession
32
+from tracim.lib.content import ContentApi
33
+from tracim.model import DBSession, Content
34
+from tracim.model.data import Workspace, ContentType, ContentRevisionRO
32
 
35
 
33
 __all__ = ['setup_app', 'setup_db', 'teardown_db', 'TestController']
36
 __all__ = ['setup_app', 'setup_db', 'teardown_db', 'TestController']
34
 
37
 
293
 class ArgumentParser(argparse.ArgumentParser):
296
 class ArgumentParser(argparse.ArgumentParser):
294
     def exit(self, status=0, message=None):
297
     def exit(self, status=0, message=None):
295
         raise Exception(message)
298
         raise Exception(message)
299
+
300
+
301
+class BaseTest(object):
302
+
303
+    def _create_workspace_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 Visa fil

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

+ 4 - 5
tracim/tracim/tests/library/test_serializers.py Visa fil

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

+ 15 - 0
tracim/tracim/tests/library/test_thread.py Visa fil

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 Visa fil

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 Visa fil

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 Visa fil

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 Visa fil

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