Browse Source

Introduce path unicity check

Bastien Sevajol (Algoo) 8 years ago
parent
commit
c2869d2262

+ 78 - 0
tracim/migration/versions/c1cea4bbae16_file_extentions_value_for_pages_and_.py View File

@@ -0,0 +1,78 @@
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 View File

@@ -2,6 +2,8 @@
2 2
 """Controllers for the tracim application."""
3 3
 from sqlalchemy.orm.exc import NoResultFound
4 4
 from tg import abort
5
+from tracim.lib.integrity import PathValidationManager
6
+from tracim.lib.integrity import render_invalid_integrity_chosen_path
5 7
 from tracim.lib.workspace import WorkspaceApi
6 8
 
7 9
 import tg
@@ -122,6 +124,12 @@ class TIMRestController(RestController, BaseController):
122 124
     TEMPLATE_NEW = 'unknown "template new"'
123 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 133
     def _before(self, *args, **kw):
126 134
         """
127 135
         Instantiate the current workspace in tg.tmpl_context
@@ -274,6 +282,12 @@ class TIMWorkspaceContentRestController(TIMRestControllerWithBreadcrumb):
274 282
             item = api.get_one(int(item_id), self._item_type, workspace)
275 283
             with new_revision(item):
276 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 291
                 api.save(item, ActionDescription.REVISION)
278 292
 
279 293
             msg = _('{} updated').format(self._item_type_label)

+ 18 - 1
tracim/tracim/controllers/admin/workspace.py View File

@@ -10,6 +10,8 @@ from tracim.controllers import TIMRestController
10 10
 from tracim.lib import CST
11 11
 from tracim.lib.base import BaseController
12 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 15
 from tracim.lib.user import UserApi
14 16
 from tracim.lib.userworkspace import RoleApi
15 17
 from tracim.lib.workspace import WorkspaceApi
@@ -139,6 +141,12 @@ class WorkspaceRestController(TIMRestController, BaseController):
139 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 150
     @property
143 151
     def _base_url(self):
144 152
         return '/admin/workspaces'
@@ -187,6 +195,10 @@ class WorkspaceRestController(TIMRestController, BaseController):
187 195
         workspace_api_controller = WorkspaceApi(user)
188 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 202
         workspace = workspace_api_controller.create_workspace(
191 203
             name,
192 204
             description,
@@ -213,8 +225,13 @@ class WorkspaceRestController(TIMRestController, BaseController):
213 225
         user = tmpl_context.current_user
214 226
         workspace_api_controller = WorkspaceApi(user)
215 227
         calendar_enabled = on_off_to_boolean(calendar_enabled)
216
-
217 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 235
         workspace.label = name
219 236
         workspace.description = description
220 237
         workspace.calendar_enabled = calendar_enabled

+ 85 - 26
tracim/tracim/controllers/content.py View File

@@ -1,22 +1,19 @@
1 1
 # -*- coding: utf-8 -*-
2
-import sys
3
-
4 2
 __author__ = 'damien'
5 3
 
6
-from cgi import FieldStorage
4
+import sys
5
+import traceback
7 6
 
7
+from cgi import FieldStorage
8 8
 import tg
9 9
 from tg import tmpl_context
10 10
 from tg.i18n import ugettext as _
11 11
 from tg.predicates import not_anonymous
12 12
 
13
-import traceback
14
-
15 13
 from tracim.controllers import TIMRestController
16 14
 from tracim.controllers import TIMRestPathContextSetup
17 15
 from tracim.controllers import TIMRestControllerWithBreadcrumb
18 16
 from tracim.controllers import TIMWorkspaceContentRestController
19
-
20 17
 from tracim.lib import CST
21 18
 from tracim.lib.base import BaseController
22 19
 from tracim.lib.base import logger
@@ -27,14 +24,16 @@ from tracim.lib.predicates import current_user_is_reader
27 24
 from tracim.lib.predicates import current_user_is_contributor
28 25
 from tracim.lib.predicates import current_user_is_content_manager
29 26
 from tracim.lib.predicates import require_current_user_is_owner
30
-
31 27
 from tracim.model.serializers import Context, CTX, DictLikeClass
32 28
 from tracim.model.data import ActionDescription
33 29
 from tracim.model import new_revision
30
+from tracim.model import DBSession
34 31
 from tracim.model.data import Content
35 32
 from tracim.model.data import ContentType
36 33
 from tracim.model.data import UserRoleInWorkspace
37 34
 from tracim.model.data import Workspace
35
+from tracim.lib.integrity import render_invalid_integrity_chosen_path
36
+
38 37
 
39 38
 class UserWorkspaceFolderThreadCommentRestController(TIMRestController):
40 39
 
@@ -259,11 +258,18 @@ class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
259 258
     def post(self, label='', file_data=None):
260 259
         # TODO - SECURE THIS
261 260
         workspace = tmpl_context.workspace
261
+        folder = tmpl_context.folder
262 262
 
263 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 273
         api.save(file, ActionDescription.CREATION)
268 274
 
269 275
         tg.flash(_('File created'), CST.STATUS_OK)
@@ -291,6 +297,15 @@ class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
291 297
                         item, label if label else item.label,
292 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 309
                     api.save(updated_item, ActionDescription.EDITION)
295 310
 
296 311
                     # This case is the default "file title and description update"
@@ -317,6 +332,16 @@ class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
317 332
 
318 333
                     if isinstance(file_data, FieldStorage):
319 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 345
                         api.save(item, ActionDescription.REVISION)
321 346
 
322 347
             msg = _('{} updated').format(self._item_type_label)
@@ -415,8 +440,15 @@ class UserWorkspaceFolderPageRestController(TIMWorkspaceContentRestController):
415 440
 
416 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 452
         api.save(page, ActionDescription.CREATION, do_notify=True)
421 453
 
422 454
         tg.flash(_('Page created'), CST.STATUS_OK)
@@ -435,6 +467,12 @@ class UserWorkspaceFolderPageRestController(TIMWorkspaceContentRestController):
435 467
             item = api.get_one(int(item_id), self._item_type, workspace)
436 468
             with new_revision(item):
437 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 476
                 api.save(item, ActionDescription.REVISION)
439 477
 
440 478
             msg = _('{} updated').format(self._item_type_label)
@@ -511,13 +549,20 @@ class UserWorkspaceFolderThreadRestController(TIMWorkspaceContentRestController)
511 549
 
512 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 566
         api.save(comment, ActionDescription.COMMENT, do_notify=False)
522 567
         api.do_notify(thread)
523 568
 
@@ -771,15 +816,23 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
771 816
             parent = None
772 817
             if parent_id:
773 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 836
             api.save(folder)
784 837
 
785 838
             tg.flash(_('Folder created'), CST.STATUS_OK)
@@ -825,6 +878,12 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
825 878
                     # TODO - D.A. - 2015-05-25 - Allow to set folder description
826 879
                     api.update_content(folder, label, folder.description)
827 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 887
                 api.save(folder)
829 888
 
830 889
             tg.flash(_('Folder updated'), CST.STATUS_OK)

+ 23 - 6
tracim/tracim/fixtures/content.py View File

@@ -42,6 +42,7 @@ class Content(Fixture):
42 42
             with_notif=False,
43 43
         )
44 44
 
45
+        # Folders
45 46
         w1f1 = content_api.create(
46 47
             content_type=ContentType.Folder,
47 48
             workspace=w1,
@@ -55,11 +56,10 @@ class Content(Fixture):
55 56
             do_save=True,
56 57
         )
57 58
 
58
-        # Folders
59 59
         w2f1 = content_api.create(
60 60
             content_type=ContentType.Folder,
61 61
             workspace=w2,
62
-            label='w1f1',
62
+            label='w2f1',
63 63
             do_save=True,
64 64
         )
65 65
         w2f2 = content_api.create(
@@ -93,16 +93,33 @@ class Content(Fixture):
93 93
         )
94 94
         w1f1t1.description = 'w1f1t1 description'
95 95
         self._session.add(w1f1t1)
96
-        w1f1d1 = content_api.create(
96
+        w1f1d1_txt = content_api.create(
97 97
             content_type=ContentType.File,
98 98
             workspace=w1,
99 99
             parent=w1f1,
100 100
             label='w1f1d1',
101 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 124
         w2f1p1 = content_api.create(
108 125
             content_type=ContentType.Page,

+ 70 - 24
tracim/tracim/lib/content.py View File

@@ -2,6 +2,8 @@
2 2
 import os
3 3
 
4 4
 from operator import itemgetter
5
+from sqlalchemy import func
6
+from sqlalchemy.orm import Query
5 7
 
6 8
 __author__ = 'damien'
7 9
 
@@ -285,6 +287,9 @@ class ContentApi(object):
285 287
 
286 288
         return result
287 289
 
290
+    def get_base_query(self, workspace: Workspace) -> Query:
291
+        return self._base_query(workspace)
292
+
288 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 295
         This method returns child items (folders or items) for left bar treeview.
@@ -340,6 +345,12 @@ class ContentApi(object):
340 345
         content.is_temporary = is_temporary
341 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 354
         if do_save:
344 355
             DBSession.add(content)
345 356
             self.save(content, ActionDescription.CREATION)
@@ -452,9 +463,6 @@ class ContentApi(object):
452 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 466
         Return content with it's label, workspace and parents labels (optional)
459 467
         :param content_label: label of content (label or file_name)
460 468
         :param workspace: workspace containing all of this
@@ -464,7 +472,6 @@ class ContentApi(object):
464 472
         :return: Found Content
465 473
         """
