浏览代码

Merge branch 'master' into 132/dev/v1.0beta/add_link_to_webdav

Bastien Sevajol (Algoo) 8 年前
父节点
当前提交
1a76e8c388
共有 36 个文件被更改,包括 1731 次插入189 次删除
  1. 77 0
      tracim/migration/versions/15305f71bfda_fill_content_file_extension_column.py
  2. 31 0
      tracim/migration/versions/59fc98c3c965_delete_content_file_name_column.py
  3. 78 0
      tracim/migration/versions/c1cea4bbae16_file_extentions_value_for_pages_and_.py
  4. 33 0
      tracim/migration/versions/e31ddc009b37_add_content_file_extension_column.py
  5. 14 0
      tracim/tracim/controllers/__init__.py
  6. 7 3
      tracim/tracim/controllers/admin/user.py
  7. 18 1
      tracim/tracim/controllers/admin/workspace.py
  8. 103 29
      tracim/tracim/controllers/content.py
  9. 1 0
      tracim/tracim/controllers/workspace.py
  10. 131 0
      tracim/tracim/fixtures/content.py
  11. 4 0
      tracim/tracim/i18n/fr/LC_MESSAGES/tracim.po
  12. 6 2
      tracim/tracim/lib/calendar.py
  13. 174 37
      tracim/tracim/lib/content.py
  14. 4 3
      tracim/tracim/lib/daemons.py
  15. 103 0
      tracim/tracim/lib/integrity.py
  16. 2 5
      tracim/tracim/lib/notifications.py
  17. 25 18
      tracim/tracim/lib/webdav/design.py
  18. 19 14
      tracim/tracim/lib/webdav/sql_dav_provider.py
  19. 45 43
      tracim/tracim/lib/webdav/sql_resources.py
  20. 4 0
      tracim/tracim/lib/workspace.py
  21. 2 1
      tracim/tracim/model/__init__.py
  22. 88 11
      tracim/tracim/model/data.py
  23. 5 5
      tracim/tracim/model/serializers.py
  24. 8 0
      tracim/tracim/public/assets/css/dashboard.css
  25. 10 1
      tracim/tracim/public/assets/js/main.js
  26. 1 1
      tracim/tracim/templates/admin/workspace_getone.mak
  27. 1 0
      tracim/tracim/templates/errors/__init__.py
  28. 18 0
      tracim/tracim/templates/errors/label_invalid_path.mak
  29. 4 4
      tracim/tracim/templates/file/toolbar.mak
  30. 1 3
      tracim/tracim/templates/master_authenticated.mak
  31. 56 2
      tracim/tracim/templates/pod.mak
  32. 12 0
      tracim/tracim/templates/thread/getone.mak
  33. 9 5
      tracim/tracim/templates/widgets/navbar_menu.mak
  34. 1 1
      tracim/tracim/tests/library/test_content_api.py
  35. 184 0
      tracim/tracim/tests/library/test_integrity.py
  36. 452 0
      tracim/tracim/tests/library/test_webdav.py

+ 77 - 0
tracim/migration/versions/15305f71bfda_fill_content_file_extension_column.py 查看文件

1
+"""fill content file_extension column
2
+
3
+Revision ID: 15305f71bfda
4
+Revises: e31ddc009b37
5
+Create Date: 2016-11-25 10:50:01.874820
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+import os
11
+
12
+revision = '15305f71bfda'
13
+down_revision = 'e31ddc009b37'
14
+
15
+from alembic import op
16
+import sqlalchemy as sa
17
+
18
+
19
+content_revision_helper = sa.Table(
20
+    'content_revisions',
21
+    sa.MetaData(),
22
+    sa.Column('revision_id', sa.Integer, primary_key=True),
23
+    sa.Column('content_id', sa.ForeignKey(u'content.id'), nullable=False),
24
+    sa.Column('owner_id', sa.ForeignKey(u'users.user_id'), index=True),
25
+    sa.Column('label', sa.String(1024), nullable=False),
26
+    sa.Column('description', sa.Text, nullable=False),
27
+    sa.Column('file_name', sa.String(255), nullable=False),
28
+    sa.Column('file_extension', sa.String(255), nullable=False),
29
+    sa.Column('file_mimetype', sa.String(255), nullable=False),
30
+    sa.Column('file_content', sa.LargeBinary),
31
+    sa.Column('properties', sa.Text, nullable=False),
32
+    sa.Column('type', sa.String(32), nullable=False),
33
+    sa.Column('status', sa.String(32), nullable=False),
34
+    sa.Column('created', sa.DateTime, nullable=False),
35
+    sa.Column('updated', sa.DateTime, nullable=False),
36
+    sa.Column('is_deleted', sa.Boolean, nullable=False),
37
+    sa.Column('is_archived', sa.Boolean, nullable=False),
38
+    sa.Column('is_temporary', sa.Boolean, nullable=False),
39
+    sa.Column('revision_type', sa.String(32), nullable=False),
40
+    sa.Column('workspace_id', sa.ForeignKey(u'workspaces.workspace_id')),
41
+    sa.Column('parent_id', sa.ForeignKey(u'content.id'), index=True),
42
+)
43
+
44
+
45
+def upgrade():
46
+    connection = op.get_bind()
47
+
48
+    for content_revision in connection.execute(
49
+            content_revision_helper.select()
50
+    ):
51
+        # On work with FILE
52
+        if content_revision.type == 'file':
53
+            file_name, file_extension = \
54
+                os.path.splitext(content_revision.file_name)
55
+
56
+            # Don't touch label if already set
57
+            if content_revision.label:
58
+                new_label = content_revision.label
59
+            # Label will be file name without extension
60
+            else:
61
+                new_label = file_name
62
+
63
+            # Update record
64
+            connection.execute(
65
+                content_revision_helper.update()
66
+                .where(
67
+                    content_revision_helper.c.revision_id ==
68
+                    content_revision.revision_id
69
+                ).values(
70
+                    label=new_label,
71
+                    file_extension=file_extension,
72
+                )
73
+            )
74
+
75
+
76
+def downgrade():
77
+    pass

+ 31 - 0
tracim/migration/versions/59fc98c3c965_delete_content_file_name_column.py 查看文件

1
+"""delete content file name column
2
+
3
+Revision ID: 59fc98c3c965
4
+Revises: 15305f71bfda
5
+Create Date: 2016-11-25 14:55:22.176175
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+revision = '59fc98c3c965'
11
+down_revision = '15305f71bfda'
12
+
13
+from alembic import op
14
+import sqlalchemy as sa
15
+
16
+
17
+def upgrade():
18
+    op.drop_column('content_revisions', 'file_name')
19
+
20
+
21
+def downgrade():
22
+    op.add_column(
23
+        'content_revisions',
24
+        sa.Column(
25
+            'file_name',
26
+            sa.VARCHAR(length=255),
27
+            server_default=sa.text("''::character varying"),
28
+            autoincrement=False,
29
+            nullable=False
30
+        ),
31
+    )

+ 78 - 0
tracim/migration/versions/c1cea4bbae16_file_extentions_value_for_pages_and_.py 查看文件

1
+"""file_extentions value for Pages and Threads
2
+
3
+Revision ID: c1cea4bbae16
4
+Revises: 59fc98c3c965
5
+Create Date: 2016-11-30 10:41:51.893531
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+
11
+revision = 'c1cea4bbae16'
12
+down_revision = '59fc98c3c965'
13
+
14
+from alembic import op
15
+import sqlalchemy as sa
16
+
17
+content_revision_helper = sa.Table(
18
+    'content_revisions',
19
+    sa.MetaData(),
20
+    sa.Column('revision_id', sa.Integer, primary_key=True),
21
+    sa.Column('content_id', sa.ForeignKey(u'content.id'), nullable=False),
22
+    sa.Column('owner_id', sa.ForeignKey(u'users.user_id'), index=True),
23
+    sa.Column('label', sa.String(1024), nullable=False),
24
+    sa.Column('description', sa.Text, nullable=False),
25
+    sa.Column('file_extension', sa.String(255), nullable=False),
26
+    sa.Column('file_mimetype', sa.String(255), nullable=False),
27
+    sa.Column('file_content', sa.LargeBinary),
28
+    sa.Column('properties', sa.Text, nullable=False),
29
+    sa.Column('type', sa.String(32), nullable=False),
30
+    sa.Column('status', sa.String(32), nullable=False),
31
+    sa.Column('created', sa.DateTime, nullable=False),
32
+    sa.Column('updated', sa.DateTime, nullable=False),
33
+    sa.Column('is_deleted', sa.Boolean, nullable=False),
34
+    sa.Column('is_archived', sa.Boolean, nullable=False),
35
+    sa.Column('is_temporary', sa.Boolean, nullable=False),
36
+    sa.Column('revision_type', sa.String(32), nullable=False),
37
+    sa.Column('workspace_id', sa.ForeignKey(u'workspaces.workspace_id')),
38
+    sa.Column('parent_id', sa.ForeignKey(u'content.id'), index=True),
39
+)
40
+
41
+
42
+def upgrade():
43
+    connection = op.get_bind()
44
+
45
+    for content_revision in connection.execute(
46
+            content_revision_helper.select()
47
+    ):
48
+        if content_revision.type in ('page', 'thread'):
49
+            # Update record
50
+            connection.execute(
51
+                content_revision_helper.update()
52
+                    .where(
53
+                        content_revision_helper.c.revision_id ==
54
+                        content_revision.revision_id
55
+                    ).values(
56
+                        file_extension='.html',
57
+                    )
58
+                )
59
+
60
+
61
+def downgrade():
62
+    connection = op.get_bind()
63
+
64
+    for content_revision in connection.execute(
65
+            content_revision_helper.select()
66
+    ):
67
+        # On work with FILE
68
+        if content_revision.type in ('page', 'thread'):
69
+            # Update record
70
+            connection.execute(
71
+                content_revision_helper.update()
72
+                    .where(
73
+                        content_revision_helper.c.revision_id ==
74
+                        content_revision.revision_id
75
+                    ).values(
76
+                        file_extension='',
77
+                    )
78
+                )

+ 33 - 0
tracim/migration/versions/e31ddc009b37_add_content_file_extension_column.py 查看文件

1
+"""add_content_file_extension_column
2
+
3
+Revision ID: e31ddc009b37
4
+Revises: 2cd20ff3d23a
5
+Create Date: 2016-11-25 10:43:23.700867
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+revision = 'e31ddc009b37'
11
+down_revision = '2cd20ff3d23a'
12
+
13
+from alembic import op
14
+import sqlalchemy as sa
15
+
16
+
17
+def upgrade():
18
+    op.add_column(
19
+        'content_revisions',
20
+        sa.Column(
21
+            'file_extension',
22
+            sa.Unicode(length=255),
23
+            server_default='',
24
+            nullable=False,
25
+        )
26
+    )
27
+
28
+
29
+def downgrade():
30
+    op.drop_column(
31
+        'content_revisions',
32
+        'file_extension',
33
+    )

+ 14 - 0
tracim/tracim/controllers/__init__.py 查看文件

2
 """Controllers for the tracim application."""
2
 """Controllers for the tracim application."""
3
 from sqlalchemy.orm.exc import NoResultFound
3
 from sqlalchemy.orm.exc import NoResultFound
4
 from tg import abort
4
 from tg import abort
5
+from tracim.lib.integrity import PathValidationManager
6
+from tracim.lib.integrity import render_invalid_integrity_chosen_path
5
 from tracim.lib.workspace import WorkspaceApi
7
 from tracim.lib.workspace import WorkspaceApi
6
 
8
 
7
 import tg
9
 import tg
122
     TEMPLATE_NEW = 'unknown "template new"'
124
     TEMPLATE_NEW = 'unknown "template new"'
123
     TEMPLATE_EDIT = 'unknown "template edit"'
125
     TEMPLATE_EDIT = 'unknown "template edit"'
124
 
126
 
127
+    def __init__(self):
128
+        super().__init__()
129
+        self._path_validation = PathValidationManager(
130
+            is_case_sensitive=False,
131
+        )
132
+
125
     def _before(self, *args, **kw):
133
     def _before(self, *args, **kw):
126
         """
134
         """
127
         Instantiate the current workspace in tg.tmpl_context
135
         Instantiate the current workspace in tg.tmpl_context
274
             item = api.get_one(int(item_id), self._item_type, workspace)
282
             item = api.get_one(int(item_id), self._item_type, workspace)
275
             with new_revision(item):
283
             with new_revision(item):
276
                 api.update_content(item, label, content)
284
                 api.update_content(item, label, content)
285
+
286
+                if not self._path_validation.validate_new_content(item):
287
+                    return render_invalid_integrity_chosen_path(
288
+                        item.get_label(),
289
+                    )
290
+
277
                 api.save(item, ActionDescription.REVISION)
291
                 api.save(item, ActionDescription.REVISION)
278
 
292
 
279
             msg = _('{} updated').format(self._item_type_label)
293
             msg = _('{} updated').format(self._item_type_label)

+ 7 - 3
tracim/tracim/controllers/admin/user.py 查看文件

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 import uuid
2
 import uuid
3
 
3
 
4
+import pytz
4
 from tracim import model  as pm
5
 from tracim import model  as pm
5
 
6
 
6
 from sprox.tablebase import TableBase
7
 from sprox.tablebase import TableBase
380
         user = api.get_one(id)
381
         user = api.get_one(id)
381
 
382
 
382
         dictified_user = Context(CTX.USER).toDict(user, 'user')
383
         dictified_user = Context(CTX.USER).toDict(user, 'user')
383
-        return DictLikeClass(result = dictified_user)
384
+        return DictLikeClass(
385
+            result=dictified_user,
386
+            timezones=pytz.all_timezones,
387
+        )
384
 
388
 
385
     @tg.require(predicates.in_group(Group.TIM_MANAGER_GROUPNAME))
389
     @tg.require(predicates.in_group(Group.TIM_MANAGER_GROUPNAME))
386
     @tg.expose()
390
     @tg.expose()
387
-    def put(self, user_id, name, email, next_url=''):
391
+    def put(self, user_id, name, email, timezone: str='', next_url=''):
388
         api = UserApi(tmpl_context.current_user)
392
         api = UserApi(tmpl_context.current_user)
389
 
393
 
390
         user = api.get_one(int(user_id))
394
         user = api.get_one(int(user_id))
391
-        api.update(user, name, email, True)
395
+        api.update(user, name, email, True, timezone=timezone)
392
 
396
 
393
         tg.flash(_('User {} updated.').format(user.get_display_name()), CST.STATUS_OK)
397
         tg.flash(_('User {} updated.').format(user.get_display_name()), CST.STATUS_OK)
394
         if next_url:
398
         if next_url:

+ 18 - 1
tracim/tracim/controllers/admin/workspace.py 查看文件

10
 from tracim.lib import CST
10
 from tracim.lib import CST
11
 from tracim.lib.base import BaseController
11
 from tracim.lib.base import BaseController
12
 from tracim.lib.helpers import on_off_to_boolean
12
 from tracim.lib.helpers import on_off_to_boolean
13
+from tracim.lib.integrity import PathValidationManager
14
+from tracim.lib.integrity import render_invalid_integrity_chosen_path
13
 from tracim.lib.user import UserApi
15
 from tracim.lib.user import UserApi
14
 from tracim.lib.userworkspace import RoleApi
16
 from tracim.lib.userworkspace import RoleApi
15
 from tracim.lib.workspace import WorkspaceApi
17
 from tracim.lib.workspace import WorkspaceApi
139
      responsible / advanced contributor. / contributor / reader
141
      responsible / advanced contributor. / contributor / reader
140
     """
142
     """
141
 
143
 
144
+    def __init__(self):
145
+        super().__init__()
146
+        self._path_validation = PathValidationManager(
147
+            is_case_sensitive=False,
148
+        )
149
+
142
     @property
150
     @property
143
     def _base_url(self):
151
     def _base_url(self):
144
         return '/admin/workspaces'
152
         return '/admin/workspaces'
187
         workspace_api_controller = WorkspaceApi(user)
195
         workspace_api_controller = WorkspaceApi(user)
188
         calendar_enabled = on_off_to_boolean(calendar_enabled)
