瀏覽代碼

Introduce path unicity check

Bastien Sevajol (Algoo) 8 年之前
父節點
當前提交
c2869d2262

+ 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
+                )

+ 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)

+ 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

+ 85 - 26
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
 
771
             parent = None
816
             parent = None
772
             if parent_id:
817
             if parent_id:
773
                 parent = api.get_one(int(parent_id), ContentType.Folder, workspace)
818
                 parent = api.get_one(int(parent_id), ContentType.Folder, workspace)
774
-            folder = api.create(ContentType.Folder, workspace, parent, label)
775
 
819
 
776
-            subcontent = dict(
777
-                folder = True if can_contain_folders=='on' else False,
778
-                thread = True if can_contain_threads=='on' else False,
779
-                file = True if can_contain_files=='on' else False,
780
-                page = True if can_contain_pages=='on' else False
781
-            )
782
-            api.set_allowed_content(folder, subcontent)
820
+            with DBSession.no_autoflush:
821
+                folder = api.create(ContentType.Folder, workspace, parent, label)
822
+
823
+                subcontent = dict(
824
+                    folder = True if can_contain_folders=='on' else False,
825
+                    thread = True if can_contain_threads=='on' else False,
826
+                    file = True if can_contain_files=='on' else False,
827
+                    page = True if can_contain_pages=='on' else False
828
+                )
829
+                api.set_allowed_content(folder, subcontent)
830
+
831
+                if not self._path_validation.validate_new_content(folder):
832
+                    return render_invalid_integrity_chosen_path(
833
+                        folder.get_label(),
834
+                    )
835
+
783
             api.save(folder)
836
             api.save(folder)
784
 
837
 
785
             tg.flash(_('Folder created'), CST.STATUS_OK)
838
             tg.flash(_('Folder created'), CST.STATUS_OK)
825
                     # TODO - D.A. - 2015-05-25 - Allow to set folder description
878
                     # TODO - D.A. - 2015-05-25 - Allow to set folder description
826
                     api.update_content(folder, label, folder.description)
879
                     api.update_content(folder, label, folder.description)
827
                 api.set_allowed_content(folder, subcontent)
880
                 api.set_allowed_content(folder, subcontent)
881
+
882
+                if not self._path_validation.validate_new_content(folder):
883
+                    return render_invalid_integrity_chosen_path(
884
+                        folder.get_label(),
885
+                    )
886
+
828
                 api.save(folder)
887
                 api.save(folder)
829
 
888
 
830
             tg.flash(_('Folder updated'), CST.STATUS_OK)
889
             tg.flash(_('Folder updated'), CST.STATUS_OK)

+ 23 - 6
tracim/tracim/fixtures/content.py 查看文件

42
             with_notif=False,
42
             with_notif=False,
43
         )
43
         )
44
 
44
 
45
+        # Folders
45
         w1f1 = content_api.create(
46
         w1f1 = content_api.create(
46
             content_type=ContentType.Folder,
47
             content_type=ContentType.Folder,
47
             workspace=w1,
48
             workspace=w1,
55
             do_save=True,
56
             do_save=True,
56
         )
57
         )
57
 
58
 
58
-        # Folders
59
         w2f1 = content_api.create(
59
         w2f1 = content_api.create(
60
             content_type=ContentType.Folder,
60
             content_type=ContentType.Folder,
61
             workspace=w2,
61
             workspace=w2,
62
-            label='w1f1',
62
+            label='w2f1',
63
             do_save=True,
63
             do_save=True,
64
         )
64
         )
65
         w2f2 = content_api.create(
65
         w2f2 = content_api.create(
93
         )
93
         )
94
         w1f1t1.description = 'w1f1t1 description'
94
         w1f1t1.description = 'w1f1t1 description'
95
         self._session.add(w1f1t1)
95
         self._session.add(w1f1t1)
96
-        w1f1d1 = content_api.create(
96
+        w1f1d1_txt = content_api.create(
97
             content_type=ContentType.File,
97
             content_type=ContentType.File,
98
             workspace=w1,
98
             workspace=w1,
99
             parent=w1f1,
99
             parent=w1f1,
100
             label='w1f1d1',
100
             label='w1f1d1',
101
             do_save=False,
101
             do_save=False,
102
         )
102
         )
103
-        w1f1d1.file_extension = '.txt'
104
-        w1f1d1.file_content = b'w1f1d1 content'
105
-        self._session.add(w1f1d1)
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
+        )
106
 
123
 
107
         w2f1p1 = content_api.create(
124
         w2f1p1 = content_api.create(
108
             content_type=ContentType.Page,
125
             content_type=ContentType.Page,

+ 70 - 24
tracim/tracim/lib/content.py 查看文件

2
 import os
2
 import os
3
 
3
 
4
 from operator import itemgetter
4
 from operator import itemgetter
5
+from sqlalchemy import func
6
+from sqlalchemy.orm import Query
5
 
7
 
6
 __author__ = 'damien'
8
 __author__ = 'damien'
7
 
9
 
285
 
287
 
286
         return result
288
         return result
287
 
289
 
290
+    def get_base_query(self, workspace: Workspace) -> Query:
291
+        return self._base_query(workspace)
292
+
288
     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]:
289
         """
294
         """
290
         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.
340
         content.is_temporary = is_temporary
345
         content.is_temporary = is_temporary
341
         content.revision_type = ActionDescription.CREATION
346
         content.revision_type = ActionDescription.CREATION
342
 
347
 
348
+        if content.type in (
349
+                ContentType.Page,
350
+                ContentType.Thread,
351
+        ):
352
+            content.file_extension = '.html'
353
+
343
         if do_save:
354
         if do_save:
344
             DBSession.add(content)
355
             DBSession.add(content)
345
             self.save(content, ActionDescription.CREATION)
356
             self.save(content, ActionDescription.CREATION)
452
             content_parent_labels: [str]=None,
463
             content_parent_labels: [str]=None,
453
     ):
