Browse Source

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

Bastien Sevajol (Algoo) 8 years ago
parent
commit
1a76e8c388
36 changed files with 1731 additions and 189 deletions
  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 View File

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

@@ -0,0 +1,31 @@
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 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
+                )

+ 33 - 0
tracim/migration/versions/e31ddc009b37_add_content_file_extension_column.py View File

@@ -0,0 +1,33 @@
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 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)

+ 7 - 3
tracim/tracim/controllers/admin/user.py View File

@@ -1,6 +1,7 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 import uuid
3 3
 
4
+import pytz
4 5
 from tracim import model  as pm
5 6
 
6 7
 from sprox.tablebase import TableBase
@@ -380,15 +381,18 @@ class UserRestController(TIMRestController):
380 381
         user = api.get_one(id)
381 382
 
382 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 389
     @tg.require(predicates.in_group(Group.TIM_MANAGER_GROUPNAME))
386 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 392
         api = UserApi(tmpl_context.current_user)
389 393
 
390 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 397
         tg.flash(_('User {} updated.').format(user.get_display_name()), CST.STATUS_OK)
394 398
         if next_url:

+ 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

+ 103 - 29
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
 
@@ -527,7 +572,14 @@ class UserWorkspaceFolderThreadRestController(TIMWorkspaceContentRestController)
527 572
 
528 573
     @tg.require(current_user_is_reader())
529 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 583
         thread_id = int(thread_id)
532 584
         user = tmpl_context.current_user
533 585
         workspace = tmpl_context.workspace
@@ -543,7 +595,15 @@ class UserWorkspaceFolderThreadRestController(TIMWorkspaceContentRestController)
543 595
         fake_api = Context(CTX.FOLDER).toDict(fake_api_content)
544 596
 
545 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,15 +827,23 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
767 827
             parent = None
768 828
             if parent_id:
769 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 847
             api.save(folder)
780 848
 
781 849
             tg.flash(_('Folder created'), CST.STATUS_OK)
@@ -821,6 +889,12 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
821 889
                     # TODO - D.A. - 2015-05-25 - Allow to set folder description
822 890
                     api.update_content(folder, label, folder.description)
823 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 898
                 api.save(folder)
825 899
 
826 900
             tg.flash(_('Folder updated'), CST.STATUS_OK)
@@ -949,4 +1023,4 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
949 1023
             back_url = self._parent_url.format(item.workspace_id, item.parent_id)
950 1024
             msg = _('{} not un-deleted: {}').format(self._item_type_label, str(e))
951 1025
             tg.flash(msg, CST.STATUS_ERROR)
952
-            tg.redirect(back_url)
1026
+            tg.redirect(back_url)

+ 1 - 0
tracim/tracim/controllers/workspace.py View File

@@ -60,6 +60,7 @@ class UserWorkspaceRestController(TIMRestController):
60 60
         )
61 61
 
62 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 64
             workspace.get_valid_children(ContentApi.DISPLAYABLE_CONTENTS)
64 65
         )
65 66
 

+ 131 - 0
tracim/tracim/fixtures/content.py View File

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

@@ -1965,6 +1965,10 @@ msgstr ""
1965 1965
 msgid "Post a reply..."
1966 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 1972
 #: tracim/templates/thread/toolbar.mak:16
1969 1973
 msgid "Edit current thread"
1970 1974
 msgstr "Modifier le sujet de discussion"

+ 6 - 2
tracim/tracim/lib/calendar.py View File

@@ -3,6 +3,7 @@ import os
3 3
 
4 4
 import re
5 5
 import transaction
6
+from caldav.lib.error import PutError
6 7
 
7 8
 from icalendar import Event as iCalendarEvent
8 9
 from sqlalchemy.orm.exc import NoResultFound
@@ -356,6 +357,9 @@ LOCATION:Here
356 357
 END:VEVENT
357 358
 END:VCALENDAR
358 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 View File

@@ -1,5 +1,9 @@
1 1
 # -*- coding: utf-8 -*-
2
+import os
3
+
2 4
 from operator import itemgetter
5
+from sqlalchemy import func
6
+from sqlalchemy.orm import Query
3 7
 