196
         calendar_enabled = on_off_to_boolean(calendar_enabled)
189
 
197
 
198
+        # Display error page to user if chosen label is in conflict
199
+        if not self._path_validation.workspace_label_is_free(name):
200
+            return render_invalid_integrity_chosen_path(name)
201
+
190
         workspace = workspace_api_controller.create_workspace(
202
         workspace = workspace_api_controller.create_workspace(
191
             name,
203
             name,
192
             description,
204
             description,
213
         user = tmpl_context.current_user
225
         user = tmpl_context.current_user
214
         workspace_api_controller = WorkspaceApi(user)
226
         workspace_api_controller = WorkspaceApi(user)
215
         calendar_enabled = on_off_to_boolean(calendar_enabled)
227
         calendar_enabled = on_off_to_boolean(calendar_enabled)
216
-
217
         workspace = workspace_api_controller.get_one(id)
228
         workspace = workspace_api_controller.get_one(id)
229
+
230
+        # Display error page to user if chosen label is in conflict
231
+        if name != workspace.label and \
232
+                not self._path_validation.workspace_label_is_free(name):
233
+            return render_invalid_integrity_chosen_path(name)
234
+
218
         workspace.label = name
235
         workspace.label = name
219
         workspace.description = description
236
         workspace.description = description
220
         workspace.calendar_enabled = calendar_enabled
237
         workspace.calendar_enabled = calendar_enabled

+ 103 - 29
tracim/tracim/controllers/content.py 查看文件

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
-import sys
3
-
4
 __author__ = 'damien'
2
 __author__ = 'damien'
5
 
3
 
6
-from cgi import FieldStorage
4
+import sys
5
+import traceback
7
 
6
 
7
+from cgi import FieldStorage
8
 import tg
8
 import tg
9
 from tg import tmpl_context
9
 from tg import tmpl_context
10
 from tg.i18n import ugettext as _
10
 from tg.i18n import ugettext as _
11
 from tg.predicates import not_anonymous
11
 from tg.predicates import not_anonymous
12
 
12
 
13
-import traceback
14
-
15
 from tracim.controllers import TIMRestController
13
 from tracim.controllers import TIMRestController
16
 from tracim.controllers import TIMRestPathContextSetup
14
 from tracim.controllers import TIMRestPathContextSetup
17
 from tracim.controllers import TIMRestControllerWithBreadcrumb
15
 from tracim.controllers import TIMRestControllerWithBreadcrumb
18
 from tracim.controllers import TIMWorkspaceContentRestController
16
 from tracim.controllers import TIMWorkspaceContentRestController
19
-
20
 from tracim.lib import CST
17
 from tracim.lib import CST
21
 from tracim.lib.base import BaseController
18
 from tracim.lib.base import BaseController
22
 from tracim.lib.base import logger
19
 from tracim.lib.base import logger
27
 from tracim.lib.predicates import current_user_is_contributor
24
 from tracim.lib.predicates import current_user_is_contributor
28
 from tracim.lib.predicates import current_user_is_content_manager
25
 from tracim.lib.predicates import current_user_is_content_manager
29
 from tracim.lib.predicates import require_current_user_is_owner
26
 from tracim.lib.predicates import require_current_user_is_owner
30
-
31
 from tracim.model.serializers import Context, CTX, DictLikeClass
27
 from tracim.model.serializers import Context, CTX, DictLikeClass
32
 from tracim.model.data import ActionDescription
28
 from tracim.model.data import ActionDescription
33
 from tracim.model import new_revision
29
 from tracim.model import new_revision
30
+from tracim.model import DBSession
34
 from tracim.model.data import Content
31
 from tracim.model.data import Content
35
 from tracim.model.data import ContentType
32
 from tracim.model.data import ContentType
36
 from tracim.model.data import UserRoleInWorkspace
33
 from tracim.model.data import UserRoleInWorkspace
37
 from tracim.model.data import Workspace
34
 from tracim.model.data import Workspace
35
+from tracim.lib.integrity import render_invalid_integrity_chosen_path
36
+
38
 
37
 
39
 class UserWorkspaceFolderThreadCommentRestController(TIMRestController):
38
 class UserWorkspaceFolderThreadCommentRestController(TIMRestController):
40
 
39
 
259
     def post(self, label='', file_data=None):
258
     def post(self, label='', file_data=None):
260
         # TODO - SECURE THIS
259
         # TODO - SECURE THIS
261
         workspace = tmpl_context.workspace
260
         workspace = tmpl_context.workspace
261
+        folder = tmpl_context.folder
262
 
262
 
263
         api = ContentApi(tmpl_context.current_user)
263
         api = ContentApi(tmpl_context.current_user)
264
-
265
-        file = api.create(ContentType.File, workspace, tmpl_context.folder, label)
266
-        api.update_file_data(file, file_data.filename, file_data.type, file_data.file.read())
264
+        with DBSession.no_autoflush:
265
+            file = api.create(ContentType.File, workspace, folder, label)
266
+            api.update_file_data(file, file_data.filename, file_data.type, file_data.file.read())
267
+
268
+            # Display error page to user if chosen label is in conflict
269
+            if not self._path_validation.validate_new_content(file):
270
+                return render_invalid_integrity_chosen_path(
271
+                    file.get_label_as_file(),
272
+                )
267
         api.save(file, ActionDescription.CREATION)
273
         api.save(file, ActionDescription.CREATION)
268
 
274
 
269
         tg.flash(_('File created'), CST.STATUS_OK)
275
         tg.flash(_('File created'), CST.STATUS_OK)
291
                         item, label if label else item.label,
297
                         item, label if label else item.label,
292
                         comment if comment else ''
298
                         comment if comment else ''
293
                     )
299
                     )
300
+
301
+                    # Display error page to user if chosen label is in conflict
302
+                    if not self._path_validation.validate_new_content(
303
+                        updated_item,
304
+                    ):
305
+                        return render_invalid_integrity_chosen_path(
306
+                            updated_item.get_label_as_file(),
307
+                        )
308
+
294
                     api.save(updated_item, ActionDescription.EDITION)
309
                     api.save(updated_item, ActionDescription.EDITION)
295
 
310
 
296
                     # This case is the default "file title and description update"
311
                     # This case is the default "file title and description update"
317
 
332
 
318
                     if isinstance(file_data, FieldStorage):
333
                     if isinstance(file_data, FieldStorage):
319
                         api.update_file_data(item, file_data.filename, file_data.type, file_data.file.read())
334
                         api.update_file_data(item, file_data.filename, file_data.type, file_data.file.read())
335
+
336
+                        # Display error page to user if chosen label is in
337
+                        # conflict
338
+                        if not self._path_validation.validate_new_content(
339
+                            item,
340
+                        ):
341
+                            return render_invalid_integrity_chosen_path(
342
+                                item.get_label_as_file(),
343
+                            )
344
+
320
                         api.save(item, ActionDescription.REVISION)
345
                         api.save(item, ActionDescription.REVISION)
321
 
346
 
322
             msg = _('{} updated').format(self._item_type_label)
347
             msg = _('{} updated').format(self._item_type_label)
415
 
440
 
416
         api = ContentApi(tmpl_context.current_user)
441
         api = ContentApi(tmpl_context.current_user)
417
 
442
 
418
-        page = api.create(ContentType.Page, workspace, tmpl_context.folder, label)
419
-        page.description = content
443
+        with DBSession.no_autoflush:
444
+            page = api.create(ContentType.Page, workspace, tmpl_context.folder, label)
445
+            page.description = content
446
+
447
+            if not self._path_validation.validate_new_content(page):
448
+                return render_invalid_integrity_chosen_path(
449
+                    page.get_label(),
450
+                )
451
+
420
         api.save(page, ActionDescription.CREATION, do_notify=True)
452
         api.save(page, ActionDescription.CREATION, do_notify=True)
421
 
453
 
422
         tg.flash(_('Page created'), CST.STATUS_OK)
454
         tg.flash(_('Page created'), CST.STATUS_OK)
435
             item = api.get_one(int(item_id), self._item_type, workspace)
467
             item = api.get_one(int(item_id), self._item_type, workspace)
436
             with new_revision(item):
468
             with new_revision(item):
437
                 api.update_content(item, label, content)
469
                 api.update_content(item, label, content)
470
+
471
+                if not self._path_validation.validate_new_content(item):
472
+                    return render_invalid_integrity_chosen_path(
473
+                        item.get_label(),
474
+                    )
475
+
438
                 api.save(item, ActionDescription.REVISION)
476
                 api.save(item, ActionDescription.REVISION)
439
 
477
 
440
             msg = _('{} updated').format(self._item_type_label)
478
             msg = _('{} updated').format(self._item_type_label)
511
 
549
 
512
         api = ContentApi(tmpl_context.current_user)
550
         api = ContentApi(tmpl_context.current_user)
513
 
551
 
514
-        thread = api.create(ContentType.Thread, workspace, tmpl_context.folder, label)
515
-        # FIXME - DO NOT DUPLCIATE FIRST MESSAGE thread.description = content
516
-        api.save(thread, ActionDescription.CREATION, do_notify=False)
552
+        with DBSession.no_autoflush:
553
+            thread = api.create(ContentType.Thread, workspace, tmpl_context.folder, label)
554
+            # FIXME - DO NOT DUPLCIATE FIRST MESSAGE thread.description = content
555
+            api.save(thread, ActionDescription.CREATION, do_notify=False)
556
+
557
+            comment = api.create(ContentType.Comment, workspace, thread, label)
558
+            comment.label = ''
559
+            comment.description = content
560
+
561
+            if not self._path_validation.validate_new_content(thread):
562
+                return render_invalid_integrity_chosen_path(
563
+                    thread.get_label(),
564
+                )
517
 
565
 
518
-        comment = api.create(ContentType.Comment, workspace, thread, label)
519
-        comment.label = ''
520
-        comment.description = content
521
         api.save(comment, ActionDescription.COMMENT, do_notify=False)
566
         api.save(comment, ActionDescription.COMMENT, do_notify=False)
522
         api.do_notify(thread)
567
         api.do_notify(thread)
523
 
568
 
527
 
572
 
528
     @tg.require(current_user_is_reader())
573
     @tg.require(current_user_is_reader())
529
     @tg.expose('tracim.templates.thread.getone')
574
     @tg.expose('tracim.templates.thread.getone')
530
-    def get_one(self, thread_id):
575
+    def get_one(self, thread_id, **kwargs):
576
+        """
577
+        :param thread_id: content_id of Thread
578
+        :param inverted: fill with True equivalent to invert order of comments
579
+                         NOTE: This parameter is in kwargs because prevent URL
580
+                         changes.
581
+        """
582
+        inverted = kwargs.get('inverted')
531
         thread_id = int(thread_id)
583
         thread_id = int(thread_id)
532
         user = tmpl_context.current_user
584
         user = tmpl_context.current_user
533
         workspace = tmpl_context.workspace
585
         workspace = tmpl_context.workspace
543
         fake_api = Context(CTX.FOLDER).toDict(fake_api_content)
595
         fake_api = Context(CTX.FOLDER).toDict(fake_api_content)
544
 
596
 
545
         dictified_thread = Context(CTX.THREAD).toDict(thread, 'thread')
597
         dictified_thread = Context(CTX.THREAD).toDict(thread, 'thread')
546
-        return DictLikeClass(result = dictified_thread, fake_api=fake_api)
598
+
599
+        if inverted:
600
+          dictified_thread.thread.history = reversed(dictified_thread.thread.history)
601
+
602
+        return DictLikeClass(
603
+            result=dictified_thread,
604
+            fake_api=fake_api,
605
+            inverted=inverted,
606
+        )
547
 
607
 
548
 
608
 
549
 
609
 
767
             parent = None
827
             parent = None
768
             if parent_id:
828
             if parent_id:
769
                 parent = api.get_one(int(parent_id), ContentType.Folder, workspace)
829
                 parent = api.get_one(int(parent_id), ContentType.Folder, workspace)
770
-            folder = api.create(ContentType.Folder, workspace, parent, label)
771
 
830
 
772
-            subcontent = dict(
773
-                folder = True if can_contain_folders=='on' else False,
774
-                thread = True if can_contain_threads=='on' else False,
775
-                file = True if can_contain_files=='on' else False,
776
-                page = True if can_contain_pages=='on' else False
777
-            )
778
-            api.set_allowed_content(folder, subcontent)
831
+            with DBSession.no_autoflush:
832
+                folder = api.create(ContentType.Folder, workspace, parent, label)
833
+
834
+                subcontent = dict(
835
+                    folder = True if can_contain_folders=='on' else False,
836
+                    thread = True if can_contain_threads=='on' else False,
837
+                    file = True if can_contain_files=='on' else False,
838
+                    page = True if can_contain_pages=='on' else False
839
+                )
840
+                api.set_allowed_content(folder, subcontent)
841
+
842
+                if not self._path_validation.validate_new_content(folder):
843
+                    return render_invalid_integrity_chosen_path(
844
+                        folder.get_label(),
845
+                    )
846
+
779
             api.save(folder)
847
             api.save(folder)
780
 
848
 
781
             tg.flash(_('Folder created'), CST.STATUS_OK)
849
             tg.flash(_('Folder created'), CST.STATUS_OK)
821
                     # TODO - D.A. - 2015-05-25 - Allow to set folder description
889
                     # TODO - D.A. - 2015-05-25 - Allow to set folder description
822
                     api.update_content(folder, label, folder.description)
890
                     api.update_content(folder, label, folder.description)
823
                 api.set_allowed_content(folder, subcontent)
891
                 api.set_allowed_content(folder, subcontent)
892
+
893
+                if not self._path_validation.validate_new_content(folder):
894
+                    return render_invalid_integrity_chosen_path(
895
+                        folder.get_label(),
896
+                    )
897
+
824
                 api.save(folder)
898
                 api.save(folder)
825
 
899
 
826
             tg.flash(_('Folder updated'), CST.STATUS_OK)
900
             tg.flash(_('Folder updated'), CST.STATUS_OK)
949
             back_url = self._parent_url.format(item.workspace_id, item.parent_id)
1023
             back_url = self._parent_url.format(item.workspace_id, item.parent_id)
950
             msg = _('{} not un-deleted: {}').format(self._item_type_label, str(e))
1024
             msg = _('{} not un-deleted: {}').format(self._item_type_label, str(e))
951
             tg.flash(msg, CST.STATUS_ERROR)
1025
             tg.flash(msg, CST.STATUS_ERROR)
952
-            tg.redirect(back_url)
1026
+            tg.redirect(back_url)

+ 1 - 0
tracim/tracim/controllers/workspace.py 查看文件

60
         )
60
         )
61
 
61
 
62
         fake_api.sub_items = Context(CTX.FOLDER_CONTENT_LIST).toDict(
62
         fake_api.sub_items = Context(CTX.FOLDER_CONTENT_LIST).toDict(
63
+            # TODO BS 20161209: Is the correct way to grab folders? No use API?
63
             workspace.get_valid_children(ContentApi.DISPLAYABLE_CONTENTS)
64
             workspace.get_valid_children(ContentApi.DISPLAYABLE_CONTENTS)
64
         )
65
         )
65
 
66
 

+ 131 - 0
tracim/tracim/fixtures/content.py 查看文件

