Browse Source

Merge branch 'develop' of github.com:tracim/tracim_backend into feature/612_user_account_endpoint

Guénaël Muller 5 years ago
parent
commit
435e31e474

+ 7 - 0
.travis.yml View File

@@ -5,6 +5,13 @@ python:
5 5
   - "3.5"
6 6
   - "3.6"
7 7
 
8
+addons:
9
+  apt:
10
+    packages:
11
+    - libreoffice
12
+    - imagemagick
13
+    - libmagickwand-dev
14
+    - ghostscript
8 15
 services:
9 16
   - docker
10 17
   - redis-server

+ 8 - 1
README.md View File

@@ -20,6 +20,13 @@ on Debian Stretch (9) with sudo:
20 20
     sudo apt install git
21 21
     sudo apt install python3 python3-venv python3-dev python3-pip
22 22
     sudo apt install redis-server
23
+    sudo apt install zlib1g-dev libjpeg-dev
24
+    sudo apt install imagemagick libmagickwand-dev ghostscript
25
+
26
+for better preview support:
27
+
28
+    sudo apt install libreoffice # most office documents file and text format
29
+    sudo apt install inkscape # for .svg files.
23 30
 
24 31
 ### Get the source ###
25 32
 
@@ -105,7 +112,7 @@ to stop them:
105 112
 
106 113
 run tracim_backend web api:
107 114
 
108
-    pserve developement.ini
115
+    pserve development.ini
109 116
 
110 117
 run wsgidav server:
111 118
 

+ 11 - 0
development.ini.sample View File

@@ -175,6 +175,17 @@ wsgidav.config_path = %(here)s/wsgidav.conf
175 175
 ## Do not set http:// prefix.
176 176
 # wsgidav.client.base_url = 127.0.0.1:<WSGIDAV_PORT>
177 177
 
178
+### Preview
179
+## You can parametrized allowed jpg preview dimension list, if not set, default
180
+## is 256x256. First {width}x{length} items is default preview dimensions.
181
+## all items should be separated by ',' and you should be really careful to do
182
+## set anything else than '{int}x{int}' item and ', ' separator
183
+# preview.jpg.allowed_dims = 256x256,1000x1000
184
+## Preview dimensions can be set as restricted, if set as restricted, access
185
+## endpoint to  to get any other preview dimensions than allowed_dims will
186
+## return error
187
+# preview.jpg.restricted_dims = True
188
+
178 189
 ###
179 190
 # wsgi server configuration
180 191
 ###

+ 3 - 1
setup.py View File