4 8
 __author__ = 'damien'
5 9
 
@@ -283,6 +287,9 @@ class ContentApi(object):
283 287
 
284 288
         return result
285 289
 
290
+    def get_base_query(self, workspace: Workspace) -> Query:
291
+        return self._base_query(workspace)
292
+
286 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 295
         This method returns child items (folders or items) for left bar treeview.
@@ -338,6 +345,12 @@ class ContentApi(object):
338 345
         content.is_temporary = is_temporary
339 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 354
         if do_save:
342 355
             DBSession.add(content)
343 356
             self.save(content, ActionDescription.CREATION)
@@ -402,8 +415,11 @@ class ContentApi(object):
402 415
 
403 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 424
         This method let us request the database to obtain a Content with its name and parent
409 425
         :param content_label: Either the content's label or the content's filename if the label is None
@@ -411,52 +427,173 @@ class ContentApi(object):
411 427
         :param workspace: The workspace's content
412 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 598
     def get_all(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> [Content]:
462 599
         assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE

+ 4 - 3
tracim/tracim/lib/daemons.py View File

@@ -303,9 +303,10 @@ class WsgiDavDaemon(Daemon):
303 303
 
304 304
         config['provider_mapping'] = {
305 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 310
                 manage_locks=config['manager_locks']
310 311
             )
311 312
         }

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

+ 2 - 5
tracim/tracim/lib/notifications.py View File

@@ -228,7 +228,7 @@ class EmailNotifier(object):
228 228
             subject = self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT
229 229
             subject = subject.replace(EST.WEBSITE_TITLE, self._global_config.WEBSITE_TITLE.__str__())
230 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 232
             subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__())
233 233
 
234 234
             message = MIMEMultipart('alternative')
@@ -306,11 +306,8 @@ class EmailNotifier(object):
306 306
                 content_intro = _('<span id="content-intro-username">{}</span> added a file entitled:').format(actor.display_name)
307 307
                 if content.description:
308 308
                     content_text = content.description
309
-                elif content.label:
310
-                    content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
311 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 312
             elif ContentType.Page == content.type:
316 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 View File

@@ -65,9 +65,10 @@ def designPage(content: data.Content, content_revision: data.ContentRevisionRO)
65 65
                        label,
66 66
                        date,
67 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 72
     histHTML += '</table>'
72 73
 
73 74
     file = '''
@@ -93,9 +94,10 @@ def designPage(content: data.Content, content_revision: data.ContentRevisionRO)
93 94
             </div>
94 95
             <div class="pull-right">
95 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 101
                 </div>
100 102
             </div>
101 103
         </div>
@@ -113,9 +115,10 @@ def designPage(content: data.Content, content_revision: data.ContentRevisionRO)
113 115
             file_location = file_location.replace(/\/[^/]*$/, '')
114 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 123
     </script>
121 124
 </body>
@@ -197,10 +200,12 @@ def designThread(content: data.Content, content_revision: data.ContentRevisionRO
197 200
                            t.owner.display_name,
198 201
                            t.create_readable_date(),
199 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 210
         page = '''
206 211
 <html>