1
+# -*- coding: utf-8 -*-
2
+from tracim import model
3
+from tracim.fixtures import Fixture
4
+from tracim.fixtures.users_and_groups import Test
5
+from tracim.lib.content import ContentApi
6
+from tracim.lib.userworkspace import RoleApi
7
+from tracim.lib.workspace import WorkspaceApi
8
+from tracim.model.data import ContentType
9
+from tracim.model.data import UserRoleInWorkspace
10
+
11
+
12
+class Content(Fixture):
13
+    require = [Test]
14
+
15
+    def insert(self):
16
+        admin = self._session.query(model.User) \
17
+            .filter(model.User.email == 'admin@admin.admin') \
18
+            .one()
19
+        bob = self._session.query(model.User) \
20
+            .filter(model.User.email == 'bob@fsf.local') \
21
+            .one()
22
+        workspace_api = WorkspaceApi(admin)
23
+        content_api = ContentApi(admin)
24
+        role_api = RoleApi(admin)
25
+
26
+        # Workspaces
27
+        w1 = workspace_api.create_workspace('w1', save_now=True)
28
+        w2 = workspace_api.create_workspace('w2', save_now=True)
29
+        w3 = workspace_api.create_workspace('w3', save_now=True)
30
+
31
+        # Workspaces roles
32
+        role_api.create_one(
33
+            user=bob,
34
+            workspace=w1,
35
+            role_level=UserRoleInWorkspace.CONTENT_MANAGER,
36
+            with_notif=False,
37
+        )
38
+        role_api.create_one(
39
+            user=bob,
40
+            workspace=w2,
41
+            role_level=UserRoleInWorkspace.CONTENT_MANAGER,
42
+            with_notif=False,
43
+        )
44
+
45
+        # Folders
46
+        w1f1 = content_api.create(
47
+            content_type=ContentType.Folder,
48
+            workspace=w1,
49
+            label='w1f1',
50
+            do_save=True,
51
+        )
52
+        w1f2 = content_api.create(
53
+            content_type=ContentType.Folder,
54
+            workspace=w1,
55
+            label='w1f2',
56
+            do_save=True,
57
+        )
58
+
59
+        w2f1 = content_api.create(
60
+            content_type=ContentType.Folder,
61
+            workspace=w2,
62
+            label='w2f1',
63
+            do_save=True,
64
+        )
65
+        w2f2 = content_api.create(
66
+            content_type=ContentType.Folder,
67
+            workspace=w2,
68
+            label='w2f2',
69
+            do_save=True,
70
+        )
71
+
72
+        w3f1 = content_api.create(
73
+            content_type=ContentType.Folder,
74
+            workspace=w3,
75
+            label='w3f3',
76
+            do_save=True,
77
+        )
78
+
79
+        # Pages, threads, ..
80
+        w1f1p1 = content_api.create(
81
+            content_type=ContentType.Page,
82
+            workspace=w1,
83
+            parent=w1f1,
84
+            label='w1f1p1',
85
+            do_save=True,
86
+        )
87
+        w1f1t1 = content_api.create(
88
+            content_type=ContentType.Thread,
89
+            workspace=w1,
90
+            parent=w1f1,
91
+            label='w1f1t1',
92
+            do_save=False,
93
+        )
94
+        w1f1t1.description = 'w1f1t1 description'
95
+        self._session.add(w1f1t1)
96
+        w1f1d1_txt = content_api.create(
97
+            content_type=ContentType.File,
98
+            workspace=w1,
99
+            parent=w1f1,
100
+            label='w1f1d1',
101
+            do_save=False,
102
+        )
103
+        w1f1d1_txt.file_extension = '.txt'
104
+        w1f1d1_txt.file_content = b'w1f1d1 content'
105
+        self._session.add(w1f1d1_txt)
106
+        w1f1d2_html = content_api.create(
107
+            content_type=ContentType.File,
108
+            workspace=w1,
109
+            parent=w1f1,
110
+            label='w1f1d2',
111
+            do_save=False,
112
+        )
113
+        w1f1d2_html.file_extension = '.html'
114
+        w1f1d2_html.file_content = b'<p>w1f1d2 content</p>'
115
+        self._session.add(w1f1d2_html)
116
+        w1f1f1 = content_api.create(
117
+            content_type=ContentType.Folder,
118
+            workspace=w1,
119
+            label='w1f1f1',
120
+            parent=w1f1,
121
+            do_save=True,
122
+        )
123
+
124
+        w2f1p1 = content_api.create(
125
+            content_type=ContentType.Page,
126
+            workspace=w2,
127
+            parent=w2f1,
128
+            label='w2f1p1',
129
+            do_save=True,
130
+        )
131
+        self._session.flush()

+ 4 - 0
tracim/tracim/i18n/fr/LC_MESSAGES/tracim.po 查看文件

1965
 msgid "Post a reply..."
1965
 msgid "Post a reply..."
1966
 msgstr "Poster une réponse..."
1966
 msgstr "Poster une réponse..."
1967
 
1967
 
1968
+#: tracim/templates/thread/getone.mak:97
1969
+msgid "Invert order"
1970
+msgstr "Inverser l'ordre"
1971
+
1968
 #: tracim/templates/thread/toolbar.mak:16
1972
 #: tracim/templates/thread/toolbar.mak:16
1969
 msgid "Edit current thread"
1973
 msgid "Edit current thread"
1970
 msgstr "Modifier le sujet de discussion"
1974
 msgstr "Modifier le sujet de discussion"

+ 6 - 2
tracim/tracim/lib/calendar.py 查看文件

3
 
3
 
4
 import re
4
 import re
5
 import transaction
5
 import transaction
6
+from caldav.lib.error import PutError
6
 
7
 
7
 from icalendar import Event as iCalendarEvent
8
 from icalendar import Event as iCalendarEvent
8
 from sqlalchemy.orm.exc import NoResultFound
9
 from sqlalchemy.orm.exc import NoResultFound
356
 END:VEVENT
357
 END:VEVENT
357
 END:VCALENDAR
358
 END:VCALENDAR
358
 """.format(uid='{0}FAKEEVENT'.format(related_object_id))
359
 """.format(uid='{0}FAKEEVENT'.format(related_object_id))
359
-        event = user_calendar.add_event(event_ics)
360
-        event.delete()
360
+        try:
361
+            event = user_calendar.add_event(event_ics)
362
+            event.delete()
363
+        except PutError:
364
+            pass  # TODO BS 20161128: Radicale is down. Record this event ?
361
 
365
 

+ 174 - 37
tracim/tracim/lib/content.py 查看文件

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
+import os
3
+
2
 from operator import itemgetter
4
 from operator import itemgetter
5
+from sqlalchemy import func
6
+from sqlalchemy.orm import Query
3
 
7
 
4
 __author__ = 'damien'
8
 __author__ = 'damien'
5
 
9
 
283
 
287
 
284
         return result
288
         return result
285
 
289
 
290
+    def get_base_query(self, workspace: Workspace) -> Query:
291
+        return self._base_query(workspace)
292
+
286
     def get_child_folders(self, parent: Content=None, workspace: Workspace=None, filter_by_allowed_content_types: list=[], removed_item_ids: list=[], allowed_node_types=None) -> [Content]:
293
     def get_child_folders(self, parent: Content=None, workspace: Workspace=None, filter_by_allowed_content_types: list=[], removed_item_ids: list=[], allowed_node_types=None) -> [Content]:
287
         """
294
         """
288
         This method returns child items (folders or items) for left bar treeview.
295
         This method returns child items (folders or items) for left bar treeview.
338
         content.is_temporary = is_temporary
345
         content.is_temporary = is_temporary
339
         content.revision_type = ActionDescription.CREATION
346
         content.revision_type = ActionDescription.CREATION
340
 
347
 
348
+        if content.type in (
349
+                ContentType.Page,
350
+                ContentType.Thread,
351
+        ):
352
+            content.file_extension = '.html'
353
+
341
         if do_save:
354
         if do_save:
342
             DBSession.add(content)
355
             DBSession.add(content)
343
             self.save(content, ActionDescription.CREATION)
356
             self.save(content, ActionDescription.CREATION)
402
 
415
 
403
         return revision
416
         return revision
404
 
417
 
405
-    def get_one_by_label_and_parent(self, content_label: str, content_parent: Content = None,
406
-                                    workspace: Workspace = None) -> Content:
418
+    def get_one_by_label_and_parent(
419
+            self,
420
+            content_label: str,
421
+            content_parent: Content=None,
422
+    ) -> Content:
407
         """
423
         """
408
         This method let us request the database to obtain a Content with its name and parent
424
         This method let us request the database to obtain a Content with its name and parent
409
         :param content_label: Either the content's label or the content's filename if the label is None
425
         :param content_label: Either the content's label or the content's filename if the label is None
411
         :param workspace: The workspace's content
427
         :param workspace: The workspace's content
412
         :return The corresponding Content
428
         :return The corresponding Content
413
         """
429
         """
414
-        assert content_label is not None# DYN_REMOVE
430
+        workspace = content_parent.workspace if content_parent else None
431
+        query = self._base_query(workspace)
432
+        parent_id = content_parent.content_id if content_parent else None
433
+        query = query.filter(Content.parent_id == parent_id)
415
 
434
 
416
-        resultset = self._base_query(workspace)
435
+        file_name, file_extension = os.path.splitext(content_label)
417
 
436
 
418
-        parent_id = content_parent.content_id if content_parent else None
437
+        return query.filter(
438
+            or_(
439
+                and_(
440
+                    Content.type == ContentType.File,
441
+                    Content.label == file_name,
442
+                    Content.file_extension == file_extension,
443
+                ),
444
+                and_(
445
+                    Content.type == ContentType.Thread,
446
+                    Content.label == file_name,
447
+                ),
448
+                and_(
449
+                    Content.type == ContentType.Page,
450
+                    Content.label == file_name,
451
+                ),
452
+                and_(
453
+                    Content.type == ContentType.Folder,
454
+                    Content.label == content_label,
455
+                ),
456
+            )
457
+        ).one()
458
+
459
+    def get_one_by_label_and_parent_labels(
460
+            self,
461
+            content_label: str,
462
+            workspace: Workspace,
463
+            content_parent_labels: [str]=None,
464
+    ):
465
+        """
466
+        Return content with it's label, workspace and parents labels (optional)
467
+        :param content_label: label of content (label or file_name)
468
+        :param workspace: workspace containing all of this
469
+        :param content_parent_labels: Ordered list of labels representing path
470
+            of folder (without workspace label).
471
+        E.g.: ['foo', 'bar'] for complete path /Workspace1/foo/bar folder
472
+        :return: Found Content
473
+        """
474
+        query = self._base_query(workspace)
475
+        parent_folder = None
476
+
477
+        # Grab content parent folder if parent path given
478
+        if content_parent_labels:
479
+            parent_folder = self.get_folder_with_workspace_path_labels(
480
+                content_parent_labels,
481
+                workspace,
482
+            )
419
 
483
 
420
-        resultset = resultset.filter(Content.parent_id == parent_id)
484
+        # Build query for found content by label
485
+        content_query = self.filter_query_for_content_label_as_path(
486
+            query=query,
487
+            content_label_as_file=content_label,
488
+        )
421
 
489
 
422
-        return resultset.filter(or_(
423
-            Content.label == content_label,
424
-            Content.file_name == content_label,
425
-            Content.label == re.sub(r'\.[^.]+$', '', content_label)
426
-        )).one()
490
+        # Modify query to apply parent folder filter if any
491
+        if parent_folder:
492
+            content_query = content_query.filter(
493
+                Content.parent_id == parent_folder.content_id,
494
+            )
427
 
495
 
428
-    def get_one_by_label_and_parent_label(self, content_label: str, content_parent_label: [str]=None, workspace: Workspace=None):
429
-        assert content_label is not None  # DYN_REMOVE
430
-        resultset = self._base_query(workspace)
496
+        # Filter with workspace
497
+        content_query = content_query.filter(
498
+            Content.workspace_id == workspace.workspace_id,
499
+        )
431
 
500
 
432
-        res =  resultset.filter(or_(
433
-            Content.label == content_label,
434
-            Content.file_name == content_label,
435
-            Content.label == re.sub(r'\.[^.]+$', '', content_label)
436
-        )).all()
501
+        # Return the content
502
+        return content_query\
503
+            .order_by(
504
+                Content.revision_id.desc(),
505
+            )\
506
+            .one()
437
 
507
 
438
-        if content_parent_label:
439
-            tmp = dict()
440
-            for content in res:
441
-                tmp[content] = content.parent
508
+    def get_folder_with_workspace_path_labels(
509
+            self,
510
+            path_labels: [str],
511
+            workspace: Workspace,
512
+    ) -> Content:
513
+        """
514
+        Return a Content folder for given relative path.
515
+        TODO BS 20161124: Not safe if web interface allow folder duplicate names
516
+        :param path_labels: List of labels representing path of folder
517
+        (without workspace label).
518
+        E.g.: ['foo', 'bar'] for complete path /Workspace1/foo/bar folder
519
+        :param workspace: workspace of folders
520
+        :return: Content folder
521
+        """
522
+        query = self._base_query(workspace)
523
+        folder = None
524
+
525
+        for label in path_labels:
526
+            # Filter query on label
527
+            folder_query = query \
528
+                .filter(
529
+                    Content.type == ContentType.Folder,
530
+                    Content.label == label,
531
+                    Content.workspace_id == workspace.workspace_id,
532
+                )
442
 
533
 
443
-            for parent_label in reversed(content_parent_label):
444
-                a = []
445
-                tmp = {content: parent.parent for content, parent in tmp.items()
446
-                       if parent and parent.label == parent_label}
534
+            # Search into parent folder (if already deep)
535
+            if folder:
536
+                folder_query = folder_query\
537
+                    .filter(
538
+                        Content.parent_id == folder.content_id,
539
+                    )
447
 
540
 
448
-                if len(tmp) == 1:
449
-                    content, last_parent = tmp.popitem()
450
-                    return content
451
-                elif len(tmp) == 0:
452
-                    return None
541
+            # Get thirst corresponding folder
542
+            folder = folder_query \
543
+                .order_by(Content.revision_id.desc()) \
544
+                .one()
453
 
545
 
454
-            for content, parent_content in tmp.items():
455
-                if not parent_content:
456
-                    return content
546
+        return folder
457
 
547
 
458
-            return None
459
-        return res[0]
548
+    def filter_query_for_content_label_as_path(
549
+            self,
550
+            query: Query,
551
+            content_label_as_file: str,
552
+            is_case_sensitive: bool = False,
553
+    ) -> Query:
554
+        """
555
+        Apply normalised filters to found Content corresponding as given label.
556
+        :param query: query to modify
557
+        :param content_label_as_file: label in this
558
+        FILE version, use Content.get_label_as_file().
559
+        :param is_case_sensitive: Take care about case or not
560
+        :return: modified query
561
+        """
562
+        file_name, file_extension = os.path.splitext(content_label_as_file)
563
+
564
+        label_filter = Content.label == content_label_as_file
565
+        file_name_filter = Content.label == file_name
566
+        file_extension_filter = Content.file_extension == file_extension
567
+
568
+        if not is_case_sensitive:
569
+            label_filter = func.lower(Content.label) == \
570
+                           func.lower(content_label_as_file)
571
+            file_name_filter = func.lower(Content.label) == \
572
+                               func.lower(file_name)
573
+            file_extension_filter = func.lower(Content.file_extension) == \
574
+                                    func.lower(file_extension)
575
+
576
+        return query.filter(or_(
577
+            and_(
578
+                Content.type == ContentType.File,
579
+                file_name_filter,
580
+                file_extension_filter,
581
+            ),
582
+            and_(
583
+                Content.type == ContentType.Thread,
584
+                file_name_filter,
585
+                file_extension_filter,
586
+            ),
587
+            and_(
588
+                Content.type == ContentType.Page,
589
+                file_name_filter,
590
+                file_extension_filter,
591
+            ),
592
+            and_(
593
+                Content.type == ContentType.Folder,
594
+                label_filter,
595
+            ),
596
+        ))
460
 
597
 