@@ -35,6 +35,7 @@ requires = [
35 35
     'filedepot',
36 36
     'babel',
37 37
     'python-slugify',
38
+    'preview-generator',
38 39
     # mail-notifier
39 40
     'mako',
40 41
     'lxml',
@@ -48,7 +49,8 @@ tests_require = [
48 49
     'pytest-cov',
49 50
     'pep8',
50 51
     'mypy',
51
-    'requests'
52
+    'requests',
53
+    'Pillow'
52 54
 ]
53 55
 
54 56
 mysql_require = [

+ 5 - 0
tracim/__init__.py View File

@@ -1,4 +1,6 @@
1 1
 # -*- coding: utf-8 -*-
2
+
3
+
2 4
 try:  # Python 3.5+
3 5
     from http import HTTPStatus
4 6
 except ImportError:
@@ -27,6 +29,7 @@ from tracim.views.core_api.system_controller import SystemController
27 29
 from tracim.views.core_api.user_controller import UserController
28 30
 from tracim.views.core_api.workspace_controller import WorkspaceController
29 31
 from tracim.views.contents_api.comment_controller import CommentController
32
+from tracim.views.contents_api.file_controller import FileController
30 33
 from tracim.views.errors import ErrorSchema
31 34
 from tracim.exceptions import NotAuthenticated
32 35
 from tracim.exceptions import UserNotActive
@@ -111,6 +114,7 @@ def web(global_config, **local_settings):
111 114
     comment_controller = CommentController()
112 115
     html_document_controller = HTMLDocumentController()
113 116
     thread_controller = ThreadController()
117
+    file_controller = FileController()
114 118
     configurator.include(session_controller.bind, route_prefix=BASE_API_V2)
115 119
     configurator.include(system_controller.bind, route_prefix=BASE_API_V2)
116 120
     configurator.include(user_controller.bind, route_prefix=BASE_API_V2)
@@ -118,6 +122,7 @@ def web(global_config, **local_settings):
118 122
     configurator.include(comment_controller.bind, route_prefix=BASE_API_V2)
119 123
     configurator.include(html_document_controller.bind, route_prefix=BASE_API_V2)  # nopep8
120 124
     configurator.include(thread_controller.bind, route_prefix=BASE_API_V2)
125
+    configurator.include(file_controller.bind, route_prefix=BASE_API_V2)
121 126
 
122 127
     hapic.add_documentation_view(
123 128
         '/api/v2/doc',

+ 33 - 0
tracim/config.py View File

@@ -411,6 +411,26 @@ class CFG(object):
411 411
         #     self.RADICALE_CLIENT_BASE_URL_HOST,
412 412
         #     self.RADICALE_CLIENT_BASE_URL_PREFIX,
413 413
         # )
414
+        self.PREVIEW_JPG_RESTRICTED_DIMS = asbool(settings.get(
415
+            'preview.jpg.restricted_dims', False
416
+        ))
417
+        preview_jpg_allowed_dims_str = settings.get('preview.jpg.allowed_dims', '')  # nopep8
418
+        allowed_dims = []
419
+        if preview_jpg_allowed_dims_str:
420
+            for sizes in preview_jpg_allowed_dims_str.split(','):
421
+                parts = sizes.split('x')
422
+                assert len(parts) == 2
423
+                width, height = parts
424
+                assert width.isdecimal()
425
+                assert height.isdecimal()
426
+                size = PreviewDim(int(width), int(height))
427
+                allowed_dims.append(size)
428
+
429
+        if not allowed_dims:
430
+            size = PreviewDim(256, 256)
431
+            allowed_dims.append(size)
432
+
433
+        self.PREVIEW_JPG_ALLOWED_DIMS = allowed_dims
414 434
 
415 435
     def configure_filedepot(self):
416 436
         depot_storage_name = self.DEPOT_STORAGE_NAME
@@ -427,3 +447,16 @@ class CFG(object):
427 447
 
428 448
         TREEVIEW_FOLDERS = 'folders'
429 449
         TREEVIEW_ALL = 'all'
450
+
451
+
452
+class PreviewDim(object):
453
+
454
+    def __init__(self, width: int, height: int) -> None:
455
+        self.width = width
456
+        self.height = height
457
+
458
+    def __repr__(self):
459
+        return "<PreviewDim width:{width} height:{height}>".format(
460
+            width=self.width,
461
+            height=self.height,
462
+        )

+ 24 - 0
tracim/exceptions.py View File

@@ -177,5 +177,29 @@ class NoUserSetted(TracimException):
177 177
     pass
178 178
 
179 179
 
180
+class RoleDoesNotExist(TracimException):
181
+    pass
182
+
183
+
184
+class EmailValidationFailed(TracimException):
185
+    pass
186
+
187
+
188
+class UserCreationFailed(TracimException):
189
+    pass
190
+
191
+
180 192
 class ParentNotFound(NotFound):
181 193
     pass
194
+
195
+
196
+class RevisionDoesNotMatchThisContent(TracimException):
197
+    pass
198
+
199
+
200
+class PageOfPreviewNotFound(NotFound):
201
+    pass
202
+
203
+
204
+class PreviewDimNotAllowed(TracimException):
205
+    pass

+ 349 - 118
tracim/lib/core/content.py View File

@@ -7,6 +7,7 @@ import typing
7 7
 from operator import itemgetter
8 8
 
9 9
 import transaction
10
+from preview_generator.manager import PreviewManager
10 11
 from sqlalchemy import func
11 12
 from sqlalchemy.orm import Query
12 13
 from depot.manager import DepotManager
@@ -25,6 +26,9 @@ from sqlalchemy.sql.elements import and_
25 26
 from tracim.lib.utils.utils import cmp_to_key
26 27
 from tracim.lib.core.notifications import NotifierFactory
27 28
 from tracim.exceptions import SameValueError
29
+from tracim.exceptions import PageOfPreviewNotFound
30
+from tracim.exceptions import PreviewDimNotAllowed
31
+from tracim.exceptions import RevisionDoesNotMatchThisContent
28 32
 from tracim.exceptions import EmptyCommentContentNotAllowed
29 33
 from tracim.exceptions import EmptyLabelNotAllowed
30 34
 from tracim.exceptions import ContentNotFound
@@ -43,11 +47,13 @@ from tracim.models.data import UserRoleInWorkspace
43 47
 from tracim.models.data import Workspace
44 48
 from tracim.lib.utils.translation import fake_translator as _
45 49
 from tracim.models.context_models import RevisionInContext
50
+from tracim.models.context_models import PreviewAllowedDim
46 51
 from tracim.models.context_models import ContentInContext
47 52
 
48 53
 __author__ = 'damien'
49 54
 
50 55
 
56
+# TODO - G.M - 2018-07-24 - [Cleanup] Is this method already needed ?
51 57
 def compare_content_for_sorting_by_type_and_name(
52 58
         content1: Content,
53 59
         content2: Content
@@ -86,7 +92,7 @@ def compare_content_for_sorting_by_type_and_name(
86 92
         else:
87 93
             return 0
88 94
 
89
-
95
+# TODO - G.M - 2018-07-24 - [Cleanup] Is this method already needed ?
90 96
 def compare_tree_items_for_sorting_by_type_and_name(
91 97
         item1: NodeTreeItem,
92 98
         item2: NodeTreeItem
@@ -133,6 +139,7 @@ class ContentApi(object):
133 139
         self._show_all_type_of_contents_in_treeview = all_content_in_treeview
134 140
         self._force_show_all_types = force_show_all_types
135 141
         self._disable_user_workspaces_filter = disable_user_workspaces_filter
142
+        self.preview_manager = PreviewManager(self._config.PREVIEW_CACHE_DIR, create_folder=True)  # nopep8
136 143
 
137 144
     @contextmanager
138 145
     def show(
@@ -163,7 +170,7 @@ class ContentApi(object):
163 170
             self._show_temporary = previous_show_temporary
164 171
 
165 172
     def get_content_in_context(self, content: Content) -> ContentInContext:
166
-        return ContentInContext(content, self._session, self._config)
173
+        return ContentInContext(content, self._session, self._config, self._user)  # nopep8
167 174
 
168 175
     def get_revision_in_context(self, revision: ContentRevisionRO) -> RevisionInContext:  # nopep8
169 176
         # TODO - G.M - 2018-06-173 - create revision in context object
@@ -190,6 +197,7 @@ class ContentApi(object):
190 197
         return self._session.query(Content)\
191 198
             .join(ContentRevisionRO, self._get_revision_join())
192 199
 
200
+    # TODO - G.M - 2018-07-24 - [Cleanup] Is this method already needed ?
193 201
     @classmethod
194 202
     def sort_tree_items(
195 203
         cls,
@@ -205,6 +213,7 @@ class ContentApi(object):
205 213
 
206 214
         return content_list
207 215
 
216
+    # TODO - G.M - 2018-07-24 - [Cleanup] Is this method already needed ?
208 217
     @classmethod
209 218
     def sort_content(
210 219
         cls,
@@ -343,56 +352,57 @@ class ContentApi(object):
343 352
     ) -> Query:
344 353
         return self._base_query(workspace)
345 354
 
346
-    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) -> typing.List[Content]:
347
-        """
348
-        This method returns child items (folders or items) for left bar treeview.
349
-
350
-        :param parent:
351
-        :param workspace:
352
-        :param filter_by_allowed_content_types:
353
-        :param removed_item_ids:
354
-        :param allowed_node_types: This parameter allow to hide folders for which the given type of content is not allowed.
355
-               For example, if you want to move a Page from a folder to another, you should show only folders that accept pages
356
-        :return:
357
-        """
358
-        filter_by_allowed_content_types = filter_by_allowed_content_types or []  # FDV
359
-        removed_item_ids = removed_item_ids or []  # FDV
360
-
361
-        if not allowed_node_types:
362
-            allowed_node_types = [ContentType.Folder]
363
-        elif allowed_node_types==ContentType.Any:
364
-            allowed_node_types = ContentType.all()
365
-
366
-        parent_id = parent.content_id if parent else None
367
-        folders = self._base_query(workspace).\
368
-            filter(Content.parent_id==parent_id).\
369
-            filter(Content.type.in_(allowed_node_types)).\
370
-            filter(Content.content_id.notin_(removed_item_ids)).\
371
-            all()
372
-
373
-        if not filter_by_allowed_content_types or \
374
-                        len(filter_by_allowed_content_types)<=0:
375
-            # Standard case for the left treeview: we want to show all contents
376
-            # in the left treeview... so we still filter because for example
377
-            # comments must not appear in the treeview
378
-            return [folder for folder in folders \
379
-                    if folder.type in ContentType.allowed_types_for_folding()]
380
-
381
-        # Now this is a case of Folders only (used for moving content)
382
-        # When moving a content, you must get only folders that allow to be filled
383
-        # with the type of content you want to move
384
-        result = []
385
-        for folder in folders:
386
-            for allowed_content_type in filter_by_allowed_content_types:
387
-
388
-                is_folder = folder.type == ContentType.Folder
389
-                content_type__allowed = folder.properties['allowed_content'][allowed_content_type] == True
390
-
391
-                if is_folder and content_type__allowed:
392
-                    result.append(folder)
393
-                    break
394
-
395
-        return result
355
+    # TODO - G.M - 2018-07-17 - [Cleanup] Drop this method if unneeded
356
+    # 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) -> typing.List[Content]:
357
+    #     """
358
+    #     This method returns child items (folders or items) for left bar treeview.
359
+    # 
360
+    #     :param parent:
361
+    #     :param workspace:
362
+    #     :param filter_by_allowed_content_types:
363
+    #     :param removed_item_ids:
364
+    #     :param allowed_node_types: This parameter allow to hide folders for which the given type of content is not allowed.
365
+    #            For example, if you want to move a Page from a folder to another, you should show only folders that accept pages
366
+    #     :return:
367
+    #     """
368
+    #     filter_by_allowed_content_types = filter_by_allowed_content_types or []  # FDV
369
+    #     removed_item_ids = removed_item_ids or []  # FDV
370
+    # 
371
+    #     if not allowed_node_types:
372
+    #         allowed_node_types = [ContentType.Folder]
373
+    #     elif allowed_node_types==ContentType.Any:
374
+    #         allowed_node_types = ContentType.all()
375
+    # 
376
+    #     parent_id = parent.content_id if parent else None
377
+    #     folders = self._base_query(workspace).\
378
+    #         filter(Content.parent_id==parent_id).\
379
+    #         filter(Content.type.in_(allowed_node_types)).\
380
+    #         filter(Content.content_id.notin_(removed_item_ids)).\
381
+    #         all()
382
+    # 
383
+    #     if not filter_by_allowed_content_types or \
384
+    #                     len(filter_by_allowed_content_types)<=0:
385
+    #         # Standard case for the left treeview: we want to show all contents
386
+    #         # in the left treeview... so we still filter because for example
387
+    #         # comments must not appear in the treeview
388
+    #         return [folder for folder in folders \
389
+    #                 if folder.type in ContentType.allowed_types_for_folding()]
390
+    # 
391
+    #     # Now this is a case of Folders only (used for moving content)
392
+    #     # When moving a content, you must get only folders that allow to be filled
393
+    #     # with the type of content you want to move
394
+    #     result = []
395
+    #     for folder in folders:
396
+    #         for allowed_content_type in filter_by_allowed_content_types:
397
+    # 
398
+    #             is_folder = folder.type == ContentType.Folder
399
+    #             content_type__allowed = folder.properties['allowed_content'][allowed_content_type] == True
400
+    # 
401
+    #             if is_folder and content_type__allowed:
402
+    #                 result.append(folder)
403
+    #                 break
404
+    # 
405
+    #     return result
396 406
 
397 407
     def create(self, content_type: str, workspace: Workspace, parent: Content=None, label: str ='', filename: str = '', do_save=False, is_temporary: bool=False, do_notify=True) -> Content:
398 408
         # TODO - G.M - 2018-07-16 - raise Exception instead of assert
@@ -499,16 +509,24 @@ class ContentApi(object):
499 509
             raise ContentNotFound('Content "{}" not found in database'.format(content_id)) from exc  # nopep8
500 510
         return content
501 511
 
502
-    def get_one_revision(self, revision_id: int = None) -> ContentRevisionRO:
512
+    def get_one_revision(self, revision_id: int = None, content: Content= None) -> ContentRevisionRO:  # nopep8
503 513
         """
504 514
         This method allow us to get directly any revision with its id
505 515
         :param revision_id: The content's revision's id that we want to return
516
+        :param content: The content related to the revision, if None do not
517
+        check if revision is related to this content.
506 518
         :return: An item Content linked with the correct revision
507 519
         """
508 520
         assert revision_id is not None# DYN_REMOVE
509 521
 
510 522
         revision = self._session.query(ContentRevisionRO).filter(ContentRevisionRO.revision_id == revision_id).one()
511
-
523
+        if content and revision.content_id != content.content_id:
524
+            raise RevisionDoesNotMatchThisContent(
525
+                'revision {revision_id} is not a revision of content {content_id}'.format(  # nopep8
526
+                    revision_id=revision.revision_id,
527
+                    content_id=content.content_id,
528
+                    )
529
+            )
512 530
         return revision
513 531
 
514 532
     # INFO - A.P - 2017-07-03 - python file object getter
@@ -631,6 +649,7 @@ class ContentApi(object):
631 649
             )\
632 650
             .one()
633 651
 
652
+    # TODO - G.M - 2018-07-24 - [Cleanup] Is this method already needed ?
634 653
     def get_folder_with_workspace_path_labels(
635 654
             self,
636 655
             path_labels: [str],
@@ -724,42 +743,157 @@ class ContentApi(object):
724 743
             ),
725 744
         ))
726 745
 
727
-    def get_all(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> typing.List[Content]:
728
-        assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
729
-        assert content_type is not None# DYN_REMOVE
730
-        assert isinstance(content_type, str) # DYN_REMOVE
731 746
 
732
-        resultset = self._base_query(workspace)
747
+    def get_pdf_preview_path(
748
+            self,
749
+            content_id: int,
750
+            revision_id: int,
751
+            page: int
752
+    ) -> str:
753
+        """
754
+        Get pdf preview of revision of content
755
+        :param content_id: id of content
756
+        :param revision_id: id of content revision
757
+        :param page: page number of the preview, useful for multipage content
758
+        :return: preview_path as string
759
+        """
760
+        file_path = self.get_one_revision_filepath(revision_id)
761
+        if page >= self.preview_manager.get_page_nb(file_path):
762
+            raise PageOfPreviewNotFound(
763
+                'page {page} of content {content_id} does not exist'.format(
764
+                    page=page,
765
+                    content_id=content_id
766
+                ),
767
+            )
768
+        jpg_preview_path = self.preview_manager.get_pdf_preview(
769
+            file_path,
770
+            page=page
771
+        )
772
+        return jpg_preview_path
733 773
 
734
-        if content_type!=ContentType.Any:
735
-            resultset = resultset.filter(Content.type==content_type)
774
+    def get_full_pdf_preview_path(self, revision_id: int) -> str:
775
+        """
776
+        Get full(multiple page) pdf preview of revision of content
777
+        :param revision_id: id of revision
778
+        :return: path of the full pdf preview of this revision
779
+        """
780
+        file_path = self.get_one_revision_filepath(revision_id)
781
+        pdf_preview_path = self.preview_manager.get_pdf_preview(file_path)
782
+        return pdf_preview_path
736 783
 
737
-        if parent_id:
738
-            resultset = resultset.filter(Content.parent_id==parent_id)
739
-        if parent_id == 0 or parent_id is False:
740
-            resultset = resultset.filter(Content.parent_id == None)
741
-        # parent_id == None give all contents
784
+    def get_jpg_preview_allowed_dim(self) -> PreviewAllowedDim:
785
+        """
786
+        Get jpg preview allowed dimensions and strict bool param.
787
+        """
788
+        return PreviewAllowedDim(
789
+            self._config.PREVIEW_JPG_RESTRICTED_DIMS,
790
+            self._config.PREVIEW_JPG_ALLOWED_DIMS,
791
+        )
742 792
 
743
-        return resultset.all()
793
+    def get_jpg_preview_path(
794
+        self,
795
+        content_id: int,
796
+        revision_id: int,
797
+        page: int,
798
+        width: int = None,
799
+        height: int = None,
800
+    ) -> str:
801
+        """
802
+        Get jpg preview of revision of content
803
+        :param content_id: id of content
804
+        :param revision_id: id of content revision
805
+        :param page: page number of the preview, useful for multipage content
806
+        :param width: width in pixel
807
+        :param height: height in pixel
808
+        :return: preview_path as string
809
+        """
810
+        file_path = self.get_one_revision_filepath(revision_id)
811
+        if page >= self.preview_manager.get_page_nb(file_path):
812
+            raise Exception(
813
+                'page {page} of revision {revision_id} of content {content_id} does not exist'.format(  # nopep8
814
+                    page=page,
815
+                    revision_id=revision_id,
816
+                    content_id=content_id,
817
+                ),
818
+            )
819
+        if not width and not height:
820
+            width = self._config.PREVIEW_JPG_ALLOWED_DIMS[0].width
821
+            height = self._config.PREVIEW_JPG_ALLOWED_DIMS[0].height
822
+
823
+        allowed_dim = False
824
+        for preview_dim in self._config.PREVIEW_JPG_ALLOWED_DIMS:
825
+            if width == preview_dim.width and height == preview_dim.height:
826
+                allowed_dim = True
827
+                break
828
+
829
+        if not allowed_dim and self._config.PREVIEW_JPG_RESTRICTED_DIMS:
830
+            raise PreviewDimNotAllowed(
831
+                'Size {width}x{height} is not allowed for jpeg preview'.format(
832
+                    width=width,
833
+                    height=height,
834
+                )
835
+            )
836
+        jpg_preview_path = self.preview_manager.get_jpeg_preview(
837
+            file_path,
838
+            page=page,
839
+            width=width,
840
+            height=height,
841
+        )
842
+        return jpg_preview_path
744 843
 
745
-    def get_children(self, parent_id: int, content_types: list, workspace: Workspace=None) -> typing.List[Content]:
844
+    def _get_all_query(
845
+        self,
846
+        parent_id: int = None,
847
+        content_type: str = ContentType.Any,
848
+        workspace: Workspace = None
849
+    ) -> Query:
746 850
         """
747
-        Return parent_id childs of given content_types
748
-        :param parent_id: parent id
749
-        :param content_types: list of types
750
-        :param workspace: workspace filter
751
-        :return: list of content
851
+        Extended filter for better "get all data" query
852
+        :param parent_id: filter by parent_id
853
+        :param content_type: filter by content_type slug
854
+        :param workspace: filter by workspace
855
+        :return:
752 856
         """
857
+        assert parent_id is None or isinstance(parent_id, int)
858
+        assert content_type is not None
753 859
         resultset = self._base_query(workspace)
754
-        resultset = resultset.filter(Content.type.in_(content_types))
860
+
861
+        if content_type!=ContentType.Any:
862
+            # INFO - G.M - 2018-07-05 - convert with
863
+            #  content type object to support legacy slug
864
+            content_type_object = ContentType(content_type)
865
+            resultset = resultset.filter(Content.type.in_(content_type_object.get_slug_aliases()))
755 866
 
756 867
         if parent_id:
757 868
             resultset = resultset.filter(Content.parent_id==parent_id)
758
-        if parent_id is False:
869
+        if parent_id == 0 or parent_id is False:
759 870
             resultset = resultset.filter(Content.parent_id == None)
760 871
 
761
-        return resultset.all()
872
+        return resultset
873
+
874
+    def get_all(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> typing.List[Content]:
875
+        return self._get_all_query(parent_id, content_type, workspace).all()
762 876
 
877
+    # TODO - G.M - 2018-07-17 - [Cleanup] Drop this method if unneeded
878
+    # def get_children(self, parent_id: int, content_types: list, workspace: Workspace=None) -> typing.List[Content]:
879
+    #     """
880
+    #     Return parent_id childs of given content_types
881
+    #     :param parent_id: parent id
882
+    #     :param content_types: list of types
883
+    #     :param workspace: workspace filter
884
+    #     :return: list of content
885
+    #     """
886
+    #     resultset = self._base_query(workspace)
887
+    #     resultset = resultset.filter(Content.type.in_(content_types))
888
+    #
889
+    #     if parent_id:
890
+    #         resultset = resultset.filter(Content.parent_id==parent_id)
891
+    #     if parent_id is False:
892
+    #         resultset = resultset.filter(Content.parent_id == None)
893
+    #
894
+    #     return resultset.all()
895
+
896
+    # TODO - G.M - 2018-07-17 - [Cleanup] Drop this method if unneeded
763 897
     # TODO find an other name to filter on is_deleted / is_archived
764 898
     def get_all_with_filter(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> typing.List[Content]:
765 899
         assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
@@ -779,6 +913,7 @@ class ContentApi(object):
779 913
 
780 914
         return resultset.all()
781 915
 
916
+    # TODO - G.M - 2018-07-24 - [Cleanup] Is this method already needed ?
782 917
     def get_all_without_exception(self, content_type: str, workspace: Workspace=None) -> typing.List[Content]:
783 918
         assert content_type is not None# DYN_REMOVE
784 919
 
@@ -839,47 +974,136 @@ class ContentApi(object):
839 974
             .filter(Workspace.is_deleted.is_(False)) \
840 975
             .subquery()
841 976
 
842
-        not_read_content_ids_query = self._session.query(
843
-            distinct(not_read_revisions.c.content_id)
844
-        )
845
-        not_read_content_ids = list(map(
846
-            itemgetter(0),
847
-            not_read_content_ids_query,
848
-        ))
849
-
850
-        not_read_contents = self._base_query(workspace) \
851
-            .filter(Content.content_id.in_(not_read_content_ids)) \
852
-            .order_by(desc(Content.updated))
977
+    # TODO - G.M - 2018-07-17 - [Cleanup] Drop this method if unneeded
978
+    # def get_all_without_exception(self, content_type: str, workspace: Workspace=None) -> typing.List[Content]:
979
+    #     assert content_type is not None# DYN_REMOVE
980
+    #
981
+    #     resultset = self._base_query(workspace)
982
+    #
983
+    #     if content_type != ContentType.Any:
984
+    #         resultset = resultset.filter(Content.type==content_type)
985
+    #
986
+    #     return resultset.all()
987
+
988
+    def get_last_active(
989
+            self,
990
+            workspace: Workspace=None,
991
+            limit: typing.Optional[int]=None,
992
+            before_datetime: typing.Optional[datetime.datetime]= None,
993
+            content_ids: typing.Optional[typing.List[int]] = None,
994
+    ) -> typing.List[Content]:
995
+        """
996
+        get contents list sorted by last update
997
+        (last modification of content itself or one of this comment)
998
+        :param workspace: Workspace to check
999
+        :param limit: maximum number of elements to return
1000
+        :param before_datetime: date from where we check older content.
1001
+        :param content_ids: restrict selection to some content ids and
1002
+        related Comments
1003
+        :return: list of content
1004
+        """
853 1005
 
854
-        if content_type != ContentType.Any:
855
-            not_read_contents = not_read_contents.filter(
856
-                Content.type==content_type)
857
-        else:
858
-            not_read_contents = not_read_contents.filter(
859
-                Content.type!=ContentType.Folder)
1006
+        resultset = self._get_all_query(
1007
+            workspace=workspace,
1008
+        )
1009
+        if content_ids:
1010
+            resultset = resultset.filter(
1011
+                or_(
1012
+                    Content.content_id.in_(content_ids),
1013
+                    and_(
1014
+                        Content.parent_id.in_(content_ids),
1015
+                        Content.type == ContentType.Comment
1016
+                    )
1017
+                )
1018
+            )
860 1019
 
861
-        if parent_id:
862
-            not_read_contents = not_read_contents.filter(
863
-                Content.parent_id==parent_id)
1020
+        resultset = resultset.order_by(desc(Content.updated))
864 1021
 
865
-        result = []
866
-        for item in not_read_contents:
867
-            new_item = None
868
-            if ContentType.Comment == item.type:
869
-                new_item = item.parent
1022
+        active_contents = []
1023
+        too_recent_content = []
1024
+        for content in resultset:
1025
+            related_active_content = None
1026
+            if ContentType.Comment == content.type:
1027
+                related_active_content = content.parent
870 1028
             else:
871
-                new_item = item
1029
+                related_active_content = content
872 1030
 
1031
+            if not before_datetime:
1032
+                before_datetime = datetime.datetime.now()
873 1033
             # INFO - D.A. - 2015-05-20
874 1034
             # We do not want to show only one item if the last 10 items are
875 1035
             # comments about one thread for example
876
-            if new_item not in result:
877
-                result.append(new_item)
878
-
879
-            if len(result) >= limit:
1036
+            if related_active_content not in active_contents and related_active_content not in too_recent_content:  # nopep8
1037
+                # we verify that content is old enough
1038
+                if content.updated < before_datetime:
1039
+                    active_contents.append(related_active_content)
1040
+                else:
1041
+                    too_recent_content.append(related_active_content)
1042
+
1043
+            if limit and len(active_contents) >= limit:
880 1044
                 break
881 1045
 
882
-        return result
1046
+        return active_contents
1047
+
1048
+    # TODO - G.M - 2018-07-19 - Find a way to update this method to something
1049
+    # usable and efficient for tracim v2 to get content with read/unread status
1050
+    # instead of relying on has_new_information_for()
1051
+    # def get_last_unread(self, parent_id: typing.Optional[int], content_type: str,
1052
+    #                     workspace: Workspace=None, limit=10) -> typing.List[Content]:
1053
+    #     assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
1054
+    #     assert content_type is not None# DYN_REMOVE
1055
+    #     assert isinstance(content_type, str) # DYN_REMOVE
1056
+    #
1057
+    #     read_revision_ids = self._session.query(RevisionReadStatus.revision_id) \
1058
+    #         .filter(RevisionReadStatus.user_id==self._user_id)
1059
+    #
1060
+    #     not_read_revisions = self._revisions_base_query(workspace) \
1061
+    #         .filter(~ContentRevisionRO.revision_id.in_(read_revision_ids)) \
1062
+    #         .filter(ContentRevisionRO.workspace_id == Workspace.workspace_id) \
1063
+    #         .filter(Workspace.is_deleted.is_(False)) \
1064
+    #         .subquery()
1065
+    #
1066
+    #     not_read_content_ids_query = self._session.query(
1067
+    #         distinct(not_read_revisions.c.content_id)
1068
+    #     )
1069
+    #     not_read_content_ids = list(map(
1070
+    #         itemgetter(0),
1071
+    #         not_read_content_ids_query,
1072
+    #     ))
1073
+    #
1074
+    #     not_read_contents = self._base_query(workspace) \
1075
+    #         .filter(Content.content_id.in_(not_read_content_ids)) \
1076
+    #         .order_by(desc(Content.updated))
1077
+    #
1078
+    #     if content_type != ContentType.Any:
1079
+    #         not_read_contents = not_read_contents.filter(
1080
+    #             Content.type==content_type)
1081
+    #     else:
1082
+    #         not_read_contents = not_read_contents.filter(
1083
+    #             Content.type!=ContentType.Folder)
1084
+    #
1085
+    #     if parent_id:
1086
+    #         not_read_contents = not_read_contents.filter(
1087
+    #             Content.parent_id==parent_id)
1088
+    #
1089
+    #     result = []
1090
+    #     for item in not_read_contents:
1091
+    #         new_item = None
1092
+    #         if ContentType.Comment == item.type:
1093
+    #             new_item = item.parent
1094
+    #         else:
1095
+    #             new_item = item
1096
+    #
1097
+    #         # INFO - D.A. - 2015-05-20
1098
+    #         # We do not want to show only one item if the last 10 items are
1099
+    #         # comments about one thread for example
1100
+    #         if new_item not in result:
1101
+    #             result.append(new_item)
1102
+    #
1103
+    #         if len(result) >= limit:
1104
+    #             break
1105
+    #
1106
+    #     return result
883 1107
 
884 1108
     def set_allowed_content(self, folder: Content, allowed_content_dict:dict):
885 1109
         """
@@ -985,7 +1209,11 @@ class ContentApi(object):
985 1209
         self.save(item, do_notify=False)
986 1210
 
987 1211
         for child in item.children:
988
-            with new_revision(child):
1212
+            with new_revision(
1213
+                session=self._session,
1214
+                tm=transaction.manager,
1215
+                content=child
1216
+            ):
989 1217
                 self.move_recursively(child, item, new_workspace)
990 1218
         return
991 1219
 
@@ -1058,11 +1286,7 @@ class ContentApi(object):
1058 1286
                        do_flush: bool=True,
1059 1287
                        recursive: bool=True
1060 1288
                        ):
1061
-
1062
-        itemset = self.get_last_unread(None, ContentType.Any)
1063
-
1064
-        for item in itemset:
1065
-            self.mark_read(item, read_datetime, do_flush, recursive)
1289
+        return self.mark_read__workspace(None, read_datetime, do_flush, recursive) # nopep8
1066 1290
 
1067 1291
     def mark_read__workspace(self,
1068 1292
                        workspace : Workspace,
@@ -1070,11 +1294,10 @@ class ContentApi(object):
1070 1294
                        do_flush: bool=True,
1071 1295
                        recursive: bool=True
1072 1296
                        ):
1073
-
1074
-        itemset = self.get_last_unread(None, ContentType.Any, workspace)
1075
-
1297
+        itemset = self.get_last_active(workspace)
1076 1298
         for item in itemset:
1077
-            self.mark_read(item, read_datetime, do_flush, recursive)
1299
+            if item.has_new_information_for(self._user):
1300
+                self.mark_read(item, read_datetime, do_flush, recursive)
1078 1301
 
1079 1302
     def mark_read(self, content: Content,
1080 1303
                   read_datetime: datetime=None,
@@ -1129,7 +1352,10 @@ class ContentApi(object):
1129 1352
             .filter(ContentRevisionRO.content_id==content.content_id).all()
1130 1353
 
1131 1354
         for revision in revisions:
1132
-            del revision.read_by[self._user]
1355
+            try:
1356
+                del revision.read_by[self._user]
1357
+            except KeyError:
1358
+                pass
1133 1359
 
1134 1360
         for child in content.get_valid_children():
1135 1361
             self.mark_unread(child, do_flush=False)
@@ -1232,6 +1458,7 @@ class ContentApi(object):
1232 1458
 
1233 1459
         return ContentType.sorted(content_types)
1234 1460
 
1461
+    # TODO - G.M - 2018-07-24 - [Cleanup] Is this method already needed ?
1235 1462
     def exclude_unavailable(
1236 1463
         self,
1237 1464
         contents: typing.List[Content],
@@ -1245,6 +1472,7 @@ class ContentApi(object):
1245 1472
                 contents.remove(content)
1246 1473
         return contents
1247 1474
 
1475
+    # TODO - G.M - 2018-07-24 - [Cleanup] Is this method already needed ?
1248 1476
     def content_under_deleted(self, content: Content) -> bool:
1249 1477
         if content.parent:
1250 1478
             if content.parent.is_deleted:
@@ -1253,6 +1481,7 @@ class ContentApi(object):
1253 1481
                 return self.content_under_deleted(content.parent)
1254 1482
         return False
1255 1483
 
1484
+    # TODO - G.M - 2018-07-24 - [Cleanup] Is this method already needed ?
1256 1485
     def content_under_archived(self, content: Content) -> bool:
1257 1486
         if content.parent:
1258 1487
             if content.parent.is_archived:
@@ -1261,6 +1490,7 @@ class ContentApi(object):
1261 1490
                 return self.content_under_archived(content.parent)
1262 1491
         return False
1263 1492
 
1493
+    # TODO - G.M - 2018-07-24 - [Cleanup] Is this method already needed ?
1264 1494
     def find_one_by_unique_property(
1265 1495
             self,
1266 1496
             property_name: str,
@@ -1289,6 +1519,7 @@ class ContentApi(object):
1289 1519
         )
1290 1520
         return query.one()
1291 1521
 
1522
+    # TODO - G.M - 2018-07-24 - [Cleanup] Is this method already needed ?
1292 1523
     def generate_folder_label(
1293 1524
             self,
1294 1525
             workspace: Workspace,

+ 63 - 1
tracim/lib/core/user.py View File

@@ -9,15 +9,17 @@ from sqlalchemy.orm.exc import NoResultFound
9 9
 from tracim import CFG
10 10
 from tracim.models.auth import User
11 11
 from tracim.models.auth import Group
12
-from tracim.exceptions import WrongUserPassword
13 12
 from tracim.exceptions import NoUserSetted
14 13
 from tracim.exceptions import PasswordDoNotMatch
14
+from tracim.exceptions import EmailValidationFailed
15 15
 from tracim.exceptions import UserDoesNotExist
16
+from tracim.exceptions import WrongUserPassword
16 17
 from tracim.exceptions import AuthenticationFailed
17 18
 from tracim.exceptions import NotificationNotSend
18 19
 from tracim.exceptions import UserNotActive
19 20
 from tracim.models.context_models import UserInContext
20 21
 from tracim.lib.mail_notifier.notifier import get_email_manager
22
+from tracim.models.context_models import TypeUser
21 23
 
22 24
 
23 25
 class UserApi(object):
@@ -70,7 +72,17 @@ class UserApi(object):
70 72
             raise UserDoesNotExist('User "{}" not found in database'.format(email)) from exc  # nopep8
71 73
         return user
72 74
 
75
+    def get_one_by_public_name(self, public_name: str) -> User:
76
+        """
77
+        Get one user by public_name
78
+        """
79
+        try:
80
+            user = self._base_query().filter(User.display_name == public_name).one()
81
+        except NoResultFound as exc:
82
+            raise UserDoesNotExist('User "{}" not found in database'.format(public_name)) from exc  # nopep8
83
+        return user
73 84
     # FIXME - G.M - 24-04-2018 - Duplicate method with get_one.
85
+
74 86
     def get_one_by_id(self, id: int) -> User:
75 87
         return self.get_one(user_id=id)
76 88
 
@@ -85,6 +97,40 @@ class UserApi(object):
85 97
     def get_all(self) -> typing.Iterable[User]:
86 98
         return self._session.query(User).order_by(User.display_name).all()
87 99
 
100
+    def find(
101
+            self,
102
+            user_id: int=None,
103
+            email: str=None,
104
+            public_name: str=None
105
+    ) -> typing.Tuple[TypeUser, User]:
106
+        """
107
+        Find existing user from all theses params.
108
+        Check is made in this order: user_id, email, public_name
109
+        If no user found raise UserDoesNotExist exception
110
+        """
111
+        user = None
112
+
113
+        if user_id:
114
+            try:
115
+                user = self.get_one(user_id)
116
+                return TypeUser.USER_ID, user
117
+            except UserDoesNotExist:
118
+                pass
119
+        if email:
120
+            try:
121
+                user = self.get_one_by_email(email)
122
+                return TypeUser.EMAIL, user
123
+            except UserDoesNotExist:
124
+                pass
125
+        if public_name:
126
+            try:
127
+                user = self.get_one_by_public_name(public_name)
128
+                return TypeUser.PUBLIC_NAME, user
129
+            except UserDoesNotExist:
130
+                pass
131
+
132
+        raise UserDoesNotExist('User not found with any of given params.')
133
+
88 134
     # Check methods
89 135
 
90 136
     def user_with_email_exists(self, email: str) -> bool:
@@ -182,6 +228,15 @@ class UserApi(object):
182 228
         )
183 229
         return user
184 230
 
231
+    def _check_email(self, email: str) -> bool:
232
+        # TODO - G.M - 2018-07-05 - find a better way to check email
233
+        if not email:
234
+            return False
235
+        email = email.split('@')
236
+        if len(email) != 2:
237
+            return False
238
+        return True
239
+
185 240
     def update(
186 241
             self,
187 242
             user: User,
@@ -196,6 +251,9 @@ class UserApi(object):
196 251
             user.display_name = name
197 252
 
198 253
         if email is not None:
254
+            email_exist = self._check_email(email)
255
+            if not email_exist:
256
+                raise EmailValidationFailed('Email given form {} is uncorrect'.format(email))  # nopep8
199 257
             user.email = email
200 258
 
201 259
         if password is not None:
@@ -260,7 +318,11 @@ class UserApi(object):
260 318
         """Previous create_user method"""
261 319
         user = User()
262 320
 
321
+        email_exist = self._check_email(email)
322
+        if not email_exist:
323
+            raise EmailValidationFailed('Email given form {} is uncorrect'.format(email))  # nopep8
263 324
         user.email = email
325
+        user.display_name = email.split('@')[0]
264 326
 
265 327
         for group in groups:
266 328
             user.groups.append(group)

+ 108 - 72
tracim/lib/core/userworkspace.py View File

@@ -3,6 +3,7 @@ import typing
3 3
 
4 4
 from tracim import CFG
5 5
 from tracim.models.context_models import UserRoleWorkspaceInContext
6
+from tracim.models.roles import WorkspaceRoles
6 7
 
7 8
 __author__ = 'damien'
8 9
 
@@ -11,40 +12,55 @@ from sqlalchemy.orm import Query
11 12
 from tracim.models.auth import User
12 13
 from tracim.models.data import Workspace
13 14
 from tracim.models.data import UserRoleInWorkspace
14
-from tracim.models.data import RoleType
15 15
 
16 16
 
17 17
 class RoleApi(object):
18 18
 
19
-    ALL_ROLE_VALUES = UserRoleInWorkspace.get_all_role_values()
19
+    # TODO - G.M - 29-06-2018 - [Cleanup] Drop this
20
+    # ALL_ROLE_VALUES = UserRoleInWorkspace.get_all_role_values()
20 21
     # Dict containing readable members roles for given role
21
-    members_read_rights = {
22
-        UserRoleInWorkspace.NOT_APPLICABLE: [],
23
-        UserRoleInWorkspace.READER: [
24
-            UserRoleInWorkspace.WORKSPACE_MANAGER,
25
-        ],
26
-        UserRoleInWorkspace.CONTRIBUTOR: [
27
-            UserRoleInWorkspace.WORKSPACE_MANAGER,
28
-            UserRoleInWorkspace.CONTENT_MANAGER,
29
-            UserRoleInWorkspace.CONTRIBUTOR,
30
-        ],
31
-        UserRoleInWorkspace.CONTENT_MANAGER: [
32
-            UserRoleInWorkspace.WORKSPACE_MANAGER,
33
-            UserRoleInWorkspace.CONTENT_MANAGER,
34
-            UserRoleInWorkspace.CONTRIBUTOR,
35
-            UserRoleInWorkspace.READER,
36
-        ],
37
-        UserRoleInWorkspace.WORKSPACE_MANAGER: [
38
-            UserRoleInWorkspace.WORKSPACE_MANAGER,
39
-            UserRoleInWorkspace.CONTENT_MANAGER,
40
-            UserRoleInWorkspace.CONTRIBUTOR,
41
-            UserRoleInWorkspace.READER,
42
-        ],
43
-    }
22
+    # members_read_rights = {
23
+    #     UserRoleInWorkspace.NOT_APPLICABLE: [],
24
+    #     UserRoleInWorkspace.READER: [
25
+    #         UserRoleInWorkspace.WORKSPACE_MANAGER,
26
+    #     ],
27
+    #     UserRoleInWorkspace.CONTRIBUTOR: [
28
+    #         UserRoleInWorkspace.WORKSPACE_MANAGER,
29
+    #         UserRoleInWorkspace.CONTENT_MANAGER,
30
+    #         UserRoleInWorkspace.CONTRIBUTOR,
31
+    #     ],
32
+    #     UserRoleInWorkspace.CONTENT_MANAGER: [
33
+    #         UserRoleInWorkspace.WORKSPACE_MANAGER,
34
+    #         UserRoleInWorkspace.CONTENT_MANAGER,
35
+    #         UserRoleInWorkspace.CONTRIBUTOR,
36
+    #         UserRoleInWorkspace.READER,
37
+    #     ],
38
+    #     UserRoleInWorkspace.WORKSPACE_MANAGER: [
39
+    #         UserRoleInWorkspace.WORKSPACE_MANAGER,
40
+    #         UserRoleInWorkspace.CONTENT_MANAGER,
41
+    #         UserRoleInWorkspace.CONTRIBUTOR,
42
+    #         UserRoleInWorkspace.READER,
43
+    #     ],
44
+    # }
45
+
46
+    # TODO - G.M - 29-06-2018 - [Cleanup] Drop this
47
+    # @classmethod
48
+    # def role_can_read_member_role(cls, reader_role: int, tested_role: int) \
49
+    #         -> bool:
50
+    #     """
51
+    #     :param reader_role: role as viewer
52
+    #     :param tested_role: role as viwed
53
+    #     :return: True if given role can view member role in workspace.
54
+    #     """
55
+    #     if reader_role in cls.members_read_rights:
56
+    #         return tested_role in cls.members_read_rights[reader_role]
57
+    #     return False
44 58
 
45 59
     def get_user_role_workspace_with_context(
46 60
             self,
47
-            user_role: UserRoleInWorkspace
61
+            user_role: UserRoleInWorkspace,
62
+            newly_created:bool = None,
63
+            email_sent: bool = None,
48 64
     ) -> UserRoleWorkspaceInContext:
49 65
         """
50 66
         Return WorkspaceInContext object from Workspace
@@ -54,27 +70,11 @@ class RoleApi(object):
54 70
             user_role=user_role,
55 71
             dbsession=self._session,
56 72
             config=self._config,
73
+            newly_created=newly_created,
74
+            email_sent=email_sent,
57 75
         )
58 76
         return workspace
59 77
 
60
-    @classmethod
61
-    def role_can_read_member_role(cls, reader_role: int, tested_role: int) \
62
-            -> bool:
63
-        """
64
-        :param reader_role: role as viewer
65
-        :param tested_role: role as viwed
66
-        :return: True if given role can view member role in workspace.
67
-        """
68
-        if reader_role in cls.members_read_rights:
69
-            return tested_role in cls.members_read_rights[reader_role]
70
-        return False
71
-
72
-    @classmethod
73
-    def create_role(cls) -> UserRoleInWorkspace:
74
-        role = UserRoleInWorkspace()
75
-
76
-        return role
77
-
78 78
     def __init__(
79 79
         self,
80 80
         session: Session,
@@ -98,6 +98,29 @@ class RoleApi(object):
98 98
     def get_one(self, user_id: int, workspace_id: int) -> UserRoleInWorkspace:
99 99
         return self._get_one_rsc(user_id, workspace_id).one()
100 100
 
101
+    def update_role(
102
+        self,
103
+        role: UserRoleInWorkspace,
104
+        role_level: int,
105
+        with_notif: typing.Optional[bool] = None,
106
+        save_now: bool=False,
107
+    ):
108
+        """
109
+        Update role of user in this workspace
110
+        :param role: UserRoleInWorkspace object
111
+        :param role_level: level of new role wanted
112
+        :param with_notif: is user notification enabled in this workspace ?
113
+        :param save_now: database flush
114
+        :return: updated role
115
+        """
116
+        role.role = role_level
117
+        if with_notif is not None:
118
+            role.do_notify == with_notif
119
+        if save_now:
120
+            self.save(role)
121
+
122
+        return role
123
+
101 124
     def create_one(
102 125
         self,
103 126
         user: User,
@@ -106,7 +129,7 @@ class RoleApi(object):
106 129
         with_notif: bool,
107 130
         flush: bool=True
108 131
     ) -> UserRoleInWorkspace:
109
-        role = self.create_role()
132
+        role = UserRoleInWorkspace()
110 133
         role.user_id = user.user_id
111 134
         role.workspace = workspace
112 135
         role.role = role_level
@@ -120,20 +143,6 @@ class RoleApi(object):
120 143
         if flush:
121 144
             self._session.flush()
122 145
 
123
-    def _get_all_for_user(self, user_id) -> typing.List[UserRoleInWorkspace]:
124
-        return self._session.query(UserRoleInWorkspace)\
125
-            .filter(UserRoleInWorkspace.user_id == user_id)
126
-
127
-    def get_all_for_user(self, user: User) -> typing.List[UserRoleInWorkspace]:
128
-        return self._get_all_for_user(user.user_id).all()
129
-
130
-    def get_all_for_user_order_by_workspace(
131
-        self,
132
-        user_id: int
133
-    ) -> typing.List[UserRoleInWorkspace]:
134
-        return self._get_all_for_user(user_id)\
135
-            .join(UserRoleInWorkspace.workspace).order_by(Workspace.label).all()
136
-
137 146
     def get_all_for_workspace(
138 147
         self,
139 148
         workspace:Workspace
@@ -145,18 +154,45 @@ class RoleApi(object):
145 154
     def save(self, role: UserRoleInWorkspace) -> None:
146 155
         self._session.flush()
147 156
 
148
-    # TODO - G.M - 07-06-2018 - [Cleanup] Check if this method is already needed
149
-    @classmethod
150
-    def get_roles_for_select_field(cls) -> typing.List[RoleType]:
151
-        """
152
-
153
-        :return: list of DictLikeClass instances representing available Roles
154
-        (to be used in select fields)
155
-        """
156
-        result = list()
157 157
 
158
-        for role_id in UserRoleInWorkspace.get_all_role_values():
159
-            role = RoleType(role_id)
160
-            result.append(role)
158
+    # TODO - G.M - 29-06-2018 - [Cleanup] Drop this
159
+    # @classmethod
160
+    # def role_can_read_member_role(cls, reader_role: int, tested_role: int) \
161
+    #         -> bool:
162
+    #     """
163
+    #     :param reader_role: role as viewer
164
+    #     :param tested_role: role as viwed
165
+    #     :return: True if given role can view member role in workspace.
166
+    #     """
167
+    #     if reader_role in cls.members_read_rights:
168
+    #         return tested_role in cls.members_read_rights[reader_role]
169
+    #     return False
170
+    # def _get_all_for_user(self, user_id) -> typing.List[UserRoleInWorkspace]:
171
+    #     return self._session.query(UserRoleInWorkspace)\
172
+    #         .filter(UserRoleInWorkspace.user_id == user_id)
173
+    #
174
+    # def get_all_for_user(self, user: User) -> typing.List[UserRoleInWorkspace]:
175
+    #     return self._get_all_for_user(user.user_id).all()
176
+    #
177
+    # def get_all_for_user_order_by_workspace(
178
+    #     self,
179
+    #     user_id: int
180
+    # ) -> typing.List[UserRoleInWorkspace]:
181
+    #     return self._get_all_for_user(user_id)\
182
+    #         .join(UserRoleInWorkspace.workspace).order_by(Workspace.label).all()
161 183
 
162
-        return result
184
+    # TODO - G.M - 07-06-2018 - [Cleanup] Check if this method is already needed
185
+    # @classmethod
186
+    # def get_roles_for_select_field(cls) -> typing.List[RoleType]:
187
+    #     """
188
+    #
189
+    #     :return: list of DictLikeClass instances representing available Roles
190
+    #     (to be used in select fields)
191
+    #     """
192
+    #     result = list()
193
+    #
194
+    #     for role_id in UserRoleInWorkspace.get_all_role_values():
195
+    #         role = RoleType(role_id)
196
+    #         result.append(role)
197
+    #
198
+    #     return result

+ 27 - 1
tracim/lib/core/workspace.py View File

@@ -5,6 +5,7 @@ from sqlalchemy.orm import Query
5 5
 from sqlalchemy.orm import Session
6 6
 
7 7
 from tracim import CFG
8
+from tracim.exceptions import EmptyLabelNotAllowed
8 9
 from tracim.lib.utils.translation import fake_translator as _
9 10
 
10 11
 from tracim.lib.core.userworkspace import RoleApi
@@ -69,7 +70,7 @@ class WorkspaceApi(object):
69 70
             save_now: bool=False,
70 71
     ) -> Workspace:
71 72
         if not label:
72
-            label = self.generate_label()
73
+            raise EmptyLabelNotAllowed('Workspace label cannot be empty')
73 74
 
74 75
         workspace = Workspace()
75 76
         workspace.label = label
@@ -105,6 +106,31 @@ class WorkspaceApi(object):
105 106
 
106 107
         return workspace
107 108
 
109
+    def update_workspace(
110
+            self,
111
+            workspace: Workspace,
112
+            label: str,
113
+            description: str,
114
+            save_now: bool=False,
115
+    ) -> Workspace:
116
+        """
117
+        Update workspace
118
+        :param workspace: workspace to update
119
+        :param label: new label of workspace
120
+        :param description: new description
121
+        :param save_now: database flush
122
+        :return: updated workspace
123
+        """
124
+        if not label:
125
+            raise EmptyLabelNotAllowed('Workspace label cannot be empty')
126
+        workspace.label = label
127
+        workspace.description = description
128
+
129
+        if save_now:
130
+            self.save(workspace)
131
+
132
+        return workspace
133
+
108 134
     def get_one(self, id):
109 135
         return self._base_query().filter(Workspace.workspace_id == id).one()
110 136
 

+ 22 - 0
tracim/models/contents.py View File

@@ -251,6 +251,19 @@ class ContentTypeLegacy(NewContentType):
251 251
                 return
252 252
         raise ContentTypeNotExist()
253 253
 
254
+    def get_slug_aliases(self) -> typing.List[str]:
255
+        """
256
+        Get all slug aliases of a content,
257
+        useful for legacy code convertion
258
+        """
259
+        # TODO - G.M - 2018-07-05 - Remove this legacy compat code
260
+        # when possible.
261
+        page_alias = [self.Page, self.PageLegacy]
262
+        if self.slug in page_alias:
263
+            return page_alias
264
+        else:
265
+            return [self.slug]
266
+
254 267
     @classmethod
255 268
     def all(cls) -> typing.List[str]:
256 269
         return cls.allowed_types()
@@ -261,6 +274,15 @@ class ContentTypeLegacy(NewContentType):
261 274
         return contents_types
262 275
 
263 276
     @classmethod
277
+    def allowed_type_values(cls) -> typing.List[str]:
278
+        """
279
+        All content type slug + special values like any
280
+        """
281
+        content_types = cls.allowed_types()
282
+        content_types.append(ContentTypeLegacy.Any)
283
+        return content_types
284
+
285
+    @classmethod
264 286
     def allowed_types_for_folding(cls):
265 287
         # This method is used for showing only "main"
266 288
         # types in the left-side treeview

+ 173 - 3
tracim/models/context_models.py View File

@@ -1,20 +1,35 @@
1 1
 # coding=utf-8
2 2
 import typing
3 3
 from datetime import datetime
4
+from enum import Enum
4 5
 
5 6
 from slugify import slugify
6 7
 from sqlalchemy.orm import Session
7 8
 from tracim import CFG
9
+from tracim.config import PreviewDim
8 10
 from tracim.models import User
9 11
 from tracim.models.auth import Profile
10 12
 from tracim.models.data import Content
11 13
 from tracim.models.data import ContentRevisionRO
12
-from tracim.models.data import Workspace, UserRoleInWorkspace
14
+from tracim.models.data import Workspace
15
+from tracim.models.data import UserRoleInWorkspace
16
+from tracim.models.roles import WorkspaceRoles
13 17
 from tracim.models.workspace_menu_entries import default_workspace_menu_entry
14 18
 from tracim.models.workspace_menu_entries import WorkspaceMenuEntry
15 19
 from tracim.models.contents import ContentTypeLegacy as ContentType
16 20
 
17 21
 
22
+class PreviewAllowedDim(object):
23
+
24
+    def __init__(
25
+            self,
26
+            restricted:bool,
27
+            dimensions: typing.List[PreviewDim]
28
+    ) -> None:
29
+        self.restricted = restricted
30
+        self.dimensions = dimensions
31
+
32
+
18 33
 class MoveParams(object):
19 34
     """
20 35
     Json body params for move action model
@@ -104,6 +119,58 @@ class WorkspaceAndContentPath(object):
104 119
         self.workspace_id = workspace_id
105 120
 
106 121
 
122
+class WorkspaceAndContentRevisionPath(object):
123
+    """
124
+    Paths params with workspace id and content_id model
125
+    """
126
+    def __init__(self, workspace_id: int, content_id: int, revision_id) -> None:
127
+        self.content_id = content_id
128
+        self.revision_id = revision_id
129
+        self.workspace_id = workspace_id
130
+
131
+
132
+class ContentPreviewSizedPath(object):
133
+    """
134
+    Paths params with workspace id and content_id, width, heigth
135
+    """
136
+    def __init__(self, workspace_id: int, content_id: int, width: int, height: int) -> None:  # nopep8
137
+        self.content_id = content_id
138
+        self.workspace_id = workspace_id
139
+        self.width = width
140
+        self.height = height
141
+
142
+
143
+class RevisionPreviewSizedPath(object):
144
+    """
145
+    Paths params with workspace id and content_id, revision_id width, heigth
146
+    """
147
+    def __init__(self, workspace_id: int, content_id: int, revision_id: int, width: int, height: int) -> None:  # nopep8
148
+        self.content_id = content_id
149
+        self.revision_id = revision_id
150
+        self.workspace_id = workspace_id
151
+        self.width = width
152
+        self.height = height
153
+
154
+
155
+class WorkspaceAndUserPath(object):
156
+    """
157
+    Paths params with workspace id and user_id
158
+    """
159
+    def __init__(self, workspace_id: int, user_id: int):
160
+        self.workspace_id = workspace_id
161
+        self.user_id = workspace_id
162
+
163
+
164
+class UserWorkspaceAndContentPath(object):
165
+    """
166
+    Paths params with user_id, workspace id and content_id model
167
+    """
168
+    def __init__(self, user_id: int, workspace_id: int, content_id: int) -> None:  # nopep8
169
+        self.content_id = content_id
170
+        self.workspace_id = workspace_id
171
+        self.user_id = user_id
172
+
173
+
107 174
 class CommentPath(object):
108 175
     """
109 176
     Paths params with workspace id and content_id and comment_id model
@@ -119,21 +186,97 @@ class CommentPath(object):
119 186
         self.comment_id = comment_id
120 187
 
121 188
 
189
+class PageQuery(object):
190
+    """
191
+    Page query model
192
+    """
193
+    def __init__(
194
+            self,
195
+            page: int = 0
196
+    ):
197
+        self.page = page
198
+
199
+
122 200
 class ContentFilter(object):
123 201
     """
124 202
     Content filter model
125 203
     """
126 204
     def __init__(
127 205
             self,
206
+            workspace_id: int = None,
128 207
             parent_id: int = None,
129 208
             show_archived: int = 0,
130 209
             show_deleted: int = 0,
131 210
             show_active: int = 1,
211
+            content_type: str = None,
212
+            offset: int = None,
213
+            limit: int = None,
132 214
     ) -> None:
133 215
         self.parent_id = parent_id
216
+        self.workspace_id = workspace_id
134 217
         self.show_archived = bool(show_archived)
135 218
         self.show_deleted = bool(show_deleted)
136 219
         self.show_active = bool(show_active)
220
+        self.limit = limit
221
+        self.offset = offset
222
+        self.content_type = content_type
223
+
224
+
225
+class ActiveContentFilter(object):
226
+    def __init__(
227
+            self,
228
+            limit: int = None,
229
+            before_datetime: datetime = None,
230
+    ):
231
+        self.limit = limit
232
+        self.before_datetime = before_datetime
233
+
234
+
235
+class ContentIdsQuery(object):
236
+    def __init__(
237
+            self,
238
+            contents_ids: typing.List[int] = None,
239
+    ):
240
+        self.contents_ids = contents_ids
241
+
242
+
243
+class RoleUpdate(object):
244
+    """
245
+    Update role
246
+    """
247
+    def __init__(
248
+        self,
249
+        role: str,
250
+    ):
251
+        self.role = role
252
+
253
+
254
+class WorkspaceMemberInvitation(object):
255
+    """
256
+    Workspace Member Invitation
257
+    """
258
+    def __init__(
259
+        self,
260
+        user_id: int,
261
+        user_email_or_public_name: str,
262
+        role: str,
263
+    ):
264
+        self.role = role
265
+        self.user_email_or_public_name = user_email_or_public_name
266
+        self.user_id = user_id
267
+
268
+
269
+class WorkspaceUpdate(object):
270
+    """
271
+    Update workspace
272
+    """
273
+    def __init__(
274
+        self,
275
+        label: str,
276
+        description: str,
277
+    ):
278
+        self.label = label
279
+        self.description = description
137 280
 
138 281
 
139 282
 class ContentCreation(object):
@@ -186,6 +329,13 @@ class TextBasedContentUpdate(object):
186 329
         self.raw_content = raw_content
187 330
 
188 331
 
332
+class TypeUser(Enum):
333
+    """Params used to find user"""
334
+    USER_ID = 'found_id'
335
+    EMAIL = 'found_email'
336
+    PUBLIC_NAME = 'found_public_name'
337
+
338
+
189 339
 class UserInContext(object):
190 340
     """
191 341
     Interface to get User data and User data related to context.
@@ -315,10 +465,16 @@ class UserRoleWorkspaceInContext(object):
315 465
             user_role: UserRoleInWorkspace,
316 466
             dbsession: Session,
317 467
             config: CFG,
468
+            # Extended params
469
+            newly_created: bool = None,
470
+            email_sent: bool = None
318 471
     )-> None:
319 472
         self.user_role = user_role
320 473
         self.dbsession = dbsession
321 474
         self.config = config
475
+        # Extended params
476
+        self.newly_created = newly_created
477
+        self.email_sent = email_sent
322 478
 
323 479
     @property
324 480
     def user_id(self) -> int:
@@ -358,7 +514,11 @@ class UserRoleWorkspaceInContext(object):
358 514
         'contributor', 'content-manager', 'workspace-manager'
359 515
         :return: user workspace role as slug.
360 516
         """
361
-        return UserRoleInWorkspace.SLUG[self.user_role.role]
517
+        return WorkspaceRoles.get_role_from_level(self.user_role.role).slug
518
+
519
+    @property
520
+    def is_active(self) -> bool:
521
+        return self.user.is_active
362 522
 
363 523
     @property
364 524
     def user(self) -> UserInContext:
@@ -390,10 +550,11 @@ class ContentInContext(object):
390 550
     Interface to get Content data and Content data related to context.
391 551
     """
392 552
 
393
-    def __init__(self, content: Content, dbsession: Session, config: CFG):
553
+    def __init__(self, content: Content, dbsession: Session, config: CFG, user: User=None):  # nopep8
394 554
         self.content = content
395 555
         self.dbsession = dbsession
396 556
         self.config = config
557
+        self._user = user
397 558
 
398 559
     # Default
399 560
     @property
@@ -486,6 +647,11 @@ class ContentInContext(object):
486 647
     def slug(self):
487 648
         return slugify(self.content.label)
488 649
 
650
+    @property
651
+    def read_by_user(self):
652
+        assert self._user
653
+        return not self.content.has_new_information_for(self._user)
654
+
489 655
 
490 656
 class RevisionInContext(object):
491 657
     """
@@ -519,6 +685,10 @@ class RevisionInContext(object):
519 685
         return self.revision.label
520 686
 
521 687
     @property
688
+    def revision_type(self) -> str:
689
+        return self.revision.revision_type
690
+
691
+    @property
522 692
     def content_type(self) -> str:
523 693
         content_type = ContentType(self.revision.type)
524 694
         if content_type:

+ 47 - 32
tracim/models/data.py View File

@@ -30,6 +30,7 @@ from tracim.lib.utils.translation import get_locale
30 30
 from tracim.exceptions import ContentRevisionUpdateError
31 31
 from tracim.models.meta import DeclarativeBase
32 32
 from tracim.models.auth import User
33
+from tracim.models.roles import WorkspaceRoles
33 34
 
34 35
 DEFAULT_PROPERTIES = dict(
35 36
     allowed_content=dict(
@@ -124,26 +125,27 @@ class UserRoleInWorkspace(DeclarativeBase):
124 125
     workspace = relationship('Workspace', remote_side=[Workspace.workspace_id], backref='roles', lazy='joined')
125 126
     user = relationship('User', remote_side=[User.user_id], backref='roles')
126 127
 
127
-    NOT_APPLICABLE = 0
128
-    READER = 1
129
-    CONTRIBUTOR = 2
130
-    CONTENT_MANAGER = 4
131
-    WORKSPACE_MANAGER = 8
132
-
133
-    SLUG = {
134
-        NOT_APPLICABLE: 'not-applicable',
135
-        READER: 'reader',
136
-        CONTRIBUTOR: 'contributor',
137
-        CONTENT_MANAGER: 'content-manager',
138
-        WORKSPACE_MANAGER: 'workspace-manager',
139
-    }
128
+    NOT_APPLICABLE = WorkspaceRoles.NOT_APPLICABLE.level
129
+    READER = WorkspaceRoles.READER.level
130
+    CONTRIBUTOR = WorkspaceRoles.CONTRIBUTOR.level
131
+    CONTENT_MANAGER = WorkspaceRoles.CONTENT_MANAGER.level
132
+    WORKSPACE_MANAGER = WorkspaceRoles.WORKSPACE_MANAGER.level
140 133
 
141
-    LABEL = dict()
142
-    LABEL[0] = l_('N/A')
143
-    LABEL[1] = l_('Reader')
144
-    LABEL[2] = l_('Contributor')
145
-    LABEL[4] = l_('Content Manager')
146
-    LABEL[8] = l_('Workspace Manager')
134
+    # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
135
+    # SLUG = {
136
+    #     NOT_APPLICABLE: 'not-applicable',
137
+    #     READER: 'reader',
138
+    #     CONTRIBUTOR: 'contributor',
139
+    #     CONTENT_MANAGER: 'content-manager',
140
+    #     WORKSPACE_MANAGER: 'workspace-manager',
141
+    # }
142
+
143
+    # LABEL = dict()
144
+    # LABEL[0] = l_('N/A')
145
+    # LABEL[1] = l_('Reader')
146
+    # LABEL[2] = l_('Contributor')
147
+    # LABEL[4] = l_('Content Manager')
148
+    # LABEL[8] = l_('Workspace Manager')
147 149
     # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
148 150
     #
149 151
     # STYLE = dict()
@@ -170,20 +172,18 @@ class UserRoleInWorkspace(DeclarativeBase):
170 172
     #     return UserRoleInWorkspace.STYLE[self.role]
171 173
     #
172 174
 
175
+    def role_object(self):
176
+        return WorkspaceRoles.get_role_from_level(level=self.role)
177
+
173 178
     def role_as_label(self):
174
-        return UserRoleInWorkspace.LABEL[self.role]
179
+        return self.role_object().label
175 180
 
176 181
     @classmethod
177 182
     def get_all_role_values(cls) -> typing.List[int]:
178 183
         """
179 184
         Return all valid role value
180 185
         """
181
-        return [
182
-            UserRoleInWorkspace.READER,
183
-            UserRoleInWorkspace.CONTRIBUTOR,
184
-            UserRoleInWorkspace.CONTENT_MANAGER,
185
-            UserRoleInWorkspace.WORKSPACE_MANAGER
186
-        ]
186
+        return [role.level for role in WorkspaceRoles.get_all_valid_role()]
187 187
 
188 188
     @classmethod
189 189
     def get_all_role_slug(cls) -> typing.List[str]:
@@ -193,13 +193,12 @@ class UserRoleInWorkspace(DeclarativeBase):
193 193
         # INFO - G.M - 25-05-2018 - Be carefull, as long as this method
194 194
         # and get_all_role_values are both used for API, this method should
195 195
         # return item in the same order as get_all_role_values
196
-        return [cls.SLUG[value] for value in cls.get_all_role_values()]
196
+        return [role.slug for role in WorkspaceRoles.get_all_valid_role()]
197 197
 
198
-
199
-class RoleType(object):
200
-    def __init__(self, role_id):
201
-        self.role_type_id = role_id
202
-        # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
198
+# TODO - G.M - 10-04-2018 - [Cleanup] Drop this
199
+# class RoleType(object):
200
+#     def __init__(self, role_id):
201
+#         self.role_type_id = role_id
203 202
         # self.fa_icon = UserRoleInWorkspace.ICON[role_id]
204 203
         # self.role_label = UserRoleInWorkspace.LABEL[role_id]
205 204
         # self.css_style = UserRoleInWorkspace.STYLE[role_id]
@@ -1257,7 +1256,23 @@ class Content(DeclarativeBase):
1257 1256
     def get_last_action(self) -> ActionDescription:
1258 1257
         return ActionDescription(self.revision_type)
1259 1258
 
1259
+    def get_simple_last_activity_date(self) -> datetime_root.datetime:
1260
+        """
1261
+        Get last activity_date, comments_included. Do not search recursively
1262
+        in revision or children.
1263
+        :return:
1264
+        """
1265
+        last_revision_date = self.updated
1266
+        for comment in self.get_comments():
1267
+            if comment.updated > last_revision_date:
1268
+                last_revision_date = comment.updated
1269
+        return last_revision_date
1270
+
1260 1271
     def get_last_activity_date(self) -> datetime_root.datetime:
1272
+        """
1273
+        Get last activity date with complete recursive search
1274
+        :return:
1275
+        """
1261 1276
         last_revision_date = self.updated
1262 1277
         for revision in self.revisions:
1263 1278
             if revision.updated > last_revision_date:

+ 61 - 0
tracim/models/roles.py View File

@@ -0,0 +1,61 @@
1
+import typing
2
+from enum import Enum
3
+
4
+from tracim.exceptions import RoleDoesNotExist
5
+
6
+
7
+class WorkspaceRoles(Enum):
8
+    """
9
+    Available role for workspace.
10
+    All roles should have a unique level and unique slug.
11
+    level is role value store in database and is also use for
12
+    permission check.
13
+    slug is for http endpoints and other place where readability is
14
+    needed.
15
+    """
16
+    NOT_APPLICABLE = (0, 'not-applicable')
17
+    READER = (1, 'reader')
18
+    CONTRIBUTOR = (2, 'contributor')
19
+    CONTENT_MANAGER = (4, 'content-manager')
20
+    WORKSPACE_MANAGER = (8, 'workspace-manager')
21
+
22
+    def __init__(self, level, slug):
23
+        self.level = level
24
+        self.slug = slug
25
+    
26
+    @property
27
+    def label(self):
28
+        """ Return valid label associated to role"""
29
+        # TODO - G.M - 2018-06-180 - Make this work correctly
30
+        return self.slug
31
+
32
+    @classmethod
33
+    def get_all_valid_role(cls) -> typing.List['WorkspaceRoles']:
34
+        """
35
+        Return all valid role value
36
+        """
37
+        return [item for item in list(WorkspaceRoles) if item.level > 0]
38
+
39
+    @classmethod
40
+    def get_role_from_level(cls, level: int) -> 'WorkspaceRoles':
41
+        """
42
+        Obtain Workspace role from a level value
43
+        :param level: level value as int
44
+        :return: correct workspace role related
45
+        """
46
+        roles = [item for item in list(WorkspaceRoles) if item.level == level]
47
+        if len(roles) != 1:
48
+            raise RoleDoesNotExist()
49
+        return roles[0]
50
+
51
+    @classmethod
52
+    def get_role_from_slug(cls, slug: str) -> 'WorkspaceRoles':
53
+        """
54
+        Obtain Workspace role from a slug value
55
+        :param slug: slug value as str
56
+        :return: correct workspace role related
57
+        """
58
+        roles = [item for item in list(WorkspaceRoles) if item.slug == slug]
59
+        if len(roles) != 1:
60
+            raise RoleDoesNotExist()
61
+        return roles[0]

+ 13 - 1
tracim/tests/__init__.py View File

@@ -25,6 +25,8 @@ from tracim.config import CFG
25 25
 from tracim.extensions import hapic
26 26
 from tracim import web
27 27
 from webtest import TestApp
28
+from io import BytesIO
29
+from PIL import Image
28 30
 
29 31
 
30 32
 def eq_(a, b, msg=None):
@@ -54,6 +56,15 @@ def set_html_document_slug_to_legacy(session_factory) -> None:
54 56
     assert content_query.count() > 0
55 57
 
56 58
 
59
+def create_1000px_png_test_image():
60
+    file = BytesIO()
61
+    image = Image.new('RGBA', size=(1000, 1000), color=(0, 0, 0))
62
+    image.save(file, 'png')
63
+    file.name = 'test_image.png'
64
+    file.seek(0)
65
+    return file
66
+
67
+
57 68
 class FunctionalTest(unittest.TestCase):
58 69
 
59 70
     fixtures = [BaseFixture]
@@ -68,7 +79,8 @@ class FunctionalTest(unittest.TestCase):
68 79
             'depot_storage_dir': '/tmp/test/depot',
69 80
             'depot_storage_name': 'test',
70 81
             'preview_cache_dir': '/tmp/test/preview_cache',
71
-
82
+            'preview.jpg.restricted_dims': True,
83
+            'email.notification.activated': 'false',
72 84
         }
73 85
         hapic.reset_context()
74 86
         self.engine = get_engine(self.settings)

File diff suppressed because it is too large
+ 1487 - 142
tracim/tests/functional/test_contents.py


+ 710 - 2
tracim/tests/functional/test_user.py View File

@@ -2,19 +2,727 @@
2 2
 """
3 3
 Tests for /api/v2/users subpath endpoints.
4 4
 """
5
+from time import sleep
6
+import pytest
5 7
 import transaction
6 8
 
7 9
 from tracim import models
8
-from tracim.lib.core.group import GroupApi
10
+from tracim.lib.core.content import ContentApi
9 11
 from tracim.lib.core.user import UserApi
12
+from tracim.lib.core.group import GroupApi
13
+from tracim.lib.core.workspace import WorkspaceApi
10 14
 from tracim.models import get_tm_session
15
+from tracim.models.contents import ContentTypeLegacy as ContentType
16
+from tracim.models.revision_protection import new_revision
11 17
 from tracim.tests import FunctionalTest
12 18
 from tracim.fixtures.content import Content as ContentFixtures
13 19
 from tracim.fixtures.users_and_groups import Base as BaseFixture
14 20
 
15 21
 
22
+class TestUserRecentlyActiveContentEndpoint(FunctionalTest):
23
+    """
24
+    Tests for /api/v2/users/{user_id}/workspaces/{workspace_id}/contents/recently_active # nopep8
25
+    """
26
+    fixtures = [BaseFixture]
27
+
28
+    def test_api__get_recently_active_content__ok__200__nominal_case(self):
29
+
30
+        # init DB
31
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
32
+        admin = dbsession.query(models.User) \
33
+            .filter(models.User.email == 'admin@admin.admin') \
34
+            .one()
35
+        workspace_api = WorkspaceApi(
36
+            current_user=admin,
37
+            session=dbsession,
38
+            config=self.app_config
39
+
40
+        )
41
+        workspace = WorkspaceApi(
42
+            current_user=admin,
43
+            session=dbsession,
44
+            config=self.app_config,
45
+        ).create_workspace(
46
+            'test workspace',
47
+            save_now=True
48
+        )
49
+        workspace2 = WorkspaceApi(
50
+            current_user=admin,
51
+            session=dbsession,
52
+            config=self.app_config,
53
+        ).create_workspace(
54
+            'test workspace2',
55
+            save_now=True
56
+        )
57
+
58
+        api = ContentApi(
59
+            current_user=admin,
60
+            session=dbsession,
61
+            config=self.app_config,
62
+        )
63
+        main_folder_workspace2 = api.create(ContentType.Folder, workspace2, None, 'Hepla', '', True)  # nopep8
64
+        main_folder = api.create(ContentType.Folder, workspace, None, 'this is randomized folder', '', True)  # nopep8
65
+        # creation order test
66
+        firstly_created = api.create(ContentType.Page, workspace, main_folder, 'creation_order_test', '', True)  # nopep8
67
+        secondly_created = api.create(ContentType.Page, workspace, main_folder, 'another creation_order_test', '', True)  # nopep8
68
+        # update order test
69
+        firstly_created_but_recently_updated = api.create(ContentType.Page, workspace, main_folder, 'update_order_test', '', True)  # nopep8
70
+        secondly_created_but_not_updated = api.create(ContentType.Page, workspace, main_folder, 'another update_order_test', '', True)  # nopep8
71
+        with new_revision(
72
+            session=dbsession,
73
+            tm=transaction.manager,
74
+            content=firstly_created_but_recently_updated,
75
+        ):
76
+            firstly_created_but_recently_updated.description = 'Just an update'
77
+        api.save(firstly_created_but_recently_updated)
78
+        # comment change order
79
+        firstly_created_but_recently_commented = api.create(ContentType.Page, workspace, main_folder, 'this is randomized label content', '', True)  # nopep8
80
+        secondly_created_but_not_commented = api.create(ContentType.Page, workspace, main_folder, 'this is another randomized label content', '', True)  # nopep8
81
+        comments = api.create_comment(workspace, firstly_created_but_recently_commented, 'juste a super comment', True)  # nopep8
82
+        content_workspace_2 = api.create(ContentType.Page, workspace2,main_folder_workspace2, 'content_workspace_2', '',True)  # nopep8
83
+        dbsession.flush()
84
+        transaction.commit()
85
+
86
+        self.testapp.authorization = (
87
+            'Basic',
88
+            (
89
+                'admin@admin.admin',
90
+                'admin@admin.admin'
91
+            )
92
+        )
93
+        res = self.testapp.get('/api/v2/users/1/workspaces/{}/contents/recently_active'.format(workspace.workspace_id), status=200) # nopep8
94
+        res = res.json_body
95
+        assert len(res) == 7
96
+        for elem in res:
97
+            assert isinstance(elem['content_id'], int)
98
+            assert isinstance(elem['content_type'], str)
99
+            assert elem['content_type'] != 'comments'
100
+            assert isinstance(elem['is_archived'], bool)
101
+            assert isinstance(elem['is_deleted'], bool)
102
+            assert isinstance(elem['label'], str)
103
+            assert isinstance(elem['parent_id'], int) or elem['parent_id'] is None
104
+            assert isinstance(elem['show_in_ui'], bool)
105
+            assert isinstance(elem['slug'], str)
106
+            assert isinstance(elem['status'], str)
107
+            assert isinstance(elem['sub_content_types'], list)
108
+            for sub_content_type in elem['sub_content_types']:
109
+                assert isinstance(sub_content_type, str)
110
+            assert isinstance(elem['workspace_id'], int)
111
+        # comment is newest than page2
112
+        assert res[0]['content_id'] == firstly_created_but_recently_commented.content_id
113
+        assert res[1]['content_id'] == secondly_created_but_not_commented.content_id
114
+        # last updated content is newer than other one despite creation
115
+        # of the other is more recent
116
+        assert res[2]['content_id'] == firstly_created_but_recently_updated.content_id
117
+        assert res[3]['content_id'] == secondly_created_but_not_updated.content_id
118
+        # creation order is inverted here as last created is last active
119
+        assert res[4]['content_id'] == secondly_created.content_id
120
+        assert res[5]['content_id'] == firstly_created.content_id
121
+        # folder subcontent modification does not change folder order
122
+        assert res[6]['content_id'] == main_folder.content_id
123
+
124
+    @pytest.mark.skip('Test should be fixed')
125
+    def test_api__get_recently_active_content__ok__200__limit_2_multiple(self):
126
+        # TODO - G.M - 2018-07-20 - Better fix for this test, do not use sleep()
127
+        # anymore to fix datetime lack of precision.
128
+
129
+        # init DB
130
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
131
+        admin = dbsession.query(models.User) \
132
+            .filter(models.User.email == 'admin@admin.admin') \
133
+            .one()
134
+        workspace_api = WorkspaceApi(
135
+            current_user=admin,
136
+            session=dbsession,
137
+            config=self.app_config
138
+
139
+        )
140
+        workspace = WorkspaceApi(
141
+            current_user=admin,
142
+            session=dbsession,
143
+            config=self.app_config,
144
+        ).create_workspace(
145
+            'test workspace',
146
+            save_now=True
147
+        )
148
+        workspace2 = WorkspaceApi(
149
+            current_user=admin,
150
+            session=dbsession,
151
+            config=self.app_config,
152
+        ).create_workspace(
153
+            'test workspace2',
154
+            save_now=True
155
+        )
156
+
157
+        api = ContentApi(
158
+            current_user=admin,
159
+            session=dbsession,
160
+            config=self.app_config,
161
+        )
162
+        main_folder_workspace2 = api.create(ContentType.Folder, workspace2, None, 'Hepla', '', True)  # nopep8
163
+        sleep(1)
164
+        main_folder = api.create(ContentType.Folder, workspace, None, 'this is randomized folder', '', True)  # nopep8
165
+        # creation order test
166
+        firstly_created = api.create(ContentType.Page, workspace, main_folder, 'creation_order_test', '', True)  # nopep8
167
+        sleep(1)
168
+        secondly_created = api.create(ContentType.Page, workspace, main_folder, 'another creation_order_test', '', True)  # nopep8
169
+        # update order test
170
+        firstly_created_but_recently_updated = api.create(ContentType.Page, workspace, main_folder, 'update_order_test', '', True)  # nopep8
171
+        sleep(1)
172
+        secondly_created_but_not_updated = api.create(ContentType.Page, workspace, main_folder, 'another update_order_test', '', True)  # nopep8
173
+        sleep(1)
174
+        with new_revision(
175
+            session=dbsession,
176
+            tm=transaction.manager,
177
+            content=firstly_created_but_recently_updated,
178
+        ):
179
+            firstly_created_but_recently_updated.description = 'Just an update'
180
+        api.save(firstly_created_but_recently_updated)
181
+        # comment change order
182
+        firstly_created_but_recently_commented = api.create(ContentType.Page, workspace, main_folder, 'this is randomized label content', '', True)  # nopep8
183
+        sleep(1)
184
+        secondly_created_but_not_commented = api.create(ContentType.Page, workspace, main_folder, 'this is another randomized label content', '', True)  # nopep8
185
+        sleep(1)
186
+        comments = api.create_comment(workspace, firstly_created_but_recently_commented, 'juste a super comment', True)  # nopep8
187
+        sleep(1)
188
+        content_workspace_2 = api.create(ContentType.Page, workspace2,main_folder_workspace2, 'content_workspace_2', '',True)  # nopep8
189
+        dbsession.flush()
190
+        transaction.commit()
191
+
192
+        self.testapp.authorization = (
193
+            'Basic',
194
+            (
195
+                'admin@admin.admin',
196
+                'admin@admin.admin'
197
+            )
198
+        )
199
+        params = {
200
+            'limit': 2,
201
+        }
202
+        res = self.testapp.get(
203
+            '/api/v2/users/1/workspaces/{}/contents/recently_active'.format(workspace.workspace_id),  # nopep8
204
+            status=200,
205
+            params=params
206
+        ) # nopep8
207
+        res = res.json_body
208
+        assert len(res) == 2
209
+        for elem in res:
210
+            assert isinstance(elem['content_id'], int)
211
+            assert isinstance(elem['content_type'], str)
212
+            assert elem['content_type'] != 'comments'
213
+            assert isinstance(elem['is_archived'], bool)
214
+            assert isinstance(elem['is_deleted'], bool)
215
+            assert isinstance(elem['label'], str)
216
+            assert isinstance(elem['parent_id'], int) or elem['parent_id'] is None
217
+            assert isinstance(elem['show_in_ui'], bool)
218
+            assert isinstance(elem['slug'], str)
219
+            assert isinstance(elem['status'], str)
220
+            assert isinstance(elem['sub_content_types'], list)
221
+            for sub_content_type in elem['sub_content_types']:
222
+                assert isinstance(sub_content_type, str)
223
+            assert isinstance(elem['workspace_id'], int)
224
+        # comment is newest than page2
225
+        assert res[0]['content_id'] == firstly_created_but_recently_commented.content_id
226
+        assert res[1]['content_id'] == secondly_created_but_not_commented.content_id
227
+
228
+        params = {
229
+            'limit': 2,
230
+            'before_datetime': secondly_created_but_not_commented.get_last_activity_date().strftime('%Y-%m-%dT%H:%M:%SZ'),  # nopep8
231
+        }
232
+        res = self.testapp.get(
233
+            '/api/v2/users/1/workspaces/{}/contents/recently_active'.format(workspace.workspace_id),  # nopep8
234
+            status=200,
235
+            params=params
236
+        )
237
+        res = res.json_body
238
+        assert len(res) == 2
239
+        # last updated content is newer than other one despite creation
240
+        # of the other is more recent
241
+        assert res[0]['content_id'] == firstly_created_but_recently_updated.content_id
242
+        assert res[1]['content_id'] == secondly_created_but_not_updated.content_id
243
+
244
+
245
+class TestUserReadStatusEndpoint(FunctionalTest):
246
+    """
247
+    Tests for /api/v2/users/{user_id}/workspaces/{workspace_id}/contents/read_status # nopep8
248
+    """
249
+    def test_api__get_read_status__ok__200__all(self):
250
+
251
+        # init DB
252
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
253
+        admin = dbsession.query(models.User) \
254
+            .filter(models.User.email == 'admin@admin.admin') \
255
+            .one()
256
+        workspace_api = WorkspaceApi(
257
+            current_user=admin,
258
+            session=dbsession,
259
+            config=self.app_config
260
+
261
+        )
262
+        workspace = WorkspaceApi(
263
+            current_user=admin,
264
+            session=dbsession,
265
+            config=self.app_config,
266
+        ).create_workspace(
267
+            'test workspace',
268
+            save_now=True
269
+        )
270
+        workspace2 = WorkspaceApi(
271
+            current_user=admin,
272
+            session=dbsession,
273
+            config=self.app_config,
274
+        ).create_workspace(
275
+            'test workspace2',
276
+            save_now=True
277
+        )
278
+
279
+        api = ContentApi(
280
+            current_user=admin,
281
+            session=dbsession,
282
+            config=self.app_config,
283
+        )
284
+        main_folder_workspace2 = api.create(ContentType.Folder, workspace2, None, 'Hepla', '', True)  # nopep8
285
+        main_folder = api.create(ContentType.Folder, workspace, None, 'this is randomized folder', '', True)  # nopep8
286
+        # creation order test
287
+        firstly_created = api.create(ContentType.Page, workspace, main_folder, 'creation_order_test', '', True)  # nopep8
288
+        secondly_created = api.create(ContentType.Page, workspace, main_folder, 'another creation_order_test', '', True)  # nopep8
289
+        # update order test
290
+        firstly_created_but_recently_updated = api.create(ContentType.Page, workspace, main_folder, 'update_order_test', '', True)  # nopep8
291
+        secondly_created_but_not_updated = api.create(ContentType.Page, workspace, main_folder, 'another update_order_test', '', True)  # nopep8
292
+        with new_revision(
293
+            session=dbsession,
294
+            tm=transaction.manager,
295
+            content=firstly_created_but_recently_updated,
296
+        ):
297
+            firstly_created_but_recently_updated.description = 'Just an update'
298
+        api.save(firstly_created_but_recently_updated)
299
+        # comment change order
300
+        firstly_created_but_recently_commented = api.create(ContentType.Page, workspace, main_folder, 'this is randomized label content', '', True)  # nopep8
301
+        secondly_created_but_not_commented = api.create(ContentType.Page, workspace, main_folder, 'this is another randomized label content', '', True)  # nopep8
302
+        comments = api.create_comment(workspace, firstly_created_but_recently_commented, 'juste a super comment', True)  # nopep8
303
+        content_workspace_2 = api.create(ContentType.Page, workspace2,main_folder_workspace2, 'content_workspace_2', '',True)  # nopep8
304
+        dbsession.flush()
305
+        transaction.commit()
306
+
307
+        self.testapp.authorization = (
308
+            'Basic',
309
+            (
310
+                'admin@admin.admin',
311
+                'admin@admin.admin'
312
+            )
313
+        )
314
+        res = self.testapp.get('/api/v2/users/1/workspaces/{}/contents/read_status'.format(workspace.workspace_id), status=200) # nopep8
315
+        res = res.json_body
316
+        assert len(res) == 7
317
+        for elem in res:
318
+            assert isinstance(elem['content_id'], int)
319
+            assert isinstance(elem['read_by_user'], bool)
320
+        # comment is newest than page2
321
+        assert res[0]['content_id'] == firstly_created_but_recently_commented.content_id
322
+        assert res[1]['content_id'] == secondly_created_but_not_commented.content_id
323
+        # last updated content is newer than other one despite creation
324
+        # of the other is more recent
325
+        assert res[2]['content_id'] == firstly_created_but_recently_updated.content_id
326
+        assert res[3]['content_id'] == secondly_created_but_not_updated.content_id
327
+        # creation order is inverted here as last created is last active
328
+        assert res[4]['content_id'] == secondly_created.content_id
329
+        assert res[5]['content_id'] == firstly_created.content_id
330
+        # folder subcontent modification does not change folder order
331
+        assert res[6]['content_id'] == main_folder.content_id
332
+
333
+    def test_api__get_read_status__ok__200__nominal_case(self):
334
+
335
+        # init DB
336
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
337
+        admin = dbsession.query(models.User) \
338
+            .filter(models.User.email == 'admin@admin.admin') \
339
+            .one()
340
+        workspace_api = WorkspaceApi(
341
+            current_user=admin,
342
+            session=dbsession,
343
+            config=self.app_config
344
+
345
+        )
346
+        workspace = WorkspaceApi(
347
+            current_user=admin,
348
+            session=dbsession,
349
+            config=self.app_config,
350
+        ).create_workspace(
351
+            'test workspace',
352
+            save_now=True
353
+        )
354
+        workspace2 = WorkspaceApi(
355
+            current_user=admin,
356
+            session=dbsession,
357
+            config=self.app_config,
358
+        ).create_workspace(
359
+            'test workspace2',
360
+            save_now=True
361
+        )
362
+
363
+        api = ContentApi(
364
+            current_user=admin,
365
+            session=dbsession,
366
+            config=self.app_config,
367
+        )
368
+        main_folder_workspace2 = api.create(ContentType.Folder, workspace2, None, 'Hepla', '', True)  # nopep8
369
+        main_folder = api.create(ContentType.Folder, workspace, None, 'this is randomized folder', '', True)  # nopep8
370
+        # creation order test
371
+        firstly_created = api.create(ContentType.Page, workspace, main_folder, 'creation_order_test', '', True)  # nopep8
372
+        secondly_created = api.create(ContentType.Page, workspace, main_folder, 'another creation_order_test', '', True)  # nopep8
373
+        # update order test
374
+        firstly_created_but_recently_updated = api.create(ContentType.Page, workspace, main_folder, 'update_order_test', '', True)  # nopep8
375
+        secondly_created_but_not_updated = api.create(ContentType.Page, workspace, main_folder, 'another update_order_test', '', True)  # nopep8
376
+        with new_revision(
377
+            session=dbsession,
378
+            tm=transaction.manager,
379
+            content=firstly_created_but_recently_updated,
380
+        ):
381
+            firstly_created_but_recently_updated.description = 'Just an update'
382
+        api.save(firstly_created_but_recently_updated)
383
+        # comment change order
384
+        firstly_created_but_recently_commented = api.create(ContentType.Page, workspace, main_folder, 'this is randomized label content', '', True)  # nopep8
385
+        secondly_created_but_not_commented = api.create(ContentType.Page, workspace, main_folder, 'this is another randomized label content', '', True)  # nopep8
386
+        comments = api.create_comment(workspace, firstly_created_but_recently_commented, 'juste a super comment', True)  # nopep8
387
+        content_workspace_2 = api.create(ContentType.Page, workspace2,main_folder_workspace2, 'content_workspace_2', '',True)  # nopep8
388
+        dbsession.flush()
389
+        transaction.commit()
390
+
391
+        self.testapp.authorization = (
392
+            'Basic',
393
+            (
394
+                'admin@admin.admin',
395
+                'admin@admin.admin'
396
+            )
397
+        )
398
+        selected_contents_id = [
399
+            firstly_created_but_recently_commented.content_id,
400
+            firstly_created_but_recently_updated.content_id,
401
+            firstly_created.content_id,
402
+            main_folder.content_id,
403
+        ]
404
+        url = '/api/v2/users/1/workspaces/{workspace_id}/contents/read_status?contents_ids={cid1}&contents_ids={cid2}&contents_ids={cid3}&contents_ids={cid4}'.format(  # nopep8
405
+              workspace_id=workspace.workspace_id,
406
+              cid1=selected_contents_id[0],
407
+              cid2=selected_contents_id[1],
408
+              cid3=selected_contents_id[2],
409
+              cid4=selected_contents_id[3],
410
+        )
411
+        res = self.testapp.get(
412
+            url=url,
413
+            status=200,
414
+        )
415
+        res = res.json_body
416
+        assert len(res) == 4
417
+        for elem in res:
418
+            assert isinstance(elem['content_id'], int)
419
+            assert isinstance(elem['read_by_user'], bool)
420
+        # comment is newest than page2
421
+        assert res[0]['content_id'] == firstly_created_but_recently_commented.content_id
422
+        # last updated content is newer than other one despite creation
423
+        # of the other is more recent
424
+        assert res[1]['content_id'] == firstly_created_but_recently_updated.content_id
425
+        # creation order is inverted here as last created is last active
426
+        assert res[2]['content_id'] == firstly_created.content_id
427
+        # folder subcontent modification does not change folder order
428
+        assert res[3]['content_id'] == main_folder.content_id
429
+
430
+
431
+class TestUserSetContentAsRead(FunctionalTest):
432
+    """
433
+    Tests for /api/v2/users/{user_id}/workspaces/{workspace_id}/contents/{content_id}/read  # nopep8
434
+    """
435
+    def test_api_set_content_as_read__ok__200__nominal_case(self):
436
+        # init DB
437
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
438
+        admin = dbsession.query(models.User) \
439
+            .filter(models.User.email == 'admin@admin.admin') \
440
+            .one()
441
+        workspace_api = WorkspaceApi(
442
+            current_user=admin,
443
+            session=dbsession,
444
+            config=self.app_config
445
+
446
+        )
447
+        workspace = WorkspaceApi(
448
+            current_user=admin,
449
+            session=dbsession,
450
+            config=self.app_config,
451
+        ).create_workspace(
452
+            'test workspace',
453
+            save_now=True
454
+        )
455
+        api = ContentApi(
456
+            current_user=admin,
457
+            session=dbsession,
458
+            config=self.app_config,
459
+        )
460
+        main_folder = api.create(ContentType.Folder, workspace, None, 'this is randomized folder', '', True)  # nopep8
461
+        # creation order test
462
+        firstly_created = api.create(ContentType.Page, workspace, main_folder, 'creation_order_test', '', True)  # nopep8
463
+        api.mark_unread(firstly_created)
464
+        dbsession.flush()
465
+        transaction.commit()
466
+
467
+        self.testapp.authorization = (
468
+            'Basic',
469
+            (
470
+                'admin@admin.admin',
471
+                'admin@admin.admin'
472
+            )
473
+        )
474
+        res = self.testapp.get('/api/v2/users/1/workspaces/{}/contents/read_status'.format(workspace.workspace_id), status=200) # nopep8
475
+        assert res.json_body[0]['content_id'] == firstly_created.content_id
476
+        assert res.json_body[0]['read_by_user'] is False
477
+        self.testapp.put(
478
+            '/api/v2/users/{user_id}/workspaces/{workspace_id}/contents/{content_id}/read'.format(  # nopep8
479
+                workspace_id=workspace.workspace_id,
480
+                content_id=firstly_created.content_id,
481
+                user_id=admin.user_id,
482
+            )
483
+        )
484
+        res = self.testapp.get('/api/v2/users/1/workspaces/{}/contents/read_status'.format(workspace.workspace_id), status=200)  # nopep8
485
+        assert res.json_body[0]['content_id'] == firstly_created.content_id
486
+        assert res.json_body[0]['read_by_user'] is True
487
+
488
+    def test_api_set_content_as_read__ok__200__with_comments(self):
489
+        # init DB
490
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
491
+        admin = dbsession.query(models.User) \
492
+            .filter(models.User.email == 'admin@admin.admin') \
493
+            .one()
494
+        workspace_api = WorkspaceApi(
495
+            current_user=admin,
496
+            session=dbsession,
497
+            config=self.app_config
498
+
499
+        )
500
+        workspace = WorkspaceApi(
501
+            current_user=admin,
502
+            session=dbsession,
503
+            config=self.app_config,
504
+        ).create_workspace(
505
+            'test workspace',
506
+            save_now=True
507
+        )
508
+        api = ContentApi(
509
+            current_user=admin,
510
+            session=dbsession,
511
+            config=self.app_config,
512
+        )
513
+        main_folder = api.create(ContentType.Folder, workspace, None, 'this is randomized folder', '', True)  # nopep8
514
+        # creation order test
515
+        firstly_created = api.create(ContentType.Page, workspace, main_folder, 'creation_order_test', '', True)  # nopep8
516
+        comments = api.create_comment(workspace, firstly_created, 'juste a super comment', True)  # nopep8
517
+        api.mark_unread(firstly_created)
518
+        api.mark_unread(comments)
519
+        dbsession.flush()
520
+        transaction.commit()
521
+
522
+        self.testapp.authorization = (
523
+            'Basic',
524
+            (
525
+                'admin@admin.admin',
526
+                'admin@admin.admin'
527
+            )
528
+        )
529
+        res = self.testapp.get('/api/v2/users/1/workspaces/{}/contents/read_status'.format(workspace.workspace_id), status=200) # nopep8
530
+        assert res.json_body[0]['content_id'] == firstly_created.content_id
531
+        assert res.json_body[0]['read_by_user'] is False
532
+        self.testapp.put(
533
+            '/api/v2/users/{user_id}/workspaces/{workspace_id}/contents/{content_id}/read'.format(  # nopep8
534
+                workspace_id=workspace.workspace_id,
535
+                content_id=firstly_created.content_id,
536
+                user_id=admin.user_id,
537
+            )
538
+        )
539
+        res = self.testapp.get('/api/v2/users/1/workspaces/{}/contents/read_status'.format(workspace.workspace_id), status=200)  # nopep8
540
+        assert res.json_body[0]['content_id'] == firstly_created.content_id
541
+        assert res.json_body[0]['read_by_user'] is True
542
+
543
+        # comment is also set as read
544
+        assert comments.has_new_information_for(admin) is False
545
+
546
+
547
+class TestUserSetContentAsUnread(FunctionalTest):
548
+    """
549
+    Tests for /api/v2/users/{user_id}/workspaces/{workspace_id}/contents/{content_id}/unread  # nopep8
550
+    """
551
+    def test_api_set_content_as_unread__ok__200__nominal_case(self):
552
+        # init DB
553
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
554
+        admin = dbsession.query(models.User) \
555
+            .filter(models.User.email == 'admin@admin.admin') \
556
+            .one()
557
+        workspace_api = WorkspaceApi(
558
+            current_user=admin,
559
+            session=dbsession,
560
+            config=self.app_config
561
+
562
+        )
563
+        workspace = WorkspaceApi(
564
+            current_user=admin,
565
+            session=dbsession,
566
+            config=self.app_config,
567
+        ).create_workspace(
568
+            'test workspace',
569
+            save_now=True
570
+        )
571
+        api = ContentApi(
572
+            current_user=admin,
573
+            session=dbsession,
574
+            config=self.app_config,
575
+        )
576
+        main_folder = api.create(ContentType.Folder, workspace, None, 'this is randomized folder', '', True)  # nopep8
577
+        # creation order test
578
+        firstly_created = api.create(ContentType.Page, workspace, main_folder, 'creation_order_test', '', True)  # nopep8
579
+        api.mark_read(firstly_created)
580
+        dbsession.flush()
581
+        transaction.commit()
582
+
583
+        self.testapp.authorization = (
584
+            'Basic',
585
+            (
586
+                'admin@admin.admin',
587
+                'admin@admin.admin'
588
+            )
589
+        )
590
+        res = self.testapp.get('/api/v2/users/1/workspaces/{}/contents/read_status'.format(workspace.workspace_id), status=200) # nopep8
591
+        assert res.json_body[0]['content_id'] == firstly_created.content_id
592
+        assert res.json_body[0]['read_by_user'] is True
593
+        self.testapp.put(
594
+            '/api/v2/users/{user_id}/workspaces/{workspace_id}/contents/{content_id}/unread'.format(  # nopep8
595
+                workspace_id=workspace.workspace_id,
596
+                content_id=firstly_created.content_id,
597
+                user_id=admin.user_id,
598
+            )
599
+        )
600
+        res = self.testapp.get('/api/v2/users/1/workspaces/{}/contents/read_status'.format(workspace.workspace_id), status=200)  # nopep8
601
+        assert res.json_body[0]['content_id'] == firstly_created.content_id
602
+        assert res.json_body[0]['read_by_user'] is False
603
+
604
+    def test_api_set_content_as_unread__ok__200__with_comments(self):
605
+        # init DB
606
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
607
+        admin = dbsession.query(models.User) \
608
+            .filter(models.User.email == 'admin@admin.admin') \
609
+            .one()
610
+        workspace_api = WorkspaceApi(
611
+            current_user=admin,
612
+            session=dbsession,
613
+            config=self.app_config
614
+
615
+        )
616
+        workspace = WorkspaceApi(
617
+            current_user=admin,
618
+            session=dbsession,
619
+            config=self.app_config,
620
+        ).create_workspace(
621
+            'test workspace',
622
+            save_now=True
623
+        )
624
+        api = ContentApi(
625
+            current_user=admin,
626
+            session=dbsession,
627
+            config=self.app_config,
628
+        )
629
+        main_folder = api.create(ContentType.Folder, workspace, None, 'this is randomized folder', '', True)  # nopep8
630
+        # creation order test
631
+        firstly_created = api.create(ContentType.Page, workspace, main_folder, 'creation_order_test', '', True)  # nopep8
632
+        comments = api.create_comment(workspace, firstly_created, 'juste a super comment', True)  # nopep8
633
+        api.mark_read(firstly_created)
634
+        api.mark_read(comments)
635
+        dbsession.flush()
636
+        transaction.commit()
637
+
638
+        self.testapp.authorization = (
639
+            'Basic',
640
+            (
641
+                'admin@admin.admin',
642
+                'admin@admin.admin'
643
+            )
644
+        )
645
+        res = self.testapp.get('/api/v2/users/1/workspaces/{}/contents/read_status'.format(workspace.workspace_id), status=200) # nopep8
646
+        assert res.json_body[0]['content_id'] == firstly_created.content_id
647
+        assert res.json_body[0]['read_by_user'] is True
648
+        self.testapp.put(
649
+            '/api/v2/users/{user_id}/workspaces/{workspace_id}/contents/{content_id}/unread'.format(  # nopep8
650
+                workspace_id=workspace.workspace_id,
651
+                content_id=firstly_created.content_id,
652
+                user_id=admin.user_id,
653
+            )
654
+        )
655
+        res = self.testapp.get('/api/v2/users/1/workspaces/{}/contents/read_status'.format(workspace.workspace_id), status=200)  # nopep8
656
+        assert res.json_body[0]['content_id'] == firstly_created.content_id
657
+        assert res.json_body[0]['read_by_user'] is False
658
+
659
+        assert comments.has_new_information_for(admin) is True
660
+
661
+
662
+class TestUserSetWorkspaceAsRead(FunctionalTest):
663
+    """
664
+    Tests for /api/v2/users/{user_id}/workspaces/{workspace_id}/read
665
+    """
666
+    def test_api_set_content_as_read__ok__200__nominal_case(self):
667
+        # init DB
668
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
669
+        admin = dbsession.query(models.User) \
670
+            .filter(models.User.email == 'admin@admin.admin') \
671
+            .one()
672
+        workspace_api = WorkspaceApi(
673
+            current_user=admin,
674
+            session=dbsession,
675
+            config=self.app_config
676
+
677
+        )
678
+        workspace = WorkspaceApi(
679
+            current_user=admin,
680
+            session=dbsession,
681
+            config=self.app_config,
682
+        ).create_workspace(
683
+            'test workspace',
684
+            save_now=True
685
+        )
686
+        api = ContentApi(
687
+            current_user=admin,
688
+            session=dbsession,
689
+            config=self.app_config,
690
+        )
691
+        main_folder = api.create(ContentType.Folder, workspace, None, 'this is randomized folder', '', True)  # nopep8
692
+        # creation order test
693
+        firstly_created = api.create(ContentType.Page, workspace, main_folder, 'creation_order_test', '', True)  # nopep8
694
+        api.mark_unread(main_folder)
695
+        api.mark_unread(firstly_created)
696
+        dbsession.flush()
697
+        transaction.commit()
698
+
699
+        self.testapp.authorization = (
700
+            'Basic',
701
+            (
702
+                'admin@admin.admin',
703
+                'admin@admin.admin'
704
+            )
705
+        )
706
+        res = self.testapp.get('/api/v2/users/1/workspaces/{}/contents/read_status'.format(workspace.workspace_id), status=200) # nopep8
707
+        assert res.json_body[0]['content_id'] == firstly_created.content_id
708
+        assert res.json_body[0]['read_by_user'] is False
709
+        assert res.json_body[1]['content_id'] == main_folder.content_id
710
+        assert res.json_body[1]['read_by_user'] is False
711
+        self.testapp.put(
712
+            '/api/v2/users/{user_id}/workspaces/{workspace_id}/read'.format(  # nopep8
713
+                workspace_id=workspace.workspace_id,
714
+                content_id=firstly_created.content_id,
715
+                user_id=admin.user_id,
716
+            )
717
+        )
718
+        res = self.testapp.get('/api/v2/users/1/workspaces/{}/contents/read_status'.format(workspace.workspace_id), status=200)  # nopep8
719
+        assert res.json_body[0]['content_id'] == firstly_created.content_id
720
+        assert res.json_body[0]['read_by_user'] is True
721
+        assert res.json_body[1]['content_id'] == main_folder.content_id
722
+        assert res.json_body[1]['read_by_user'] is True
723
+
724
+
16 725
 class TestUserWorkspaceEndpoint(FunctionalTest):
17
-    # -*- coding: utf-8 -*-
18 726
     """
19 727
     Tests for /api/v2/users/{user_id}/workspaces
20 728
     """

+ 633 - 1
tracim/tests/functional/test_workspaces.py View File

@@ -2,6 +2,15 @@
2 2
 """
3 3
 Tests for /api/v2/workspaces subpath endpoints.
4 4
 """
5
+
6
+import transaction
7
+from depot.io.utils import FileIntent
8
+
9
+from tracim import models
10
+from tracim.lib.core.content import ContentApi
11
+from tracim.lib.core.workspace import WorkspaceApi
12
+from tracim.models import get_tm_session
13
+from tracim.models.data import ContentType
5 14
 from tracim.tests import FunctionalTest
6 15
 from tracim.tests import set_html_document_slug_to_legacy
7 16
 from tracim.fixtures.content import Content as ContentFixtures
@@ -83,6 +92,133 @@ class TestWorkspaceEndpoint(FunctionalTest):
83 92
         assert sidebar_entry['hexcolor'] == "#757575"
84 93
         assert sidebar_entry['fa_icon'] == "calendar"
85 94
 
95
+    def test_api__update_workspace__ok_200__nominal_case(self) -> None:
96
+        """
97
+        Test update workspace
98
+        """
99
+        self.testapp.authorization = (
100
+            'Basic',
101
+            (
102
+                'admin@admin.admin',
103
+                'admin@admin.admin'
104
+            )
105
+        )
106
+        params = {
107
+            'label': 'superworkspace',
108
+            'description': 'mysuperdescription'
109
+        }
110
+        # Before
111
+        res = self.testapp.get(
112
+            '/api/v2/workspaces/1',
113
+            status=200
114
+        )
115
+        assert res.json_body
116
+        workspace = res.json_body
117
+        assert workspace['workspace_id'] == 1
118
+        assert workspace['slug'] == 'business'
119
+        assert workspace['label'] == 'Business'
120
+        assert workspace['description'] == 'All importants documents'
121
+        assert len(workspace['sidebar_entries']) == 7
122
+
123
+        # modify workspace
124
+        res = self.testapp.put_json(
125
+            '/api/v2/workspaces/1',
126
+            status=200,
127
+            params=params,
128
+        )
129
+        assert res.json_body
130
+        workspace = res.json_body
131
+        assert workspace['workspace_id'] == 1
132
+        assert workspace['slug'] == 'superworkspace'
133
+        assert workspace['label'] == 'superworkspace'
134
+        assert workspace['description'] == 'mysuperdescription'
135
+        assert len(workspace['sidebar_entries']) == 7
136
+
137
+        # after
138
+        res = self.testapp.get(
139
+            '/api/v2/workspaces/1',
140
+            status=200
141
+        )
142
+        assert res.json_body
143
+        workspace = res.json_body
144
+        assert workspace['workspace_id'] == 1
145
+        assert workspace['slug'] == 'superworkspace'
146
+        assert workspace['label'] == 'superworkspace'
147
+        assert workspace['description'] == 'mysuperdescription'
148
+        assert len(workspace['sidebar_entries']) == 7
149
+
150
+    def test_api__update_workspace__err_400__empty_label(self) -> None:
151
+        """
152
+        Test update workspace with empty label
153
+        """
154
+        self.testapp.authorization = (
155
+            'Basic',
156
+            (
157
+                'admin@admin.admin',
158
+                'admin@admin.admin'
159
+            )
160
+        )
161
+        params = {
162
+            'label': '',
163
+            'description': 'mysuperdescription'
164
+        }
165
+        res = self.testapp.put_json(
166
+            '/api/v2/workspaces/1',
167
+            status=400,
168
+            params=params,
169
+        )
170
+
171
+    def test_api__create_workspace__ok_200__nominal_case(self) -> None:
172
+        """
173
+        Test create workspace
174
+        """
175
+        self.testapp.authorization = (
176
+            'Basic',
177
+            (
178
+                'admin@admin.admin',
179
+                'admin@admin.admin'
180
+            )
181
+        )
182
+        params = {
183
+            'label': 'superworkspace',
184
+            'description': 'mysuperdescription'
185
+        }
186
+        res = self.testapp.post_json(
187
+            '/api/v2/workspaces',
188
+            status=200,
189
+            params=params,
190
+        )
191
+        assert res.json_body
192
+        workspace = res.json_body
193
+        workspace_id = res.json_body['workspace_id']
194
+        res = self.testapp.get(
195
+            '/api/v2/workspaces/{}'.format(workspace_id),
196
+            status=200
197
+        )
198
+        workspace_2 = res.json_body
199
+        assert workspace == workspace_2
200
+
201
+    def test_api__create_workspace__err_400__empty_label(self) -> None:
202
+        """
203
+        Test create workspace with empty label
204
+        """
205
+        self.testapp.authorization = (
206
+            'Basic',
207
+            (
208
+                'admin@admin.admin',
209
+                'admin@admin.admin'
210
+            )
211
+        )
212
+        params = {
213
+            'label': '',
214
+            'description': 'mysuperdescription'
215
+        }
216
+        res = self.testapp.post_json(
217
+            '/api/v2/workspaces',
218
+            status=400,
219
+            params=params,
220
+        )
221
+
86 222
     def test_api__get_workspace__err_400__unallowed_user(self) -> None:
87 223
         """
88 224
         Check obtain workspace unreachable for user
@@ -159,7 +295,12 @@ class TestWorkspaceMembersEndpoint(FunctionalTest):
159 295
         assert user_role['role'] == 'workspace-manager'
160 296
         assert user_role['user_id'] == 1
161 297
         assert user_role['workspace_id'] == 1
298
+        assert user_role['workspace']['workspace_id'] == 1
299
+        assert user_role['workspace']['label'] == 'Business'
300
+        assert user_role['workspace']['slug'] == 'business'
162 301
         assert user_role['user']['public_name'] == 'Global manager'
302
+        assert user_role['user']['user_id'] == 1
303
+        assert user_role['is_active'] is True
163 304
         # TODO - G.M - 24-05-2018 - [Avatar] Replace
164 305
         # by correct value when avatar feature will be enabled
165 306
         assert user_role['user']['avatar_url'] is None
@@ -217,6 +358,259 @@ class TestWorkspaceMembersEndpoint(FunctionalTest):
217 358
         assert 'message' in res.json.keys()
218 359
         assert 'details' in res.json.keys()
219 360
 
361
+    def test_api__create_workspace_member_role__ok_200__user_id(self):
362
+        """
363
+        Create workspace member role
364
+        :return:
365
+        """
366
+        self.testapp.authorization = (
367
+            'Basic',
368
+            (
369
+                'admin@admin.admin',
370
+                'admin@admin.admin'
371
+            )
372
+        )
373
+        # create workspace role
374
+        params = {
375
+            'user_id': 2,
376
+            'user_email_or_public_name': None,
377
+            'role': 'content-manager',
378
+        }
379
+        res = self.testapp.post_json(
380
+            '/api/v2/workspaces/1/members',
381
+            status=200,
382
+            params=params,
383
+        )
384
+        user_role_found = res.json_body
385
+        assert user_role_found['role'] == 'content-manager'
386
+        assert user_role_found['user_id'] == 2
387
+        assert user_role_found['workspace_id'] == 1
388
+        assert user_role_found['newly_created'] is False
389
+        assert user_role_found['email_sent'] is False
390
+
391
+        res = self.testapp.get('/api/v2/workspaces/1/members', status=200).json_body   # nopep8
392
+        assert len(res) == 2
393
+        user_role = res[0]
394
+        assert user_role['role'] == 'workspace-manager'
395
+        assert user_role['user_id'] == 1
396
+        assert user_role['workspace_id'] == 1
397
+        user_role = res[1]
398
+        assert user_role_found['role'] == user_role['role']
399
+        assert user_role_found['user_id'] == user_role['user_id']
400
+        assert user_role_found['workspace_id'] == user_role['workspace_id']
401
+
402
+    def test_api__create_workspace_member_role__ok_200__user_email(self):
403
+        """
404
+        Create workspace member role
405
+        :return:
406
+        """
407
+        self.testapp.authorization = (
408
+            'Basic',
409
+            (
410
+                'admin@admin.admin',
411
+                'admin@admin.admin'
412
+            )
413
+        )
414
+        # create workspace role
415
+        params = {
416
+            'user_id': None,
417
+            'user_email_or_public_name': 'lawrence-not-real-email@fsf.local',
418
+            'role': 'content-manager',
419
+        }
420
+        res = self.testapp.post_json(
421
+            '/api/v2/workspaces/1/members',
422
+            status=200,
423
+            params=params,
424
+        )
425
+        user_role_found = res.json_body
426
+        assert user_role_found['role'] == 'content-manager'
427
+        assert user_role_found['user_id'] == 2
428
+        assert user_role_found['workspace_id'] == 1
429
+        assert user_role_found['newly_created'] is False
430
+        assert user_role_found['email_sent'] is False
431
+
432
+        res = self.testapp.get('/api/v2/workspaces/1/members', status=200).json_body   # nopep8
433
+        assert len(res) == 2
434
+        user_role = res[0]
435
+        assert user_role['role'] == 'workspace-manager'
436
+        assert user_role['user_id'] == 1
437
+        assert user_role['workspace_id'] == 1
438
+        user_role = res[1]
439
+        assert user_role_found['role'] == user_role['role']
440
+        assert user_role_found['user_id'] == user_role['user_id']
441
+        assert user_role_found['workspace_id'] == user_role['workspace_id']
442
+
443
+    def test_api__create_workspace_member_role__ok_200__user_public_name(self):
444
+        """
445
+        Create workspace member role
446
+        :return:
447
+        """
448
+        self.testapp.authorization = (
449
+            'Basic',
450
+            (
451
+                'admin@admin.admin',
452
+                'admin@admin.admin'
453
+            )
454
+        )
455
+        # create workspace role
456
+        params = {
457
+            'user_id': None,
458
+            'user_email_or_public_name': 'Lawrence L.',
459
+            'role': 'content-manager',
460
+        }
461
+        res = self.testapp.post_json(
462
+            '/api/v2/workspaces/1/members',
463
+            status=200,
464
+            params=params,
465
+        )
466
+        user_role_found = res.json_body
467
+        assert user_role_found['role'] == 'content-manager'
468
+        assert user_role_found['user_id'] == 2
469
+        assert user_role_found['workspace_id'] == 1
470
+        assert user_role_found['newly_created'] is False
471
+        assert user_role_found['email_sent'] is False
472
+
473
+        res = self.testapp.get('/api/v2/workspaces/1/members', status=200).json_body   # nopep8
474
+        assert len(res) == 2
475
+        user_role = res[0]
476
+        assert user_role['role'] == 'workspace-manager'
477
+        assert user_role['user_id'] == 1
478
+        assert user_role['workspace_id'] == 1
479
+        user_role = res[1]
480
+        assert user_role_found['role'] == user_role['role']
481
+        assert user_role_found['user_id'] == user_role['user_id']
482
+        assert user_role_found['workspace_id'] == user_role['workspace_id']
483
+
484
+    def test_api__create_workspace_member_role__err_400__nothing(self):
485
+        """
486
+        Create workspace member role
487
+        :return:
488
+        """
489
+        self.testapp.authorization = (
490
+            'Basic',
491
+            (
492
+                'admin@admin.admin',
493
+                'admin@admin.admin'
494
+            )
495
+        )
496
+        # create workspace role
497
+        params = {
498
+            'user_id': None,
499
+            'user_email_or_public_name': None,
500
+            'role': 'content-manager',
501
+        }
502
+        res = self.testapp.post_json(
503
+            '/api/v2/workspaces/1/members',
504
+            status=400,
505
+            params=params,
506
+        )
507
+
508
+    def test_api__create_workspace_member_role__err_400__wrong_user_id(self):
509
+        """
510
+        Create workspace member role
511
+        :return:
512
+        """
513
+        self.testapp.authorization = (
514
+            'Basic',
515
+            (
516
+                'admin@admin.admin',
517
+                'admin@admin.admin'
518
+            )
519
+        )
520
+        # create workspace role
521
+        params = {
522
+            'user_id': 47,
523
+            'user_email_or_public_name': None,
524
+            'role': 'content-manager',
525
+        }
526
+        res = self.testapp.post_json(
527
+            '/api/v2/workspaces/1/members',
528
+            status=400,
529
+            params=params,
530
+        )
531
+
532
+    def test_api__create_workspace_member_role__ok_200__new_user(self):  # nopep8
533
+        """
534
+        Create workspace member role
535
+        :return:
536
+        """
537
+        self.testapp.authorization = (
538
+            'Basic',
539
+            (
540
+                'admin@admin.admin',
541
+                'admin@admin.admin'
542
+            )
543
+        )
544
+        # create workspace role
545
+        params = {
546
+            'user_id': None,
547
+            'user_email_or_public_name': 'nothing@nothing.nothing',
548
+            'role': 'content-manager',
549
+        }
550
+        res = self.testapp.post_json(
551
+            '/api/v2/workspaces/1/members',
552
+            status=200,
553
+            params=params,
554
+        )
555
+        user_role_found = res.json_body
556
+        assert user_role_found['role'] == 'content-manager'
557
+        assert user_role_found['user_id']
558
+        user_id = user_role_found['user_id']
559
+        assert user_role_found['workspace_id'] == 1
560
+        assert user_role_found['newly_created'] is True
561
+        assert user_role_found['email_sent'] is False
562
+
563
+        res = self.testapp.get('/api/v2/workspaces/1/members',
564
+                               status=200).json_body  # nopep8
565
+        assert len(res) == 2
566
+        user_role = res[0]
567
+        assert user_role['role'] == 'workspace-manager'
568
+        assert user_role['user_id'] == 1
569
+        assert user_role['workspace_id'] == 1
570
+        user_role = res[1]
571
+        assert user_role_found['role'] == user_role['role']
572
+        assert user_role_found['user_id'] == user_role['user_id']
573
+        assert user_role_found['workspace_id'] == user_role['workspace_id']
574
+
575
+    def test_api__update_workspace_member_role__ok_200__nominal_case(self):
576
+        """
577
+        Update worskpace member role
578
+        """
579
+        # before
580
+        self.testapp.authorization = (
581
+            'Basic',
582
+            (
583
+                'admin@admin.admin',
584
+                'admin@admin.admin'
585
+            )
586
+        )
587
+        res = self.testapp.get('/api/v2/workspaces/1/members', status=200).json_body   # nopep8
588
+        assert len(res) == 1
589
+        user_role = res[0]
590
+        assert user_role['role'] == 'workspace-manager'
591
+        assert user_role['user_id'] == 1
592
+        assert user_role['workspace_id'] == 1
593
+        # update workspace role
594
+        params = {
595
+            'role': 'content-manager',
596
+        }
597
+        res = self.testapp.put_json(
598
+            '/api/v2/workspaces/1/members/1',
599
+            status=200,
600
+            params=params,
601
+        )
602
+        user_role = res.json_body
603
+        assert user_role['role'] == 'content-manager'
604
+        assert user_role['user_id'] == 1
605
+        assert user_role['workspace_id'] == 1
606
+        # after
607
+        res = self.testapp.get('/api/v2/workspaces/1/members', status=200).json_body   # nopep8
608
+        assert len(res) == 1
609
+        user_role = res[0]
610
+        assert user_role['role'] == 'content-manager'
611
+        assert user_role['user_id'] == 1
612
+        assert user_role['workspace_id'] == 1
613
+
220 614
 
221 615
 class TestWorkspaceContents(FunctionalTest):
222 616
     """
@@ -241,6 +635,7 @@ class TestWorkspaceContents(FunctionalTest):
241 635
         assert len(res) == 3
242 636
         content = res[0]
243 637
         assert content['content_id'] == 1
638
+        assert content['content_type'] == 'folder'
244 639
         assert content['is_archived'] is False
245 640
         assert content['is_deleted'] is False
246 641
         assert content['label'] == 'Tools'
@@ -252,6 +647,7 @@ class TestWorkspaceContents(FunctionalTest):
252 647
         assert content['workspace_id'] == 1
253 648
         content = res[1]
254 649
         assert content['content_id'] == 2
650
+        assert content['content_type'] == 'folder'
255 651
         assert content['is_archived'] is False
256 652
         assert content['is_deleted'] is False
257 653
         assert content['label'] == 'Menus'
@@ -263,6 +659,37 @@ class TestWorkspaceContents(FunctionalTest):
263 659
         assert content['workspace_id'] == 1
264 660
         content = res[2]
265 661
         assert content['content_id'] == 11
662
+        assert content['content_type'] == 'html-documents'
663
+        assert content['is_archived'] is False
664
+        assert content['is_deleted'] is False
665
+        assert content['label'] == 'Current Menu'
666
+        assert content['parent_id'] == 2
667
+        assert content['show_in_ui'] is True
668
+        assert content['slug'] == 'current-menu'
669
+        assert content['status'] == 'open'
670
+        assert set(content['sub_content_types']) == {'thread', 'html-documents', 'folder', 'file'}  # nopep8
671
+        assert content['workspace_id'] == 1
672
+
673
+    def test_api__get_workspace_content__ok_200__get_default_html_documents(self):
674
+        """
675
+        Check obtain workspace contents with defaults filters + content_filter
676
+        """
677
+        self.testapp.authorization = (
678
+            'Basic',
679
+            (
680
+                'admin@admin.admin',
681
+                'admin@admin.admin'
682
+            )
683
+        )
684
+        params = {
685
+            'content_type': 'html-documents',
686
+        }
687
+        res = self.testapp.get('/api/v2/workspaces/1/contents', status=200, params=params).json_body   # nopep8
688
+        assert len(res) == 1
689
+        content = res[0]
690
+        assert content
691
+        assert content['content_id'] == 11
692
+        assert content['content_type'] == 'html-documents'
266 693
         assert content['is_archived'] is False
267 694
         assert content['is_deleted'] is False
268 695
         assert content['label'] == 'Current Menu'
@@ -274,7 +701,7 @@ class TestWorkspaceContents(FunctionalTest):
274 701
         assert content['workspace_id'] == 1
275 702
 
276 703
     # Root related
277
-    def test_api__get_workspace_content__ok_200__get_all_root_content__legacy_html_slug(self):
704
+    def test_api__get_workspace_content__ok_200__get_all_root_content__legacy_html_slug(self):  # nopep8
278 705
         """
279 706
         Check obtain workspace all root contents
280 707
         """
@@ -539,6 +966,210 @@ class TestWorkspaceContents(FunctionalTest):
539 966
         assert res == []
540 967
 
541 968
     # Folder related
969
+    def test_api__get_workspace_content__ok_200__get_all_filter_content_thread(self):
970
+        # prepare data
971
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
972
+        admin = dbsession.query(models.User) \
973
+            .filter(models.User.email == 'admin@admin.admin') \
974
+            .one()
975
+        workspace_api = WorkspaceApi(
976
+            current_user=admin,
977
+            session=dbsession,
978
+            config=self.app_config
979
+        )
980
+        business_workspace = workspace_api.get_one(1)
981
+        content_api = ContentApi(
982
+            current_user=admin,
983
+            session=dbsession,
984
+            config=self.app_config
985
+        )
986
+        tool_folder = content_api.get_one(1, content_type=ContentType.Any)
987
+        test_thread = content_api.create(
988
+            content_type=ContentType.Thread,
989
+            workspace=business_workspace,
990
+            parent=tool_folder,
991
+            label='Test Thread',
992
+            do_save=False,
993
+            do_notify=False,
994
+        )
995
+        test_thread.description = 'Thread description'
996
+        dbsession.add(test_thread)
997
+        test_file = content_api.create(
998
+            content_type=ContentType.File,
999
+            workspace=business_workspace,
1000
+            parent=tool_folder,
1001
+            label='Test file',
1002
+            do_save=False,
1003
+            do_notify=False,
1004
+        )
1005
+        test_file.file_extension = '.txt'
1006
+        test_file.depot_file = FileIntent(
1007
+            b'Test file',
1008
+            'Test_file.txt',
1009
+            'text/plain',
1010
+        )
1011
+        test_page_legacy = content_api.create(
1012
+            content_type=ContentType.Page,
1013
+            workspace=business_workspace,
1014
+            label='test_page',
1015
+            do_save=False,
1016
+            do_notify=False,
1017
+        )
1018
+        test_page_legacy.type = ContentType.PageLegacy
1019
+        content_api.update_content(test_page_legacy, 'test_page', '<p>PAGE</p>')
1020
+        test_html_document = content_api.create(
1021
+            content_type=ContentType.Page,
1022
+            workspace=business_workspace,
1023
+            label='test_html_page',
1024
+            do_save=False,
1025
+            do_notify=False,
1026
+        )
1027
+        content_api.update_content(test_html_document, 'test_page', '<p>HTML_DOCUMENT</p>')  # nopep8
1028
+        dbsession.flush()
1029
+        transaction.commit()
1030
+        # test-itself
1031
+        params = {
1032
+            'parent_id': 1,
1033
+            'show_archived': 1,
1034
+            'show_deleted': 1,
1035
+            'show_active': 1,
1036
+            'content_type': 'thread',
1037
+        }
1038
+        self.testapp.authorization = (
1039
+            'Basic',
1040
+            (
1041
+                'admin@admin.admin',
1042
+                'admin@admin.admin'
1043
+            )
1044
+        )
1045
+        res = self.testapp.get(
1046
+            '/api/v2/workspaces/1/contents',
1047
+            status=200,
1048
+            params=params,
1049
+        ).json_body
1050
+        assert len(res) == 1
1051
+        content = res[0]
1052
+        assert content['content_type'] == 'thread'
1053
+        assert content['content_id']
1054
+        assert content['is_archived'] is False
1055
+        assert content['is_deleted'] is False
1056
+        assert content['label'] == 'Test Thread'
1057
+        assert content['parent_id'] == 1
1058
+        assert content['show_in_ui'] is True
1059
+        assert content['slug'] == 'test-thread'
1060
+        assert content['status'] == 'open'
1061
+        assert set(content['sub_content_types']) == {'thread', 'html-documents', 'folder', 'file'}  # nopep8
1062
+        assert content['workspace_id'] == 1
1063
+
1064
+    def test_api__get_workspace_content__ok_200__get_all_filter_content_html_and_legacy_page(self):  # nopep8
1065
+        # prepare data
1066
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
1067
+        admin = dbsession.query(models.User) \
1068
+            .filter(models.User.email == 'admin@admin.admin') \
1069
+            .one()
1070
+        workspace_api = WorkspaceApi(
1071
+            current_user=admin,
1072
+            session=dbsession,
1073
+            config=self.app_config
1074
+        )
1075
+        business_workspace = workspace_api.get_one(1)
1076
+        content_api = ContentApi(
1077
+            current_user=admin,
1078
+            session=dbsession,
1079
+            config=self.app_config
1080
+        )
1081
+        tool_folder = content_api.get_one(1, content_type=ContentType.Any)
1082
+        test_thread = content_api.create(
1083
+            content_type=ContentType.Thread,
1084
+            workspace=business_workspace,
1085
+            parent=tool_folder,
1086
+            label='Test Thread',
1087
+            do_save=False,
1088
+            do_notify=False,
1089
+        )
1090
+        test_thread.description = 'Thread description'
1091
+        dbsession.add(test_thread)
1092
+        test_file = content_api.create(
1093
+            content_type=ContentType.File,
1094
+            workspace=business_workspace,
1095
+            parent=tool_folder,
1096
+            label='Test file',
1097
+            do_save=False,
1098
+            do_notify=False,
1099
+        )
1100
+        test_file.file_extension = '.txt'
1101
+        test_file.depot_file = FileIntent(
1102
+            b'Test file',
1103
+            'Test_file.txt',
1104
+            'text/plain',
1105
+        )
1106
+        test_page_legacy = content_api.create(
1107
+            content_type=ContentType.Page,
1108
+            workspace=business_workspace,
1109
+            parent=tool_folder,
1110
+            label='test_page',
1111
+            do_save=False,
1112
+            do_notify=False,
1113
+        )
1114
+        test_page_legacy.type = ContentType.PageLegacy
1115
+        content_api.update_content(test_page_legacy, 'test_page', '<p>PAGE</p>')
1116
+        test_html_document = content_api.create(
1117
+            content_type=ContentType.Page,
1118
+            workspace=business_workspace,
1119
+            parent=tool_folder,
1120
+            label='test_html_page',
1121
+            do_save=False,
1122
+            do_notify=False,
1123
+        )
1124
+        content_api.update_content(test_html_document, 'test_html_page', '<p>HTML_DOCUMENT</p>')  # nopep8
1125
+        dbsession.flush()
1126
+        transaction.commit()
1127
+        # test-itself
1128
+        params = {
1129
+            'parent_id': 1,
1130
+            'show_archived': 1,
1131
+            'show_deleted': 1,
1132
+            'show_active': 1,
1133
+            'content_type': 'html-documents',
1134
+        }
1135
+        self.testapp.authorization = (
1136
+            'Basic',
1137
+            (
1138
+                'admin@admin.admin',
1139
+                'admin@admin.admin'
1140
+            )
1141
+        )
1142
+        res = self.testapp.get(
1143
+            '/api/v2/workspaces/1/contents',
1144
+            status=200,
1145
+            params=params,
1146
+        ).json_body
1147
+        assert len(res) == 2
1148
+        content = res[0]
1149
+        assert content['content_type'] == 'html-documents'
1150
+        assert content['content_id']
1151
+        assert content['is_archived'] is False
1152
+        assert content['is_deleted'] is False
1153
+        assert content['label'] == 'test_page'
1154
+        assert content['parent_id'] == 1
1155
+        assert content['show_in_ui'] is True
1156
+        assert content['slug'] == 'test-page'
1157
+        assert content['status'] == 'open'
1158
+        assert set(content['sub_content_types']) == {'thread', 'html-documents', 'folder', 'file'}  # nopep8
1159
+        assert content['workspace_id'] == 1
1160
+        content = res[1]
1161
+        assert content['content_type'] == 'html-documents'
1162
+        assert content['content_id']
1163
+        assert content['is_archived'] is False
1164
+        assert content['is_deleted'] is False
1165
+        assert content['label'] == 'test_html_page'
1166
+        assert content['parent_id'] == 1
1167
+        assert content['show_in_ui'] is True
1168
+        assert content['slug'] == 'test-html-page'
1169
+        assert content['status'] == 'open'
1170
+        assert set(content['sub_content_types']) == {'thread', 'html-documents', 'folder', 'file'}  # nopep8
1171
+        assert content['workspace_id'] == 1
1172
+        assert res[0]['content_id'] != res[1]['content_id']
542 1173
 
543 1174
     def test_api__get_workspace_content__ok_200__get_all_folder_content(self):
544 1175
         """
@@ -549,6 +1180,7 @@ class TestWorkspaceContents(FunctionalTest):
549 1180
             'show_archived': 1,
550 1181
             'show_deleted': 1,
551 1182
             'show_active': 1,
1183
+            'content_type': 'any'
552 1184
         }
553 1185
         self.testapp.authorization = (
554 1186
             'Basic',

+ 355 - 1
tracim/tests/library/test_content_api.py View File

@@ -1,5 +1,5 @@
1 1
 # -*- coding: utf-8 -*-
2
-
2
+import datetime
3 3
 import transaction
4 4
 import pytest
5 5
 
@@ -1977,6 +1977,360 @@ class TestContentApi(DefaultTest):
1977 1977
         eq_(ActionDescription.UNDELETION, updated2.revision_type)
1978 1978
         eq_(u1id, updated2.owner_id)
1979 1979
 
1980
+    def test_unit__get_last_active__ok__nominal_case(self):
1981
+        uapi = UserApi(
1982
+            session=self.session,
1983
+            config=self.app_config,
1984
+            current_user=None,
1985
+        )
1986
+        group_api = GroupApi(
1987
+            current_user=None,
1988
+            session=self.session,
1989
+            config=self.app_config,
1990
+        )
1991
+        groups = [group_api.get_one(Group.TIM_USER),
1992
+                  group_api.get_one(Group.TIM_MANAGER),
1993
+                  group_api.get_one(Group.TIM_ADMIN)]
1994
+
1995
+        user = uapi.create_minimal_user(email='this.is@user',
1996
+                                        groups=groups, save_now=True)
1997
+        workspace = WorkspaceApi(
1998
+            current_user=user,
1999
+            session=self.session,
2000
+            config=self.app_config,
2001
+        ).create_workspace(
2002
+            'test workspace',
2003
+            save_now=True
2004
+        )
2005
+        workspace2 = WorkspaceApi(
2006
+            current_user=user,
2007
+            session=self.session,
2008
+            config=self.app_config,
2009
+        ).create_workspace(
2010
+            'test workspace2',
2011
+            save_now=True
2012
+        )
2013
+
2014
+        api = ContentApi(
2015
+            current_user=user,
2016
+            session=self.session,
2017
+            config=self.app_config,
2018
+        )
2019
+        main_folder_workspace2 = api.create(ContentType.Folder, workspace2, None, 'Hepla', '', True)  # nopep8
2020
+        main_folder = api.create(ContentType.Folder, workspace, None, 'this is randomized folder', '', True)  # nopep8
2021
+        # creation order test
2022
+        firstly_created = api.create(ContentType.Page, workspace, main_folder, 'creation_order_test', '', True)  # nopep8
2023
+        secondly_created = api.create(ContentType.Page, workspace, main_folder, 'another creation_order_test', '', True)  # nopep8
2024
+        # update order test
2025
+        firstly_created_but_recently_updated = api.create(ContentType.Page, workspace, main_folder, 'update_order_test', '', True)  # nopep8
2026
+        secondly_created_but_not_updated = api.create(ContentType.Page, workspace, main_folder, 'another update_order_test', '', True)  # nopep8
2027
+        with new_revision(
2028
+            session=self.session,
2029
+            tm=transaction.manager,
2030
+            content=firstly_created_but_recently_updated,
2031
+        ):
2032
+            firstly_created_but_recently_updated.description = 'Just an update'
2033
+        api.save(firstly_created_but_recently_updated)
2034
+        # comment change order
2035
+        firstly_created_but_recently_commented = api.create(ContentType.Page, workspace, main_folder, 'this is randomized label content', '', True)  # nopep8
2036
+        secondly_created_but_not_commented = api.create(ContentType.Page, workspace, main_folder, 'this is another randomized label content', '', True)  # nopep8
2037
+        comments = api.create_comment(workspace, firstly_created_but_recently_commented, 'juste a super comment', True)  # nopep8
2038
+
2039
+        content_workspace_2 = api.create(ContentType.Page, workspace2 ,main_folder_workspace2, 'content_workspace_2', '',True)  # nopep8
2040
+        last_actives = api.get_last_active()
2041
+        assert len(last_actives) == 9
2042
+        # workspace_2 content
2043
+        assert last_actives[0] == content_workspace_2
2044
+        # comment is newest than page2
2045
+        assert last_actives[1] == firstly_created_but_recently_commented
2046
+        assert last_actives[2] == secondly_created_but_not_commented
2047
+        # last updated content is newer than other one despite creation
2048
+        # of the other is more recent
2049
+        assert last_actives[3] == firstly_created_but_recently_updated
2050
+        assert last_actives[4] == secondly_created_but_not_updated
2051
+        # creation order is inverted here as last created is last active
2052
+        assert last_actives[5] == secondly_created
2053
+        assert last_actives[6] == firstly_created
2054
+        # folder subcontent modification does not change folder order
2055
+        assert last_actives[7] == main_folder
2056
+        # folder subcontent modification does not change folder order
2057
+        # (workspace2)
2058
+        assert last_actives[8] == main_folder_workspace2
2059
+
2060
+    def test_unit__get_last_active__ok__workspace_filter_workspace_full(self):
2061
+        uapi = UserApi(
2062
+            session=self.session,
2063
+            config=self.app_config,
2064
+            current_user=None,
2065
+        )
2066
+        group_api = GroupApi(
2067
+            current_user=None,
2068
+            session=self.session,
2069
+            config=self.app_config,
2070
+        )
2071
+        groups = [group_api.get_one(Group.TIM_USER),
2072
+                  group_api.get_one(Group.TIM_MANAGER),
2073
+                  group_api.get_one(Group.TIM_ADMIN)]
2074
+
2075
+        user = uapi.create_minimal_user(email='this.is@user',
2076
+                                        groups=groups, save_now=True)
2077
+        workspace = WorkspaceApi(
2078
+            current_user=user,
2079
+            session=self.session,
2080
+            config=self.app_config,
2081
+        ).create_workspace(
2082
+            'test workspace',
2083
+            save_now=True
2084
+        )
2085
+
2086
+        api = ContentApi(
2087
+            current_user=user,
2088
+            session=self.session,
2089
+            config=self.app_config,
2090
+        )
2091
+        main_folder = api.create(ContentType.Folder, workspace, None, 'this is randomized folder', '', True)  # nopep8
2092
+        # creation order test
2093
+        firstly_created = api.create(ContentType.Page, workspace, main_folder, 'creation_order_test', '', True)  # nopep8
2094
+        secondly_created = api.create(ContentType.Page, workspace, main_folder, 'another creation_order_test', '', True)  # nopep8
2095
+        # update order test
2096
+        firstly_created_but_recently_updated = api.create(ContentType.Page, workspace, main_folder, 'update_order_test', '', True)  # nopep8
2097
+        secondly_created_but_not_updated = api.create(ContentType.Page, workspace, main_folder, 'another update_order_test', '', True)  # nopep8
2098
+        with new_revision(
2099
+            session=self.session,
2100
+            tm=transaction.manager,
2101
+            content=firstly_created_but_recently_updated,
2102
+        ):
2103
+            firstly_created_but_recently_updated.description = 'Just an update'
2104
+        api.save(firstly_created_but_recently_updated)
2105
+        # comment change order
2106
+        firstly_created_but_recently_commented = api.create(ContentType.Page, workspace, main_folder, 'this is randomized label content', '', True)  # nopep8
2107
+        secondly_created_but_not_commented = api.create(ContentType.Page, workspace, main_folder, 'this is another randomized label content', '', True)  # nopep8
2108
+        comments = api.create_comment(workspace, firstly_created_but_recently_commented, 'juste a super comment', True)  # nopep8
2109
+
2110
+        last_actives = api.get_last_active(workspace=workspace)
2111
+        assert len(last_actives) == 7
2112
+        # comment is newest than page2
2113
+        assert last_actives[0] == firstly_created_but_recently_commented
2114
+        assert last_actives[1] == secondly_created_but_not_commented
2115
+        # last updated content is newer than other one despite creation
2116
+        # of the other is more recent
2117
+        assert last_actives[2] == firstly_created_but_recently_updated
2118
+        assert last_actives[3] == secondly_created_but_not_updated
2119
+        # creation order is inverted here as last created is last active
2120
+        assert last_actives[4] == secondly_created
2121
+        assert last_actives[5] == firstly_created
2122
+        # folder subcontent modification does not change folder order
2123
+        assert last_actives[6] == main_folder
2124
+
2125
+    def test_unit__get_last_active__ok__workspace_filter_workspace_content_ids(self):
2126
+        uapi = UserApi(
2127
+            session=self.session,
2128
+            config=self.app_config,
2129
+            current_user=None,
2130
+        )
2131
+        group_api = GroupApi(
2132
+            current_user=None,
2133
+            session=self.session,
2134
+            config=self.app_config,
2135
+        )
2136
+        groups = [group_api.get_one(Group.TIM_USER),
2137
+                  group_api.get_one(Group.TIM_MANAGER),
2138
+                  group_api.get_one(Group.TIM_ADMIN)]
2139
+
2140
+        user = uapi.create_minimal_user(email='this.is@user',
2141
+                                        groups=groups, save_now=True)
2142
+        workspace = WorkspaceApi(
2143
+            current_user=user,
2144
+            session=self.session,
2145
+            config=self.app_config,
2146
+        ).create_workspace(
2147
+            'test workspace',
2148
+            save_now=True
2149
+        )
2150
+
2151
+        api = ContentApi(
2152
+            current_user=user,
2153
+            session=self.session,
2154
+            config=self.app_config,
2155
+        )
2156
+        main_folder = api.create(ContentType.Folder, workspace, None, 'this is randomized folder', '', True)  # nopep8
2157
+        # creation order test
2158
+        firstly_created = api.create(ContentType.Page, workspace, main_folder, 'creation_order_test', '', True)  # nopep8
2159
+        secondly_created = api.create(ContentType.Page, workspace, main_folder, 'another creation_order_test', '', True)  # nopep8
2160
+        # update order test
2161
+        firstly_created_but_recently_updated = api.create(ContentType.Page, workspace, main_folder, 'update_order_test', '', True)  # nopep8
2162
+        secondly_created_but_not_updated = api.create(ContentType.Page, workspace, main_folder, 'another update_order_test', '', True)  # nopep8
2163
+        with new_revision(
2164
+            session=self.session,
2165
+            tm=transaction.manager,
2166
+            content=firstly_created_but_recently_updated,
2167
+        ):
2168
+            firstly_created_but_recently_updated.description = 'Just an update'
2169
+        api.save(firstly_created_but_recently_updated)
2170
+        # comment change order
2171
+        firstly_created_but_recently_commented = api.create(ContentType.Page, workspace, main_folder, 'this is randomized label content', '', True)  # nopep8
2172
+        secondly_created_but_not_commented = api.create(ContentType.Page, workspace, main_folder, 'this is another randomized label content', '', True)  # nopep8
2173
+        comments = api.create_comment(workspace, firstly_created_but_recently_commented, 'juste a super comment', True)  # nopep8
2174
+
2175
+        selected_contents = [
2176
+            firstly_created_but_recently_commented,
2177
+            firstly_created_but_recently_updated,
2178
+            firstly_created,
2179
+            main_folder,
2180
+        ]
2181
+        content_ids = [content.content_id for content in selected_contents]
2182
+        last_actives = api.get_last_active(
2183
+            workspace=workspace,
2184
+            content_ids=content_ids,
2185
+        )
2186
+        assert len(last_actives) == 4
2187
+        # comment is newest than page2
2188
+        assert last_actives[0] == firstly_created_but_recently_commented
2189
+        assert secondly_created_but_not_commented not in last_actives
2190
+        # last updated content is newer than other one despite creation
2191
+        # of the other is more recent
2192
+        assert last_actives[1] == firstly_created_but_recently_updated
2193
+        assert secondly_created_but_not_updated not in last_actives
2194
+        # creation order is inverted here as last created is last active
2195
+        assert secondly_created not in last_actives
2196
+        assert last_actives[2] == firstly_created
2197
+        # folder subcontent modification does not change folder order
2198
+        assert last_actives[3] == main_folder
2199
+
2200
+    def test_unit__get_last_active__ok__workspace_filter_workspace_limit_2_multiples_times(self):  # nopep8
2201
+        uapi = UserApi(
2202
+            session=self.session,
2203
+            config=self.app_config,
2204
+            current_user=None,
2205
+        )
2206
+        group_api = GroupApi(
2207
+            current_user=None,
2208
+            session=self.session,
2209
+            config=self.app_config,
2210
+        )
2211
+        groups = [group_api.get_one(Group.TIM_USER),
2212
+                  group_api.get_one(Group.TIM_MANAGER),
2213
+                  group_api.get_one(Group.TIM_ADMIN)]
2214
+
2215
+        user = uapi.create_minimal_user(email='this.is@user',
2216
+                                        groups=groups, save_now=True)
2217
+        workspace = WorkspaceApi(
2218
+            current_user=user,
2219
+            session=self.session,
2220
+            config=self.app_config,
2221
+        ).create_workspace(
2222
+            'test workspace',
2223
+            save_now=True
2224
+        )
2225
+
2226
+        api = ContentApi(
2227
+            current_user=user,
2228
+            session=self.session,
2229
+            config=self.app_config,
2230
+        )
2231
+        main_folder = api.create(ContentType.Folder, workspace, None, 'this is randomized folder', '', True)  # nopep8
2232
+        # creation order test
2233
+        firstly_created = api.create(ContentType.Page, workspace, main_folder, 'creation_order_test', '', True)  # nopep8
2234
+        secondly_created = api.create(ContentType.Page, workspace, main_folder, 'another creation_order_test', '', True)  # nopep8
2235
+        # update order test
2236
+        firstly_created_but_recently_updated = api.create(ContentType.Page, workspace, main_folder, 'update_order_test', '', True)  # nopep8
2237
+        secondly_created_but_not_updated = api.create(ContentType.Page, workspace, main_folder, 'another update_order_test', '', True)  # nopep8
2238
+        with new_revision(
2239
+            session=self.session,
2240
+            tm=transaction.manager,
2241
+            content=firstly_created_but_recently_updated,
2242
+        ):
2243
+            firstly_created_but_recently_updated.description = 'Just an update'
2244
+        api.save(firstly_created_but_recently_updated)
2245
+        # comment change order
2246
+        firstly_created_but_recently_commented = api.create(ContentType.Page, workspace, main_folder, 'this is randomized label content', '', True)  # nopep8
2247
+        secondly_created_but_not_commented = api.create(ContentType.Page, workspace, main_folder, 'this is another randomized label content', '', True)  # nopep8
2248
+        comments = api.create_comment(workspace, firstly_created_but_recently_commented, 'juste a super comment', True)  # nopep8
2249
+
2250
+        last_actives = api.get_last_active(workspace=workspace, limit=2, before_datetime=datetime.datetime.now())  # nopep8
2251
+        assert len(last_actives) == 2
2252
+        # comment is newest than page2
2253
+        assert last_actives[0] == firstly_created_but_recently_commented
2254
+        assert last_actives[1] == secondly_created_but_not_commented
2255
+
2256
+        last_actives = api.get_last_active(workspace=workspace, limit=2, before_datetime=last_actives[1].get_simple_last_activity_date())  # nopep8
2257
+        assert len(last_actives) == 2
2258
+        # last updated content is newer than other one despite creation
2259
+        # of the other is more recent
2260
+        assert last_actives[0] == firstly_created_but_recently_updated
2261
+        assert last_actives[1] == secondly_created_but_not_updated
2262
+
2263
+        last_actives = api.get_last_active(workspace=workspace, limit=2, before_datetime=last_actives[1].get_simple_last_activity_date())  # nopep8
2264
+        assert len(last_actives) == 2
2265
+        # creation order is inverted here as last created is last active
2266
+        assert last_actives[0] == secondly_created
2267
+        assert last_actives[1] == firstly_created
2268
+
2269
+        last_actives = api.get_last_active(workspace=workspace, limit=2, before_datetime=last_actives[1].get_simple_last_activity_date())  # nopep8
2270
+        assert len(last_actives) == 1
2271
+        # folder subcontent modification does not change folder order
2272
+        assert last_actives[0] == main_folder
2273
+
2274
+    def test_unit__get_last_active__ok__workspace_filter_workspace_empty(self):
2275
+        uapi = UserApi(
2276
+            session=self.session,
2277
+            config=self.app_config,
2278
+            current_user=None,
2279
+        )
2280
+        group_api = GroupApi(
2281
+            current_user=None,
2282
+            session=self.session,
2283
+            config=self.app_config,
2284
+        )
2285
+        groups = [group_api.get_one(Group.TIM_USER),
2286
+                  group_api.get_one(Group.TIM_MANAGER),
2287
+                  group_api.get_one(Group.TIM_ADMIN)]
2288
+
2289
+        user = uapi.create_minimal_user(email='this.is@user',
2290
+                                        groups=groups, save_now=True)
2291
+        workspace = WorkspaceApi(
2292
+            current_user=user,
2293
+            session=self.session,
2294
+            config=self.app_config,
2295
+        ).create_workspace(
2296
+            'test workspace',
2297
+            save_now=True
2298
+        )
2299
+        workspace2 = WorkspaceApi(
2300
+            current_user=user,
2301
+            session=self.session,
2302
+            config=self.app_config,
2303
+        ).create_workspace(
2304
+            'test workspace2',
2305
+            save_now=True
2306
+        )
2307
+        api = ContentApi(
2308
+            current_user=user,
2309
+            session=self.session,
2310
+            config=self.app_config,
2311
+        )
2312
+        main_folder = api.create(ContentType.Folder, workspace, None, 'this is randomized folder', '', True)  # nopep8
2313
+        # creation order test
2314
+        firstly_created = api.create(ContentType.Page, workspace, main_folder, 'creation_order_test', '', True)  # nopep8
2315
+        secondly_created = api.create(ContentType.Page, workspace, main_folder, 'another creation_order_test', '', True)  # nopep8
2316
+        # update order test
2317
+        firstly_created_but_recently_updated = api.create(ContentType.Page, workspace, main_folder, 'update_order_test', '', True)  # nopep8
2318
+        secondly_created_but_not_updated = api.create(ContentType.Page, workspace, main_folder, 'another update_order_test', '', True)  # nopep8
2319
+        with new_revision(
2320
+            session=self.session,
2321
+            tm=transaction.manager,
2322
+            content=firstly_created_but_recently_updated,
2323
+        ):
2324
+            firstly_created_but_recently_updated.description = 'Just an update'
2325
+        api.save(firstly_created_but_recently_updated)
2326
+        # comment change order
2327
+        firstly_created_but_recently_commented = api.create(ContentType.Page, workspace, main_folder, 'this is randomized label content', '', True)  # nopep8
2328
+        secondly_created_but_not_commented = api.create(ContentType.Page, workspace, main_folder, 'this is another randomized label content', '', True)  # nopep8
2329
+        comments = api.create_comment(workspace, firstly_created_but_recently_commented, 'juste a super comment', True)  # nopep8
2330
+
2331
+        last_actives = api.get_last_active(workspace=workspace2)
2332
+        assert len(last_actives) == 0
2333
+
1980 2334
     def test_search_in_label(self):
1981 2335
         # HACK - D.A. - 2015-03-09
1982 2336
         # This test is based on a bug which does NOT return results found

+ 77 - 0
tracim/tests/library/test_role_api.py View File

@@ -0,0 +1,77 @@
1
+# coding=utf-8
2
+import pytest
3
+from sqlalchemy.orm.exc import NoResultFound
4
+
5
+from tracim.lib.core.userworkspace import RoleApi
6
+from tracim.models import User
7
+from tracim.models.roles import WorkspaceRoles
8
+from tracim.tests import DefaultTest
9
+from tracim.fixtures.users_and_groups import Base as BaseFixture
10
+from tracim.fixtures.content import Content as ContentFixture
11
+
12
+
13
+class TestRoleApi(DefaultTest):
14
+
15
+    fixtures = [BaseFixture, ContentFixture]
16
+
17
+    def test_unit__get_one__ok__nominal_case(self):
18
+        admin = self.session.query(User)\
19
+            .filter(User.email == 'admin@admin.admin').one()
20
+        rapi = RoleApi(
21
+            current_user=admin,
22
+            session=self.session,
23
+            config=self.config,
24
+        )
25
+        rapi.get_one(admin.user_id, 1)
26
+
27
+    def test_unit__get_one__err__role_does_not_exist(self):
28
+        admin = self.session.query(User)\
29
+            .filter(User.email == 'admin@admin.admin').one()
30
+        rapi = RoleApi(
31
+            current_user=admin,
32
+            session=self.session,
33
+            config=self.config,
34
+        )
35
+        with pytest.raises(NoResultFound):
36
+            rapi.get_one(admin.user_id, 100)  # workspace 100 does not exist
37
+
38
+    def test_unit__create_one__nominal_case(self):
39
+        admin = self.session.query(User)\
40
+            .filter(User.email == 'admin@admin.admin').one()
41
+        workspace = self._create_workspace_and_test(
42
+            'workspace_1',
43
+            admin
44
+        )
45
+        bob = self.session.query(User)\
46
+            .filter(User.email == 'bob@fsf.local').one()
47
+        rapi = RoleApi(
48
+            current_user=admin,
49
+            session=self.session,
50
+            config=self.config,
51
+        )
52
+        created_role = rapi.create_one(
53
+            user=bob,
54
+            workspace=workspace,
55
+            role_level=WorkspaceRoles.CONTENT_MANAGER.level,
56
+            with_notif=False,
57
+        )
58
+        obtain_role = rapi.get_one(bob.user_id, workspace.workspace_id)
59
+        assert created_role == obtain_role
60
+
61
+    def test_unit__get_all_for_usages(self):
62
+        admin = self.session.query(User)\
63
+            .filter(User.email == 'admin@admin.admin').one()
64
+        rapi = RoleApi(
65
+            current_user=admin,
66
+            session=self.session,
67
+            config=self.config,
68
+        )
69
+        workspace = self._create_workspace_and_test(
70
+            'workspace_1',
71
+            admin
72
+        )
73
+        roles = rapi.get_all_for_workspace(workspace)
74
+        len(roles) == 1
75
+        roles[0].user_id == admin.user_id
76
+        roles[0].role == WorkspaceRoles.WORKSPACE_MANAGER.level
77
+

+ 1 - 1
tracim/tests/library/test_user_api.py View File

@@ -23,7 +23,7 @@ class TestUserApi(DefaultTest):
23 23
         )
24 24
         u = api.create_minimal_user('bob@bob')
25 25
         assert u.email == 'bob@bob'
26
-        assert u.display_name is None
26
+        assert u.display_name == 'bob'
27 27
 
28 28
     def test_unit__create_minimal_user_and_update__ok__nominal_case(self):
29 29
         api = UserApi(

+ 80 - 0
tracim/tests/models/tests_roles.py View File

@@ -0,0 +1,80 @@
1
+# coding=utf-8
2
+import unittest
3
+import pytest
4
+from tracim.exceptions import RoleDoesNotExist
5
+from tracim.models.roles import WorkspaceRoles
6
+
7
+
8
+class TestWorkspacesRoles(unittest.TestCase):
9
+    """
10
+    Test for WorkspaceRoles Enum Object
11
+    """
12
+    def test_workspace_roles__ok__all_list(self):
13
+        roles = list(WorkspaceRoles)
14
+        assert len(roles) == 5
15
+        for role in roles:
16
+            assert role
17
+            assert role.slug
18
+            assert isinstance(role.slug, str)
19
+            assert role.level or role.level == 0
20
+            assert isinstance(role.level, int)
21
+            assert role.label
22
+            assert isinstance(role.slug, str)
23
+        assert WorkspaceRoles['READER']
24
+        assert WorkspaceRoles['NOT_APPLICABLE']
25
+        assert WorkspaceRoles['CONTRIBUTOR']
26
+        assert WorkspaceRoles['WORKSPACE_MANAGER']
27
+        assert WorkspaceRoles['CONTENT_MANAGER']
28
+
29
+    def test__workspace_roles__ok__check_model(self):
30
+        role = WorkspaceRoles.WORKSPACE_MANAGER
31
+        assert role
32
+        assert role.slug
33
+        assert isinstance(role.slug, str)
34
+        assert role.level
35
+        assert isinstance(role.level, int)
36
+        assert role.label
37
+        assert isinstance(role.slug, str)
38
+
39
+    def test_workspace_roles__ok__get_all_valid_roles(self):
40
+        roles = WorkspaceRoles.get_all_valid_role()
41
+        assert len(roles) == 4
42
+        for role in roles:
43
+            assert role
44
+            assert role.slug
45
+            assert isinstance(role.slug, str)
46
+            assert role.level or role.level == 0
47
+            assert isinstance(role.level, int)
48
+            assert role.level > 0
49
+            assert role.label
50
+            assert isinstance(role.slug, str)
51
+
52
+    def test_workspace_roles__ok__get_role__from_level__ok__nominal_case(self):
53
+        role = WorkspaceRoles.get_role_from_level(0)
54
+
55
+        assert role
56
+        assert role.slug
57
+        assert isinstance(role.slug, str)
58
+        assert role.level == 0
59
+        assert isinstance(role.level, int)
60
+        assert role.label
61
+        assert isinstance(role.slug, str)
62
+
63
+    def test_workspace_roles__ok__get_role__from_slug__ok__nominal_case(self):
64
+        role = WorkspaceRoles.get_role_from_slug('reader')
65
+
66
+        assert role
67
+        assert role.slug
68
+        assert isinstance(role.slug, str)
69
+        assert role.level > 0
70
+        assert isinstance(role.level, int)
71
+        assert role.label
72
+        assert isinstance(role.slug, str)
73
+
74
+    def test_workspace_roles__ok__get_role__from_level__err__role_does_not_exist(self):  # nopep8
75
+        with pytest.raises(RoleDoesNotExist):
76
+            WorkspaceRoles.get_role_from_level(-1000)
77
+
78
+    def test_workspace_roles__ok__get_role__from_slug__err__role_does_not_exist(self):  # nopep8
79
+        with pytest.raises(RoleDoesNotExist):
80
+            WorkspaceRoles.get_role_from_slug('this slug does not exist')

+ 572 - 0
tracim/views/contents_api/file_controller.py View File

@@ -0,0 +1,572 @@
1
+# coding=utf-8
2
+import typing
3
+
4
+import transaction
5
+from depot.manager import DepotManager
6
+from preview_generator.exception import UnavailablePreviewType
7
+from pyramid.config import Configurator
8
+from pyramid.response import FileResponse, FileIter
9
+
10
+try:  # Python 3.5+
11
+    from http import HTTPStatus
12
+except ImportError:
13
+    from http import client as HTTPStatus
14
+
15
+from tracim import TracimRequest
16
+from tracim.extensions import hapic
17
+from tracim.lib.core.content import ContentApi
18
+from tracim.views.controllers import Controller
19
+from tracim.views.core_api.schemas import FileContentSchema
20
+from tracim.views.core_api.schemas import AllowedJpgPreviewDimSchema
21
+from tracim.views.core_api.schemas import ContentPreviewSizedPathSchema
22
+from tracim.views.core_api.schemas import RevisionPreviewSizedPathSchema
23
+from tracim.views.core_api.schemas import PageQuerySchema
24
+from tracim.views.core_api.schemas import WorkspaceAndContentRevisionIdPathSchema  # nopep8
25
+from tracim.views.core_api.schemas import FileRevisionSchema
26
+from tracim.views.core_api.schemas import SetContentStatusSchema
27
+from tracim.views.core_api.schemas import FileContentModifySchema
28
+from tracim.views.core_api.schemas import WorkspaceAndContentIdPathSchema
29
+from tracim.views.core_api.schemas import NoContentSchema
30
+from tracim.lib.utils.authorization import require_content_types
31
+from tracim.lib.utils.authorization import require_workspace_role
32
+from tracim.models.data import UserRoleInWorkspace
33
+from tracim.models.context_models import ContentInContext
34
+from tracim.models.context_models import RevisionInContext
35
+from tracim.models.contents import ContentTypeLegacy as ContentType
36
+from tracim.models.contents import file_type
37
+from tracim.models.revision_protection import new_revision
38
+from tracim.exceptions import EmptyLabelNotAllowed
39
+from tracim.exceptions import PageOfPreviewNotFound
40
+from tracim.exceptions import PreviewDimNotAllowed
41
+
42
+FILE_ENDPOINTS_TAG = 'Files'
43
+
44
+
45
+class FileController(Controller):
46
+    """
47
+    Endpoints for File Content
48
+    """
49
+
50
+    # File data
51
+    @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG])
52
+    @require_workspace_role(UserRoleInWorkspace.CONTRIBUTOR)
53
+    @require_content_types([file_type])
54
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
55
+    # TODO - G.M - 2018-07-24 - Use hapic for input file
56
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
57
+    def upload_file(self, context, request: TracimRequest, hapic_data=None):
58
+        """
59
+        Upload a new version of raw file of content. This will create a new
60
+        revision.
61
+        """
62
+        app_config = request.registry.settings['CFG']
63
+        api = ContentApi(
64
+            current_user=request.current_user,
65
+            session=request.dbsession,
66
+            config=app_config,
67
+        )
68
+        content = api.get_one(
69
+            hapic_data.path.content_id,
70
+            content_type=ContentType.Any
71
+        )
72
+        file = request.POST['files']
73
+        with new_revision(
74
+                session=request.dbsession,
75
+                tm=transaction.manager,
76
+                content=content
77
+        ):
78
+            api.update_file_data(
79
+                content,
80
+                new_filename=file.filename,
81
+                new_mimetype=file.type,
82
+                new_content=file.file,
83
+            )
84
+
85
+        return
86
+
87
+    @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG])
88
+    @require_workspace_role(UserRoleInWorkspace.READER)
89
+    @require_content_types([file_type])
90
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
91
+    @hapic.output_file([])
92
+    def download_file(self, context, request: TracimRequest, hapic_data=None):
93
+        """
94
+        Download raw file of last revision of content.
95
+        """
96
+        app_config = request.registry.settings['CFG']
97
+        api = ContentApi(
98
+            current_user=request.current_user,
99
+            session=request.dbsession,
100
+            config=app_config,
101
+        )
102
+        content = api.get_one(
103
+            hapic_data.path.content_id,
104
+            content_type=ContentType.Any
105
+        )
106
+        file = DepotManager.get().get(content.depot_file)
107
+        response = request.response
108
+        response.content_type = file.content_type
109
+        response.app_iter = FileIter(file)
110
+        return response
111
+
112
+    @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG])
113
+    @require_workspace_role(UserRoleInWorkspace.READER)
114
+    @require_content_types([file_type])
115
+    @hapic.input_path(WorkspaceAndContentRevisionIdPathSchema())
116
+    @hapic.output_file([])
117
+    def download_revisions_file(self, context, request: TracimRequest, hapic_data=None):  # nopep8
118
+        """
119
+        Download raw file for specific revision of content.
120
+        """
121
+        app_config = request.registry.settings['CFG']
122
+        api = ContentApi(
123
+            current_user=request.current_user,
124
+            session=request.dbsession,
125
+            config=app_config,
126
+        )
127
+        content = api.get_one(
128
+            hapic_data.path.content_id,
129
+            content_type=ContentType.Any
130
+        )
131
+        revision = api.get_one_revision(
132
+            revision_id=hapic_data.path.revision_id,
133
+            content=content
134
+        )
135
+        file = DepotManager.get().get(revision.depot_file)
136
+        response = request.response
137
+        response.content_type = file.content_type
138
+        response.app_iter = FileIter(file)
139
+        return response
140
+
141
+    # preview
142
+    # pdf
143
+    @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG])
144
+    @require_workspace_role(UserRoleInWorkspace.READER)
145
+    @require_content_types([file_type])
146
+    @hapic.handle_exception(UnavailablePreviewType, HTTPStatus.BAD_REQUEST)
147
+    @hapic.handle_exception(PageOfPreviewNotFound, HTTPStatus.BAD_REQUEST)
148
+    @hapic.input_query(PageQuerySchema())
149
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
150
+    @hapic.output_file([])
151
+    def preview_pdf(self, context, request: TracimRequest, hapic_data=None):
152
+        """
153
+        Obtain a specific page pdf preview of last revision of content.
154
+        """
155
+        app_config = request.registry.settings['CFG']
156
+        api = ContentApi(
157
+            current_user=request.current_user,
158
+            session=request.dbsession,
159
+            config=app_config,
160
+        )
161
+        content = api.get_one(
162
+            hapic_data.path.content_id,
163
+            content_type=ContentType.Any
164
+        )
165
+        pdf_preview_path = api.get_pdf_preview_path(
166
+            content.content_id,
167
+            content.revision_id,
168
+            page=hapic_data.query.page
169
+        )
170
+        return FileResponse(pdf_preview_path)
171
+
172
+    @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG])
173
+    @require_workspace_role(UserRoleInWorkspace.READER)
174
+    @require_content_types([file_type])
175
+    @hapic.handle_exception(UnavailablePreviewType, HTTPStatus.BAD_REQUEST)
176
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
177
+    @hapic.output_file([])
178
+    def preview_pdf_full(self, context, request: TracimRequest, hapic_data=None):  # nopep8
179
+        """
180
+        Obtain a full pdf preview (all page) of last revision of content.
181
+        """
182
+        app_config = request.registry.settings['CFG']
183
+        api = ContentApi(
184
+            current_user=request.current_user,
185
+            session=request.dbsession,
186
+            config=app_config,
187
+        )
188
+        content = api.get_one(
189
+            hapic_data.path.content_id,
190
+            content_type=ContentType.Any
191
+        )
192
+        pdf_preview_path = api.get_full_pdf_preview_path(content.revision_id)
193
+        return FileResponse(pdf_preview_path)
194
+
195
+    @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG])
196
+    @require_workspace_role(UserRoleInWorkspace.READER)
197
+    @require_content_types([file_type])
198
+    @hapic.handle_exception(UnavailablePreviewType, HTTPStatus.BAD_REQUEST)
199
+    @hapic.input_path(WorkspaceAndContentRevisionIdPathSchema())
200
+    @hapic.input_query(PageQuerySchema())
201
+    @hapic.output_file([])
202
+    def preview_pdf_revision(self, context, request: TracimRequest, hapic_data=None):  # nopep8
203
+        """
204
+        Obtain a specific page pdf preview of a specific revision of content.
205
+        """
206
+        app_config = request.registry.settings['CFG']
207
+        api = ContentApi(
208
+            current_user=request.current_user,
209
+            session=request.dbsession,
210
+            config=app_config,
211
+        )
212
+        content = api.get_one(
213
+            hapic_data.path.content_id,
214
+            content_type=ContentType.Any
215
+        )
216
+        revision = api.get_one_revision(
217
+            revision_id=hapic_data.path.revision_id,
218
+            content=content
219
+        )
220
+        pdf_preview_path = api.get_pdf_preview_path(
221
+            revision.content_id,
222
+            revision.revision_id,
223
+            page=hapic_data.query.page
224
+        )
225
+        return FileResponse(pdf_preview_path)
226
+
227
+    # jpg
228
+    @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG])
229
+    @require_workspace_role(UserRoleInWorkspace.READER)
230
+    @require_content_types([file_type])
231
+    @hapic.handle_exception(PageOfPreviewNotFound, HTTPStatus.BAD_REQUEST)
232
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
233
+    @hapic.input_query(PageQuerySchema())
234
+    @hapic.output_file([])
235
+    def preview_jpg(self, context, request: TracimRequest, hapic_data=None):
236
+        """
237
+        Obtain normally sied jpg preview of last revision of content.
238
+        """
239
+        app_config = request.registry.settings['CFG']
240
+        api = ContentApi(
241
+            current_user=request.current_user,
242
+            session=request.dbsession,
243
+            config=app_config,
244
+        )
245
+        content = api.get_one(
246
+            hapic_data.path.content_id,
247
+            content_type=ContentType.Any
248
+        )
249
+        allowed_dim = api.get_jpg_preview_allowed_dim()
250
+        jpg_preview_path = api.get_jpg_preview_path(
251
+            content_id=content.content_id,
252
+            revision_id=content.revision_id,
253
+            page=hapic_data.query.page,
254
+            width=allowed_dim.dimensions[0].width,
255
+            height=allowed_dim.dimensions[0].height,
256
+        )
257
+        return FileResponse(jpg_preview_path)
258
+
259
+    @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG])
260
+    @require_workspace_role(UserRoleInWorkspace.READER)
261
+    @require_content_types([file_type])
262
+    @hapic.handle_exception(PageOfPreviewNotFound, HTTPStatus.BAD_REQUEST)
263
+    @hapic.handle_exception(PreviewDimNotAllowed, HTTPStatus.BAD_REQUEST)
264
+    @hapic.input_query(PageQuerySchema())
265
+    @hapic.input_path(ContentPreviewSizedPathSchema())
266
+    @hapic.output_file([])
267
+    def sized_preview_jpg(self, context, request: TracimRequest, hapic_data=None):  # nopep8
268
+        """
269
+        Obtain resized jpg preview of last revision of content.
270
+        """
271
+        app_config = request.registry.settings['CFG']
272
+        api = ContentApi(
273
+            current_user=request.current_user,
274
+            session=request.dbsession,
275
+            config=app_config,
276
+        )
277
+        content = api.get_one(
278
+            hapic_data.path.content_id,
279
+            content_type=ContentType.Any
280
+        )
281
+        jpg_preview_path = api.get_jpg_preview_path(
282
+            content_id=content.content_id,
283
+            revision_id=content.revision_id,
284
+            page=hapic_data.query.page,
285
+            height=hapic_data.path.height,
286
+            width=hapic_data.path.width,
287
+        )
288
+        return FileResponse(jpg_preview_path)
289
+
290
+    @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG])
291
+    @require_workspace_role(UserRoleInWorkspace.READER)
292
+    @require_content_types([file_type])
293
+    @hapic.handle_exception(PageOfPreviewNotFound, HTTPStatus.BAD_REQUEST)
294
+    @hapic.handle_exception(PreviewDimNotAllowed, HTTPStatus.BAD_REQUEST)
295
+    @hapic.input_path(RevisionPreviewSizedPathSchema())
296
+    @hapic.input_query(PageQuerySchema())
297
+    @hapic.output_file([])
298
+    def sized_preview_jpg_revision(self, context, request: TracimRequest, hapic_data=None):  # nopep8
299
+        """
300
+        Obtain resized jpg preview of a specific revision of content.
301
+        """
302
+        app_config = request.registry.settings['CFG']
303
+        api = ContentApi(
304
+            current_user=request.current_user,
305
+            session=request.dbsession,
306
+            config=app_config,
307
+        )
308
+        content = api.get_one(
309
+            hapic_data.path.content_id,
310
+            content_type=ContentType.Any
311
+        )
312
+        revision = api.get_one_revision(
313
+            revision_id=hapic_data.path.revision_id,
314
+            content=content
315
+        )
316
+        jpg_preview_path = api.get_jpg_preview_path(
317
+            content_id=content.content_id,
318
+            revision_id=revision.revision_id,
319
+            page=hapic_data.query.page,
320
+            height=hapic_data.path.height,
321
+            width=hapic_data.path.width,
322
+        )
323
+        return FileResponse(jpg_preview_path)
324
+
325
+    @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG])
326
+    @require_workspace_role(UserRoleInWorkspace.READER)
327
+    @require_content_types([file_type])
328
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
329
+    @hapic.output_body(AllowedJpgPreviewDimSchema())
330
+    def allowed_dim_preview_jpg(self, context, request: TracimRequest, hapic_data=None):  # nopep8
331
+        """
332
+        Get allowed dimensions of jpg preview. If restricted is true,
333
+        only those dimensions are strictly accepted.
334
+        """
335
+        app_config = request.registry.settings['CFG']
336
+        api = ContentApi(
337
+            current_user=request.current_user,
338
+            session=request.dbsession,
339
+            config=app_config,
340
+        )
341
+        return api.get_jpg_preview_allowed_dim()
342
+
343
+    # File infos
344
+    @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG])
345
+    @require_workspace_role(UserRoleInWorkspace.READER)
346
+    @require_content_types([file_type])
347
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
348
+    @hapic.output_body(FileContentSchema())
349
+    def get_file_infos(self, context, request: TracimRequest, hapic_data=None) -> ContentInContext:  # nopep8
350
+        """
351
+        Get thread content
352
+        """
353
+        app_config = request.registry.settings['CFG']
354
+        api = ContentApi(
355
+            current_user=request.current_user,
356
+            session=request.dbsession,
357
+            config=app_config,
358
+        )
359
+        content = api.get_one(
360
+            hapic_data.path.content_id,
361
+            content_type=ContentType.Any
362
+        )
363
+        return api.get_content_in_context(content)
364
+
365
+    @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG])
366
+    @hapic.handle_exception(EmptyLabelNotAllowed, HTTPStatus.BAD_REQUEST)
367
+    @require_workspace_role(UserRoleInWorkspace.CONTRIBUTOR)
368
+    @require_content_types([file_type])
369
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
370
+    @hapic.input_body(FileContentModifySchema())
371
+    @hapic.output_body(FileContentSchema())
372
+    def update_file_info(self, context, request: TracimRequest, hapic_data=None) -> ContentInContext:  # nopep8
373
+        """
374
+        update thread
375
+        """
376
+        app_config = request.registry.settings['CFG']
377
+        api = ContentApi(
378
+            current_user=request.current_user,
379
+            session=request.dbsession,
380
+            config=app_config,
381
+        )
382
+        content = api.get_one(
383
+            hapic_data.path.content_id,
384
+            content_type=ContentType.Any
385
+        )
386
+        with new_revision(
387
+                session=request.dbsession,
388
+                tm=transaction.manager,
389
+                content=content
390
+        ):
391
+            api.update_content(
392
+                item=content,
393
+                new_label=hapic_data.body.label,
394
+                new_content=hapic_data.body.raw_content,
395
+
396
+            )
397
+            api.save(content)
398
+        return api.get_content_in_context(content)
399
+
400
+    @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG])
401
+    @require_workspace_role(UserRoleInWorkspace.READER)
402
+    @require_content_types([file_type])
403
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
404
+    @hapic.output_body(FileRevisionSchema(many=True))
405
+    def get_file_revisions(
406
+            self,
407
+            context,
408
+            request: TracimRequest,
409
+            hapic_data=None
410
+    ) -> typing.List[RevisionInContext]:
411
+        """
412
+        get file revisions
413
+        """
414
+        app_config = request.registry.settings['CFG']
415
+        api = ContentApi(
416
+            current_user=request.current_user,
417
+            session=request.dbsession,
418
+            config=app_config,
419
+        )
420
+        content = api.get_one(
421
+            hapic_data.path.content_id,
422
+            content_type=ContentType.Any
423
+        )
424
+        revisions = content.revisions
425
+        return [
426
+            api.get_revision_in_context(revision)
427
+            for revision in revisions
428
+        ]
429
+
430
+    @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG])
431
+    @hapic.handle_exception(EmptyLabelNotAllowed, HTTPStatus.BAD_REQUEST)
432
+    @require_workspace_role(UserRoleInWorkspace.CONTRIBUTOR)
433
+    @require_content_types([file_type])
434
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
435
+    @hapic.input_body(SetContentStatusSchema())
436
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
437
+    def set_file_status(self, context, request: TracimRequest, hapic_data=None) -> None:  # nopep8
438
+        """
439
+        set file status
440
+        """
441
+        app_config = request.registry.settings['CFG']
442
+        api = ContentApi(
443
+            current_user=request.current_user,
444
+            session=request.dbsession,
445
+            config=app_config,
446
+        )
447
+        content = api.get_one(
448
+            hapic_data.path.content_id,
449
+            content_type=ContentType.Any
450
+        )
451
+        with new_revision(
452
+                session=request.dbsession,
453
+                tm=transaction.manager,
454
+                content=content
455
+        ):
456
+            api.set_status(
457
+                content,
458
+                hapic_data.body.status,
459
+            )
460
+            api.save(content)
461
+        return
462
+
463
+    def bind(self, configurator: Configurator) -> None:
464
+        """
465
+        Add route to configurator.
466
+        """
467
+
468
+        # file info #
469
+        # Get file info
470
+        configurator.add_route(
471
+            'file_info',
472
+            '/workspaces/{workspace_id}/files/{content_id}',
473
+            request_method='GET'
474
+        )
475
+        configurator.add_view(self.get_file_infos, route_name='file_info')  # nopep8
476
+        # update file
477
+        configurator.add_route(
478
+            'update_file_info',
479
+            '/workspaces/{workspace_id}/files/{content_id}',
480
+            request_method='PUT'
481
+        )  # nopep8
482
+        configurator.add_view(self.update_file_info, route_name='update_file_info')  # nopep8
483
+
484
+        # raw file #
485
+        # upload raw file
486
+        configurator.add_route(
487
+            'upload_file',
488
+            '/workspaces/{workspace_id}/files/{content_id}/raw',  # nopep8
489
+            request_method='PUT'
490
+        )
491
+        configurator.add_view(self.upload_file, route_name='upload_file')  # nopep8
492
+        # download raw file
493
+        configurator.add_route(
494
+            'download_file',
495
+            '/workspaces/{workspace_id}/files/{content_id}/raw',  # nopep8
496
+            request_method='GET'
497
+        )
498
+        configurator.add_view(self.download_file, route_name='download_file')  # nopep8
499
+        # download raw file of revision
500
+        configurator.add_route(
501
+            'download_revision',
502
+            '/workspaces/{workspace_id}/files/{content_id}/revisions/{revision_id}/raw',  # nopep8
503
+            request_method='GET'
504
+        )
505
+        configurator.add_view(self.download_revisions_file, route_name='download_revision')  # nopep8
506
+
507
+        # previews #
508
+        # get preview pdf full
509
+        configurator.add_route(
510
+            'preview_pdf_full',
511
+            '/workspaces/{workspace_id}/files/{content_id}/preview/pdf/full',  # nopep8
512
+            request_method='GET'
513
+        )
514
+        configurator.add_view(self.preview_pdf_full, route_name='preview_pdf_full')  # nopep8
515
+        # get preview pdf
516
+        configurator.add_route(
517
+            'preview_pdf',
518
+            '/workspaces/{workspace_id}/files/{content_id}/preview/pdf',  # nopep8
519
+            request_method='GET'
520
+        )
521
+        configurator.add_view(self.preview_pdf, route_name='preview_pdf')  # nopep8
522
+        # get preview jpg allowed dims
523
+        configurator.add_route(
524
+            'allowed_dim_preview_jpg',
525
+            '/workspaces/{workspace_id}/files/{content_id}/preview/jpg/allowed_dims',  # nopep8
526
+            request_method='GET'
527
+        )
528
+        configurator.add_view(self.allowed_dim_preview_jpg, route_name='allowed_dim_preview_jpg')  # nopep8
529
+        # get preview jpg
530
+        configurator.add_route(
531
+            'preview_jpg',
532
+            '/workspaces/{workspace_id}/files/{content_id}/preview/jpg',  # nopep8
533
+            request_method='GET'
534
+        )
535
+        configurator.add_view(self.preview_jpg, route_name='preview_jpg')  # nopep8
536
+        # get preview jpg with size
537
+        configurator.add_route(
538
+            'sized_preview_jpg',
539
+            '/workspaces/{workspace_id}/files/{content_id}/preview/jpg/{width}x{height}',  # nopep8
540
+            request_method='GET'
541
+        )
542
+        configurator.add_view(self.sized_preview_jpg, route_name='sized_preview_jpg')  # nopep8
543
+        # get jpg preview for revision
544
+        configurator.add_route(
545
+            'sized_preview_jpg_revision',
546
+            '/workspaces/{workspace_id}/files/{content_id}/revisions/{revision_id}/preview/jpg/{width}x{height}',  # nopep8
547
+            request_method='GET'
548
+        )
549
+        configurator.add_view(self.sized_preview_jpg_revision, route_name='sized_preview_jpg_revision')  # nopep8
550
+        # get jpg preview for revision
551
+        configurator.add_route(
552
+            'preview_pdf_revision',
553
+            '/workspaces/{workspace_id}/files/{content_id}/revisions/{revision_id}/preview/pdf',  # nopep8
554
+            request_method='GET'
555
+        )
556
+        configurator.add_view(self.preview_pdf_revision, route_name='preview_pdf_revision')  # nopep8
557
+        # others #
558
+        # get file revisions
559
+        configurator.add_route(
560
+            'file_revisions',
561
+            '/workspaces/{workspace_id}/files/{content_id}/revisions',  # nopep8
562
+            request_method='GET'
563
+        )
564
+        configurator.add_view(self.get_file_revisions, route_name='file_revisions')  # nopep8
565
+
566
+        # get file status
567
+        configurator.add_route(
568
+            'set_file_status',
569
+            '/workspaces/{workspace_id}/files/{content_id}/status',  # nopep8
570
+            request_method='PUT'
571
+        )
572
+        configurator.add_view(self.set_file_status, route_name='set_file_status')  # nopep8

+ 229 - 1
tracim/views/core_api/schemas.py View File

@@ -10,21 +10,33 @@ from tracim.models.contents import GlobalStatus
10 10
 from tracim.models.contents import open_status
11 11
 from tracim.models.contents import ContentTypeLegacy as ContentType
12 12
 from tracim.models.contents import ContentStatusLegacy as ContentStatus
13
+from tracim.models.context_models import ActiveContentFilter
14
+from tracim.models.context_models import ContentIdsQuery
15
+from tracim.models.context_models import UserWorkspaceAndContentPath
13 16
 from tracim.models.context_models import ContentCreation
14 17
 from tracim.models.context_models import UserCreation
15 18
 from tracim.models.context_models import SetEmail
16 19
 from tracim.models.context_models import SetPassword
17 20
 from tracim.models.context_models import UserInfos
18 21
 from tracim.models.context_models import UserProfile
22
+from tracim.models.context_models import ContentPreviewSizedPath
23
+from tracim.models.context_models import RevisionPreviewSizedPath
24
+from tracim.models.context_models import PageQuery
25
+from tracim.models.context_models import WorkspaceAndContentRevisionPath
26
+from tracim.models.context_models import WorkspaceMemberInvitation
27
+from tracim.models.context_models import WorkspaceUpdate
28
+from tracim.models.context_models import RoleUpdate
19 29
 from tracim.models.context_models import CommentCreation
20 30
 from tracim.models.context_models import TextBasedContentUpdate
21 31
 from tracim.models.context_models import SetContentStatus
22 32
 from tracim.models.context_models import CommentPath
23 33
 from tracim.models.context_models import MoveParams
24 34
 from tracim.models.context_models import WorkspaceAndContentPath
35
+from tracim.models.context_models import WorkspaceAndUserPath
25 36
 from tracim.models.context_models import ContentFilter
26 37
 from tracim.models.context_models import LoginCredentials
27 38
 from tracim.models.data import UserRoleInWorkspace
39
+from tracim.models.data import ActionDescription
28 40
 
29 41
 
30 42
 class UserDigestSchema(marshmallow.Schema):
@@ -180,6 +192,19 @@ class ContentIdPathSchema(marshmallow.Schema):
180 192
     )
181 193
 
182 194
 
195
+class RevisionIdPathSchema(marshmallow.Schema):
196
+    revision_id = marshmallow.fields.Int(example=6, required=True)
197
+
198
+
199
+class WorkspaceAndUserIdPathSchema(
200
+    UserIdPathSchema,
201
+    WorkspaceIdPathSchema
202
+):
203
+    @post_load
204
+    def make_path_object(self, data):
205
+        return WorkspaceAndUserPath(**data)
206
+
207
+
183 208
 class WorkspaceAndContentIdPathSchema(
184 209
     WorkspaceIdPathSchema,
185 210
     ContentIdPathSchema
@@ -189,6 +214,71 @@ class WorkspaceAndContentIdPathSchema(
189 214
         return WorkspaceAndContentPath(**data)
190 215
 
191 216
 
217
+class WidthAndHeightPathSchema(marshmallow.Schema):
218
+    width = marshmallow.fields.Int(example=256)
219
+    height = marshmallow.fields.Int(example=256)
220
+
221
+
222
+class AllowedJpgPreviewSizesSchema(marshmallow.Schema):
223
+    width = marshmallow.fields.Int(example=256)
224
+    height = marshmallow.fields.Int(example=256)
225
+
226
+
227
+class AllowedJpgPreviewDimSchema(marshmallow.Schema):
228
+    restricted = marshmallow.fields.Bool()
229
+    dimensions = marshmallow.fields.Nested(
230
+        AllowedJpgPreviewSizesSchema,
231
+        many=True
232
+    )
233
+
234
+
235
+class WorkspaceAndContentRevisionIdPathSchema(
236
+    WorkspaceIdPathSchema,
237
+    ContentIdPathSchema,
238
+    RevisionIdPathSchema,
239
+):
240
+    @post_load
241
+    def make_path_object(self, data):
242
+        return WorkspaceAndContentRevisionPath(**data)
243
+
244
+
245
+class ContentPreviewSizedPathSchema(
246
+    WorkspaceAndContentIdPathSchema,
247
+    WidthAndHeightPathSchema
248
+):
249
+    @post_load
250
+    def make_path_object(self, data):
251
+        return ContentPreviewSizedPath(**data)
252
+
253
+
254
+class RevisionPreviewSizedPathSchema(
255
+    WorkspaceAndContentRevisionIdPathSchema,
256
+    WidthAndHeightPathSchema
257
+):
258
+    @post_load
259
+    def make_path_object(self, data):
260
+        return RevisionPreviewSizedPath(**data)
261
+
262
+
263
+class UserWorkspaceAndContentIdPathSchema(
264
+    UserIdPathSchema,
265
+    WorkspaceIdPathSchema,
266
+    ContentIdPathSchema,
267
+):
268
+    @post_load
269
+    def make_path_object(self, data):
270
+        return UserWorkspaceAndContentPath(**data)
271
+
272
+
273
+class UserWorkspaceIdPathSchema(
274
+    UserIdPathSchema,
275
+    WorkspaceIdPathSchema,
276
+):
277
+    @post_load
278
+    def make_path_object(self, data):
279
+        return WorkspaceAndUserPath(**data)
280
+
281
+
192 282
 class CommentsPathSchema(WorkspaceAndContentIdPathSchema):
193 283
     comment_id = marshmallow.fields.Int(
194 284
         example=6,
@@ -202,6 +292,19 @@ class CommentsPathSchema(WorkspaceAndContentIdPathSchema):
202 292
         return CommentPath(**data)
203 293
 
204 294
 
295
+class PageQuerySchema(marshmallow.Schema):
296
+    page = marshmallow.fields.Int(
297
+        example=2,
298
+        default=0,
299
+        description='allow to show a specific page of a pdf file',
300
+        validate=Range(min=0, error="Value must be positive or 0"),
301
+    )
302
+
303
+    @post_load
304
+    def make_page_query(self, data):
305
+        return PageQuery(**data)
306
+
307
+
205 308
 class FilterContentQuerySchema(marshmallow.Schema):
206 309
     parent_id = marshmallow.fields.Int(
207 310
         example=2,
@@ -238,14 +341,77 @@ class FilterContentQuerySchema(marshmallow.Schema):
238 341
                     'to allow to show only archived documents',
239 342
         validate=Range(min=0, max=1, error="Value must be 0 or 1"),
240 343
     )
344
+    content_type = marshmallow.fields.String(
345
+        example=ContentType.Any,
346
+        default=ContentType.Any,
347
+        validate=OneOf(ContentType.allowed_type_values())
348
+    )
241 349
 
242 350
     @post_load
243 351
     def make_content_filter(self, data):
244 352
         return ContentFilter(**data)
245 353
 
354
+
355
+class ActiveContentFilterQuerySchema(marshmallow.Schema):
356
+    limit = marshmallow.fields.Int(
357
+        example=2,
358
+        default=0,
359
+        description='if 0 or not set, return all elements, else return only '
360
+                    'the first limit elem (according to offset)',
361
+        validate=Range(min=0, error="Value must be positive or 0"),
362
+    )
363
+    before_datetime = marshmallow.fields.DateTime(
364
+        format=DATETIME_FORMAT,
365
+        description='return only content lastly updated before this date',
366
+    )
367
+    @post_load
368
+    def make_content_filter(self, data):
369
+        return ActiveContentFilter(**data)
370
+
371
+
372
+class ContentIdsQuerySchema(marshmallow.Schema):
373
+    contents_ids = marshmallow.fields.List(
374
+        marshmallow.fields.Int(
375
+            example=6,
376
+            validate=Range(min=1, error="Value must be greater than 0"),
377
+        )
378
+    )
379
+    @post_load
380
+    def make_contents_ids(self, data):
381
+        return ContentIdsQuery(**data)
382
+
383
+
246 384
 ###
247 385
 
248 386
 
387
+class RoleUpdateSchema(marshmallow.Schema):
388
+    role = marshmallow.fields.String(
389
+        example='contributor',
390
+        validate=OneOf(UserRoleInWorkspace.get_all_role_slug())
391
+    )
392
+
393
+    @post_load
394
+    def make_role(self, data):
395
+        return RoleUpdate(**data)
396
+
397
+
398
+class WorkspaceMemberInviteSchema(RoleUpdateSchema):
399
+    user_id = marshmallow.fields.Int(
400
+        example=5,
401
+        default=None,
402
+        allow_none=True,
403
+    )
404
+    user_email_or_public_name = marshmallow.fields.String(
405
+        example='suri@cate.fr',
406
+        default=None,
407
+        allow_none=True,
408
+    )
409
+
410
+    @post_load
411
+    def make_role(self, data):
412
+        return WorkspaceMemberInvitation(**data)
413
+
414
+
249 415
 class BasicAuthSchema(marshmallow.Schema):
250 416
 
251 417
     email = marshmallow.fields.Email(
@@ -270,6 +436,23 @@ class LoginOutputHeaders(marshmallow.Schema):
270 436
     expire_after = marshmallow.fields.String()
271 437
 
272 438
 
439
+class WorkspaceModifySchema(marshmallow.Schema):
440
+    label = marshmallow.fields.String(
441
+        example='My Workspace',
442
+    )
443
+    description = marshmallow.fields.String(
444
+        example='A super description of my workspace.',
445
+    )
446
+
447
+    @post_load
448
+    def make_workspace_modifications(self, data):
449
+        return WorkspaceUpdate(**data)
450
+
451
+
452
+class WorkspaceCreationSchema(WorkspaceModifySchema):
453
+    pass
454
+
455
+
273 456
 class NoContentSchema(marshmallow.Schema):
274 457
 
275 458
     class Meta:
@@ -337,13 +520,31 @@ class WorkspaceMemberSchema(marshmallow.Schema):
337 520
         validate=Range(min=1, error="Value must be greater than 0"),
338 521
     )
339 522
     user = marshmallow.fields.Nested(
340
-        UserSchema(only=('public_name', 'avatar_url'))
523
+        UserDigestSchema()
341 524
     )
525
+    workspace = marshmallow.fields.Nested(
526
+        WorkspaceDigestSchema(exclude=('sidebar_entries',))
527
+    )
528
+    is_active = marshmallow.fields.Bool()
342 529
 
343 530
     class Meta:
344 531
         description = 'Workspace Member information'
345 532
 
346 533
 
534
+class WorkspaceMemberCreationSchema(WorkspaceMemberSchema):
535
+    newly_created = marshmallow.fields.Bool(
536
+        exemple=False,
537
+        description='Is the user completely new '
538
+                    '(and account was just created) or not ?',
539
+    )
540
+    email_sent = marshmallow.fields.Bool(
541
+        exemple=False,
542
+        description='Has an email been sent to user to inform him about '
543
+                    'this new workspace registration and eventually his account'
544
+                    'creation'
545
+    )
546
+
547
+
347 548
 class ApplicationConfigSchema(marshmallow.Schema):
348 549
     pass
349 550
     #  TODO - G.M - 24-05-2018 - Set this
@@ -505,6 +706,12 @@ class ContentDigestSchema(marshmallow.Schema):
505 706
     )
506 707
 
507 708
 
709
+class ReadStatusSchema(marshmallow.Schema):
710
+    content_id = marshmallow.fields.Int(
711
+        example=6,
712
+        validate=Range(min=1, error="Value must be greater than 0"),
713
+    )
714
+    read_by_user = marshmallow.fields.Bool(example=False, default=False)
508 715
 #####
509 716
 # Content
510 717
 #####
@@ -529,10 +736,19 @@ class TextBasedDataAbstractSchema(marshmallow.Schema):
529 736
     )
530 737
 
531 738
 
739
+class FileInfoAbstractSchema(marshmallow.Schema):
740
+    raw_content = marshmallow.fields.String(
741
+        description='raw text or html description of the file'
742
+    )
743
+
744
+
532 745
 class TextBasedContentSchema(ContentSchema, TextBasedDataAbstractSchema):
533 746
     pass
534 747
 
535 748
 
749
+class FileContentSchema(ContentSchema, FileInfoAbstractSchema):
750
+    pass
751
+
536 752
 #####
537 753
 # Revision
538 754
 #####
@@ -549,6 +765,10 @@ class RevisionSchema(ContentDigestSchema):
549 765
         example=12,
550 766
         validate=Range(min=1, error="Value must be greater than 0"),
551 767
     )
768
+    revision_type = marshmallow.fields.String(
769
+        example=ActionDescription.CREATION,
770
+        validate=OneOf(ActionDescription.allowed_values()),
771
+    )
552 772
     created = marshmallow.fields.DateTime(
553 773
         format=DATETIME_FORMAT,
554 774
         description='Content creation date',
@@ -560,6 +780,10 @@ class TextBasedRevisionSchema(RevisionSchema, TextBasedDataAbstractSchema):
560 780
     pass
561 781
 
562 782
 
783
+class FileRevisionSchema(RevisionSchema, FileInfoAbstractSchema):
784
+    pass
785
+
786
+
563 787
 class CommentSchema(marshmallow.Schema):
564 788
     content_id = marshmallow.fields.Int(
565 789
         example=6,
@@ -604,6 +828,10 @@ class TextBasedContentModifySchema(ContentModifyAbstractSchema, TextBasedDataAbs
604 828
         return TextBasedContentUpdate(**data)
605 829
 
606 830
 
831
+class FileContentModifySchema(TextBasedContentModifySchema):
832
+    pass
833
+
834
+
607 835
 class SetContentStatusSchema(marshmallow.Schema):
608 836
     status = marshmallow.fields.Str(
609 837
         example='closed-deprecated',

+ 155 - 13
tracim/views/core_api/user_controller.py View File

@@ -1,30 +1,35 @@
1
-import transaction
2 1
 from pyramid.config import Configurator
3
-
4 2
 try:  # Python 3.5+
5 3
     from http import HTTPStatus
6 4
 except ImportError:
7 5
     from http import client as HTTPStatus
8 6
 
9
-from tracim import hapic, TracimRequest
10
-
11
-from tracim.exceptions import WrongUserPassword, PasswordDoNotMatch
12
-from tracim.lib.core.group import GroupApi
13
-from tracim.lib.utils.authorization import require_same_user_or_profile
14
-from tracim.lib.utils.authorization import require_profile
7
+from tracim import hapic
8
+from tracim import TracimRequest
15 9
 from tracim.models import Group
16
-from tracim.models.context_models import WorkspaceInContext
10
+from tracim.lib.core.group import GroupApi
17 11
 from tracim.lib.core.user import UserApi
18 12
 from tracim.lib.core.workspace import WorkspaceApi
13
+from tracim.lib.core.content import ContentApi
19 14
 from tracim.views.controllers import Controller
20
-from tracim.views.core_api.schemas import UserIdPathSchema
15
+from tracim.lib.utils.authorization import require_same_user_or_profile
16
+from tracim.lib.utils.authorization import require_profile
17
+from tracim.exceptions import WrongUserPassword
18
+from tracim.exceptions import PasswordDoNotMatch
21 19
 from tracim.views.core_api.schemas import UserSchema
22 20
 from tracim.views.core_api.schemas import SetEmailSchema
23 21
 from tracim.views.core_api.schemas import SetPasswordSchema
24 22
 from tracim.views.core_api.schemas import UserInfosSchema
25
-from tracim.views.core_api.schemas import NoContentSchema
26 23
 from tracim.views.core_api.schemas import UserCreationSchema
27 24
 from tracim.views.core_api.schemas import UserProfileSchema
25
+from tracim.views.core_api.schemas import UserIdPathSchema
26
+from tracim.views.core_api.schemas import ReadStatusSchema
27
+from tracim.views.core_api.schemas import ContentIdsQuerySchema
28
+from tracim.views.core_api.schemas import NoContentSchema
29
+from tracim.views.core_api.schemas import UserWorkspaceIdPathSchema
30
+from tracim.views.core_api.schemas import UserWorkspaceAndContentIdPathSchema
31
+from tracim.views.core_api.schemas import ContentDigestSchema
32
+from tracim.views.core_api.schemas import ActiveContentFilterQuerySchema
28 33
 from tracim.views.core_api.schemas import WorkspaceDigestSchema
29 34
 
30 35
 USER_ENDPOINTS_TAG = 'Users'
@@ -42,14 +47,14 @@ class UserController(Controller):
42 47
         """
43 48
         app_config = request.registry.settings['CFG']
44 49
         wapi = WorkspaceApi(
45
-            current_user=request.current_user,  # User
50
+            current_user=request.candidate_user,  # User
46 51
             session=request.dbsession,
47 52
             config=app_config,
48 53
         )
49 54
         
50 55
         workspaces = wapi.get_all_for_user(request.candidate_user)
51 56
         return [
52
-            WorkspaceInContext(workspace, request.dbsession, app_config)
57
+            wapi.get_workspace_with_context(workspace)
53 58
             for workspace in workspaces
54 59
         ]
55 60
 
@@ -236,6 +241,126 @@ class UserController(Controller):
236 241
         )
237 242
         return
238 243
 
244
+    @hapic.with_api_doc(tags=[USER_ENDPOINTS_TAG])
245
+    @require_same_user_or_profile(Group.TIM_ADMIN)
246
+    @hapic.input_path(UserWorkspaceIdPathSchema())
247
+    @hapic.input_query(ActiveContentFilterQuerySchema())
248
+    @hapic.output_body(ContentDigestSchema(many=True))
249
+    def last_active_content(self, context, request: TracimRequest, hapic_data=None):  # nopep8
250
+        """
251
+        Get last_active_content for user
252
+        """
253
+        app_config = request.registry.settings['CFG']
254
+        content_filter = hapic_data.query
255
+        api = ContentApi(
256
+            current_user=request.candidate_user,  # User
257
+            session=request.dbsession,
258
+            config=app_config,
259
+        )
260
+        wapi = WorkspaceApi(
261
+            current_user=request.candidate_user,  # User
262
+            session=request.dbsession,
263
+            config=app_config,
264
+        )
265
+        workspace = None
266
+        if hapic_data.path.workspace_id:
267
+            workspace = wapi.get_one(hapic_data.path.workspace_id)
268
+        last_actives = api.get_last_active(
269
+            workspace=workspace,
270
+            limit=content_filter.limit or None,
271
+            before_datetime=content_filter.before_datetime or None,
272
+        )
273
+        return [
274
+            api.get_content_in_context(content)
275
+            for content in last_actives
276
+        ]
277
+
278
+    @hapic.with_api_doc(tags=[USER_ENDPOINTS_TAG])
279
+    @require_same_user_or_profile(Group.TIM_ADMIN)
280
+    @hapic.input_path(UserWorkspaceIdPathSchema())
281
+    @hapic.input_query(ContentIdsQuerySchema(), as_list=['contents_ids'])
282
+    @hapic.output_body(ReadStatusSchema(many=True))  # nopep8
283
+    def contents_read_status(self, context, request: TracimRequest, hapic_data=None):  # nopep8
284
+        """
285
+        get user_read status of contents
286
+        """
287
+        app_config = request.registry.settings['CFG']
288
+        content_filter = hapic_data.query
289
+        api = ContentApi(
290
+            current_user=request.candidate_user,  # User
291
+            session=request.dbsession,
292
+            config=app_config,
293
+        )
294
+        wapi = WorkspaceApi(
295
+            current_user=request.candidate_user,  # User
296
+            session=request.dbsession,
297
+            config=app_config,
298
+        )
299
+        workspace = None
300
+        if hapic_data.path.workspace_id:
301
+            workspace = wapi.get_one(hapic_data.path.workspace_id)
302
+        last_actives = api.get_last_active(
303
+            workspace=workspace,
304
+            limit=None,
305
+            before_datetime=None,
306
+            content_ids=hapic_data.query.contents_ids or None
307
+        )
308
+        return [
309
+            api.get_content_in_context(content)
310
+            for content in last_actives
311
+        ]
312
+
313
+    @hapic.with_api_doc(tags=[USER_ENDPOINTS_TAG])
314
+    @require_same_user_or_profile(Group.TIM_ADMIN)
315
+    @hapic.input_path(UserWorkspaceAndContentIdPathSchema())
316
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
317
+    def set_content_as_read(self, context, request: TracimRequest, hapic_data=None):  # nopep8
318
+        """
319
+        set user_read status of content to read
320
+        """
321
+        app_config = request.registry.settings['CFG']
322
+        api = ContentApi(
323
+            current_user=request.candidate_user,
324
+            session=request.dbsession,
325
+            config=app_config,
326
+        )
327
+        api.mark_read(request.current_content, do_flush=True)
328
+        return
329
+
330
+    @hapic.with_api_doc(tags=[USER_ENDPOINTS_TAG])
331
+    @require_same_user_or_profile(Group.TIM_ADMIN)
332
+    @hapic.input_path(UserWorkspaceAndContentIdPathSchema())
333
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
334
+    def set_content_as_unread(self, context, request: TracimRequest, hapic_data=None):  # nopep8
335
+        """
336
+        set user_read status of content to unread
337
+        """
338
+        app_config = request.registry.settings['CFG']
339
+        api = ContentApi(
340
+            current_user=request.candidate_user,
341
+            session=request.dbsession,
342
+            config=app_config,
343
+        )
344
+        api.mark_unread(request.current_content, do_flush=True)
345
+        return
346
+
347
+    @hapic.with_api_doc(tags=[USER_ENDPOINTS_TAG])
348
+    @require_same_user_or_profile(Group.TIM_ADMIN)
349
+    @hapic.input_path(UserWorkspaceIdPathSchema())
350
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
351
+    def set_workspace_as_read(self, context, request: TracimRequest, hapic_data=None):  # nopep8
352
+        """
353
+        set user_read status of all content of workspace to read
354
+        """
355
+        app_config = request.registry.settings['CFG']
356
+        api = ContentApi(
357
+            current_user=request.candidate_user,
358
+            session=request.dbsession,
359
+            config=app_config,
360
+        )
361
+        api.mark_read__workspace(request.current_workspace)
362
+        return
363
+
239 364
     def bind(self, configurator: Configurator) -> None:
240 365
         """
241 366
         Create all routes and views using pyramid configurator
@@ -277,3 +402,20 @@ class UserController(Controller):
277 402
         # set user profile
278 403
         configurator.add_route('set_user_profile', '/users/{user_id}/profile', request_method='PUT')  # nopep8
279 404
         configurator.add_view(self.set_profile, route_name='set_user_profile')
405
+
406
+        # user content
407
+        configurator.add_route('contents_read_status', '/users/{user_id}/workspaces/{workspace_id}/contents/read_status', request_method='GET')  # nopep8
408
+        configurator.add_view(self.contents_read_status, route_name='contents_read_status')  # nopep8
409
+        # last active content for user
410
+        configurator.add_route('last_active_content', '/users/{user_id}/workspaces/{workspace_id}/contents/recently_active', request_method='GET')  # nopep8
411
+        configurator.add_view(self.last_active_content, route_name='last_active_content')  # nopep8
412
+
413
+        # set content as read/unread
414
+        configurator.add_route('read_content', '/users/{user_id}/workspaces/{workspace_id}/contents/{content_id}/read', request_method='PUT')  # nopep8
415
+        configurator.add_view(self.set_content_as_read, route_name='read_content')  # nopep8
416
+        configurator.add_route('unread_content', '/users/{user_id}/workspaces/{workspace_id}/contents/{content_id}/unread', request_method='PUT')  # nopep8
417
+        configurator.add_view(self.set_content_as_unread, route_name='unread_content')  # nopep8
418
+
419
+        # set workspace as read
420
+        configurator.add_route('read_workspace', '/users/{user_id}/workspaces/{workspace_id}/read', request_method='PUT')  # nopep8
421
+        configurator.add_view(self.set_workspace_as_read, route_name='read_workspace')  # nopep8

+ 170 - 7
tracim/views/core_api/workspace_controller.py View File

@@ -1,6 +1,10 @@
1 1
 import typing
2 2
 import transaction
3 3
 from pyramid.config import Configurator
4
+
5
+from tracim.lib.core.user import UserApi
6
+from tracim.models.roles import WorkspaceRoles
7
+
4 8
 try:  # Python 3.5+
5 9
     from http import HTTPStatus
6 10
 except ImportError:
@@ -12,17 +16,28 @@ from tracim.lib.core.workspace import WorkspaceApi
12 16
 from tracim.lib.core.content import ContentApi
13 17
 from tracim.lib.core.userworkspace import RoleApi
14 18
 from tracim.lib.utils.authorization import require_workspace_role
19
+from tracim.lib.utils.authorization import require_profile
20
+from tracim.models import Group
15 21
 from tracim.lib.utils.authorization import require_candidate_workspace_role
16 22
 from tracim.models.data import UserRoleInWorkspace
17 23
 from tracim.models.data import ActionDescription
18 24
 from tracim.models.context_models import UserRoleWorkspaceInContext
19 25
 from tracim.models.context_models import ContentInContext
20 26
 from tracim.exceptions import EmptyLabelNotAllowed
27
+from tracim.exceptions import EmailValidationFailed
28
+from tracim.exceptions import UserCreationFailed
29
+from tracim.exceptions import UserDoesNotExist
21 30
 from tracim.exceptions import ContentNotFound
22 31
 from tracim.exceptions import WorkspacesDoNotMatch
23 32
 from tracim.exceptions import ParentNotFound
24 33
 from tracim.views.controllers import Controller
25 34
 from tracim.views.core_api.schemas import FilterContentQuerySchema
35
+from tracim.views.core_api.schemas import WorkspaceMemberCreationSchema
36
+from tracim.views.core_api.schemas import WorkspaceMemberInviteSchema
37
+from tracim.views.core_api.schemas import RoleUpdateSchema
38
+from tracim.views.core_api.schemas import WorkspaceCreationSchema
39
+from tracim.views.core_api.schemas import WorkspaceModifySchema
40
+from tracim.views.core_api.schemas import WorkspaceAndUserIdPathSchema
26 41
 from tracim.views.core_api.schemas import ContentMoveSchema
27 42
 from tracim.views.core_api.schemas import NoContentSchema
28 43
 from tracim.views.core_api.schemas import ContentCreationSchema
@@ -47,7 +62,6 @@ class WorkspaceController(Controller):
47 62
         """
48 63
         Get workspace informations
49 64
         """
50
-        wid = hapic_data.path['workspace_id']
51 65
         app_config = request.registry.settings['CFG']
52 66
         wapi = WorkspaceApi(
53 67
             current_user=request.current_user,  # User
@@ -57,6 +71,52 @@ class WorkspaceController(Controller):
57 71
         return wapi.get_workspace_with_context(request.current_workspace)
58 72
 
59 73
     @hapic.with_api_doc(tags=[WORKSPACE_ENDPOINTS_TAG])
74
+    @hapic.handle_exception(EmptyLabelNotAllowed, HTTPStatus.BAD_REQUEST)
75
+    @require_workspace_role(UserRoleInWorkspace.WORKSPACE_MANAGER)
76
+    @hapic.input_path(WorkspaceIdPathSchema())
77
+    @hapic.input_body(WorkspaceModifySchema())
78
+    @hapic.output_body(WorkspaceSchema())
79
+    def update_workspace(self, context, request: TracimRequest, hapic_data=None):  # nopep8
80
+        """
81
+        Update workspace informations
82
+        """
83
+        app_config = request.registry.settings['CFG']
84
+        wapi = WorkspaceApi(
85
+            current_user=request.current_user,  # User
86
+            session=request.dbsession,
87
+            config=app_config,
88
+        )
89
+        wapi.update_workspace(
90
+            request.current_workspace,
91
+            label=hapic_data.body.label,
92
+            description=hapic_data.body.description,
93
+            save_now=True,
94
+        )
95
+        return wapi.get_workspace_with_context(request.current_workspace)
96
+
97
+    @hapic.with_api_doc(tags=[WORKSPACE_ENDPOINTS_TAG])
98
+    @hapic.handle_exception(EmptyLabelNotAllowed, HTTPStatus.BAD_REQUEST)
99
+    @require_profile(Group.TIM_MANAGER)
100
+    @hapic.input_body(WorkspaceCreationSchema())
101
+    @hapic.output_body(WorkspaceSchema())
102
+    def create_workspace(self, context, request: TracimRequest, hapic_data=None):  # nopep8
103
+        """
104
+        create workspace
105
+        """
106
+        app_config = request.registry.settings['CFG']
107
+        wapi = WorkspaceApi(
108
+            current_user=request.current_user,  # User
109
+            session=request.dbsession,
110
+            config=app_config,
111
+        )
112
+        workspace = wapi.create_workspace(
113
+            label=hapic_data.body.label,
114
+            description=hapic_data.body.description,
115
+            save_now=True,
116
+        )
117
+        return wapi.get_workspace_with_context(workspace)
118
+
119
+    @hapic.with_api_doc(tags=[WORKSPACE_ENDPOINTS_TAG])
60 120
     @require_workspace_role(UserRoleInWorkspace.READER)
61 121
     @hapic.input_path(WorkspaceIdPathSchema())
62 122
     @hapic.output_body(WorkspaceMemberSchema(many=True))
@@ -83,6 +143,96 @@ class WorkspaceController(Controller):
83 143
         ]
84 144
 
85 145
     @hapic.with_api_doc(tags=[WORKSPACE_ENDPOINTS_TAG])
146
+    @require_workspace_role(UserRoleInWorkspace.WORKSPACE_MANAGER)
147
+    @hapic.input_path(WorkspaceAndUserIdPathSchema())
148
+    @hapic.input_body(RoleUpdateSchema())
149
+    @hapic.output_body(WorkspaceMemberSchema())
150
+    def update_workspaces_members_role(
151
+            self,
152
+            context,
153
+            request: TracimRequest,
154
+            hapic_data=None
155
+    ) -> UserRoleWorkspaceInContext:
156
+        """
157
+        Update Members to this workspace
158
+        """
159
+        app_config = request.registry.settings['CFG']
160
+        rapi = RoleApi(
161
+            current_user=request.current_user,
162
+            session=request.dbsession,
163
+            config=app_config,
164
+        )
165
+
166
+        role = rapi.get_one(
167
+            user_id=hapic_data.path.user_id,
168
+            workspace_id=hapic_data.path.workspace_id,
169
+        )
170
+        workspace_role = WorkspaceRoles.get_role_from_slug(hapic_data.body.role)
171
+        role = rapi.update_role(
172
+            role,
173
+            role_level=workspace_role.level
174
+        )
175
+        return rapi.get_user_role_workspace_with_context(role)
176
+
177
+    @hapic.with_api_doc(tags=[WORKSPACE_ENDPOINTS_TAG])
178
+    @hapic.handle_exception(UserCreationFailed, HTTPStatus.BAD_REQUEST)
179
+    @require_workspace_role(UserRoleInWorkspace.WORKSPACE_MANAGER)
180
+    @hapic.input_path(WorkspaceIdPathSchema())
181
+    @hapic.input_body(WorkspaceMemberInviteSchema())
182
+    @hapic.output_body(WorkspaceMemberCreationSchema())
183
+    def create_workspaces_members_role(
184
+            self,
185
+            context,
186
+            request: TracimRequest,
187
+            hapic_data=None
188
+    ) -> UserRoleWorkspaceInContext:
189
+        """
190
+        Add Members to this workspace
191
+        """
192
+        newly_created = False
193
+        email_sent = False
194
+        app_config = request.registry.settings['CFG']
195
+        rapi = RoleApi(
196
+            current_user=request.current_user,
197
+            session=request.dbsession,
198
+            config=app_config,
199
+        )
200
+        uapi = UserApi(
201
+            current_user=request.current_user,
202
+            session=request.dbsession,
203
+            config=app_config,
204
+        )
205
+        try:
206
+            _, user = uapi.find(
207
+                user_id=hapic_data.body.user_id,
208
+                email=hapic_data.body.user_email_or_public_name,
209
+                public_name=hapic_data.body.user_email_or_public_name
210
+            )
211
+        except UserDoesNotExist:
212
+            try:
213
+                # TODO - G.M - 2018-07-05 - [UserCreation] Reenable email
214
+                # notification for creation
215
+                user = uapi.create_user(
216
+                    hapic_data.body.user_email_or_public_name,
217
+                    do_notify=False
218
+                )  # nopep8
219
+                newly_created = True
220
+            except EmailValidationFailed:
221
+                raise UserCreationFailed('no valid mail given')
222
+        role = rapi.create_one(
223
+            user=user,
224
+            workspace=request.current_workspace,
225
+            role_level=WorkspaceRoles.get_role_from_slug(hapic_data.body.role).level,  # nopep8
226
+            with_notif=False,
227
+            flush=True,
228
+        )
229
+        return rapi.get_user_role_workspace_with_context(
230
+            role,
231
+            newly_created=newly_created,
232
+            email_sent=email_sent,
233
+        )
234
+
235
+    @hapic.with_api_doc(tags=[WORKSPACE_ENDPOINTS_TAG])
86 236
     @require_workspace_role(UserRoleInWorkspace.READER)
87 237
     @hapic.input_path(WorkspaceIdPathSchema())
88 238
     @hapic.input_query(FilterContentQuerySchema())
@@ -109,6 +259,7 @@ class WorkspaceController(Controller):
109 259
         contents = api.get_all(
110 260
             parent_id=content_filter.parent_id,
111 261
             workspace=request.current_workspace,
262
+            content_type=content_filter.content_type or ContentType.Any,
112 263
         )
113 264
         contents = [
114 265
             api.get_content_in_context(content) for content in contents
@@ -167,7 +318,7 @@ class WorkspaceController(Controller):
167 318
             context,
168 319
             request: TracimRequest,
169 320
             hapic_data=None,
170
-    ) -> typing.List[ContentInContext]:
321
+    ) -> ContentInContext:
171 322
         """
172 323
         move a content
173 324
         """
@@ -216,7 +367,7 @@ class WorkspaceController(Controller):
216 367
             context,
217 368
             request: TracimRequest,
218 369
             hapic_data=None,
219
-    ) -> typing.List[ContentInContext]:
370
+    ) -> None:
220 371
         """
221 372
         delete a content
222 373
         """
@@ -248,7 +399,7 @@ class WorkspaceController(Controller):
248 399
             context,
249 400
             request: TracimRequest,
250 401
             hapic_data=None,
251
-    ) -> typing.List[ContentInContext]:
402
+    ) -> None:
252 403
         """
253 404
         undelete a content
254 405
         """
@@ -281,7 +432,7 @@ class WorkspaceController(Controller):
281 432
             context,
282 433
             request: TracimRequest,
283 434
             hapic_data=None,
284
-    ) -> typing.List[ContentInContext]:
435
+    ) -> None:
285 436
         """
286 437
         archive a content
287 438
         """
@@ -292,7 +443,7 @@ class WorkspaceController(Controller):
292 443
             session=request.dbsession,
293 444
             config=app_config,
294 445
         )
295
-        content = api.get_one(path_data.content_id, content_type=ContentType.Any)
446
+        content = api.get_one(path_data.content_id, content_type=ContentType.Any)  # nopep8
296 447
         with new_revision(
297 448
                 session=request.dbsession,
298 449
                 tm=transaction.manager,
@@ -310,7 +461,7 @@ class WorkspaceController(Controller):
310 461
             context,
311 462
             request: TracimRequest,
312 463
             hapic_data=None,
313
-    ) -> typing.List[ContentInContext]:
464
+    ) -> None:
314 465
         """
315 466
         unarchive a content
316 467
         """
@@ -343,9 +494,21 @@ class WorkspaceController(Controller):
343 494
         # Workspace
344 495
         configurator.add_route('workspace', '/workspaces/{workspace_id}', request_method='GET')  # nopep8
345 496
         configurator.add_view(self.workspace, route_name='workspace')
497
+        # Create workspace
498
+        configurator.add_route('create_workspace', '/workspaces', request_method='POST')  # nopep8
499
+        configurator.add_view(self.create_workspace, route_name='create_workspace')  # nopep8
500
+        # Update Workspace
501
+        configurator.add_route('update_workspace', '/workspaces/{workspace_id}', request_method='PUT')  # nopep8
502
+        configurator.add_view(self.update_workspace, route_name='update_workspace')  # nopep8
346 503
         # Workspace Members (Roles)
347 504
         configurator.add_route('workspace_members', '/workspaces/{workspace_id}/members', request_method='GET')  # nopep8
348 505
         configurator.add_view(self.workspaces_members, route_name='workspace_members')  # nopep8
506
+        # Update Workspace Members roles
507
+        configurator.add_route('update_workspace_member', '/workspaces/{workspace_id}/members/{user_id}', request_method='PUT')  # nopep8
508
+        configurator.add_view(self.update_workspaces_members_role, route_name='update_workspace_member')  # nopep8
509
+        # Create Workspace Members roles
510
+        configurator.add_route('create_workspace_member', '/workspaces/{workspace_id}/members', request_method='POST')  # nopep8
511
+        configurator.add_view(self.create_workspaces_members_role, route_name='create_workspace_member')  # nopep8
349 512
         # Workspace Content
350 513
         configurator.add_route('workspace_content', '/workspaces/{workspace_id}/contents', request_method='GET')  # nopep8
351 514
         configurator.add_view(self.workspace_content, route_name='workspace_content')  # nopep8