@@ -222,9 +227,10 @@ def designThread(content: data.Content, content_revision: data.ContentRevisionRO
222 227
             </div>
223 228
             <div class="pull-right">
224 229
                 <div class="btn-group btn-group-vertical">
230
+                    <!-- NOTE: Not omplemented yet, don't display not working link
225 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 234
                     <a class="btn btn-default">
229 235
                         <i class="fa fa-external-link"></i> View in tracim</a>
230 236
                     </a>
@@ -244,9 +250,10 @@ def designThread(content: data.Content, content_revision: data.ContentRevisionRO
244 250
             file_location = file_location.replace(/\/[^/]*$/, '')
245 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 259
         function hide_elements() {

+ 19 - 14
tracim/tracim/lib/webdav/sql_dav_provider.py View File

@@ -2,6 +2,7 @@
2 2
 
3 3
 import re
4 4
 from os.path import basename, dirname, normpath
5
+from sqlalchemy.orm.exc import NoResultFound
5 6
 from tracim.lib.webdav.utils import transform_to_bdd
6 7
 
7 8
 from wsgidav.dav_provider import DAVProvider
@@ -75,8 +76,8 @@ class Provider(DAVProvider):
75 76
 
76 77
         content_api = ContentApi(
77 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 83
         content = self.get_content_from_path(
@@ -160,7 +161,9 @@ class Provider(DAVProvider):
160 161
         if parent_path == root_path or workspace is None:
161 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 168
         revision_id = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) ([^/].+)$', path)
166 169
 
@@ -238,26 +241,28 @@ class Provider(DAVProvider):
238 241
         path = self.reduce_path(path)
239 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 251
         parents = [transform_to_bdd(x) for x in parents]
247 252
 
248 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 260
             return None
256 261
 
257 262
     def get_content_from_revision(self, revision: ContentRevisionRO, api: ContentApi) -> Content:
258 263
         try:
259 264
             return api.get_one(revision.content_id, ContentType.Any)
260
-        except:
265
+        except NoResultFound:
261 266
             return None
262 267
 
263 268
     def get_parent_from_path(self, path, api: ContentApi, workspace) -> Content:
@@ -266,5 +271,5 @@ class Provider(DAVProvider):
266 271
     def get_workspace_from_path(self, path: str, api: WorkspaceApi) -> Workspace:
267 272
         try:
268 273
             return api.get_one_by_label(transform_to_bdd(path.split('/')[1]))
269
-        except:
274
+        except NoResultFound:
270 275
             return None

+ 45 - 43
tracim/tracim/lib/webdav/sql_resources.py View File

@@ -54,7 +54,7 @@ class ManageActions(object):
54 54
         try:
55 55
             # When undeleting/unarchiving we except a content with the new name to not exist, thus if we
56 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 58
             raise DAVError(HTTP_FORBIDDEN)
59 59
         except NoResultFound:
60 60
             with new_revision(self.content):
@@ -69,7 +69,7 @@ class ManageActions(object):
69 69
         Will create the new name, either by adding '- deleted the [date]' after the name when archiving/deleting or
70 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 73
         extension = ''
74 74
 
75 75
         # if the content has no label, the last .ext is important
@@ -219,7 +219,7 @@ class Workspace(DAVCollection):
219 219
             # the purpose is to display .history only if there's at least one content's type that has a history
220 220
             if content.type != ContentType.Folder:
221 221
                 self._file_count += 1
222
-            retlist.append(content.get_label())
222
+            retlist.append(content.get_label_as_file())
223 223
 
224 224
         return retlist
225 225
 
@@ -310,7 +310,7 @@ class Workspace(DAVCollection):
310 310
         children = self.content_api.get_all(False, ContentType.Any, self.workspace)
311 311
 
312 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 315
             if content.type == ContentType.Folder:
316 316
                 members.append(Folder(content_path, self.environ, self.workspace, content))
@@ -374,7 +374,7 @@ class Folder(Workspace):
374 374
         return mktime(self.content.created.timetuple())
375 375
 
376 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 379
     def getLastModified(self) -> float:
380 380
         return mktime(self.content.updated.timetuple())
@@ -472,7 +472,7 @@ class Folder(Workspace):
472 472
         )
473 473
 
474 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 477
             if content.type == ContentType.Folder:
478 478
                 members.append(Folder(content_path, self.environ, self.workspace, content))
@@ -555,7 +555,7 @@ class HistoryFolder(Folder):
555 555
         )
556 556
 
557 557
         return HistoryFileFolder(
558
-            path='%s/%s' % (self.path, content.get_label()),
558
+            path='%s/%s' % (self.path, content.get_label_as_file()),
559 559
             environ=self.environ,
560 560
             content=content)
561 561
 
@@ -568,7 +568,7 @@ class HistoryFolder(Folder):
568 568
                 self._is_deleted and content.is_deleted or
569 569
                 not (content.is_archived or self._is_archived or content.is_deleted or self._is_deleted))\
570 570
                     and content.type != ContentType.Folder:
571
-                ret.append(content.get_label())
571
+                ret.append(content.get_label_as_file())
572 572
 
573 573
         return ret
574 574
 
@@ -601,7 +601,7 @@ class HistoryFolder(Folder):
601 601
         for content in children:
602 602
             if content.is_archived == self._is_archived and content.is_deleted == self._is_deleted:
603 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 605
                     environ=self.environ,
606 606
                     content=content))