461
     def get_all(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> [Content]:
598
     def get_all(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> [Content]:
462
         assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
599
         assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE

+ 4 - 3
tracim/tracim/lib/daemons.py 查看文件

303
 
303
 
304
         config['provider_mapping'] = {
304
         config['provider_mapping'] = {
305
             config['root_path']: Provider(
305
             config['root_path']: Provider(
306
-                show_archived=config['show_archived'],
307
-                show_deleted=config['show_deleted'],
308
-                show_history=config['show_history'],
306
+                # TODO: Test to Re enabme archived and deleted
307
+                show_archived=False,  # config['show_archived'],
308
+                show_deleted=False,  # config['show_deleted'],
309
+                show_history=False,  # config['show_history'],
309
                 manage_locks=config['manager_locks']
310
                 manage_locks=config['manager_locks']
310
             )
311
             )
311
         }
312
         }

+ 103 - 0
tracim/tracim/lib/integrity.py 查看文件

1
+# -*- coding: utf-8 -*-
2
+import os
3
+
4
+from sqlalchemy import func
5
+from tg import tmpl_context
6
+from tg.render import render
7
+from tracim.lib.content import ContentApi
8
+from tracim.lib.workspace import UnsafeWorkspaceApi
9
+from tracim.model.data import Workspace
10
+from tracim.model.data import Content
11
+from tracim.model.serializers import DictLikeClass, Context, CTX
12
+
13
+
14
+class PathValidationManager(object):
15
+    def __init__(self, is_case_sensitive: bool=False):
16
+        """
17
+        :param is_case_sensitive: If True, consider name with different
18
+        case as different.
19
+        """
20
+        self._is_case_sensitive = is_case_sensitive
21
+        self._workspace_api = UnsafeWorkspaceApi(None)
22
+        self._content_api = ContentApi(None)
23
+
24
+    def workspace_label_is_free(self, workspace_name: str) -> bool:
25
+        """
26
+        :param workspace_name: Workspace name
27
+        :return: True if workspace is available
28
+        """
29
+        query = self._workspace_api.get_base_query()
30
+
31
+        label_filter = Workspace.label == workspace_name
32
+        if not self._is_case_sensitive:
33
+            label_filter = func.lower(Workspace.label) == \
34
+                           func.lower(workspace_name)
35
+
36
+        return not bool(query.filter(label_filter).count())
37
+
38
+    def content_label_is_free(
39
+            self,
40
+            content_label_as_file,
41
+            workspace: Workspace,
42
+            parent: Content=None,
43
+            exclude_content_id: int=None,
44
+    ) -> bool:
45
+        """
46
+        :param content_label_as_file:
47
+        :param workspace:
48
+        :param parent:
49
+        :return: True if content label is available
50
+        """
51
+        query = self._content_api.get_base_query(workspace)
52
+
53
+        if parent:
54
+            query = query.filter(Content.parent_id == parent.content_id)
55
+
56
+        if exclude_content_id:
57
+            query = query.filter(Content.content_id != exclude_content_id)
58
+
59
+        query = query.filter(Content.workspace_id == workspace.workspace_id)
60
+
61
+        return not \
62
+            bool(
63
+                self._content_api.filter_query_for_content_label_as_path(
64
+                    query=query,
65
+                    content_label_as_file=content_label_as_file,
66
+                    is_case_sensitive=self._is_case_sensitive,
67
+                ).count()
68
+            )
69
+
70
+    def validate_new_content(self, content: Content) -> bool:
71
+        """
72
+        :param content: Content with label to test
73
+        :return: True if content label is not in conflict with existing
74
+        resource
75
+        """
76
+        return self.content_label_is_free(
77
+            content_label_as_file=content.get_label_as_file(),
78
+            workspace=content.workspace,
79
+            parent=content.parent,
80
+            exclude_content_id=content.content_id,
81
+        )
82
+
83
+
84
+def render_invalid_integrity_chosen_path(invalid_label: str) -> str:
85
+    """
86
+    Return html page code of error about invalid label choice.
87
+    :param invalid_label: the invalid label
88
+    :return: html page code
89
+    """
90
+    user = tmpl_context.current_user
91
+    fake_api_content = DictLikeClass(
92
+        current_user=user,
93
+    )
94
+    fake_api = Context(CTX.USER).toDict(fake_api_content)
95
+
96
+    return render(
97
+        template_vars=dict(
98
+            invalid_label=invalid_label,
99
+            fake_api=fake_api,
100
+        ),
101
+        template_engine='mako',
102
+        template_name='tracim.templates.errors.label_invalid_path',
103
+    )

+ 2 - 5
tracim/tracim/lib/notifications.py 查看文件

228
             subject = self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT
228
             subject = self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT
229
             subject = subject.replace(EST.WEBSITE_TITLE, self._global_config.WEBSITE_TITLE.__str__())
229
             subject = subject.replace(EST.WEBSITE_TITLE, self._global_config.WEBSITE_TITLE.__str__())
230
             subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__())
230
             subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__())
231
-            subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__() if main_content.label else main_content.file_name.__str__())
231
+            subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__())
232
             subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__())
232
             subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__())
233
 
233
 
234
             message = MIMEMultipart('alternative')
234
             message = MIMEMultipart('alternative')
306
                 content_intro = _('<span id="content-intro-username">{}</span> added a file entitled:').format(actor.display_name)
306
                 content_intro = _('<span id="content-intro-username">{}</span> added a file entitled:').format(actor.display_name)
307
                 if content.description:
307
                 if content.description:
308
                     content_text = content.description
308
                     content_text = content.description
309
-                elif content.label:
310
-                    content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
311
                 else:
309
                 else:
312
-                    content_text = '<span id="content-body-only-title">{}</span>'.format(content.file_name)
313
-
310
+                    content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
314
 
311
 
315
             elif ContentType.Page == content.type:
312
             elif ContentType.Page == content.type:
316
                 content_intro = _('<span id="content-intro-username">{}</span> added a page entitled:').format(actor.display_name)
313
                 content_intro = _('<span id="content-intro-username">{}</span> added a page entitled:').format(actor.display_name)

+ 25 - 18
tracim/tracim/lib/webdav/design.py 查看文件

65
                        label,
65
                        label,
66
                        date,
66
                        date,
67
                        event.owner.display_name,
67
                        event.owner.display_name,
68
-                       '<i class="fa fa-caret-left"></i> shown' if event.id == content_revision.revision_id else '''<span><a class="revision-link" href="/.history/%s/(%s - %s) %s.html">(View revision)</a></span>''' % (
69
-                       content.label, event.id, event.type.id, event.ref_object.label) if event.type.id in ['revision', 'creation', 'edition'] else '')
70
-
68
+                       # NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
69
+                       '<i class="fa fa-caret-left"></i> shown'  if event.id == content_revision.revision_id else '' # '''<span><a class="revision-link" href="/.history/%s/(%s - %s) %s.html">(View revision)</a></span>''' % (
70
+                       # content.label, event.id, event.type.id, event.ref_object.label) if event.type.id in ['revision', 'creation', 'edition'] else '')
71
+                   )
71
     histHTML += '</table>'
72
     histHTML += '</table>'
72
 
73
 
73
     file = '''
74
     file = '''
93
             </div>
94
             </div>
94
             <div class="pull-right">
95
             <div class="pull-right">
95
                 <div class="btn-group btn-group-vertical">
96
                 <div class="btn-group btn-group-vertical">
96
-                    <a class="btn btn-default">
97
-                        <i class="fa fa-external-link"></i> View in tracim</a>
98
-                    </a>
97
+                    <!-- NOTE: Not omplemented yet, don't display not working link
98
+                     <a class="btn btn-default">
99
+                         <i class="fa fa-external-link"></i> View in tracim</a>
100
+                     </a>-->
99
                 </div>
101
                 </div>
100
             </div>
102
             </div>
101
         </div>
103
         </div>
113
             file_location = file_location.replace(/\/[^/]*$/, '')
115
             file_location = file_location.replace(/\/[^/]*$/, '')
114
             file_location = file_location.replace(/\/.history\/[^/]*$/, '')
116
             file_location = file_location.replace(/\/.history\/[^/]*$/, '')
115
 
117
 
116
-            $('.revision-link').each(function() {
117
-                $(this).attr('href', file_location + $(this).attr('href'))
118
-            });
118
+            // NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
119
+            // $('.revision-link').each(function() {
120
+            //    $(this).attr('href', file_location + $(this).attr('href'))
121
+            // });
119
         }
122
         }
120
     </script>
123
     </script>
121
 </body>
124
 </body>
197
                            t.owner.display_name,
200
                            t.owner.display_name,
198
                            t.create_readable_date(),
201
                            t.create_readable_date(),
199
                            label,
202
                            label,
200
-                            '<i class="fa fa-caret-left"></i> shown' if t.id == content_revision.revision_id else '''<span><a class="revision-link" href="/.history/%s/%s-%s">(View revision)</a></span>''' % (
201
-                               content.label,
202
-                               t.id,
203
-                               t.ref_object.label) if t.type.id in ['revision', 'creation', 'edition'] else '')
203
+                            # NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
204
+                            '<i class="fa fa-caret-left"></i> shown' if t.id == content_revision.revision_id else '' # else '''<span><a class="revision-link" href="/.history/%s/%s-%s">(View revision)</a></span>''' % (
205
+                               # content.label,
206
+                               # t.id,
207
+                               # t.ref_object.label) if t.type.id in ['revision', 'creation', 'edition'] else '')
208
+                           )
204
 
209
 
205
         page = '''
210
         page = '''
206
 <html>
211
 <html>
222
             </div>
227
             </div>
223
             <div class="pull-right">
228
             <div class="pull-right">
224
                 <div class="btn-group btn-group-vertical">
229
                 <div class="btn-group btn-group-vertical">
230
+                    <!-- NOTE: Not omplemented yet, don't display not working link
225
                     <a class="btn btn-default" onclick="hide_elements()">
231
                     <a class="btn btn-default" onclick="hide_elements()">
226
-                        <i id="hideshow" class="fa fa-eye-slash"></i> <span id="hideshowtxt" >Hide history</span></a>
227
-                    </a>
232
+                       <i id="hideshow" class="fa fa-eye-slash"></i> <span id="hideshowtxt" >Hide history</span></a>
233
+                    </a>-->
228
                     <a class="btn btn-default">
234
                     <a class="btn btn-default">
229
                         <i class="fa fa-external-link"></i> View in tracim</a>
235
                         <i class="fa fa-external-link"></i> View in tracim</a>
230
                     </a>
236
                     </a>
244
             file_location = file_location.replace(/\/[^/]*$/, '')
250
             file_location = file_location.replace(/\/[^/]*$/, '')
245
             file_location = file_location.replace(/\/.history\/[^/]*$/, '')
251
             file_location = file_location.replace(/\/.history\/[^/]*$/, '')
246
 
252
 
247
-            $('.revision-link').each(function() {
248
-                $(this).attr('href', file_location + $(this).attr('href'))
249
-            });
253
+            // NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
254
+            // $('.revision-link').each(function() {
255
+            //     $(this).attr('href', file_location + $(this).attr('href'))
256
+            // });
250
         }
257
         }
251
 
258
 
252
         function hide_elements() {
259
         function hide_elements() {

+ 19 - 14
tracim/tracim/lib/webdav/sql_dav_provider.py 查看文件

2
 
2
 
3
 import re
3
 import re
4
 from os.path import basename, dirname, normpath
4
 from os.path import basename, dirname, normpath
5
+from sqlalchemy.orm.exc import NoResultFound
5
 from tracim.lib.webdav.utils import transform_to_bdd
6
 from tracim.lib.webdav.utils import transform_to_bdd
6
 
7
 
7
 from wsgidav.dav_provider import DAVProvider
8
 from wsgidav.dav_provider import DAVProvider
75
 
76
 
76
         content_api = ContentApi(
77
         content_api = ContentApi(
77
             user,
78
             user,
78
-            show_archived=self._show_archive,
79
-            show_deleted=self._show_delete
79
+            show_archived=False,  # self._show_archive,
80
+            show_deleted=False,  # self._show_delete
80
         )
81
         )
81
 
82
 
82
         content = self.get_content_from_path(
83
         content = self.get_content_from_path(
160
         if parent_path == root_path or workspace is None:
161
         if parent_path == root_path or workspace is None:
161
             return workspace is not None
162
             return workspace is not None
162
 
163
 
163
-        content_api = ContentApi(user, show_archived=True, show_deleted=True)
164
+        # TODO bastien: Arnaud avait mis a True, verif le comportement
165
+        # lorsque l'on explore les dossiers archive et deleted
166
+        content_api = ContentApi(user, show_archived=False, show_deleted=False)
164
 
167
 
165
         revision_id = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) ([^/].+)$', path)
168
         revision_id = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) ([^/].+)$', path)
166
 
169
 
238
         path = self.reduce_path(path)
241
         path = self.reduce_path(path)
239
         parent_path = dirname(path)
242
         parent_path = dirname(path)
240
 
243
 
241
-        blbl = parent_path.replace('/'+workspace.label, '')
244
+        relative_parents_path = parent_path[len(workspace.label)+1:]
245
+        parents = relative_parents_path.split('/')
242
 
246
 
243
-        parents = blbl.split('/')
244
-
245
-        parents.remove('')
247
+        try:
248
+            parents.remove('')
249
+        except ValueError:
250
+            pass
246
         parents = [transform_to_bdd(x) for x in parents]
251
         parents = [transform_to_bdd(x) for x in parents]
247
 
252
 
248
         try:
253
         try:
249
-            return content_api.get_one_by_label_and_parent_label(
250
-                transform_to_bdd(basename(path)),
251
-                parents,
252
-                workspace
254
+            return content_api.get_one_by_label_and_parent_labels(
255
+                content_label=transform_to_bdd(basename(path)),
256
+                content_parent_labels=parents,
257
+                workspace=workspace,
253
             )
258
             )
254
-        except:
259
+        except NoResultFound:
255
             return None
260
             return None
256
 
261
 
257
     def get_content_from_revision(self, revision: ContentRevisionRO, api: ContentApi) -> Content:
262
     def get_content_from_revision(self, revision: ContentRevisionRO, api: ContentApi) -> Content:
258
         try:
263
         try:
259
             return api.get_one(revision.content_id, ContentType.Any)
264
             return api.get_one(revision.content_id, ContentType.Any)
260
-        except:
265
+        except NoResultFound:
261
             return None
266
             return None
262
 
267
 
263
     def get_parent_from_path(self, path, api: ContentApi, workspace) -> Content:
268
     def get_parent_from_path(self, path, api: ContentApi, workspace) -> Content:
266
     def get_workspace_from_path(self, path: str, api: WorkspaceApi) -> Workspace:
271
     def get_workspace_from_path(self, path: str, api: WorkspaceApi) -> Workspace:
267
         try:
272
         try:
268
             return api.get_one_by_label(transform_to_bdd(path.split('/')[1]))
273
             return api.get_one_by_label(transform_to_bdd(path.split('/')[1]))
269
-        except:
274
+        except NoResultFound:
270
             return None
275
             return None

+ 45 - 43
tracim/tracim/lib/webdav/sql_resources.py 查看文件

54
         try:
54
         try:
55
             # When undeleting/unarchiving we except a content with the new name to not exist, thus if we
55
             # When undeleting/unarchiving we except a content with the new name to not exist, thus if we
56
             # don't get an error and the database request send back a result, we stop the action
56
             # don't get an error and the database request send back a result, we stop the action
57
-            self.content_api.get_one_by_label_and_parent(self._new_name, self.content.parent, self.content.workspace)
57
+            self.content_api.get_one_by_label_and_parent(self._new_name, self.content.parent)
58
             raise DAVError(HTTP_FORBIDDEN)
58
             raise DAVError(HTTP_FORBIDDEN)
59
         except NoResultFound:
59
         except NoResultFound:
60
             with new_revision(self.content):
60
             with new_revision(self.content):
69
         Will create the new name, either by adding '- deleted the [date]' after the name when archiving/deleting or
69
         Will create the new name, either by adding '- deleted the [date]' after the name when archiving/deleting or
70
         removing this string when undeleting/unarchiving
70
         removing this string when undeleting/unarchiving
71
         """
71
         """
72
-        new_name = self.content.get_label()
72
+        new_name = self.content.get_label_as_file()
73
         extension = ''
73
         extension = ''
74
 
74
 
75
         # if the content has no label, the last .ext is important
75
         # if the content has no label, the last .ext is important
219
             # the purpose is to display .history only if there's at least one content's type that has a history
219
             # the purpose is to display .history only if there's at least one content's type that has a history
220
             if content.type != ContentType.Folder:
220
             if content.type != ContentType.Folder:
221
                 self._file_count += 1
221
                 self._file_count += 1
222
-            retlist.append(content.get_label())
222
+            retlist.append(content.get_label_as_file())
223
 
223
 
224
         return retlist
224
         return retlist
225
 
225
 
310
         children = self.content_api.get_all(False, ContentType.Any, self.workspace)
310
         children = self.content_api.get_all(False, ContentType.Any, self.workspace)
311
 
311
 
312
         for content in children:
312
         for content in children:
313
-            content_path = '%s/%s' % (self.path, transform_to_display(content.get_label()))
313
+            content_path = '%s/%s' % (self.path, transform_to_display(content.get_label_as_file()))
314
 
314
 
315
             if content.type == ContentType.Folder:
315
             if content.type == ContentType.Folder:
316
                 members.append(Folder(content_path, self.environ, self.workspace, content))
316
                 members.append(Folder(content_path, self.environ, self.workspace, content))
374
         return mktime(self.content.created.timetuple())
374
         return mktime(self.content.created.timetuple())
375
 
375
 
376
     def getDisplayName(self) -> str:
376
     def getDisplayName(self) -> str:
377
-        return transform_to_display(self.content.get_label())
377
+        return transform_to_display(self.content.get_label_as_file())
378
 
378
 
379
     def getLastModified(self) -> float:
379
     def getLastModified(self) -> float:
380
         return mktime(self.content.updated.timetuple())
380
         return mktime(self.content.updated.timetuple())
472
         )
472
         )