464
     ):
454
         """
465
         """
455
-        TODO: Assurer l'unicité des path dans Tracim
456
-        TODO: Ecrire tous les cas de tests par rapports aux
457
-         cas d'erreurs possible (duplications de labels, etc) et TROUVES
458
         Return content with it's label, workspace and parents labels (optional)
466
         Return content with it's label, workspace and parents labels (optional)
459
         :param content_label: label of content (label or file_name)
467
         :param content_label: label of content (label or file_name)
460
         :param workspace: workspace containing all of this
468
         :param workspace: workspace containing all of this
464
         :return: Found Content
472
         :return: Found Content
465
         """
473
         """
466
         query = self._base_query(workspace)
474
         query = self._base_query(workspace)
467
-        file_name, file_extension = os.path.splitext(content_label)
468
         parent_folder = None
475
         parent_folder = None
469
 
476
 
470
         # Grab content parent folder if parent path given
477
         # Grab content parent folder if parent path given
475
             )
482
             )
476
 
483
 
477
         # Build query for found content by label
484
         # Build query for found content by label
478
-        content_query = query.filter(or_(
479
-            and_(
480
-                Content.type == ContentType.File,
481
-                Content.label == file_name,
482
-                Content.file_extension == file_extension,
483
-            ),
484
-            and_(
485
-                Content.type == ContentType.Thread,
486
-                Content.label == file_name,
487
-            ),
488
-            and_(
489
-                Content.type == ContentType.Page,
490
-                Content.label == file_name,
491
-            ),
492
-            and_(
493
-                Content.type == ContentType.Folder,
494
-                Content.label == content_label,
495
-            ),
496
-        ))
485
+        content_query = self.filter_query_for_content_label_as_path(
486
+            query=query,
487
+            content_label_as_file=content_label,
488
+        )
497
 
489
 
498
         # Modify query to apply parent folder filter if any
490
         # Modify query to apply parent folder filter if any
499
         if parent_folder:
491
         if parent_folder:
500
             content_query = content_query.filter(
492
             content_query = content_query.filter(
501
                 Content.parent_id == parent_folder.content_id,
493
                 Content.parent_id == parent_folder.content_id,
502
-                Content.workspace_id == workspace.workspace_id,
503
             )
494
             )
504
 
495
 
496
+        # Filter with workspace
497
+        content_query = content_query.filter(
498
+            Content.workspace_id == workspace.workspace_id,
499
+        )
500
+
505
         # Return the content
501
         # Return the content
506
         return content_query\
502
         return content_query\
507
             .order_by(
503
             .order_by(
549
 
545
 
550
         return folder
546
         return folder
551
 
547
 
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
+        ))
597
+
552
     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]:
553
         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
554
         assert content_type is not None# DYN_REMOVE
600
         assert content_type is not None# DYN_REMOVE

+ 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
+    )

+ 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):

+ 3 - 2
tracim/tracim/model/data.py 查看文件

659
         return False
659
         return False
660
 
660
 
661
     def get_label_as_file(self):
661
     def get_label_as_file(self):
662
-        file_extension = self.file_extension
662
+        file_extension = self.file_extension or ''
663
 
663
 
664
         if self.type == ContentType.Thread:
664
         if self.type == ContentType.Thread:
665
             file_extension = '.html'
665
             file_extension = '.html'
788
     @file_name.setter
788
     @file_name.setter
789
     def file_name(self, value: str) -> None:
789
     def file_name(self, value: str) -> None:
790
         file_name, file_extension = os.path.splitext(value)
790
         file_name, file_extension = os.path.splitext(value)
791
-        self.revision.label = file_name
791
+        if not self.revision.label:
792
+            self.revision.label = file_name
792
         self.revision.file_extension = file_extension
793
         self.revision.file_extension = file_extension
793
 
794
 
794
     @file_name.expression
795
     @file_name.expression

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

+ 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
+        )

+ 14 - 2
tracim/tracim/tests/library/test_webdav.py 查看文件

154
 
154
 
155
         children = w1f1.getMemberList()
155
         children = w1f1.getMemberList()
156
         eq_(
156
         eq_(
157
-            3,
157
+            5,
158
             len(children),
158
             len(children),
159
-            msg='w1f1 should list 3 folders instead {0}'.format(
159
+            msg='w1f1 should list 5 folders instead {0}'.format(
160
                 len(children),
160
                 len(children),
161
             ),
161
             ),
162
         )
162
         )
180
                 content_names,
180
                 content_names,
181
             )
181
             )
182
         )
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
+        )
183
 
195
 
184
     def test_unit__get_content__ok(self):
196
     def test_unit__get_content__ok(self):
185
         provider = self._get_provider()
197
         provider = self._get_provider()