607 607
 
@@ -638,7 +638,7 @@ class DeletedFolder(HistoryFolder):
638 638
         )
639 639
 
640 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 642
             environ=self.environ
643 643
             )
644 644
 
@@ -652,7 +652,7 @@ class DeletedFolder(HistoryFolder):
652 652
 
653 653
         for content in children:
654 654
             if content.is_deleted:
655
-                retlist.append(content.get_label())
655
+                retlist.append(content.get_label_as_file())
656 656
 
657 657
                 if content.type != ContentType.Folder:
658 658
                     self._file_count += 1
@@ -669,7 +669,7 @@ class DeletedFolder(HistoryFolder):
669 669
 
670 670
         for content in children:
671 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 674
                 if content.type == ContentType.Folder:
675 675
                     members.append(Folder(content_path, self.environ, self.workspace, content))
@@ -723,7 +723,7 @@ class ArchivedFolder(HistoryFolder):
723 723
         )
724 724
 
725 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 727
             environ=self.environ
728 728
         )
729 729
 
@@ -732,7 +732,7 @@ class ArchivedFolder(HistoryFolder):
732 732
 
733 733
         for content in self.content_api.get_all_with_filter(
734 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 737
             if content.type != ContentType.Folder:
738 738
                 self._file_count += 1
@@ -749,7 +749,7 @@ class ArchivedFolder(HistoryFolder):
749 749
 
750 750
         for content in children:
751 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 754
                 if content.type == ContentType.Folder:
755 755
                     members.append(Folder(content_path, self.environ, self.workspace, content))
@@ -786,7 +786,7 @@ class HistoryFileFolder(HistoryFolder):
786 786
         return "<DAVCollection: HistoryFileFolder (%s)" % self.content.file_name
787 787
 
788 788
     def getDisplayName(self) -> str:
789
-        return self.content.get_label()
789
+        return self.content.get_label_as_file()
790 790
 
791 791
     def createCollection(self, name):
792 792
         raise DAVError(HTTP_FORBIDDEN)
@@ -817,7 +817,7 @@ class HistoryFileFolder(HistoryFolder):
817 817
                 content_revision=revision)
818 818
         else:
819 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 821
                 environ=self.environ,
822 822
                 content=self.content,
823 823
                 content_revision=revision)
@@ -862,15 +862,6 @@ class File(DAVNonCollection):
862 862
         # but i wasn't able to set this property so you'll have to look into it >.>
863 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 865
     def __repr__(self) -> str:
875 866
         return "<DAVNonCollection: File (%d)>" % self.content.revision_id
876 867
 
@@ -884,7 +875,7 @@ class File(DAVNonCollection):
884 875
         return mktime(self.content.created.timetuple())
885 876
 
886 877
     def getDisplayName(self) -> str:
887
-        return self.content.get_label()
878
+        return self.content.file_name
888 879
 
889 880
     def getLastModified(self) -> float:
890 881
         return mktime(self.content.updated.timetuple())
@@ -900,7 +891,7 @@ class File(DAVNonCollection):
900 891
         return FakeFileStream(
901 892
             content=self.content,
902 893
             content_api=self.content_api,
903
-            file_name=self.content.get_label(),
894
+            file_name=self.content.get_label_as_file(),
904 895
             workspace=self.content.workspace,
905 896
             path=self.path
906 897
         )
@@ -957,30 +948,41 @@ class File(DAVNonCollection):
957 948
 
958 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 954
         with new_revision(self.content):
972 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 960
                 self.content_api.update_content(
974 961
                     self.content,
975
-                    transform_to_bdd(basename(destpath)),
962
+                    new_file_name,
976 963
                 )
964
+                self.content.file_extension = new_file_extension
977 965
                 self.content_api.save(self.content)