473
 
473
 
474
         for content in visible_children:
474
         for content in visible_children:
475
-            content_path = '%s/%s' % (self.path, transform_to_display(content.get_label()))
475
+            content_path = '%s/%s' % (self.path, transform_to_display(content.get_label_as_file()))
476
 
476
 
477
             if content.type == ContentType.Folder:
477
             if content.type == ContentType.Folder:
478
                 members.append(Folder(content_path, self.environ, self.workspace, content))
478
                 members.append(Folder(content_path, self.environ, self.workspace, content))
555
         )
555
         )
556
 
556
 
557
         return HistoryFileFolder(
557
         return HistoryFileFolder(
558
-            path='%s/%s' % (self.path, content.get_label()),
558
+            path='%s/%s' % (self.path, content.get_label_as_file()),
559
             environ=self.environ,
559
             environ=self.environ,
560
             content=content)
560
             content=content)
561
 
561
 
568
                 self._is_deleted and content.is_deleted or
568
                 self._is_deleted and content.is_deleted or
569
                 not (content.is_archived or self._is_archived or content.is_deleted or self._is_deleted))\
569
                 not (content.is_archived or self._is_archived or content.is_deleted or self._is_deleted))\
570
                     and content.type != ContentType.Folder:
570
                     and content.type != ContentType.Folder:
571
-                ret.append(content.get_label())
571
+                ret.append(content.get_label_as_file())
572
 
572
 
573
         return ret
573
         return ret
574
 
574
 
601
         for content in children:
601
         for content in children:
602
             if content.is_archived == self._is_archived and content.is_deleted == self._is_deleted:
602
             if content.is_archived == self._is_archived and content.is_deleted == self._is_deleted:
603
                 members.append(HistoryFileFolder(
603
                 members.append(HistoryFileFolder(
604
-                    path='%s/%s' % (self.path, content.get_label()),
604
+                    path='%s/%s' % (self.path, content.get_label_as_file()),
605
                     environ=self.environ,
605
                     environ=self.environ,
606
                     content=content))
606
                     content=content))
607
 
607
 
638
         )
638
         )
639
 
639
 
640
         return self.provider.getResourceInst(
640
         return self.provider.getResourceInst(
641
-            path='%s/%s' % (self.path, transform_to_display(content.get_label())),
641
+            path='%s/%s' % (self.path, transform_to_display(content.get_label_as_file())),
642
             environ=self.environ
642
             environ=self.environ
643
             )
643
             )
644
 
644
 
652
 
652
 
653
         for content in children:
653
         for content in children:
654
             if content.is_deleted:
654
             if content.is_deleted:
655
-                retlist.append(content.get_label())
655
+                retlist.append(content.get_label_as_file())
656
 
656
 
657
                 if content.type != ContentType.Folder:
657
                 if content.type != ContentType.Folder:
658
                     self._file_count += 1
658
                     self._file_count += 1
669
 
669
 
670
         for content in children:
670
         for content in children:
671
             if content.is_deleted:
671
             if content.is_deleted:
672
-                content_path = '%s/%s' % (self.path, transform_to_display(content.get_label()))
672
+                content_path = '%s/%s' % (self.path, transform_to_display(content.get_label_as_file()))
673
 
673
 
674
                 if content.type == ContentType.Folder:
674
                 if content.type == ContentType.Folder:
675
                     members.append(Folder(content_path, self.environ, self.workspace, content))
675
                     members.append(Folder(content_path, self.environ, self.workspace, content))
723
         )
723
         )
724
 
724
 
725
         return self.provider.getResourceInst(
725
         return self.provider.getResourceInst(
726
-            path=self.path + '/' + transform_to_display(content.get_label()),
726
+            path=self.path + '/' + transform_to_display(content.get_label_as_file()),
727
             environ=self.environ
727
             environ=self.environ
728
         )
728
         )
729
 
729
 
732
 
732
 
733
         for content in self.content_api.get_all_with_filter(
733
         for content in self.content_api.get_all_with_filter(
734
                 self.content if self.content is None else self.content.id, ContentType.Any):
734
                 self.content if self.content is None else self.content.id, ContentType.Any):
735
-            retlist.append(content.get_label())
735
+            retlist.append(content.get_label_as_file())
736
 
736
 
737
             if content.type != ContentType.Folder:
737
             if content.type != ContentType.Folder:
738
                 self._file_count += 1
738
                 self._file_count += 1
749
 
749
 
750
         for content in children:
750
         for content in children:
751
             if content.is_archived:
751
             if content.is_archived:
752
-                content_path = '%s/%s' % (self.path, transform_to_display(content.get_label()))
752
+                content_path = '%s/%s' % (self.path, transform_to_display(content.get_label_as_file()))
753
 
753
 
754
                 if content.type == ContentType.Folder:
754
                 if content.type == ContentType.Folder:
755
                     members.append(Folder(content_path, self.environ, self.workspace, content))
755
                     members.append(Folder(content_path, self.environ, self.workspace, content))
786
         return "<DAVCollection: HistoryFileFolder (%s)" % self.content.file_name
786
         return "<DAVCollection: HistoryFileFolder (%s)" % self.content.file_name
787
 
787
 
788
     def getDisplayName(self) -> str:
788
     def getDisplayName(self) -> str:
789
-        return self.content.get_label()
789
+        return self.content.get_label_as_file()
790
 
790
 
791
     def createCollection(self, name):
791
     def createCollection(self, name):
792
         raise DAVError(HTTP_FORBIDDEN)
792
         raise DAVError(HTTP_FORBIDDEN)
817
                 content_revision=revision)
817
                 content_revision=revision)
818
         else:
818
         else:
819
             return HistoryOtherFile(
819
             return HistoryOtherFile(
820
-                path='%s%s' % (left_side, transform_to_display(revision.get_label())),
820
+                path='%s%s' % (left_side, transform_to_display(revision.get_label_as_file())),
821
                 environ=self.environ,
821
                 environ=self.environ,
822
                 content=self.content,
822
                 content=self.content,
823
                 content_revision=revision)
823
                 content_revision=revision)
862
         # but i wasn't able to set this property so you'll have to look into it >.>
862
         # but i wasn't able to set this property so you'll have to look into it >.>
863
         # self.setPropertyValue('Win32FileAttributes', '00000021')
863
         # self.setPropertyValue('Win32FileAttributes', '00000021')
864
 
864
 
865
-    def getPreferredPath(self):
866
-        fix_txt = '.txt' if self.getContentType() == 'text/plain' else mimetypes.guess_extension(self.getContentType())
867
-        if not fix_txt:
868
-            fix_txt = ''
869
-        if self.content and self.path and (self.content.label == '' or self.path.endswith(fix_txt)):
870
-            return self.path
871
-        else:
872
-            return self.path + fix_txt
873
-
874
     def __repr__(self) -> str:
865
     def __repr__(self) -> str:
875
         return "<DAVNonCollection: File (%d)>" % self.content.revision_id
866
         return "<DAVNonCollection: File (%d)>" % self.content.revision_id
876
 
867
 
884
         return mktime(self.content.created.timetuple())
875
         return mktime(self.content.created.timetuple())
885
 
876
 
886
     def getDisplayName(self) -> str:
877
     def getDisplayName(self) -> str:
887
-        return self.content.get_label()
878
+        return self.content.file_name
888
 
879
 
889
     def getLastModified(self) -> float:
880
     def getLastModified(self) -> float:
890
         return mktime(self.content.updated.timetuple())
881
         return mktime(self.content.updated.timetuple())
900
         return FakeFileStream(
891
         return FakeFileStream(
901
             content=self.content,
892
             content=self.content,
902
             content_api=self.content_api,
893
             content_api=self.content_api,
903
-            file_name=self.content.get_label(),
894
+            file_name=self.content.get_label_as_file(),
904
             workspace=self.content.workspace,
895
             workspace=self.content.workspace,
905
             path=self.path
896
             path=self.path
906
         )
897
         )
957
 
948
 
958
     def move_file(self, destpath):
949
     def move_file(self, destpath):
959
 
950
 
960
-        workspace = self.provider.get_workspace_from_path(
961
-            normpath(destpath),
962
-            WorkspaceApi(self.user)
963
-        )
964
-
965
-        parent = self.provider.get_parent_from_path(
966
-            normpath(destpath),
967
-            self.content_api,
968
-            workspace
969
-        )
951
+        workspace = self.content.workspace
952
+        parent = self.content.parent
970
 
953
 
971
         with new_revision(self.content):
954
         with new_revision(self.content):
972
             if basename(destpath) != self.getDisplayName():
955
             if basename(destpath) != self.getDisplayName():
956
+                new_given_file_name = transform_to_bdd(basename(destpath))
957
+                new_file_name, new_file_extension = \
958
+                    os.path.splitext(new_given_file_name)
959
+
973
                 self.content_api.update_content(
960
                 self.content_api.update_content(
974
                     self.content,
961
                     self.content,
975
-                    transform_to_bdd(basename(destpath)),
962
+                    new_file_name,
976
                 )
963
                 )
964
+                self.content.file_extension = new_file_extension
977
                 self.content_api.save(self.content)
965
                 self.content_api.save(self.content)
978
             else:
966
             else:
967
+                workspace_api = WorkspaceApi(self.user)
968
+                content_api = ContentApi(self.user)
969
+
970
+                destination_workspace = self.provider.get_workspace_from_path(
971
+                    destpath,
972
+                    workspace_api,
973
+                )
974
+
975
+                destination_parent = self.provider.get_parent_from_path(
976
+                    destpath,
977
+                    content_api,
978
+                    workspace,
979
+                )
980
+
979
                 self.content_api.move(
981
                 self.content_api.move(
980
                     item=self.content,
982
                     item=self.content,
981
-                    new_parent=parent,
983
+                    new_parent=destination_parent,
982
                     must_stay_in_same_workspace=False,
984
                     must_stay_in_same_workspace=False,
983
-                    new_workspace=workspace
985
+                    new_workspace=destination_workspace
984
                 )
986
                 )
985
 
987
 
986
         transaction.commit()
988
         transaction.commit()
1048
             self.path += '.html'
1050
             self.path += '.html'
1049
 
1051
 
1050
     def getDisplayName(self) -> str:
1052
     def getDisplayName(self) -> str:
1051
-        return self.content.get_label()
1053
+        return self.content.get_label_as_file()
1052
 
1054
 
1053
     def getPreferredPath(self):
1055
     def getPreferredPath(self):
1054
         return self.path
1056
         return self.path
1094
 
1096
 
1095
     def getDisplayName(self) -> str:
1097
     def getDisplayName(self) -> str:
1096
         left_side = '(%d - %s) ' % (self.content_revision.revision_id, self.content_revision.revision_type)
1098
         left_side = '(%d - %s) ' % (self.content_revision.revision_id, self.content_revision.revision_type)
1097
-        return '%s%s' % (left_side, transform_to_display(self.content_revision.get_label()))
1099
+        return '%s%s' % (left_side, transform_to_display(self.content_revision.get_label_as_file()))
1098
 
1100
 
1099
     def getContent(self):
1101
     def getContent(self):
1100
         filestream = compat.BytesIO()
1102
         filestream = compat.BytesIO()

+ 4 - 0
tracim/tracim/lib/workspace.py 查看文件

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 import transaction
2
 import transaction
3
+from sqlalchemy.orm import Query
3
 
4
 
4
 from tracim.lib.userworkspace import RoleApi
5
 from tracim.lib.userworkspace import RoleApi
5
 from tracim.model.auth import Group
6
 from tracim.model.auth import Group
142
                 related_object_id=workspace.workspace_id,
143
                 related_object_id=workspace.workspace_id,
143
             )
144
             )
144
 
145
 
146
+    def get_base_query(self) -> Query:
147
+        return self._base_query()
148
+
145
 
149
 
146
 class UnsafeWorkspaceApi(WorkspaceApi):
150
 class UnsafeWorkspaceApi(WorkspaceApi):
147
     def _base_query(self):
151
     def _base_query(self):

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

31
 
31
 
32
     @classmethod
32
     @classmethod
33
     def remove_from_updatable(cls, revision: 'ContentRevisionRO') -> None:
33
     def remove_from_updatable(cls, revision: 'ContentRevisionRO') -> None:
34
-        cls._updatable_revisions.remove(revision)
34
+        if revision in cls._updatable_revisions:
35
+            cls._updatable_revisions.remove(revision)
35
 
36
 
36
     @classmethod
37
     @classmethod
37
     def is_updatable(cls, revision: 'ContentRevisionRO') -> bool:
38
     def is_updatable(cls, revision: 'ContentRevisionRO') -> bool:

+ 88 - 11
tracim/tracim/model/data.py 查看文件

2
 
2
 
3
 import datetime as datetime_root
3
 import datetime as datetime_root
4
 import json
4
 import json
5
+import os
5
 from datetime import datetime
6
 from datetime import datetime
6
 
7
 
7
 import tg
8
 import tg
63
     revisions = relationship("ContentRevisionRO")
64
     revisions = relationship("ContentRevisionRO")
64
 
65
 
65
     @hybrid_property
66
     @hybrid_property
66
-    def contents(self):
67
+    def contents(self) -> ['Content']:
67
         # Return a list of unique revisions parent content
68
         # Return a list of unique revisions parent content
68
-        return list(set([revision.node for revision in self.revisions]))
69
+        contents = []
70
+        for revision in self.revisions:
71
+            # TODO BS 20161209: This ``revision.node.workspace`` make a lot
72
+            # of SQL queries !
73
+            if revision.node.workspace == self and revision.node not in contents:
74
+                contents.append(revision.node)
75
+
76
+        return contents
69
 
77
 
70
     @property
78
     @property
71
     def calendar_url(self) -> str:
79
     def calendar_url(self) -> str:
522
 
530
 
523
     label = Column(Unicode(1024), unique=False, nullable=False)
531
     label = Column(Unicode(1024), unique=False, nullable=False)
524
     description = Column(Text(), unique=False, nullable=False, default='')
532
     description = Column(Text(), unique=False, nullable=False, default='')
525
-    file_name = Column(Unicode(255),  unique=False, nullable=False, default='')
533
+    file_extension = Column(
534
+        Unicode(255),
535
+        unique=False,
536
+        nullable=False,
537
+        server_default='',
538
+    )
526
     file_mimetype = Column(Unicode(255),  unique=False, nullable=False, default='')
539
     file_mimetype = Column(Unicode(255),  unique=False, nullable=False, default='')
527
     file_content = deferred(Column(LargeBinary(), unique=False, nullable=True))
540
     file_content = deferred(Column(LargeBinary(), unique=False, nullable=True))
528
     properties = Column('properties', Text(), unique=False, nullable=False, default='')
541
     properties = Column('properties', Text(), unique=False, nullable=False, default='')
547
 
560
 
548
     """ List of column copied when make a new revision from another """
561
     """ List of column copied when make a new revision from another """