466 474
         query = self._base_query(workspace)
467
-        file_name, file_extension = os.path.splitext(content_label)
468 475
         parent_folder = None
469 476
 
470 477
         # Grab content parent folder if parent path given
@@ -475,33 +482,22 @@ class ContentApi(object):
475 482
             )
476 483
 
477 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 490
         # Modify query to apply parent folder filter if any
499 491
         if parent_folder:
500 492
             content_query = content_query.filter(
501 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 501
         # Return the content
506 502
         return content_query\
507 503
             .order_by(
@@ -549,6 +545,56 @@ class ContentApi(object):
549 545
 
550 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 598
     def get_all(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> [Content]:
553 599
         assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
554 600
         assert content_type is not None# DYN_REMOVE

+ 103 - 0
tracim/tracim/lib/integrity.py View File

@@ -0,0 +1,103 @@
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 View File

@@ -1,5 +1,6 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 import transaction
3
+from sqlalchemy.orm import Query
3 4
 
4 5
 from tracim.lib.userworkspace import RoleApi
5 6
 from tracim.model.auth import Group
@@ -142,6 +143,9 @@ class WorkspaceApi(object):
142 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 150
 class UnsafeWorkspaceApi(WorkspaceApi):
147 151
     def _base_query(self):

+ 3 - 2
tracim/tracim/model/data.py View File

@@ -659,7 +659,7 @@ class ContentRevisionRO(DeclarativeBase):
659 659
         return False
660 660
 
661 661
     def get_label_as_file(self):
662
-        file_extension = self.file_extension
662
+        file_extension = self.file_extension or ''
663 663
 
664 664
         if self.type == ContentType.Thread:
665 665
             file_extension = '.html'
@@ -788,7 +788,8 @@ class Content(DeclarativeBase):
788 788
     @file_name.setter
789 789
     def file_name(self, value: str) -> None:
790 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 793
         self.revision.file_extension = file_extension
793 794
 
794 795
     @file_name.expression

+ 1 - 0
tracim/tracim/templates/errors/__init__.py View File

@@ -0,0 +1 @@
1
+# -*- coding: utf-8 -*-

+ 18 - 0
tracim/tracim/templates/errors/label_invalid_path.mak View File

@@ -0,0 +1,18 @@
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 View File

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

+ 184 - 0
tracim/tracim/tests/library/test_integrity.py View File

@@ -0,0 +1,184 @@
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 View File

@@ -154,9 +154,9 @@ class TestWebDav(TestStandard):
154 154
 
155 155
         children = w1f1.getMemberList()
156 156
         eq_(
157
-            3,
157
+            5,
158 158
             len(children),
159
-            msg='w1f1 should list 3 folders instead {0}'.format(
159
+            msg='w1f1 should list 5 folders instead {0}'.format(
160 160
                 len(children),
161 161
             ),
162 162
         )
@@ -180,6 +180,18 @@ class TestWebDav(TestStandard):
180 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 196
     def test_unit__get_content__ok(self):
185 197
         provider = self._get_provider()