978 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 981
                 self.content_api.move(
980 982
                     item=self.content,
981
-                    new_parent=parent,
983
+                    new_parent=destination_parent,
982 984
                     must_stay_in_same_workspace=False,
983
-                    new_workspace=workspace
985
+                    new_workspace=destination_workspace
984 986
                 )
985 987
 
986 988
         transaction.commit()
@@ -1048,7 +1050,7 @@ class OtherFile(File):
1048 1050
             self.path += '.html'
1049 1051
 
1050 1052
     def getDisplayName(self) -> str:
1051
-        return self.content.get_label()
1053
+        return self.content.get_label_as_file()
1052 1054
 
1053 1055
     def getPreferredPath(self):
1054 1056
         return self.path
@@ -1094,7 +1096,7 @@ class HistoryOtherFile(OtherFile):
1094 1096
 
1095 1097
     def getDisplayName(self) -> str:
1096 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 1101
     def getContent(self):
1100 1102
         filestream = compat.BytesIO()

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

+ 2 - 1
tracim/tracim/model/__init__.py View File

@@ -31,7 +31,8 @@ class RevisionsIntegrity(object):
31 31
 
32 32
     @classmethod
33 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 37
     @classmethod
37 38
     def is_updatable(cls, revision: 'ContentRevisionRO') -> bool:

+ 88 - 11
tracim/tracim/model/data.py View File

@@ -2,6 +2,7 @@
2 2
 
3 3
 import datetime as datetime_root
4 4
 import json
5
+import os
5 6
 from datetime import datetime
6 7
 
7 8
 import tg
@@ -63,9 +64,16 @@ class Workspace(DeclarativeBase):
63 64
     revisions = relationship("ContentRevisionRO")
64 65
 
65 66
     @hybrid_property
66
-    def contents(self):
67
+    def contents(self) -> ['Content']:
67 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 78
     @property
71 79
     def calendar_url(self) -> str:
@@ -522,7 +530,12 @@ class ContentRevisionRO(DeclarativeBase):
522 530
 
523 531
     label = Column(Unicode(1024), unique=False, nullable=False)
524 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 539
     file_mimetype = Column(Unicode(255),  unique=False, nullable=False, default='')
527 540
     file_content = deferred(Column(LargeBinary(), unique=False, nullable=True))
528 541
     properties = Column('properties', Text(), unique=False, nullable=False, default='')
@@ -547,9 +560,28 @@ class ContentRevisionRO(DeclarativeBase):
547 560
 
548 561
     """ List of column copied when make a new revision from another """
549 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 587
     # Read by must be used like this:
@@ -562,6 +594,13 @@ class ContentRevisionRO(DeclarativeBase):
562 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 604
     @classmethod
566 605
     def new_from(cls, revision: 'ContentRevisionRO') -> 'ContentRevisionRO':