549
     _cloned_columns = (
562
     _cloned_columns = (
550
-        'content_id', 'created', 'description', 'file_content', 'file_mimetype', 'file_name', 'is_archived',
551
-        'is_deleted', 'label', 'node', 'owner', 'owner_id', 'parent', 'parent_id', 'properties', 'revision_type',
552
-        'status', 'type', 'updated', 'workspace', 'workspace_id', 'is_temporary',
563
+        'content_id',
564
+        'created',
565
+        'description',
566
+        'file_content',
567
+        'file_mimetype',
568
+        'file_extension',
569
+        'is_archived',
570
+        'is_deleted',
571
+        'label',
572
+        'node',
573
+        'owner',
574
+        'owner_id',
575
+        'parent',
576
+        'parent_id',
577
+        'properties',
578
+        'revision_type',
579
+        'status',
580
+        'type',
581
+        'updated',
582
+        'workspace',
583
+        'workspace_id',
584
+        'is_temporary',
553
     )
585
     )
554
 
586
 
555
     # Read by must be used like this:
587
     # Read by must be used like this:
562
             RevisionReadStatus(user=k, view_datetime=v)
594
             RevisionReadStatus(user=k, view_datetime=v)
563
     )
595
     )
564
 
596
 
597
+    @property
598
+    def file_name(self):
599
+        return '{0}{1}'.format(
600
+            self.label,
601
+            self.file_extension,
602
+        )
603
+
565
     @classmethod
604
     @classmethod
566
     def new_from(cls, revision: 'ContentRevisionRO') -> 'ContentRevisionRO':
605
     def new_from(cls, revision: 'ContentRevisionRO') -> 'ContentRevisionRO':
567
         """
606
         """
607
         return ContentStatus(self.status)
646
         return ContentStatus(self.status)
608
 
647
 
609
     def get_label(self) -> str:
648
     def get_label(self) -> str:
610
-        return self.label if self.label else self.file_name if self.file_name else ''
649
+        return self.label or self.file_name or ''
611
 
650
 
612
     def get_last_action(self) -> ActionDescription:
651
     def get_last_action(self) -> ActionDescription:
613
         return ActionDescription(self.revision_type)
652
         return ActionDescription(self.revision_type)
626
 
665
 
627
         return False
666
         return False
628
 
667
 
668
+    def get_label_as_file(self):
669
+        file_extension = self.file_extension or ''
670
+
671
+        if self.type == ContentType.Thread:
672
+            file_extension = '.html'
673
+        elif self.type == ContentType.Page:
674
+            file_extension = '.html'
675
+
676
+        return '{0}{1}'.format(
677
+            self.label,
678
+            file_extension,
679
+        )
680
+
681
+
629
 Index('idx__content_revisions__owner_id', ContentRevisionRO.owner_id)
682
 Index('idx__content_revisions__owner_id', ContentRevisionRO.owner_id)
630
 Index('idx__content_revisions__parent_id', ContentRevisionRO.parent_id)
683
 Index('idx__content_revisions__parent_id', ContentRevisionRO.parent_id)
631
 
684
 
734
 
787
 
735
     @hybrid_property
788
     @hybrid_property
736
     def file_name(self) -> str:
789
     def file_name(self) -> str:
737
-        return self.revision.file_name
790
+        return '{0}{1}'.format(
791
+            self.revision.label,
792
+            self.revision.file_extension,
793
+        )
738
 
794
 
739
     @file_name.setter
795
     @file_name.setter
740
     def file_name(self, value: str) -> None:
796
     def file_name(self, value: str) -> None:
741
-        self.revision.file_name = value
797
+        file_name, file_extension = os.path.splitext(value)
798
+        if not self.revision.label:
799
+            self.revision.label = file_name
800
+        self.revision.file_extension = file_extension
742
 
801
 
743
     @file_name.expression
802
     @file_name.expression
744
     def file_name(cls) -> InstrumentedAttribute:
803
     def file_name(cls) -> InstrumentedAttribute:
745
-        return ContentRevisionRO.file_name
804
+        return ContentRevisionRO.file_name + ContentRevisionRO.file_extension
805
+
806
+    @hybrid_property
807
+    def file_extension(self) -> str:
808
+        return self.revision.file_extension
809
+
810
+    @file_extension.setter
811
+    def file_extension(self, value: str) -> None:
812
+        self.revision.file_extension = value
813
+
814
+    @file_extension.expression
815
+    def file_extension(cls) -> InstrumentedAttribute:
816
+        return ContentRevisionRO.file_extension
746
 
817
 
747
     @hybrid_property
818
     @hybrid_property
748
     def file_mimetype(self) -> str:
819
     def file_mimetype(self) -> str:
1042
         return child_nb
1113
         return child_nb
1043
 
1114
 
1044
     def get_label(self):
1115
     def get_label(self):
1045
-        return self.label if self.label else self.file_name if self.file_name else ''
1116
+        return self.label or self.file_name or ''
1117
+
1118
+    def get_label_as_file(self) -> str:
1119
+        """
1120
+        :return: Return content label in file representation context
1121
+        """
1122
+        return self.revision.get_label_as_file()
1046
 
1123
 
1047
     def get_status(self) -> ContentStatus:
1124
     def get_status(self) -> ContentStatus:
1048
         return ContentStatus(self.status, self.type.__str__())
1125
         return ContentStatus(self.status, self.type.__str__())

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

262
 def serialize_version_for_page_or_file(version: ContentRevisionRO, context: Context):
262
 def serialize_version_for_page_or_file(version: ContentRevisionRO, context: Context):
263
     return DictLikeClass(
263
     return DictLikeClass(
264
         id = version.revision_id,
264
         id = version.revision_id,
265
-        label = version.label if version.label else version.file_name,
265
+        label = version.label,
266
         owner = context.toDict(version.owner),
266
         owner = context.toDict(version.owner),
267
         created = version.created,
267
         created = version.created,
268
         action = context.toDict(version.get_last_action())
268
         action = context.toDict(version.get_last_action())
285
 
285
 
286
     result = DictLikeClass(
286
     result = DictLikeClass(
287
         id = content.content_id,
287
         id = content.content_id,
288
-        label = content.label if content.label else content.file_name,
288
+        label = content.label,
289
         icon = ContentType.get_icon(content.type),
289
         icon = ContentType.get_icon(content.type),
290
         status = context.toDict(content.get_status()),
290
         status = context.toDict(content.get_status()),
291
         folder = context.toDict(DictLikeClass(id = content.parent.content_id if content.parent else None)),
291
         folder = context.toDict(DictLikeClass(id = content.parent.content_id if content.parent else None)),
341
     if content.type==ContentType.File:
341
     if content.type==ContentType.File:
342
         result = DictLikeClass(
342
         result = DictLikeClass(
343
             id = content.content_id,
343
             id = content.content_id,
344
-            label = content.label if content.label else content.file_name,
344
+            label = content.label,
345
             status = context.toDict(content.get_status()),
345
             status = context.toDict(content.get_status()),
346
             folder = Context(CTX.DEFAULT).toDict(content.parent)
346
             folder = Context(CTX.DEFAULT).toDict(content.parent)
347
         )
347
         )
396
         )
396
         )
397
 
397
 
398
         if content.type==ContentType.File:
398
         if content.type==ContentType.File:
399
-            result.label = content.label if content.label else content.file_name
399
+            result.label = content.label
400
             result['file'] = DictLikeClass(
400
             result['file'] = DictLikeClass(
401
                 name = data_container.file_name,
401
                 name = data_container.file_name,
402
                 size = len(data_container.file_content),
402
                 size = len(data_container.file_content),
763
         )
763
         )
764
 
764
 
765
         if content.type==ContentType.File:
765
         if content.type==ContentType.File:
766
-            result.label = content.label.__str__() if content.label else content.file_name.__str__()
766
+            result.label = content.label.__str__()
767
 
767
 
768
         if not result.label or ''==result.label:
768
         if not result.label or ''==result.label:
769
             result.label = 'No title'
769
             result.label = 'No title'

+ 8 - 0
tracim/tracim/public/assets/css/dashboard.css 查看文件

382
 .no-padding {
382
 .no-padding {
383
     padding: 0px;
383
     padding: 0px;
384
 }
384
 }
385
+.threads-history-reverse {
386
+  float: right;
387
+  padding: 10px 15px 0 0;
388
+  font-size: 16px;
389
+}
390
+.threads-history-reverse > i {
391
+  font-size: 12px;
392
+}

+ 10 - 1
tracim/tracim/public/assets/js/main.js 查看文件

2
 
2
 
3
   // add select2 for admin/workspace/<id> for user selection
3
   // add select2 for admin/workspace/<id> for user selection
4
   if ($('#add-role-from-existing-user-form').length > 0) {
4
   if ($('#add-role-from-existing-user-form').length > 0) {
5
-    $('#user_id').select2();
5
+    $('#user_id').select2({
6
+      "language": {
7
+        "noResults": function () {
8
+          return "Aucun résultats"
9
+        }
10
+      },
11
+      escapeMarkup: function (markup) {
12
+        return markup
13
+      }
14
+    })
6
   }
15
   }
7
 })
16
 })

+ 1 - 1
tracim/tracim/templates/admin/workspace_getone.mak 查看文件

75
                                     <form role="form" method="POST" action="${tg.url('/admin/workspaces/{}/roles'.format(result.workspace.id))}">
75
                                     <form role="form" method="POST" action="${tg.url('/admin/workspaces/{}/roles'.format(result.workspace.id))}">
76
                                         <div class="form-group">
76
                                         <div class="form-group">
77
                                             <label for="user_id">${_('User')}</label>
77
                                             <label for="user_id">${_('User')}</label>
78
-                                            <select name="user_id" id="user_id" class="form-control">
78
+                                            <select name="user_id" id="user_id" class="form-control" style="width:100%">
79
                                                 % for user in fake_api.users:
79
                                                 % for user in fake_api.users:
80
                                                     % if user.id not in (user.id for user in result.workspace.members):
80
                                                     % if user.id not in (user.id for user in result.workspace.members):
81
                                                         <option value="${user.id}">${user.name}</option>
81
                                                         <option value="${user.id}">${user.name}</option>

+ 1 - 0
tracim/tracim/templates/errors/__init__.py 查看文件

1
+# -*- coding: utf-8 -*-

+ 18 - 0
tracim/tracim/templates/errors/label_invalid_path.mak 查看文件

1
+<%inherit file="local:templates.master_authenticated"/>
2
+
3
+<div class="row">
4
+    <div class="col-sm-7 col-sm-offset-3 main">
5
+        <p>
6
+            ${_('Chosen label "{0}" is invalid because is in conflict with other resource.').format(invalid_label)}
7
+        </p>
8
+
9
+        <button onclick="goBack()">${_('Go Back')}</button>
10
+
11
+        <script>
12
+        function goBack() {
13
+            window.history.back();
14
+        }
15
+        </script>
16
+
17
+    </div>
18
+</div>

+ 4 - 4
tracim/tracim/templates/file/toolbar.mak 查看文件

11
 
11
 
12
     <% download_url = tg.url('/workspaces/{}/folders/{}/files/{}/download?revision_id={}'.format(result.file.workspace.id, result.file.parent.id,result.file.id,result.file.selected_revision)) %>
12
     <% download_url = tg.url('/workspaces/{}/folders/{}/files/{}/download?revision_id={}'.format(result.file.workspace.id, result.file.parent.id,result.file.id,result.file.selected_revision)) %>
13
     <% edit_disabled = ('', 'disabled')[file.selected_revision!='latest' or file.status.id[:6]=='closed'] %>
13
     <% edit_disabled = ('', 'disabled')[file.selected_revision!='latest' or file.status.id[:6]=='closed'] %>
14
-    <% delete_or_archive_disabled = ('', 'disabled')[file.selected_revision!='latest'] %> 
14
+    <% delete_or_archive_disabled = ('', 'disabled')[file.selected_revision!='latest'] %>
15
     % if h.user_role(user, workspace)>1:
15
     % if h.user_role(user, workspace)>1:
16
         <div class="btn-group btn-group-vertical">
16
         <div class="btn-group btn-group-vertical">
17
             <a title="${_('Edit current file')}" class="btn btn-default ${edit_disabled}" data-toggle="modal" data-target="#file-edit-modal-dialog" data-remote="${tg.url('/workspaces/{}/folders/{}/files/{}/edit'.format(file.workspace.id, file.parent.id, file.id))}" >${ICON.FA_FW('fa fa-edit t-less-visible')} ${_('Edit')}</a>
17
             <a title="${_('Edit current file')}" class="btn btn-default ${edit_disabled}" data-toggle="modal" data-target="#file-edit-modal-dialog" data-remote="${tg.url('/workspaces/{}/folders/{}/files/{}/edit'.format(file.workspace.id, file.parent.id, file.id))}" >${ICON.FA_FW('fa fa-edit t-less-visible')} ${_('Edit')}</a>
18
         </div>
18
         </div>
19
         <p></p>
19
         <p></p>
20
     % endif
20
     % endif
21
-    
21
+
22
     <div class="btn-group btn-group-vertical">
22
     <div class="btn-group btn-group-vertical">
23
         <a href="${download_url}" role="button" class="btn btn-default" data-toggle="modal" title="${_('Download the file')}">${ICON.FA('fa fa-download t-less-visible')} ${_('Download')}</a>
23
         <a href="${download_url}" role="button" class="btn btn-default" data-toggle="modal" title="${_('Download the file')}">${ICON.FA('fa fa-download t-less-visible')} ${_('Download')}</a>
24
         <a href="#file-versions" role="button" class="btn btn-default" data-toggle="modal" title="${_('View versions of the file')}">${ICON.FA('fa fa-history t-less-visible')} ${_('Revisions')}</a>
24
         <a href="#file-versions" role="button" class="btn btn-default" data-toggle="modal" title="${_('View versions of the file')}">${ICON.FA('fa fa-history t-less-visible')} ${_('Revisions')}</a>
25
 ## RESTORE LINKS IF REQUIRED        <a href="#file-associated-links" role="button" class="btn btn-default" data-toggle="modal" title="${_('View all links')}">${TIM.ICO(32, 'apps/internet-web-browser')}</a>
25
 ## RESTORE LINKS IF REQUIRED        <a href="#file-associated-links" role="button" class="btn btn-default" data-toggle="modal" title="${_('View all links')}">${TIM.ICO(32, 'apps/internet-web-browser')}</a>
26
     </div>
26
     </div>
27
     <p></p>
27
     <p></p>
28
-    
28
+
29
     % if user.profile.id>=3 or h.user_role(user, workspace)>=4:
29
     % if user.profile.id>=3 or h.user_role(user, workspace)>=4:
30
         ## if the user can see the toolbar, it means he is the workspace manager.
30
         ## if the user can see the toolbar, it means he is the workspace manager.
31
         ## So now, we need to know if he alsa has right to delete workspaces
31
         ## So now, we need to know if he alsa has right to delete workspaces
32
         <div class="btn-group btn-group-vertical">
32
         <div class="btn-group btn-group-vertical">
33
             ## SHOW_ARCHIVE_BUTTON__BUG_#81
33
             ## SHOW_ARCHIVE_BUTTON__BUG_#81
34
             <a title="${_('Archive file')}" class="btn btn-default ${delete_or_archive_disabled}" href="${tg.url('/workspaces/{}/folders/{}/files/{}/put_archive'.format(file.workspace.id, file.parent.id, file.id))}">
34
             <a title="${_('Archive file')}" class="btn btn-default ${delete_or_archive_disabled}" href="${tg.url('/workspaces/{}/folders/{}/files/{}/put_archive'.format(file.workspace.id, file.parent.id, file.id))}">
35
-                ${ICON.FA_FW('fa fa-archive t-less-visible')} ${_('Archive')}test
35
+                ${ICON.FA_FW('fa fa-archive t-less-visible')} ${_('Archive')}
36
             </a>
36
             </a>
37
             <a title="${_('Delete file')}" class="btn btn-default ${delete_or_archive_disabled}" href="${tg.url('/workspaces/{}/folders/{}/files/{}/put_delete'.format(file.workspace.id, file.parent.id, file.id))}">
37
             <a title="${_('Delete file')}" class="btn btn-default ${delete_or_archive_disabled}" href="${tg.url('/workspaces/{}/folders/{}/files/{}/put_delete'.format(file.workspace.id, file.parent.id, file.id))}">
38
                 ${ICON.FA_FW('fa fa-trash t-less-visible')} ${_('Delete')}
38
                 ${ICON.FA_FW('fa fa-trash t-less-visible')} ${_('Delete')}

+ 1 - 3
tracim/tracim/templates/master_authenticated.mak 查看文件

115
                     <ul class="nav navbar-nav navbar-left">
115
                     <ul class="nav navbar-nav navbar-left">
116
                         <li class="active"><a href="${tg.url('/home')}">${TIM.FA('fa-home fa-lg')} ${_('My Home')}</a></li>
116
                         <li class="active"><a href="${tg.url('/home')}">${TIM.FA('fa-home fa-lg')} ${_('My Home')}</a></li>
117
                         <li class=""><a href="${tg.url('/calendar')}">${TIM.FA('fa-calendar')} ${_('Calendar')}</a></li>
117
                         <li class=""><a href="${tg.url('/calendar')}">${TIM.FA('fa-calendar')} ${_('Calendar')}</a></li>
118
-                        % if fake_api.current_user.profile.id>2:
119
-                            ${NAVBAR_MENU.ADMIN_ITEMS()}
120
-                        % endif
118
+                        ${NAVBAR_MENU.ADMIN_ITEMS()}
121
                     </ul>
119
                     </ul>
122
                 % endif
120
                 % endif
123
 
121
 

+ 56 - 2
tracim/tracim/templates/pod.mak 查看文件

30
 
30
 
31
 <%def name="TINYMCE_INIT_SCRIPT(selector)">
31
 <%def name="TINYMCE_INIT_SCRIPT(selector)">
32
     <script>
32
     <script>
33
+        function base64EncodeAndTinyMceInsert (files) {
34
+          for (var i = 0; i < files.length; i++) {
35
+            if (files[i].size > 1000000)
36
+              files[i].allowed = confirm(files[i].name + " fait plus de 1mo et peut prendre du temps à insérer, voulez-vous continuer ?")
37
+          }
38
+
39
+          for (var i = 0; i < files.length; i++) {
40
+            if (files[i].allowed !== false && files[i].type.match('image.*')) {
41
+              var img = document.createElement('img')
42
+
43
+              var fr = new FileReader()
44
+
45
+              fr.readAsDataURL(files[i])
46
+
47
+              fr.onloadend = function (e) {
48
+                img.src = e.target.result
49
+                tinymce.activeEditor.execCommand('mceInsertContent', false, img.outerHTML)
50
+              }
51
+            }
52
+          }
53
+        }
54
+
33
         tinymce.init({
55
         tinymce.init({
34
             menubar:false,
56
             menubar:false,
35
             statusbar:true,
57
             statusbar:true,
38
             skin : 'tracim',
60
             skin : 'tracim',
39
             selector:'${selector}',
61
             selector:'${selector}',
40
             toolbar: [
62
             toolbar: [
41
-              "undo redo | bold italic underline strikethrough | bullist numlist outdent indent | table | charmap | styleselect | alignleft aligncenter alignright | fullscreen",
63
+              "undo redo | bold italic underline strikethrough | bullist numlist outdent indent | table | charmap | styleselect | alignleft aligncenter alignright | fullscreen | customInsertImage",
42
             ],
64
             ],
43
             paste_data_images: true,
65
             paste_data_images: true,
44
             table_default_attributes: {
66
             table_default_attributes: {
54
                 {title: 'Normal', value: 'user_content'},
76
                 {title: 'Normal', value: 'user_content'},
55
                 {title: 'First row is header', value: 'user_content first_row_headers'},
77
                 {title: 'First row is header', value: 'user_content first_row_headers'},
56
                 {title: 'First column is header', value: 'user_content first_column_headers'}
78
                 {title: 'First column is header', value: 'user_content first_column_headers'}
57
-            ]
79
+            ],
80
+            setup: function ($editor) {
81
+              //////////////////////////////////////////////
82
+              // add custom btn to handle image by selecting them with system explorer
83
+              $editor.addButton('customInsertImage', {
84
+                text: 'Image',
85
+                icon: false,
86
+                onclick: function () {
87
+                  if ($('#hidden_tinymce_fileinput').length > 0) $('#hidden_tinymce_fileinput').remove()
88
+
89
+                  fileTag = document.createElement('input')
90
+                  fileTag.id = 'hidden_tinymce_fileinput'
91
+                  fileTag.type = 'file'
92
+                  $('body').append(fileTag)
93
+
94
+                  $('#hidden_tinymce_fileinput').on('change', function () {
95
+                    base64EncodeAndTinyMceInsert($(this)[0].files)
96
+                  })
97
+
98
+                  $('#hidden_tinymce_fileinput').click()
99
+                }
100
+              })
58
 
101
 
102
+              //////////////////////////////////////////////
103
+              // Handle drag & drop image into TinyMce by encoding them in base64 (to avoid uploading them somewhere and keep saving comment in string format)
104
+              $editor
105
+              .on('drag dragstart dragend dragover dragenter dragleave drop', function (e) {
106
+                e.preventDefault()
107
+                e.stopPropagation()
108
+              })
109
+              .on('drop', function(e) {
110
+                base64EncodeAndTinyMceInsert(e.dataTransfer.files)
111
+              })
112
+            }
59
         });
113
         });
60
     </script>
114
     </script>
61
 </%def>
115
 </%def>

+ 12 - 0
tracim/tracim/templates/thread/getone.mak 查看文件

85
 
85
 
86
 <div class="row">
86
 <div class="row">
87
     <div class="col-sm-7 col-sm-offset-3">
87
     <div class="col-sm-7 col-sm-offset-3">
88
+        <div class="threads-history-reverse">
89
+        % if inverted:
90
+            <a href="${tg.url('/workspaces/{}/folders/{}/threads/{}'.format(result.thread.workspace.id, result.thread.parent.id, result.thread.id))}">
91
+                <i class="fa fa-chevron-down" aria-hidden="true"></i>
92
+        % else:
93
+            <a href="${tg.url('/workspaces/{}/folders/{}/threads/{}?inverted=1'.format(result.thread.workspace.id, result.thread.parent.id, result.thread.id))}">
94
+                <i class="fa fa-chevron-up" aria-hidden="true"></i>
95
+        % endif
96
+                ${_('Invert order')}
97
+            </a>
98
+        </div>
88
         % if h.user_role(fake_api.current_user, result.thread.workspace)<=1:
99
         % if h.user_role(fake_api.current_user, result.thread.workspace)<=1:
89
             ## READONLY USER
100
             ## READONLY USER
90
             <% a = 'b' %>
101
             <% a = 'b' %>
101
     </div>
112
     </div>
102
 </div>
113
 </div>
103
 
114
 
115
+<!-- % for event in reversed(result.thread.history): -->
104
 % for event in result.thread.history:
116
 % for event in result.thread.history:
105
     ## TODO - D.A. - 2015-08-20
117
     ## TODO - D.A. - 2015-08-20
106
     ## Allow to show full history (with status change and archive/unarchive)
118
     ## Allow to show full history (with status change and archive/unarchive)

+ 9 - 5
tracim/tracim/templates/widgets/navbar_menu.mak 查看文件

1
 <%namespace name="ICON" file="tracim.templates.widgets.icon"/>
1
 <%namespace name="ICON" file="tracim.templates.widgets.icon"/>
2
 
2
 
3
 <%def name="ADMIN_ITEMS()">
3
 <%def name="ADMIN_ITEMS()">
4
+    % if fake_api.current_user.profile.id>=2:
4
     <li class="">
5
     <li class="">
5
         <a href="${tg.url('/admin/workspaces')}">
6
         <a href="${tg.url('/admin/workspaces')}">
6
             ${ICON.FA('fa-bank fa-fw')} ${_('Workspaces')}
7
             ${ICON.FA('fa-bank fa-fw')} ${_('Workspaces')}
7
         </a>
8
         </a>
8
     </li>
9
     </li>
9
-    <li class="">
10
-        <a href="${tg.url('/admin/users')}">
11
-            ${ICON.FA('fa-user fa-fw')} ${_('Users')}
12
-        </a>
13
-    </li>
10
+    % endif
11
+    % if fake_api.current_user.profile.id>2:
12
+        <li class="">
13
+            <a href="${tg.url('/admin/users')}">
14
+                ${ICON.FA('fa-user fa-fw')} ${_('Users')}
15
+            </a>
16
+        </li>
17
+    % endif
14
 </%def>
18
 </%def>

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

474
         eq_(u2id, updated.owner_id,
474
         eq_(u2id, updated.owner_id,
475
             'the owner id should be {} (found {})'.format(u2id,
475
             'the owner id should be {} (found {})'.format(u2id,
476
                                                           updated.owner_id))
476
                                                           updated.owner_id))
477
-        eq_('index.html', updated.file_name)
477
+        eq_('this_is_a_page.html', updated.file_name)
478
         eq_('text/html', updated.file_mimetype)
478
         eq_('text/html', updated.file_mimetype)
479
         eq_(b'<html>hello world</html>', updated.file_content)
479
         eq_(b'<html>hello world</html>', updated.file_content)
480
         eq_(ActionDescription.REVISION, updated.revision_type)
480
         eq_(ActionDescription.REVISION, updated.revision_type)

+ 184 - 0
tracim/tracim/tests/library/test_integrity.py 查看文件

1
+# -*- coding: utf-8 -*-
2
+from nose.tools import ok_
3
+
4
+from tracim.fixtures.content import Content as ContentFixtures
5
+from tracim.lib.integrity import PathValidationManager
6
+from tracim.model import DBSession
7
+from tracim.model.data import Workspace
8
+from tracim.model.data import Content
9
+from tracim.model.data import ContentRevisionRO
10
+from tracim.tests import TestStandard
11
+
12
+
13
+class TestWebDav(TestStandard):
14
+    fixtures = [ContentFixtures]
15
+
16
+    def _get_content_by_label(self, label: str) -> Content:
17
+        revision = DBSession.query(ContentRevisionRO) \
18
+            .filter(ContentRevisionRO.label == label) \
19
+            .one()
20
+
21
+        return DBSession.query(Content) \
22
+            .filter(Content.id == revision.content_id) \
23
+            .one()
24
+
25
+    def test_unit__workspace_label_available__ok(self):
26
+        integrity_manager = PathValidationManager()
27
+        ok_(
28
+            integrity_manager.workspace_label_is_free('w42'),
29
+            msg='label w42 should not be used',
30
+        )
31
+
32
+    def test_unit__workspace_label_reserved__ok(self):
33
+        integrity_manager = PathValidationManager()
34
+        ok_(
35
+            not integrity_manager.workspace_label_is_free('w1'),
36
+            msg='label w1 should be reserved',
37
+        )
38
+
39
+    def test_unit__workspace_label_reserved_with_case__ok(self):
40
+        integrity_manager = PathValidationManager()
41
+        ok_(
42
+            not integrity_manager.workspace_label_is_free('W1'),
43
+            msg='label W1 should be reserved',
44
+        )
45
+
46
+    def test_unit__folder_label_available__ok__folder_at_root(self):
47
+        integrity_manager = PathValidationManager()
48
+        w1 = DBSession.query(Workspace).filter(Workspace.label == 'w1').one()
49
+
50
+        ok_(
51
+            integrity_manager.content_label_is_free(
52
+                content_label_as_file='f42',
53
+                workspace=w1,
54
+                parent=None,
55
+            ),
56
+            msg='label f42 should not be used',
57
+        )
58
+
59
+    def test_unit__folder_label_reserved__ok__folder_at_root(self):
60
+        integrity_manager = PathValidationManager()
61
+        w1 = DBSession.query(Workspace).filter(Workspace.label == 'w1').one()
62
+
63
+        ok_(
64
+            not integrity_manager.content_label_is_free(
65
+                content_label_as_file='w1f1',
66
+                workspace=w1,
67
+                parent=None,
68
+            ),
69
+            msg='label w1f1 should be reserved',
70
+        )
71
+
72
+    def test_unit__folder_label_reserved_with_case__ok__folder_at_root(self):
73
+        integrity_manager = PathValidationManager()
74
+        w1 = DBSession.query(Workspace).filter(Workspace.label == 'w1').one()
75
+
76
+        ok_(
77
+            not integrity_manager.content_label_is_free(
78
+                content_label_as_file='W1F1',
79
+                workspace=w1,
80
+                parent=None,
81
+            ),
82
+            msg='label W1F1 should be reserved',
83
+        )
84
+
85
+    def test_unit__folder_label_reserved__ok__folder_in_folder(self):
86
+        integrity_manager = PathValidationManager()
87
+        w1 = DBSession.query(Workspace).filter(Workspace.label == 'w1').one()
88
+        w1f1 = self._get_content_by_label('w1f1')
89
+
90
+        ok_(
91
+            not integrity_manager.content_label_is_free(
92
+                content_label_as_file='w1f1f1',
93
+                workspace=w1,
94
+                parent=w1f1,
95
+            ),
96
+            msg='label w1f1f1 should be reserved',
97
+        )
98
+
99
+    def test_unit__content_label_reserved__ok__because_page_name(self):
100
+        integrity_manager = PathValidationManager()
101
+        w1 = DBSession.query(Workspace).filter(Workspace.label == 'w1').one()
102
+        w1f1 = self._get_content_by_label('w1f1')
103
+
104
+        ok_(
105
+            not integrity_manager.content_label_is_free(
106
+                content_label_as_file='w1f1p1.html',
107
+                workspace=w1,
108
+                parent=w1f1,
109
+            ),
110
+            msg='label w1f1p1.html should be reserved '
111
+                'because page w1f1p1.html',
112
+        )
113
+
114
+    def test_unit__content_label_available__ok(self):
115
+        integrity_manager = PathValidationManager()
116
+        w1 = DBSession.query(Workspace).filter(Workspace.label == 'w1').one()
117
+        w1f1 = self._get_content_by_label('w1f1')
118
+
119
+        ok_(
120
+            integrity_manager.content_label_is_free(
121
+                content_label_as_file='w1f1p42.html',
122
+                workspace=w1,
123
+                parent=w1f1,
124
+            ),
125
+            msg='label w1f1p42.html should be available',
126
+        )
127
+
128
+    def test_unit__content_label_available__ok__without_extension(self):
129
+        integrity_manager = PathValidationManager()
130
+        w1 = DBSession.query(Workspace).filter(Workspace.label == 'w1').one()
131
+        w1f1 = self._get_content_by_label('w1f1')
132
+
133
+        ok_(
134
+            integrity_manager.content_label_is_free(
135
+                content_label_as_file='w1f1p42',
136
+                workspace=w1,
137
+                parent=w1f1,
138
+            ),
139
+            msg='label w1f1p42 should be available',
140
+        )
141
+
142
+    def test_unit__content_label_reserved__ok(self):
143
+        integrity_manager = PathValidationManager()
144
+        w1 = DBSession.query(Workspace).filter(Workspace.label == 'w1').one()
145
+        w1f1 = self._get_content_by_label('w1f1')
146
+
147
+        ok_(
148
+            not integrity_manager.content_label_is_free(
149
+                content_label_as_file='w1f1p1.html',
150
+                workspace=w1,
151
+                parent=w1f1,
152
+            ),
153
+            msg='label w1f1p1.html should be reserved',
154
+        )
155
+
156
+    def test_unit__content_label_reserved__ok__because_thread_extension(self):
157
+        integrity_manager = PathValidationManager()
158
+        w1 = DBSession.query(Workspace).filter(Workspace.label == 'w1').one()
159
+        w1f1 = self._get_content_by_label('w1f1')
160
+
161
+        ok_(
162
+            not integrity_manager.content_label_is_free(
163
+                content_label_as_file='w1f1t1.html',
164
+                workspace=w1,
165
+                parent=w1f1,
166
+            ),
167
+            msg='label w1f1t1 should be reserved because '
168
+                'w1f1t1 rendered with .html',
169
+        )
170
+
171
+    def test_unit__content_label_reserved__ok__because_html_file(self):
172
+        integrity_manager = PathValidationManager()
173
+        w1 = DBSession.query(Workspace).filter(Workspace.label == 'w1').one()
174
+        w1f1 = self._get_content_by_label('w1f1')
175
+
176
+        ok_(
177
+            not integrity_manager.content_label_is_free(
178
+                content_label_as_file='w1f1d2.html',
179
+                workspace=w1,
180
+                parent=w1f1,
181
+            ),
182
+            msg='label w1f1d2.html should be reserved because '
183
+                'w1f1d2 rendered with .html and file w1f1d2.html exist',
184
+        )