567 606
         """
@@ -607,7 +646,7 @@ class ContentRevisionRO(DeclarativeBase):
607 646
         return ContentStatus(self.status)
608 647
 
609 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 651
     def get_last_action(self) -> ActionDescription:
613 652
         return ActionDescription(self.revision_type)
@@ -626,6 +665,20 @@ class ContentRevisionRO(DeclarativeBase):
626 665
 
627 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 682
 Index('idx__content_revisions__owner_id', ContentRevisionRO.owner_id)
630 683
 Index('idx__content_revisions__parent_id', ContentRevisionRO.parent_id)
631 684
 
@@ -734,15 +787,33 @@ class Content(DeclarativeBase):
734 787
 
735 788
     @hybrid_property
736 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 795
     @file_name.setter
740 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 802
     @file_name.expression
744 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 818
     @hybrid_property
748 819
     def file_mimetype(self) -> str:
@@ -1042,7 +1113,13 @@ class Content(DeclarativeBase):
1042 1113
         return child_nb
1043 1114
 
1044 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 1124
     def get_status(self) -> ContentStatus:
1048 1125
         return ContentStatus(self.status, self.type.__str__())

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

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

+ 8 - 0
tracim/tracim/public/assets/css/dashboard.css View File

@@ -382,3 +382,11 @@ span.info.readonly {
382 382
 .no-padding {
383 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 View File

@@ -2,6 +2,15 @@ $(document).ready(function () {
2 2
 
3 3
   // add select2 for admin/workspace/<id> for user selection
4 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 View File

@@ -75,7 +75,7 @@
75 75
                                     <form role="form" method="POST" action="${tg.url('/admin/workspaces/{}/roles'.format(result.workspace.id))}">
76 76
                                         <div class="form-group">
77 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 79
                                                 % for user in fake_api.users:
80 80
                                                     % if user.id not in (user.id for user in result.workspace.members):
81 81
                                                         <option value="${user.id}">${user.name}</option>

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

+ 4 - 4
tracim/tracim/templates/file/toolbar.mak View File

@@ -11,28 +11,28 @@
11 11
 
12 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 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 15
     % if h.user_role(user, workspace)>1:
16 16
         <div class="btn-group btn-group-vertical">
17 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 18
         </div>
19 19
         <p></p>
20 20
     % endif
21
-    
21
+
22 22
     <div class="btn-group btn-group-vertical">
23 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 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 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 26
     </div>
27 27
     <p></p>
28
-    
28
+
29 29
     % if user.profile.id>=3 or h.user_role(user, workspace)>=4:
30 30
         ## if the user can see the toolbar, it means he is the workspace manager.
31 31
         ## So now, we need to know if he alsa has right to delete workspaces
32 32
         <div class="btn-group btn-group-vertical">
33 33
             ## SHOW_ARCHIVE_BUTTON__BUG_#81
34 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 36
             </a>
37 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 38
                 ${ICON.FA_FW('fa fa-trash t-less-visible')} ${_('Delete')}

+ 1 - 3
tracim/tracim/templates/master_authenticated.mak View File

@@ -115,9 +115,7 @@
115 115
                     <ul class="nav navbar-nav navbar-left">
116 116
                         <li class="active"><a href="${tg.url('/home')}">${TIM.FA('fa-home fa-lg')} ${_('My Home')}</a></li>
117 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 119
                     </ul>
122 120
                 % endif
123 121
 

+ 56 - 2
tracim/tracim/templates/pod.mak View File

@@ -30,6 +30,28 @@
30 30
 
31 31
 <%def name="TINYMCE_INIT_SCRIPT(selector)">
32 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 55
         tinymce.init({
34 56
             menubar:false,
35 57
             statusbar:true,
@@ -38,7 +60,7 @@
38 60
             skin : 'tracim',
39 61
             selector:'${selector}',
40 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 65
             paste_data_images: true,
44 66
             table_default_attributes: {
@@ -54,8 +76,40 @@
54 76
                 {title: 'Normal', value: 'user_content'},
55 77
                 {title: 'First row is header', value: 'user_content first_row_headers'},
56 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 114
     </script>
61 115
 </%def>

+ 12 - 0
tracim/tracim/templates/thread/getone.mak View File

@@ -85,6 +85,17 @@
85 85
 
86 86
 <div class="row">
87 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 99
         % if h.user_role(fake_api.current_user, result.thread.workspace)<=1:
89 100
             ## READONLY USER
90 101
             <% a = 'b' %>
@@ -101,6 +112,7 @@
101 112
     </div>
102 113
 </div>
103 114
 
115
+<!-- % for event in reversed(result.thread.history): -->
104 116
 % for event in result.thread.history:
105 117
     ## TODO - D.A. - 2015-08-20
106 118
     ## Allow to show full history (with status change and archive/unarchive)

+ 9 - 5
tracim/tracim/templates/widgets/navbar_menu.mak View File

@@ -1,14 +1,18 @@
1 1
 <%namespace name="ICON" file="tracim.templates.widgets.icon"/>
2 2
 
3 3
 <%def name="ADMIN_ITEMS()">
4
+    % if fake_api.current_user.profile.id>=2:
4 5
     <li class="">
5 6
         <a href="${tg.url('/admin/workspaces')}">
6 7
             ${ICON.FA('fa-bank fa-fw')} ${_('Workspaces')}
7 8
         </a>
8 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 18
 </%def>

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

+ 452 - 0
tracim/tracim/tests/library/test_webdav.py View File

@@ -0,0 +1,452 @@
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
+        )