+ 452 - 0
tracim/tracim/tests/library/test_webdav.py 查看文件

1
+# -*- coding: utf-8 -*-
2
+import io
3
+from nose.tools import eq_
4
+from nose.tools import ok_
5
+from tracim.lib.webdav.sql_dav_provider import Provider
6
+from tracim.lib.webdav.sql_resources import Root
7
+from tracim.model import Content
8
+from tracim.model import ContentRevisionRO
9
+from tracim.model import DBSession
10
+from tracim.tests import TestStandard
11
+from tracim.fixtures.content import Content as ContentFixtures
12
+from wsgidav import util
13
+
14
+
15
+class TestWebDav(TestStandard):
16
+    fixtures = [ContentFixtures]
17
+
18
+    def _get_provider(self):
19
+        return Provider(
20
+            show_archived=False,
21
+            show_deleted=False,
22
+            show_history=False,
23
+        )
24
+
25
+    def _get_environ(
26
+            self,
27
+            provider: Provider,
28
+            username: str,
29
+    ) -> dict:
30
+        return {
31
+            'http_authenticator.username': username,
32
+            'http_authenticator.realm': '/',
33
+            'wsgidav.provider': provider,
34
+        }
35
+
36
+    def _put_new_text_file(
37
+            self,
38
+            provider,
39
+            environ,
40
+            file_path,
41
+            file_content,
42
+    ):
43
+        # This part id a reproduction of
44
+        # wsgidav.request_server.RequestServer#doPUT
45
+
46
+        # Grab parent folder where create file
47
+        parentRes = provider.getResourceInst(
48
+            util.getUriParent(file_path),
49
+            environ,
50
+        )
51
+        ok_(parentRes, msg='we should found folder for {0}'.format(file_path))
52
+
53
+        new_resource = parentRes.createEmptyResource(
54
+            util.getUriName(file_path),
55
+        )
56
+        write_object = new_resource.beginWrite(
57
+            contentType='application/octet-stream',
58
+        )
59
+        write_object.write(b'hello\n')
60
+        write_object.close()
61
+        new_resource.endWrite(withErrors=False)
62
+
63
+        # Now file should exist
64
+        return provider.getResourceInst(
65
+            file_path,
66
+            environ,
67
+        )
68
+
69
+    def test_unit__get_root__ok(self):
70
+        provider = self._get_provider()
71
+        root = provider.getResourceInst(
72
+            '/',
73
+            self._get_environ(
74
+                provider,
75
+                'bob@fsf.local',
76
+            )
77
+        )
78
+        ok_(root, msg='Path / should return a Root instance')
79
+        ok_(isinstance(root, Root))
80
+
81
+    def test_unit__list_workspaces_with_admin__ok(self):
82
+        provider = self._get_provider()
83
+        root = provider.getResourceInst(
84
+            '/',
85
+            self._get_environ(
86
+                provider,
87
+                'bob@fsf.local',
88
+            )
89
+        )
90
+        ok_(root, msg='Path / should return a Root instance')
91
+        ok_(isinstance(root, Root), msg='Path / should return a Root instance')
92
+
93
+        children = root.getMemberList()
94
+        eq_(
95
+            2,
96
+            len(children),
97
+            msg='Root should return 2 workspaces instead {0}'.format(
98
+                len(children),
99
+            )
100
+        )
101
+
102
+        workspaces_names = [w.name for w in children]
103
+        ok_('w1' in workspaces_names, msg='w1 should be in names ({0})'.format(
104
+            workspaces_names,
105
+        ))
106
+        ok_('w2' in workspaces_names, msg='w2 should be in names ({0})'.format(
107
+            workspaces_names,
108
+        ))
109
+
110
+    def test_unit__list_workspace_folders__ok(self):
111
+        provider = self._get_provider()
112
+        w1 = provider.getResourceInst(
113
+            '/w1/',
114
+            self._get_environ(
115
+                provider,
116
+                'bob@fsf.local',
117
+            )
118
+        )
119
+        ok_(w1, msg='Path /w1 should return a Wrkspace instance')
120
+
121
+        children = w1.getMemberList()
122
+        eq_(
123
+            2,
124
+            len(children),
125
+            msg='w1 should list 2 folders instead {0}'.format(
126
+                len(children),
127
+            ),
128
+        )
129
+
130
+        folders_names = [f.name for f in children]
131
+        ok_(
132
+            'w1f1' in folders_names,
133
+            msg='w1f1 should be in names ({0})'.format(
134
+                folders_names,
135
+            )
136
+        )
137
+        ok_(
138
+            'w1f2' in folders_names,
139
+            msg='w1f2 should be in names ({0})'.format(
140
+                folders_names,
141
+            )
142
+        )
143
+
144
+    def test_unit__list_content__ok(self):
145
+        provider = self._get_provider()
146
+        w1f1 = provider.getResourceInst(
147
+            '/w1/w1f1',
148
+            self._get_environ(
149
+                provider,
150
+                'bob@fsf.local',
151
+            )
152
+        )
153
+        ok_(w1f1, msg='Path /w1f1 should return a Wrkspace instance')
154
+
155
+        children = w1f1.getMemberList()
156
+        eq_(
157
+            5,
158
+            len(children),
159
+            msg='w1f1 should list 5 folders instead {0}'.format(
160
+                len(children),
161
+            ),
162
+        )
163
+
164
+        content_names = [c.name for c in children]
165
+        ok_(
166
+            'w1f1p1.html' in content_names,
167
+            msg='w1f1.html should be in names ({0})'.format(
168
+                content_names,
169
+            )
170
+        )
171
+        ok_(
172
+            'w1f1t1.html' in content_names,
173
+            msg='w1f1t1.html should be in names ({0})'.format(
174
+                content_names,
175
+            )
176
+        )
177
+        ok_(
178
+            'w1f1d1.txt' in content_names,
179
+            msg='w1f1d1.txt should be in names ({0})'.format(
180
+                content_names,
181
+            )
182
+        )
183
+        ok_(
184
+            'w1f1f1' in content_names,
185
+            msg='w1f1f1 should be in names ({0})'.format(
186
+                content_names,
187
+            )
188
+        )
189
+        ok_(
190
+            'w1f1d2.html' in content_names,
191
+            msg='w1f1d2.html should be in names ({0})'.format(
192
+                content_names,
193
+            )
194
+        )
195
+
196
+    def test_unit__get_content__ok(self):
197
+        provider = self._get_provider()
198
+        w1f1d1 = provider.getResourceInst(
199
+            '/w1/w1f1/w1f1d1.txt',
200
+            self._get_environ(
201
+                provider,
202
+                'bob@fsf.local',
203
+            )
204
+        )
205
+
206
+        ok_(w1f1d1, msg='w1f1d1 should be found')
207
+        eq_('w1f1d1.txt', w1f1d1.name)
208
+
209
+    def test_unit__delete_content__ok(self):
210
+        provider = self._get_provider()
211
+        w1f1d1 = provider.getResourceInst(
212
+            '/w1/w1f1/w1f1d1.txt',
213
+            self._get_environ(
214
+                provider,
215
+                'bob@fsf.local',
216
+            )
217
+        )
218
+
219
+        content_w1f1d1 = DBSession.query(ContentRevisionRO) \
220
+            .filter(Content.label == 'w1f1d1') \
221
+            .one()  # It must exist only one revision, cf fixtures
222
+        eq_(
223
+            False,
224
+            content_w1f1d1.is_deleted,
225
+            msg='Content should not be deleted !'
226
+        )
227
+        content_w1f1d1_id = content_w1f1d1.content_id
228
+
229
+        w1f1d1.delete()
230
+
231
+        DBSession.flush()
232
+        content_w1f1d1 = DBSession.query(ContentRevisionRO) \
233
+            .filter(Content.content_id == content_w1f1d1_id) \
234
+            .order_by(Content.revision_id.desc()) \
235
+            .first()
236
+        eq_(
237
+            True,
238
+            content_w1f1d1.is_deleted,
239
+            msg='Content should be deleted !'
240
+        )
241
+
242
+        result = provider.getResourceInst(
243
+            '/w1/w1f1/w1f1d1.txt',
244
+            self._get_environ(
245
+                provider,
246
+                'bob@fsf.local',
247
+            )
248
+        )
249
+        eq_(None, result, msg='Result should be None instead {0}'.format(
250
+            result
251
+        ))
252
+
253
+    def test_unit__create_content__ok(self):
254
+        provider = self._get_provider()
255
+        environ = self._get_environ(
256
+            provider,
257
+            'bob@fsf.local',
258
+        )
259
+        result = provider.getResourceInst(
260
+            '/w1/w1f1/new_file.txt',
261
+            environ,
262
+        )
263
+
264
+        eq_(None, result, msg='Result should be None instead {0}'.format(
265
+            result
266
+        ))
267
+
268
+        result = self._put_new_text_file(
269
+            provider,
270
+            environ,
271
+            '/w1/w1f1/new_file.txt',
272
+            b'hello\n',
273
+        )
274
+
275
+        ok_(result, msg='Result should not be None instead {0}'.format(
276
+            result
277
+        ))
278
+        eq_(
279
+            b'hello\n',
280
+            result.content.file_content,
281
+            msg='fiel content should be "hello\n" but it is {0}'.format(
282
+                result.content.file_content
283
+            )
284
+        )
285
+
286
+    def test_unit__create_delete_and_create_file__ok(self):
287
+        provider = self._get_provider()
288
+        environ = self._get_environ(
289
+            provider,
290
+            'bob@fsf.local',
291
+        )
292
+        new_file = provider.getResourceInst(
293
+            '/w1/w1f1/new_file.txt',
294
+            environ,
295
+        )
296
+
297
+        eq_(None, new_file, msg='Result should be None instead {0}'.format(
298
+            new_file
299
+        ))
300
+
301
+        # create it
302
+        new_file = self._put_new_text_file(
303
+            provider,
304
+            environ,
305
+            '/w1/w1f1/new_file.txt',
306
+            b'hello\n',
307
+        )
308
+        ok_(new_file, msg='Result should not be None instead {0}'.format(
309
+            new_file
310
+        ))
311
+
312
+        content_new_file = DBSession.query(ContentRevisionRO) \
313
+            .filter(Content.label == 'new_file') \
314
+            .one()  # It must exist only one revision
315
+        eq_(
316
+            False,
317
+            content_new_file.is_deleted,
318
+            msg='Content should not be deleted !'
319
+        )
320
+        content_new_file_id = content_new_file.content_id
321
+
322
+        # Delete if
323
+        new_file.delete()
324
+
325
+        DBSession.flush()
326
+        content_w1f1d1 = DBSession.query(ContentRevisionRO) \
327
+            .filter(Content.content_id == content_new_file_id) \
328
+            .order_by(Content.revision_id.desc()) \
329
+            .first()
330
+        eq_(
331
+            True,
332
+            content_w1f1d1.is_deleted,
333
+            msg='Content should be deleted !'
334
+        )
335
+
336
+        result = provider.getResourceInst(
337
+            '/w1/w1f1/new_file.txt',
338
+            self._get_environ(
339
+                provider,
340
+                'bob@fsf.local',
341
+            )
342
+        )
343
+        eq_(None, result, msg='Result should be None instead {0}'.format(
344
+            result
345
+        ))
346
+
347
+        # Then create it again
348
+        new_file = self._put_new_text_file(
349
+            provider,
350
+            environ,
351
+            '/w1/w1f1/new_file.txt',
352
+            b'hello\n',
353
+        )
354
+        ok_(new_file, msg='Result should not be None instead {0}'.format(
355
+            new_file
356
+        ))
357
+
358
+        # Previous file is still dleeted
359
+        DBSession.flush()
360
+        content_w1f1d1 = DBSession.query(ContentRevisionRO) \
361
+            .filter(Content.content_id == content_new_file_id) \
362
+            .order_by(Content.revision_id.desc()) \
363
+            .first()
364
+        eq_(
365
+            True,
366
+            content_w1f1d1.is_deleted,
367
+            msg='Content should be deleted !'
368
+        )
369
+
370
+        # And an other file exist for this name
371
+        content_new_new_file = DBSession.query(ContentRevisionRO) \
372
+            .filter(Content.label == 'new_file') \
373
+            .order_by(Content.revision_id.desc()) \
374
+            .first()
375
+        ok_(
376
+            content_new_new_file.content_id != content_new_file_id,
377
+            msg='Contents ids should not be same !'
378
+        )
379
+        eq_(
380
+            False,
381
+            content_new_new_file.is_deleted,
382
+            msg='Content should not be deleted !'
383
+        )
384
+
385
+    def test_unit__rename_content__ok(self):
386
+        provider = self._get_provider()
387
+        environ = self._get_environ(
388
+            provider,
389
+            'bob@fsf.local',
390
+        )
391
+        w1f1d1 = provider.getResourceInst(
392
+            '/w1/w1f1/w1f1d1.txt',
393
+            environ,
394
+        )
395
+
396
+        content_w1f1d1 = DBSession.query(ContentRevisionRO) \
397
+            .filter(Content.label == 'w1f1d1') \
398
+            .one()  # It must exist only one revision, cf fixtures
399
+        ok_(content_w1f1d1, msg='w1f1d1 should be exist')
400
+        content_w1f1d1_id = content_w1f1d1.content_id
401
+
402
+        w1f1d1.moveRecursive('/w1/w1f1/w1f1d1_RENAMED.txt')
403
+
404
+        # Database content is renamed
405
+        content_w1f1d1 = DBSession.query(ContentRevisionRO) \
406
+            .filter(ContentRevisionRO.content_id == content_w1f1d1_id) \
407
+            .order_by(ContentRevisionRO.revision_id.desc()) \
408
+            .first()
409
+        eq_(
410
+            'w1f1d1_RENAMED',
411
+            content_w1f1d1.label,
412
+            msg='File should be labeled w1f1d1_RENAMED, not {0}'.format(
413
+                content_w1f1d1.label
414
+            )
415
+        )
416
+
417
+    def test_unit__move_content__ok(self):
418
+        provider = self._get_provider()
419
+        environ = self._get_environ(
420
+            provider,
421
+            'bob@fsf.local',
422
+        )
423
+        w1f1d1 = provider.getResourceInst(
424
+            '/w1/w1f1/w1f1d1.txt',
425
+            environ,
426
+        )
427
+
428
+        content_w1f1d1 = DBSession.query(ContentRevisionRO) \
429
+            .filter(Content.label == 'w1f1d1') \
430
+            .one()  # It must exist only one revision, cf fixtures
431
+        ok_(content_w1f1d1, msg='w1f1d1 should be exist')
432
+        content_w1f1d1_id = content_w1f1d1.content_id
433
+        content_w1f1d1_parent = content_w1f1d1.parent
434
+        eq_(
435
+            content_w1f1d1_parent.label,
436
+            'w1f1',
437
+            msg='field parent should be w1f1',
438
+        )
439
+
440
+        w1f1d1.moveRecursive('/w1/w1f2/w1f1d1.txt')  # move in f2
441
+
442
+        # Database content is moved
443
+        content_w1f1d1 = DBSession.query(ContentRevisionRO) \
444
+            .filter(ContentRevisionRO.content_id == content_w1f1d1_id) \
445
+            .order_by(ContentRevisionRO.revision_id.desc()) \
446
+            .first()
447
+        ok_(
448
+            content_w1f1d1.parent.label != content_w1f1d1_parent.label,
449
+            msg='file should be moved in w1f2 but is in {0}'.format(
450
+                content_w1f1d1.parent.label
451
+            )
452
+        )